Add more file properties, organize storage, add video block
This commit is contained in:
63
CHDataManagement/Generator/Blocks/AudioBlock.swift
Normal file
63
CHDataManagement/Generator/Blocks/AudioBlock.swift
Normal file
@ -0,0 +1,63 @@
|
||||
|
||||
struct AudioBlock: KeyedBlockProcessor {
|
||||
|
||||
enum Key: String {
|
||||
case name
|
||||
case artist
|
||||
case album
|
||||
case file
|
||||
case cover
|
||||
}
|
||||
|
||||
static let blockId: ContentBlock = .audio
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
func process(_ arguments: [Key : String], markdown: Substring) -> String {
|
||||
guard let name = arguments[.name],
|
||||
let artist = arguments[.artist],
|
||||
let album = arguments[.album],
|
||||
let fileId = arguments[.file],
|
||||
let cover = arguments[.cover] else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let image = content.image(cover) else {
|
||||
results.missing(file: cover, source: "Audio Block")
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, source: "Audio Block")
|
||||
return ""
|
||||
}
|
||||
|
||||
let coverSize = 2 * content.settings.audioPlayer.smallCoverImageSize
|
||||
let coverImage = image.imageVersion(width: coverSize, height: coverSize, type: image.type)
|
||||
let footer = SingleFilePlayer.footer(
|
||||
name: name,
|
||||
artist: artist,
|
||||
album: album,
|
||||
url: file.absoluteUrl,
|
||||
cover: coverImage.outputPath)
|
||||
|
||||
results.require(file: file)
|
||||
results.require(image: coverImage)
|
||||
results.require(footer: footer)
|
||||
results.require(headers: .audioPlayerJs, .audioPlayerCss)
|
||||
results.require(icons: .audioPlayerPlay, .audioPlayerPause)
|
||||
|
||||
return SingleFilePlayer().content
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
|
||||
struct AudioBlockProcessor: KeyedBlockProcessor {
|
||||
struct AudioBlock: KeyedBlockProcessor {
|
||||
|
||||
enum Key: String {
|
||||
case name
|
||||
|
@ -5,10 +5,13 @@ enum ContentBlock: String, CaseIterable {
|
||||
|
||||
case swift
|
||||
|
||||
case video
|
||||
|
||||
var processor: BlockProcessor.Type {
|
||||
switch self {
|
||||
case .audio: return AudioBlockProcessor.self
|
||||
case .swift: return SwiftBlockProcessor.self
|
||||
case .audio: return AudioBlock.self
|
||||
case .swift: return SwiftBlock.self
|
||||
case .video: return VideoBlock.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
|
||||
struct OtherCodeProcessor {
|
||||
struct OtherCodeBlock {
|
||||
|
||||
private let codeHighlightFooter = "<script>hljs.highlightAll();</script>"
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Splash
|
||||
|
||||
struct SwiftBlockProcessor: BlockProcessor {
|
||||
struct SwiftBlock: BlockProcessor {
|
||||
|
||||
static let blockId: ContentBlock = .swift
|
||||
|
@ -8,8 +8,8 @@ extension BlockLineProcessor {
|
||||
|
||||
func process(_ markdown: Substring) -> String {
|
||||
let lines = markdown
|
||||
.between("```\(Self.blockId.self)", and: "```")
|
||||
.components(separatedBy: "\n")
|
||||
return process(lines, markdown: markdown)
|
||||
.dropFirst().dropLast()
|
||||
return process(Array(lines), markdown: markdown)
|
||||
}
|
||||
}
|
||||
|
267
CHDataManagement/Generator/Blocks/VideoBlock.swift
Normal file
267
CHDataManagement/Generator/Blocks/VideoBlock.swift
Normal file
@ -0,0 +1,267 @@
|
||||
|
||||
struct VideoBlock: OrderedKeyBlockProcessor {
|
||||
|
||||
static let blockId: ContentBlock = .audio
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
func process(_ arguments: [(key: Key, value: String)], markdown: Substring) -> String {
|
||||
var options: [Option] = []
|
||||
var sources: [Source] = []
|
||||
|
||||
for (key, value) in arguments {
|
||||
guard let sourceType = key.sourceType else {
|
||||
guard let option = makeOption(key: key, value: value) else {
|
||||
invalid(markdown)
|
||||
continue
|
||||
}
|
||||
options.append(option)
|
||||
continue
|
||||
}
|
||||
let fileId = value.removingSurroundingQuotes
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, source: "Video Block: \(key)")
|
||||
continue
|
||||
}
|
||||
let source = Source(file: file, type: sourceType)
|
||||
sources.append(source)
|
||||
}
|
||||
|
||||
guard !sources.isEmpty else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
return VersionedVideo(sources: sources, options: options).content
|
||||
}
|
||||
|
||||
private func makeOption(key: Key, value: String) -> Option? {
|
||||
switch key {
|
||||
case .controls: return .controls
|
||||
case .autoplay: return .autoplay
|
||||
case .muted: return .muted
|
||||
case .loop: return .loop
|
||||
case .playsinline: return .playsinline
|
||||
default: break
|
||||
}
|
||||
|
||||
let value = value.removingSurroundingQuotes
|
||||
|
||||
guard value != "" else {
|
||||
return nil
|
||||
}
|
||||
switch key {
|
||||
case .height:
|
||||
guard let height = Int(value) else {
|
||||
return nil
|
||||
}
|
||||
return .height(height)
|
||||
|
||||
case .width:
|
||||
guard let width = Int(value) else {
|
||||
return nil
|
||||
}
|
||||
return .width(width)
|
||||
case .preload:
|
||||
guard let preloadOption = Option.Preload(rawValue: value) else {
|
||||
return nil
|
||||
}
|
||||
return .preload(preloadOption)
|
||||
case .poster:
|
||||
guard let image = content.image(value) else {
|
||||
results.missing(file: value, source: "Video Block: poster")
|
||||
return nil
|
||||
}
|
||||
let width = 2 * content.settings.pages.contentWidth
|
||||
let version = image.imageVersion(width: width, height: width, type: .jpg)
|
||||
results.require(image: version)
|
||||
return .poster(image: version.outputPath)
|
||||
case .src:
|
||||
guard let file = content.file(value) else {
|
||||
results.missing(file: value, source: "Video Block: src")
|
||||
return nil
|
||||
}
|
||||
results.warning("Use 'h264' and 'h265' instead of 'src'")
|
||||
return .src(file.absoluteUrl)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoBlock {
|
||||
|
||||
enum Key: String {
|
||||
|
||||
/// The H264 video file to use
|
||||
case h264
|
||||
|
||||
/// The H265 video file to use
|
||||
case h265
|
||||
|
||||
/// The WebM video file to use
|
||||
case webm
|
||||
|
||||
// MARK: Video options
|
||||
|
||||
/// Specifies that video controls should be displayed (such as a play/pause button etc).
|
||||
case controls
|
||||
|
||||
/// Specifies that the video will start playing as soon as it is ready
|
||||
case autoplay
|
||||
|
||||
/// Specifies that the video will start over again, every time it is finished
|
||||
case loop
|
||||
|
||||
/// Specifies that the audio output of the video should be muted
|
||||
case muted
|
||||
|
||||
/// Mobile browsers will play the video right where it is instead of the default, which is to open it up fullscreen while it plays
|
||||
case playsinline
|
||||
|
||||
/// Sets the height of the video player
|
||||
case height
|
||||
|
||||
/// Sets the width of the video player
|
||||
case width
|
||||
|
||||
/// Specifies if and how the author thinks the video should be loaded when the page loads
|
||||
case preload
|
||||
|
||||
/// Specifies an image to be shown while the video is downloading, or until the user hits the play button
|
||||
case poster
|
||||
|
||||
/// Specifies the URL of the video file
|
||||
case src
|
||||
|
||||
var isOption: Bool {
|
||||
switch self {
|
||||
case .controls, .autoplay, .loop, .muted, .playsinline, .height, .width, .preload, .poster, .src:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var sourceType: SourceType? {
|
||||
switch self {
|
||||
case .h264: .h264
|
||||
case .h265: .h265
|
||||
case .webm: .webm
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SourceType {
|
||||
case h264
|
||||
case h265
|
||||
case webm
|
||||
|
||||
var order: Int {
|
||||
switch self {
|
||||
case .h265: 1
|
||||
case .webm: 2
|
||||
case .h264: 3
|
||||
}
|
||||
}
|
||||
|
||||
var mimeType: String {
|
||||
switch self {
|
||||
case .h265, .h264: "video/mp4"
|
||||
case .webm: "video/webm"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Source {
|
||||
|
||||
let file: FileResource
|
||||
|
||||
let type: SourceType
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoBlock {
|
||||
|
||||
enum Option {
|
||||
|
||||
/// Specifies that video controls should be displayed (such as a play/pause button etc).
|
||||
case controls
|
||||
|
||||
/// Specifies that the video will start playing as soon as it is ready
|
||||
case autoplay
|
||||
|
||||
/// Specifies that the video will start over again, every time it is finished
|
||||
case loop
|
||||
|
||||
/// Specifies that the audio output of the video should be muted
|
||||
case muted
|
||||
|
||||
/// Mobile browsers will play the video right where it is instead of the default, which is to open it up fullscreen while it plays
|
||||
case playsinline
|
||||
|
||||
/// Sets the height of the video player
|
||||
case height(Int)
|
||||
|
||||
/// Sets the width of the video player
|
||||
case width(Int)
|
||||
|
||||
/// Specifies if and how the author thinks the video should be loaded when the page loads
|
||||
case preload(Preload)
|
||||
|
||||
/// Specifies an image to be shown while the video is downloading, or until the user hits the play button
|
||||
case poster(image: String)
|
||||
|
||||
/// Specifies the URL of the video file
|
||||
case src(String)
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .controls: return "controls"
|
||||
case .autoplay: return "autoplay"
|
||||
case .muted: return "muted"
|
||||
case .loop: return "loop"
|
||||
case .playsinline: return "playsinline"
|
||||
case .height(let height): return "height='\(height)'"
|
||||
case .width(let width): return "width='\(width)'"
|
||||
case .preload(let option): return "preload='\(option)'"
|
||||
case .poster(let image): return "poster='\(image)'"
|
||||
case .src(let url): return "src='\(url)'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoBlock.Option {
|
||||
|
||||
/**
|
||||
The `preload` attribute specifies if and how the author thinks that the video should be loaded when the page loads.
|
||||
|
||||
The `preload` attribute allows the author to provide a hint to the browser about what he/she thinks will lead to the best user experience.
|
||||
This attribute may be ignored in some instances.
|
||||
|
||||
Note: The `preload` attribute is ignored if `autoplay` is present.
|
||||
*/
|
||||
enum Preload: String {
|
||||
|
||||
/// The author thinks that the browser should load the entire video when the page loads
|
||||
case auto
|
||||
|
||||
/// The author thinks that the browser should load only metadata when the page loads
|
||||
case metadata
|
||||
|
||||
/// The author thinks that the browser should NOT load the video when the page loads
|
||||
case none
|
||||
}
|
||||
}
|
@ -9,33 +9,15 @@ final class ImageGenerator {
|
||||
|
||||
private let settings: Settings
|
||||
|
||||
private var generatedImages: [String : Set<String>] = [:]
|
||||
|
||||
init(storage: Storage, settings: Settings) {
|
||||
self.storage = storage
|
||||
self.settings = settings
|
||||
self.generatedImages = storage.loadListOfGeneratedImages() ?? [:]
|
||||
print("ImageGenerator: Loaded list of \(totalImageCount) already generated images")
|
||||
}
|
||||
|
||||
private var outputFolder: String {
|
||||
settings.paths.imagesOutputFolderPath
|
||||
}
|
||||
|
||||
private var totalImageCount: Int {
|
||||
generatedImages.values.reduce(0) { $0 + $1.count }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save() -> Bool {
|
||||
guard storage.save(listOfGeneratedImages: generatedImages) else {
|
||||
print("ImageGenerator: Failed to save list of generated images")
|
||||
return false
|
||||
}
|
||||
print("ImageGenerator: Saved list of \(totalImageCount) images")
|
||||
return true
|
||||
}
|
||||
|
||||
private var avifCommands: Set<String> = []
|
||||
|
||||
/**
|
||||
@ -54,49 +36,18 @@ final class ImageGenerator {
|
||||
storage.write(content, to: "generate-images.sh")
|
||||
}
|
||||
|
||||
/**
|
||||
Remove all versions of an image, so that they will be recreated on the next run.
|
||||
|
||||
This function does not remove the images from the output folder.
|
||||
*/
|
||||
func removeVersions(of image: String) {
|
||||
generatedImages[image] = nil
|
||||
save()
|
||||
}
|
||||
|
||||
func recalculateGeneratedImages(by images: Set<String>) {
|
||||
self.generatedImages = storage.calculateImages(generatedBy: images, in: outputFolder)
|
||||
let versionCount = generatedImages.values.reduce(0) { $0 + $1.count }
|
||||
print("Image generator: \(generatedImages.count)/\(images.count) images (\(versionCount) versions)")
|
||||
}
|
||||
|
||||
private func hasPreviouslyGenerated(_ version: ImageVersion) -> Bool {
|
||||
guard let versions = generatedImages[version.image.id] else {
|
||||
return false
|
||||
}
|
||||
return versions.contains(version.versionId)
|
||||
}
|
||||
|
||||
private func needsToGenerate(_ version: ImageVersion) -> Bool {
|
||||
if hasPreviouslyGenerated(version) {
|
||||
if version.wasPreviouslyGenerated {
|
||||
return false
|
||||
}
|
||||
if exists(version) {
|
||||
// Mark as already generated
|
||||
hasNowGenerated(version)
|
||||
version.wasNowGenerated()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func hasNowGenerated(_ version: ImageVersion) {
|
||||
generatedImages[version.image.id, default: []].insert(version.versionId)
|
||||
}
|
||||
|
||||
private func removeVersions(for image: String) {
|
||||
generatedImages[image] = nil
|
||||
}
|
||||
|
||||
// MARK: Files
|
||||
|
||||
private func exists(_ version: ImageVersion) -> Bool {
|
||||
@ -155,7 +106,7 @@ final class ImageGenerator {
|
||||
guard write(imageData: data, of: version) else {
|
||||
return false
|
||||
}
|
||||
hasNowGenerated(version)
|
||||
version.wasNowGenerated()
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -41,6 +41,14 @@ struct ImageVersion {
|
||||
var outputPath: String {
|
||||
image.outputPath(width: maximumWidth, height: maximumHeight, type: type)
|
||||
}
|
||||
|
||||
var wasPreviouslyGenerated: Bool {
|
||||
image.generatedImageVersions.contains(versionId)
|
||||
}
|
||||
|
||||
func wasNowGenerated() {
|
||||
image.generatedImageVersions.insert(versionId)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageVersion: Identifiable {
|
||||
|
@ -8,7 +8,7 @@ struct MarkdownCodeProcessor: MarkdownProcessor {
|
||||
|
||||
private let blocks: [ContentBlock : BlockProcessor]
|
||||
|
||||
private let other: OtherCodeProcessor
|
||||
private let other: OtherCodeBlock
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.results = results
|
||||
@ -23,10 +23,18 @@ struct MarkdownCodeProcessor: MarkdownProcessor {
|
||||
|
||||
func process(html: String, markdown: Substring) -> String {
|
||||
let input = String(markdown)
|
||||
let rawBlockId = input.dropAfterFirst("\n").dropBeforeFirst("```").trimmed
|
||||
let rawBlockId = input.dropAfterFirst("\n").dropBeforeFirst("```").trimmed.lowercased()
|
||||
guard let blockId = ContentBlock(rawValue: rawBlockId) else {
|
||||
guard knownCodeBlocks.contains(rawBlockId) else {
|
||||
results.invalid(block: nil, markdown)
|
||||
return ""
|
||||
}
|
||||
return other.process(html: html)
|
||||
}
|
||||
return blocks[blockId]!.process(markdown)
|
||||
}
|
||||
|
||||
private let knownCodeBlocks: Set<String> = [
|
||||
"bash", "nginx", "json", "css", "html", "markdown", ""
|
||||
]
|
||||
}
|
||||
|
Reference in New Issue
Block a user