diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 2c944b0..6714f09 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -219,14 +219,14 @@ E2FE0F2A2D2AFBE6002963B7 /* ImageCompareIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F292D2AFBE3002963B7 /* ImageCompareIcons.swift */; }; E2FE0F2C2D2B119A002963B7 /* ImageCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F2B2D2B1196002963B7 /* ImageCommand.swift */; }; E2FE0F312D2B1952002963B7 /* PartialSvgImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F302D2B1952002963B7 /* PartialSvgImage.swift */; }; - E2FE0F332D2B2665002963B7 /* AudioBlockProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F322D2B265F002963B7 /* AudioBlockProcessor.swift */; }; + E2FE0F332D2B2665002963B7 /* AudioBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F322D2B265F002963B7 /* AudioBlock.swift */; }; E2FE0F362D2B27F9002963B7 /* BlockProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F352D2B27F6002963B7 /* BlockProcessor.swift */; }; E2FE0F382D2B32F4002963B7 /* SingleFilePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F372D2B32ED002963B7 /* SingleFilePlayer.swift */; }; E2FE0F3A2D2B3E4F002963B7 /* AudioPlayerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F392D2B3E4E002963B7 /* AudioPlayerSettings.swift */; }; E2FE0F3C2D2B3F45002963B7 /* AudioPlayerSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F3B2D2B3F42002963B7 /* AudioPlayerSettingsFile.swift */; }; E2FE0F3E2D2B4225002963B7 /* AudioSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F3D2D2B4225002963B7 /* AudioSettingsDetailView.swift */; }; - E2FE0F402D2B45D3002963B7 /* SwiftProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F3F2D2B45CD002963B7 /* SwiftProcessor.swift */; }; - E2FE0F422D2B4821002963B7 /* OtherCodeProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F412D2B480B002963B7 /* OtherCodeProcessor.swift */; }; + E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F3F2D2B45CD002963B7 /* SwiftBlock.swift */; }; + E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F412D2B480B002963B7 /* OtherCodeBlock.swift */; }; E2FE0F462D2BC777002963B7 /* MarkdownImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F452D2BC772002963B7 /* MarkdownImageProcessor.swift */; }; E2FE0F482D2BC7D1002963B7 /* MarkdownProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F472D2BC7CD002963B7 /* MarkdownProcessor.swift */; }; E2FE0F4B2D2BCCAA002963B7 /* MarkdownHeadlineProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F4A2D2BCCA5002963B7 /* MarkdownHeadlineProcessor.swift */; }; @@ -238,6 +238,9 @@ E2FE0F572D2BCFD4002963B7 /* BlockLineProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F562D2BCFD4002963B7 /* BlockLineProcessor.swift */; }; E2FE0F592D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F582D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift */; }; E2FE0F5B2D2BCFF2002963B7 /* KeyedBlockProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F5A2D2BCFF2002963B7 /* KeyedBlockProcessor.swift */; }; + E2FE0F5E2D2BE190002963B7 /* FileResourceFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F5D2D2BE18B002963B7 /* FileResourceFile.swift */; }; + E2FE0F602D2C0422002963B7 /* VideoBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F5F2D2C041E002963B7 /* VideoBlock.swift */; }; + E2FE0F622D2C0D8D002963B7 /* VersionedVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F612D2C0D8D002963B7 /* VersionedVideo.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -448,14 +451,14 @@ E2FE0F292D2AFBE3002963B7 /* ImageCompareIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCompareIcons.swift; sourceTree = ""; }; E2FE0F2B2D2B1196002963B7 /* ImageCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommand.swift; sourceTree = ""; }; E2FE0F302D2B1952002963B7 /* PartialSvgImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartialSvgImage.swift; sourceTree = ""; }; - E2FE0F322D2B265F002963B7 /* AudioBlockProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBlockProcessor.swift; sourceTree = ""; }; + E2FE0F322D2B265F002963B7 /* AudioBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBlock.swift; sourceTree = ""; }; E2FE0F352D2B27F6002963B7 /* BlockProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockProcessor.swift; sourceTree = ""; }; E2FE0F372D2B32ED002963B7 /* SingleFilePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleFilePlayer.swift; sourceTree = ""; }; E2FE0F392D2B3E4E002963B7 /* AudioPlayerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerSettings.swift; sourceTree = ""; }; E2FE0F3B2D2B3F42002963B7 /* AudioPlayerSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerSettingsFile.swift; sourceTree = ""; }; E2FE0F3D2D2B4225002963B7 /* AudioSettingsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSettingsDetailView.swift; sourceTree = ""; }; - E2FE0F3F2D2B45CD002963B7 /* SwiftProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftProcessor.swift; sourceTree = ""; }; - E2FE0F412D2B480B002963B7 /* OtherCodeProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherCodeProcessor.swift; sourceTree = ""; }; + E2FE0F3F2D2B45CD002963B7 /* SwiftBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBlock.swift; sourceTree = ""; }; + E2FE0F412D2B480B002963B7 /* OtherCodeBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherCodeBlock.swift; sourceTree = ""; }; E2FE0F452D2BC772002963B7 /* MarkdownImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownImageProcessor.swift; sourceTree = ""; }; E2FE0F472D2BC7CD002963B7 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = ""; }; E2FE0F4A2D2BCCA5002963B7 /* MarkdownHeadlineProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHeadlineProcessor.swift; sourceTree = ""; }; @@ -467,6 +470,9 @@ E2FE0F562D2BCFD4002963B7 /* BlockLineProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockLineProcessor.swift; sourceTree = ""; }; E2FE0F582D2BCFE4002963B7 /* OrderedKeyBlockProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedKeyBlockProcessor.swift; sourceTree = ""; }; E2FE0F5A2D2BCFF2002963B7 /* KeyedBlockProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyedBlockProcessor.swift; sourceTree = ""; }; + E2FE0F5D2D2BE18B002963B7 /* FileResourceFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileResourceFile.swift; sourceTree = ""; }; + E2FE0F5F2D2C041E002963B7 /* VideoBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoBlock.swift; sourceTree = ""; }; + E2FE0F612D2C0D8D002963B7 /* VersionedVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionedVideo.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -509,7 +515,7 @@ E2A37D102CE537670000979F /* PageFile.swift */, E2A37D142CE68BEA0000979F /* PostFile.swift */, E2A37D162CE73F170000979F /* TagFile.swift */, - E22990212D0ED129009F8D77 /* TagOverviewFile.swift */, + E2FE0F5D2D2BE18B002963B7 /* FileResourceFile.swift */, ); path = Model; sourceTree = ""; @@ -525,6 +531,7 @@ E25DA5352D0041E200AEF16D /* PostSettingsFile.swift */, E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */, E21850342CFAFA570090B18B /* SettingsFile.swift */, + E22990212D0ED129009F8D77 /* TagOverviewFile.swift */, ); path = Settings; sourceTree = ""; @@ -577,6 +584,7 @@ E29D31212D0363FA0051B7F4 /* ContentButtons.swift */, E29D311F2D0320E20051B7F4 /* ContentLabels.swift */, E2FE0F272D2AFB0A002963B7 /* ImageCompare.swift */, + E2FE0F612D2C0D8D002963B7 /* VersionedVideo.swift */, ); path = ContentElements; sourceTree = ""; @@ -919,10 +927,11 @@ isa = PBXGroup; children = ( E2FE0F5C2D2BD006002963B7 /* Types */, - E2FE0F322D2B265F002963B7 /* AudioBlockProcessor.swift */, + E2FE0F322D2B265F002963B7 /* AudioBlock.swift */, E2FE0F542D2BCFC4002963B7 /* ContentBlock.swift */, - E2FE0F412D2B480B002963B7 /* OtherCodeProcessor.swift */, - E2FE0F3F2D2B45CD002963B7 /* SwiftProcessor.swift */, + E2FE0F412D2B480B002963B7 /* OtherCodeBlock.swift */, + E2FE0F3F2D2B45CD002963B7 /* SwiftBlock.swift */, + E2FE0F5F2D2C041E002963B7 /* VideoBlock.swift */, ); path = Blocks; sourceTree = ""; @@ -1167,19 +1176,21 @@ E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */, E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */, E2FE0F0D2D268A09002963B7 /* PostListPageGeneratorSource.swift in Sources */, - E2FE0F402D2B45D3002963B7 /* SwiftProcessor.swift in Sources */, + E2FE0F402D2B45D3002963B7 /* SwiftBlock.swift in Sources */, E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */, E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */, E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */, E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */, - E2FE0F422D2B4821002963B7 /* OtherCodeProcessor.swift in Sources */, + E2FE0F422D2B4821002963B7 /* OtherCodeBlock.swift in Sources */, E21850332CFAFA2F0090B18B /* Settings.swift in Sources */, E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */, E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */, E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */, E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */, + E2FE0F622D2C0D8D002963B7 /* VersionedVideo.swift in Sources */, E2A21C202CB28ED20060935B /* MockImage.swift in Sources */, E2FE0EEE2D1C22F3002963B7 /* MarkdownLinkProcessor.swift in Sources */, + E2FE0F602D2C0422002963B7 /* VideoBlock.swift in Sources */, E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */, E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */, E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */, @@ -1195,6 +1206,7 @@ E29D31B82D0DAC250051B7F4 /* ButtonCommand.swift in Sources */, E29D31962D0C186E0051B7F4 /* PathSettings.swift in Sources */, E2B85F412C4294790047CD0C /* PageHead.swift in Sources */, + E2FE0F5E2D2BE190002963B7 /* FileResourceFile.swift in Sources */, E2FE0F2A2D2AFBE6002963B7 /* ImageCompareIcons.swift in Sources */, E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */, E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */, @@ -1215,7 +1227,7 @@ E29D314B2D04FC950051B7F4 /* FileToAdd.swift in Sources */, E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */, E29D31B52D0DA8490051B7F4 /* PageIcon.swift in Sources */, - E2FE0F332D2B2665002963B7 /* AudioBlockProcessor.swift in Sources */, + E2FE0F332D2B2665002963B7 /* AudioBlock.swift in Sources */, E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */, E29D319B2D0C452B0051B7F4 /* PageIssue.swift in Sources */, E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */, diff --git a/CHDataManagement/Extensions/String+Extensions.swift b/CHDataManagement/Extensions/String+Extensions.swift index 4f58134..d110c5a 100644 --- a/CHDataManagement/Extensions/String+Extensions.swift +++ b/CHDataManagement/Extensions/String+Extensions.swift @@ -95,8 +95,10 @@ extension String { /** Split the string at the first occurence of the separator + + If the String does not contain the separator, then `before` will contain the whole string, and `after` will be empty */ - func splitAtFirst(_ separator: String) -> (String, String) { + func splitAtFirst(_ separator: String) -> (before: String, after: String) { let parts = components(separatedBy: separator) return (parts.first!, parts.dropFirst().joined(separator: separator)) } diff --git a/CHDataManagement/Generator/Blocks/AudioBlock.swift b/CHDataManagement/Generator/Blocks/AudioBlock.swift new file mode 100644 index 0000000..eb52f9a --- /dev/null +++ b/CHDataManagement/Generator/Blocks/AudioBlock.swift @@ -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 + } +} diff --git a/CHDataManagement/Generator/Blocks/AudioBlockProcessor.swift b/CHDataManagement/Generator/Blocks/AudioBlockProcessor.swift index 4d177c7..eb52f9a 100644 --- a/CHDataManagement/Generator/Blocks/AudioBlockProcessor.swift +++ b/CHDataManagement/Generator/Blocks/AudioBlockProcessor.swift @@ -1,5 +1,5 @@ -struct AudioBlockProcessor: KeyedBlockProcessor { +struct AudioBlock: KeyedBlockProcessor { enum Key: String { case name diff --git a/CHDataManagement/Generator/Blocks/ContentBlock.swift b/CHDataManagement/Generator/Blocks/ContentBlock.swift index 506c155..bbcfb34 100644 --- a/CHDataManagement/Generator/Blocks/ContentBlock.swift +++ b/CHDataManagement/Generator/Blocks/ContentBlock.swift @@ -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 } } } diff --git a/CHDataManagement/Generator/Blocks/OtherCodeProcessor.swift b/CHDataManagement/Generator/Blocks/OtherCodeBlock.swift similarity index 93% rename from CHDataManagement/Generator/Blocks/OtherCodeProcessor.swift rename to CHDataManagement/Generator/Blocks/OtherCodeBlock.swift index dc47131..73309a5 100644 --- a/CHDataManagement/Generator/Blocks/OtherCodeProcessor.swift +++ b/CHDataManagement/Generator/Blocks/OtherCodeBlock.swift @@ -1,5 +1,5 @@ -struct OtherCodeProcessor { +struct OtherCodeBlock { private let codeHighlightFooter = "" diff --git a/CHDataManagement/Generator/Blocks/SwiftProcessor.swift b/CHDataManagement/Generator/Blocks/SwiftBlock.swift similarity index 93% rename from CHDataManagement/Generator/Blocks/SwiftProcessor.swift rename to CHDataManagement/Generator/Blocks/SwiftBlock.swift index 0b1b8b4..1629aaf 100644 --- a/CHDataManagement/Generator/Blocks/SwiftProcessor.swift +++ b/CHDataManagement/Generator/Blocks/SwiftBlock.swift @@ -1,6 +1,6 @@ import Splash -struct SwiftBlockProcessor: BlockProcessor { +struct SwiftBlock: BlockProcessor { static let blockId: ContentBlock = .swift diff --git a/CHDataManagement/Generator/Blocks/Types/BlockLineProcessor.swift b/CHDataManagement/Generator/Blocks/Types/BlockLineProcessor.swift index 4081a28..2c55f9a 100644 --- a/CHDataManagement/Generator/Blocks/Types/BlockLineProcessor.swift +++ b/CHDataManagement/Generator/Blocks/Types/BlockLineProcessor.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) } } diff --git a/CHDataManagement/Generator/Blocks/VideoBlock.swift b/CHDataManagement/Generator/Blocks/VideoBlock.swift new file mode 100644 index 0000000..6009866 --- /dev/null +++ b/CHDataManagement/Generator/Blocks/VideoBlock.swift @@ -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 + } +} diff --git a/CHDataManagement/Generator/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift index a14db81..2f5dedc 100644 --- a/CHDataManagement/Generator/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -9,33 +9,15 @@ final class ImageGenerator { private let settings: Settings - private var generatedImages: [String : Set] = [:] - 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 = [] /** @@ -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) { - 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 } diff --git a/CHDataManagement/Generator/ImageVersion.swift b/CHDataManagement/Generator/ImageVersion.swift index daac315..25c1b19 100644 --- a/CHDataManagement/Generator/ImageVersion.swift +++ b/CHDataManagement/Generator/ImageVersion.swift @@ -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 { diff --git a/CHDataManagement/Generator/Markdown/MarkdownCodeProcessor.swift b/CHDataManagement/Generator/Markdown/MarkdownCodeProcessor.swift index a032ceb..3326327 100644 --- a/CHDataManagement/Generator/Markdown/MarkdownCodeProcessor.swift +++ b/CHDataManagement/Generator/Markdown/MarkdownCodeProcessor.swift @@ -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 = [ + "bash", "nginx", "json", "css", "html", "markdown", "" + ] } diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift index aacd0a2..0ab1546 100644 --- a/CHDataManagement/Model/Content+Generation.swift +++ b/CHDataManagement/Model/Content+Generation.swift @@ -82,7 +82,6 @@ extension Content { results.failed(image: image) } - imageGenerator.save() imageGenerator.writeAvifCommandScript() //let images = Set(self.images.map { $0.id }) //imageGenerator.recalculateGeneratedImages(by: images) diff --git a/CHDataManagement/Model/Content+Load.swift b/CHDataManagement/Model/Content+Load.swift index 434d1a8..1d7fd17 100644 --- a/CHDataManagement/Model/Content+Load.swift +++ b/CHDataManagement/Model/Content+Load.swift @@ -45,9 +45,6 @@ extension Content { } let settings = storage.loadSettings() ?? .default - let imageDescriptions = storage.loadFileDescriptions() - .default([]) - .reduce(into: [:]) { $0[$1.fileId] = $1 } guard let tagData = storage.loadAllTags() else { print("Failed to load file tags") @@ -73,30 +70,14 @@ extension Content { } if fileList.isEmpty { print("No files loaded") } - let externalFiles = storage.loadExternalFileList() ?? [] - if externalFiles.isEmpty { print("No external files loaded") } - print("Loaded data from disk, processing...") // All data loaded from storage, start constructing the data model - var files: [String : FileResource] = fileList.reduce(into: [:]) { files, fileId in - let descriptions = imageDescriptions[fileId] - files[fileId] = FileResource( - content: self, - id: fileId, - isExternallyStored: false, - en: descriptions?.english ?? "", - de: descriptions?.german ?? "") - } - - for fileId in externalFiles { - let descriptions = imageDescriptions[fileId] - files[fileId] = FileResource( - content: self, - id: fileId, - isExternallyStored: true, - en: descriptions?.english ?? "", - de: descriptions?.german ?? "") + let files: [String : FileResource] = fileList.reduce(into: [:]) { (files, data) in + let fileId = data.key + let fileData = data.value.data + let isExternal = data.value.isExternal + files[fileId] = FileResource(content: self, id: fileId, file: fileData, isExternalFile: isExternal) } let images = files.filter { $0.value.type.isImage } diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index 41522e4..9aa03c7 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -13,21 +13,7 @@ extension Content { failedSaves += posts.count { !storage.save(post: $0.postFile, for: $0.id) } failedSaves += tags.count { !storage.save(tagMetadata: $0.file, for: $0.id) } failedSaves.increment(!storage.save(settings: settings.file(tagOverview: tagOverview))) - - let fileDescriptions: [FileDescriptions] = files.sorted().compactMap { file in - guard !file.english.isEmpty || !file.german.isEmpty else { - return nil - } - return FileDescriptions( - fileId: file.id, - german: file.german.nonEmpty, - english: file.english.nonEmpty) - } - - failedSaves.increment(!storage.save(fileDescriptions: fileDescriptions)) - - let externalFileList = files.filter { $0.isExternallyStored }.map { $0.id } - failedSaves.increment(!storage.save(externalFileList: externalFileList)) + failedSaves += files.count { !storage.save(fileInfo: $0.fileInfo, for: $0.id) } if failedSaves > 0 { print("Save partially failed with \(failedSaves) errors") diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index 273e2a7..1cbf86a 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -14,6 +14,24 @@ final class FileResource: Item { @Published var english: String + @Published + var version: String? + + @Published + var sourceUrl: String? + + @Published + var generatedImageVersions: Set + + @Published + var customOutputPath: String? + + @Published + var addedDate: Date + + @Published + var modifiedDate: Date + /// The dimensions of the image @Published var imageDimensions: CGSize? = nil @@ -22,11 +40,41 @@ final class FileResource: Item { @Published var fileSize: Int? = nil - init(content: Content, id: String, isExternallyStored: Bool, en: String, de: String) { + init(content: Content, + id: String, + isExternallyStored: Bool, + english: String?, + german: String?, + version: String? = nil, + sourceUrl: String? = nil, + generatedImageVersions: Set = [], + customOutputPath: String? = nil, + addedDate: Date = .now, + modifiedDate: Date = .now) { self.type = FileType(fileExtension: id.fileExtension) - self.english = en - self.german = de self.isExternallyStored = isExternallyStored + self.german = german ?? "" + self.english = english ?? "" + self.version = version + self.sourceUrl = sourceUrl + self.generatedImageVersions = generatedImageVersions + self.customOutputPath = customOutputPath + self.addedDate = addedDate + self.modifiedDate = modifiedDate + super.init(content: content, id: id) + } + + init(content: Content, id: String, file: FileResourceFile, isExternalFile: Bool) { + self.type = FileType(fileExtension: id.fileExtension) + self.isExternallyStored = isExternalFile + self.german = file.germanDescription ?? "" + self.english = file.englishDescription ?? "" + self.version = file.version + self.sourceUrl = file.sourceUrl + self.generatedImageVersions = Set(file.generatedImages ?? []) + self.customOutputPath = file.customOutputPath + self.addedDate = file.addedDate + self.modifiedDate = file.modifiedDate super.init(content: content, id: id) } @@ -38,6 +86,12 @@ final class FileResource: Item { self.english = "A test image included in the bundle" self.german = "Ein Testbild aus dem Bundle" self.isExternallyStored = true + self.version = nil + self.sourceUrl = nil + self.generatedImageVersions = [] + self.customOutputPath = nil + self.addedDate = Date.now + self.modifiedDate = Date.now super.init(content: .mock, id: resourceImage) // TODO: Add images to mock } @@ -143,8 +197,10 @@ final class FileResource: Item { func removeGeneratedImages() { guard type.isImage else { return } - content.imageGenerator.removeVersions(of: id) - content.storage.deleteInOutputFolder(outputImageFolder) + guard content.storage.deleteInOutputFolder(outputImageFolder) else { + return + } + self.generatedImageVersions = [] } private var failureImage: Image { @@ -236,6 +292,21 @@ final class FileResource: Item { } } +extension FileResource { + + var fileInfo: FileResourceFile { + .init( + englishDescription: english.nonEmpty, + germanDescription: german.nonEmpty, + generatedImages: generatedImageVersions.sorted().nonEmpty, + customOutputPath: customOutputPath, + version: version, + sourceUrl: sourceUrl, + addedDate: addedDate, + modifiedDate: modifiedDate) + } +} + extension FileResource: LocalizedItem { } diff --git a/CHDataManagement/Page Elements/ContentElements/VersionedVideo.swift b/CHDataManagement/Page Elements/ContentElements/VersionedVideo.swift new file mode 100644 index 0000000..bb4adac --- /dev/null +++ b/CHDataManagement/Page Elements/ContentElements/VersionedVideo.swift @@ -0,0 +1,19 @@ + +struct VersionedVideo: HtmlProducer { + + let sources: [VideoBlock.Source] + + let options: [VideoBlock.Option] + + func populate(_ result: inout String) { + result += "Video not supported." + for source in sources.sorted(using: { $0.type.order }) { + result += "" + } + result += "" + } + + private var optionString: String { + options.map { " " + $0.rawValue }.joined() + } +} diff --git a/CHDataManagement/Preview Content/File+Mock.swift b/CHDataManagement/Preview Content/File+Mock.swift index 88f4a73..0e9e671 100644 --- a/CHDataManagement/Preview Content/File+Mock.swift +++ b/CHDataManagement/Preview Content/File+Mock.swift @@ -2,6 +2,11 @@ extension FileResource { static var mock: FileResource { - .init(content: .mock, id: "my-file.txt", isExternallyStored: true, en: "Some text file", de: "Eine Textdatei") + .init( + content: .mock, + id: "my-file.txt", + isExternallyStored: true, + english: "Some text file", + german: "Eine Textdatei") } } diff --git a/CHDataManagement/Storage/Model/FileResourceFile.swift b/CHDataManagement/Storage/Model/FileResourceFile.swift new file mode 100644 index 0000000..6cca463 --- /dev/null +++ b/CHDataManagement/Storage/Model/FileResourceFile.swift @@ -0,0 +1,36 @@ +import Foundation + +/** + This struct holds metadata about a file resource that is stored in the content folder. + */ +struct FileResourceFile { + + /// The file/image description in German + let englishDescription: String? + + /// The file/image description in English + let germanDescription: String? + + /// The list of generated image versions for this image + let generatedImages: [String]? + + /// A custom file path in the output folder where this file is located + let customOutputPath: String? + + /// A version string of this resource, mostly for assets + let version: String? + + /// A URL where the resource was copied/downloaded from + let sourceUrl: String? + + /// The date when the file was added + let addedDate: Date + + /// The date when the file was last modified + let modifiedDate: Date +} + + +extension FileResourceFile: Codable { + +} diff --git a/CHDataManagement/Storage/Model/TagOverviewFile.swift b/CHDataManagement/Storage/Model/Settings/TagOverviewFile.swift similarity index 100% rename from CHDataManagement/Storage/Model/TagOverviewFile.swift rename to CHDataManagement/Storage/Model/Settings/TagOverviewFile.swift diff --git a/CHDataManagement/Storage/SecurityBookmark.swift b/CHDataManagement/Storage/SecurityBookmark.swift index 6883f5f..8b18b83 100644 --- a/CHDataManagement/Storage/SecurityBookmark.swift +++ b/CHDataManagement/Storage/SecurityBookmark.swift @@ -162,13 +162,17 @@ struct SecurityBookmark { ifFileExists overwrite: OverwriteBehaviour = .fail) -> Bool { with(relativePath: relativeSource) { source in if !exists(source) { - return failIfMissing + if !failIfMissing { return true } + print("ContentScope: Could not find file \(relativeSource) to move") + return false } let destination = url.appending(path: relativeDestination) if exists(destination) { switch overwrite { - case .fail: return false + case .fail: + print("ContentScope: Could not move file \(relativeSource), file exists") + return false case .skip: return true case .write: break case .writeIfChanged: diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 80795c0..d0853b3 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -18,18 +18,14 @@ final class Storage: ObservableObject { private let filesFolderName = "files" + private let fileInfoFolderName = "file metadata" + private let pagesFolderName = "pages" private let postsFolderName = "posts" private let tagsFolderName = "tags" - private let externalFileListName = "external-files.json" - - private let fileDescriptionFilename = "file-descriptions.json" - - private let generatedImagesListName = "generated-images.json" - private let outputPathFileName = "outputPath.bin" private let settingsDataFileName = "settings.json" @@ -118,7 +114,8 @@ final class Storage: ObservableObject { func move(page pageId: String, to newId: String) -> Bool { guard let contentScope else { return false } - guard contentScope.move(pageFileName(pageId), to: pageFileName(newId)) else { + guard contentScope.move(pageMetadataPath(page: pageId), to: pageMetadataPath(page: newId)) else { + print("Failed to move page file \(pageId)") return false } // Move the existing content files @@ -127,9 +124,10 @@ final class Storage: ObservableObject { // Copy as many files as possible, since metadata was already moved // Don't fail early if !contentScope.move( - pageContentFileName(pageId, language), - to: pageContentFileName(newId, language), + pageContentPath(page: pageId, language: language), + to: pageContentPath(page: newId, language: language), failIfMissing: false) { + print("Failed to move content file \(language) of page \(pageId)") result = false } } @@ -214,17 +212,6 @@ final class Storage: ObservableObject { return contentScope.move(tagFilePath(tag: tagId), to: tagFilePath(tag: newId)) } - // MARK: File descriptions - - func loadFileDescriptions() -> [FileDescriptions]? { - contentScope?.decode(at: fileDescriptionFilename) - } - - func save(fileDescriptions: [FileDescriptions]) -> Bool { - guard let contentScope else { return false } - return contentScope.encode(fileDescriptions, to: fileDescriptionFilename) - } - // MARK: Files func size(of file: String) -> Int? { @@ -241,9 +228,22 @@ final class Storage: ObservableObject { } /** - Delete a file resource from the content folder + Completely delete a file resource from the content folder */ func delete(file fileId: String) -> Bool { + guard let contentScope else { + return false + } + guard contentScope.deleteFile(at: filePath(file: fileId)) else { + return false + } + return contentScope.deleteFile(at: fileInfoPath(file: fileId)) + } + + /** + Delete a file resource from the content folder, making it an external file + */ + func removeFileContent(file fileId: String) -> Bool { guard let contentScope else { return false } @@ -266,6 +266,11 @@ final class Storage: ObservableObject { filesFolderName + "/" + fileId } + /// The path to a metadata file for a file resource + private func fileInfoPath(file fileId: String) -> String { + fileInfoFolderName + "/" + fileId + ".json" + } + @discardableResult func deleteInOutputFolder(_ relativePath: String) -> Bool { guard let outputScope else { return false } @@ -298,8 +303,30 @@ final class Storage: ObservableObject { to: relativeOutputPath, of: outputScope) } - func loadAllFiles() -> [String]? { - contentScope?.fileNames(inRelativeFolder: filesFolderName) + /** + Load the file metadata from the content folder. + + - Returns: A dictionary with the file ids as keys and the metadata file as a value. + */ + func loadAllFiles() -> [String : (data: FileResourceFile, isExternal: Bool)]? { + guard let contentScope else { return nil } + guard let list: [String : FileResourceFile] = contentScope.decodeJsonFiles(in: fileInfoFolderName) else { + return nil + } + guard let existingFiles = contentScope.fileNames(inRelativeFolder: filesFolderName).map(Set.init) else { + return nil + } + return Dictionary(uniqueKeysWithValues: list.map { fileId, data in + let isExternal = !existingFiles.contains(fileId) + return (fileId, (data: data, isExternal: isExternal)) + }) + } + + @discardableResult + func save(fileInfo: FileResourceFile, for fileId: String) -> Bool { + guard let contentScope else { return false } + let path = fileInfoPath(file: fileId) + return contentScope.encode(fileInfo, to: path) } /** @@ -327,18 +354,6 @@ final class Storage: ObservableObject { return contentScope.readData(at: path) } - // MARK: External file list - - func loadExternalFileList() -> [String]? { - guard let contentScope else { return nil } - return contentScope.decode(at: externalFileListName) - } - - func save(externalFileList: [String]) -> Bool { - guard let contentScope else { return false } - return contentScope.encode(externalFileList.sorted(), to: externalFileListName) - } - // MARK: Settings func loadSettings() -> SettingsFile? { @@ -353,17 +368,8 @@ final class Storage: ObservableObject { // MARK: Image generation data - func loadListOfGeneratedImages() -> [String : Set]? { - guard let contentScope else { return nil } - return contentScope.decode(at: generatedImagesListName) - } - - func save(listOfGeneratedImages: [String : Set]) -> Bool { - guard let contentScope else { return false } - return contentScope.encode(listOfGeneratedImages, to: generatedImagesListName) - } - func calculateImages(generatedBy imageSet: Set, in folder: String) -> [String : Set] { + #warning("TODO: Move to file resource") guard let outputScope else { return [:] } guard let allImages = outputScope.fileNames(inRelativeFolder: folder) else { print("Failed to get list of generated images in output folder") @@ -375,7 +381,6 @@ final class Storage: ObservableObject { } print("Found \(allImages.count) generated images") let images = Set(allImages) - #warning("TODO: Fix counting generated images") return imageSet.reduce(into: [:]) { result, imageName in let prefix = imageName.fileNameWithoutExtension + "@" let versions = images.filter { $0.hasPrefix(prefix) } diff --git a/CHDataManagement/Views/Files/AddFileView.swift b/CHDataManagement/Views/Files/AddFileView.swift index f7d0188..44ddd6d 100644 --- a/CHDataManagement/Views/Files/AddFileView.swift +++ b/CHDataManagement/Views/Files/AddFileView.swift @@ -98,7 +98,9 @@ struct AddFileView: View { content: content, id: file.uniqueId, isExternallyStored: file.url == nil, - en: "", de: "") + english: "", + german: "") + content.add(resource) selectedFile = resource } diff --git a/CHDataManagement/Views/Files/FileDetailView.swift b/CHDataManagement/Views/Files/FileDetailView.swift index 4a75628..f6c294f 100644 --- a/CHDataManagement/Views/Files/FileDetailView.swift +++ b/CHDataManagement/Views/Files/FileDetailView.swift @@ -1,10 +1,42 @@ import SwiftUI +import SFSafeSymbols + +private extension Button { + + init(_ symbol: SFSymbol, action: @escaping @MainActor () -> Void) where Label == Image { + self.init(action: action, label: { Image(systemSymbol: symbol) }) + } +} + +private struct ButtonIcon: View { + + let symbol: SFSymbol + + let action: @MainActor () -> Void + + init(_ symbol: SFSymbol, action: @escaping @MainActor () -> Void) { + self.symbol = symbol + self.action = action + } + + var body: some View { + Button(action: action) { + Image(systemSymbol: symbol) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 20) + } + } +} struct FileDetailView: View { @EnvironmentObject private var content: Content + @Environment(\.language) + private var language + @ObservedObject var file: FileResource @@ -15,58 +47,97 @@ struct FileDetailView: View { private var selectedFile: FileResource? var body: some View { - VStack(alignment: .leading) { - DetailTitle( - title: "File", - text: "A file that can be used in a post or page") - + ScrollView { VStack(alignment: .leading) { - Button("Show in Finder", action: showFileInFinder) - Button("Mark as changed", action: markFileAsChanged) - Button("Delete resource", action: deleteFile) - if file.isExternallyStored { - Button("Import file", action: replaceFile) - } else { - Button("Replace file", action: replaceFile) - Button("Make external", action: convertToExternal) - } - } + DetailTitle( + title: "File", + text: "A file that can be used in a post or page") - IdPropertyView( - id: $file.id, - title: "Name", - footer: "The unique name of the file, which is also used to reference it in posts and pages.", - validation: file.isValid, - update: { file.update(id: $0) }) - - StringPropertyView( - title: "German Description", - text: $file.german, - footer: "The description for the file in German. Descriptions are used for images and to explain the content of a file.") - - StringPropertyView( - title: "English Description", - text: $file.english, - footer: "The description for the file in English. Descriptions are used for images and to explain the content of a file.") - - if let imageDimensions = file.imageDimensions { - GenericPropertyView(title: "Image dimensions") { - Text("\(Int(imageDimensions.width)) x \(Int(imageDimensions.height)) (\(file.aspectRatio))") + GenericPropertyView(title: "Actions") { + HStack(spacing: 10) { + ButtonIcon(.folder, action: showFileInFinder) + ButtonIcon(.arrowClockwise, action: markFileAsChanged) + if file.isExternallyStored { + ButtonIcon(.squareAndArrowDown, action: replaceFile) + } else { + ButtonIcon(.arrowLeftArrowRight, action: replaceFile) + ButtonIcon(.squareDashed, action: convertToExternal) + } + ButtonIcon(.trash, action: deleteFile) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .foregroundStyle(.blue) } - #warning("Add button to show image versions") - } - if let fileSize = file.fileSize { - GenericPropertyView(title: "File size") { - Text(formatBytes(fileSize)) + + IdPropertyView( + id: $file.id, + title: "Name", + footer: "The unique name of the file, which is also used to reference it in posts and pages.", + validation: file.isValid, + update: { file.update(id: $0) }) + + switch language { + case .english: + StringPropertyView( + title: "Description", + text: $file.english, + footer: "The description for the file. Descriptions are used for images and to explain the content of a file.") + case .german: + StringPropertyView( + title: "Description", + text: $file.german, + footer: "The description for the file. Descriptions are used for images and to explain the content of a file.") } - } - Spacer() - }.padding() - .onAppear { - if file.fileSize == nil { - file.determineFileSize() + + OptionalStringPropertyView( + title: "Version", + text: $file.version, + footer: "The version of this file.") + + OptionalStringPropertyView( + title: "Source URL", + text: $file.sourceUrl, + footer: "The url where this file can be downloaded from.") + + OptionalStringPropertyView( + title: "Custom output path", + text: $file.customOutputPath, + footer: "A custom path where the file is stored in the output folder") + + if let imageDimensions = file.imageDimensions { + GenericPropertyView(title: "Image dimensions") { + Text("\(Int(imageDimensions.width)) x \(Int(imageDimensions.height)) (\(file.aspectRatio))") + } } + if let fileSize = file.fileSize { + GenericPropertyView(title: "File size") { + Text(formatBytes(fileSize)) + } + } + GenericPropertyView(title: "Added") { + Text(file.addedDate.formatted()) + } + + GenericPropertyView(title: "Modified") { + Text(file.modifiedDate.formatted()) + } + + if !file.generatedImageVersions.isEmpty { + GenericPropertyView(title: "Image versions") { + VStack(alignment: .leading) { + ForEach(file.generatedImageVersions.sorted(), id: \.self) { version in + Text(version) + } + } + } + } + }.padding() + }.onAppear { + if file.fileSize == nil { + file.determineFileSize() } + } } private func formatBytes(_ bytes: Int) -> String { @@ -133,7 +204,7 @@ struct FileDetailView: View { return } - guard content.storage.delete(file: file.id) else { + guard content.storage.removeFileContent(file: file.id) else { print("File '\(file.id)': Failed to delete file to make it external") return } diff --git a/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift b/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift index dd2f198..41750de 100644 --- a/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift +++ b/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift @@ -184,8 +184,8 @@ struct PageIssueView: View { content: content, id: fileId, isExternallyStored: true, - en: "", - de: "") + english: "", + german: "") content.add(file) retryPageCheck()