From 1e4682dad1dac06df54bf4b5767fa78ae2e0e399 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Wed, 25 Dec 2024 18:06:05 +0100 Subject: [PATCH] Full generation, file type cleanup --- CHDataManagement.xcodeproj/project.pbxproj | 74 ++--- CHDataManagement/Extensions/Array+Split.swift | 4 + .../Extensions/ConvertThrowing.swift | 11 + .../Extensions/Sequence+Sorted.swift | 17 + ...tAnomaly.swift => GenerationAnomaly.swift} | 14 +- .../Generator/GenerationResults.swift | 199 ++++++++++++ .../Generator/HeaderElement.swift | 8 +- .../Generator/ImageGenerator.swift | 71 +--- CHDataManagement/Generator/ImageJob.swift | 65 +++- .../Generator/LocalizedWebsiteGenerator.swift | 97 ------ .../Page Content/AudioPlayerCommand.swift | 19 +- .../Page Content/ButtonCommand.swift | 12 +- .../Page Content/InlineLinkProcessor.swift | 75 +++++ .../Page Content/LabelsCommand.swift | 2 +- .../Generator/PageContentGenerator.swift | 143 +++------ .../Generator/PageGenerationResults.swift | 239 +++++++++++--- .../Generator/PageGenerator.swift | 21 +- .../Generator/PostListPageGenerator.swift | 50 +-- CHDataManagement/Main/MainView.swift | 6 +- .../Model/Content+Generation.swift | 303 ++++++++++++------ CHDataManagement/Model/Content+Load.swift | 52 +-- CHDataManagement/Model/Content+Save.swift | 8 +- CHDataManagement/Model/Content.swift | 23 +- CHDataManagement/Model/FileResource.swift | 26 +- CHDataManagement/Model/FileType.swift | 240 ++++++++++++++ CHDataManagement/Model/Item/ItemId.swift | 11 +- CHDataManagement/Model/Item/ItemType.swift | 57 +++- CHDataManagement/Model/LocalizedPage.swift | 29 -- CHDataManagement/Model/Page.swift | 20 +- CHDataManagement/Model/Post.swift | 28 +- CHDataManagement/Model/Tag.swift | 2 +- .../Model/Types/CodeFileType.swift | 21 -- CHDataManagement/Model/Types/FileType.swift | 116 ------- .../Model/Types/ImageFileType.swift | 41 --- .../Model/Types/ModelFileType.swift | 21 -- .../Model/Types/ResourceFileType.swift | 54 ---- .../Model/Types/TextFileType.swift | 18 -- .../Model/Types/VideoFileType.swift | 30 -- .../Preview Content/Page+Mock.swift | 13 +- .../Storage/Model/FileOnDisk.swift | 25 -- CHDataManagement/Storage/Model/PageFile.swift | 22 +- .../Storage/Model/PageOnDisk.swift | 11 - .../Storage/Model/Settings/SettingsFile.swift | 9 +- CHDataManagement/Storage/Storage.swift | 6 + .../Views/Files/FileContentView.swift | 16 +- .../Views/Files/MultiFileSelectionView.swift | 130 ++++++++ .../Views/Generic/IdPropertyView.swift | 2 +- .../Views/Pages/AddPageView.swift | 3 +- .../Pages/LocalizedPageContentView.swift | 43 ++- .../Views/Pages/PageContentResultsView.swift | 23 +- .../Views/Pages/PageDetailView.swift | 59 +++- .../Views/Posts/PostImagesView.swift | 3 + .../{ => Content}/GenerationContentView.swift | 70 +++- .../Settings/Content/Pages/PageIssue.swift | 4 +- .../Content/Pages/PageIssueChecker.swift | 11 +- .../Content/Pages/PageIssueView.swift | 3 +- 56 files changed, 1577 insertions(+), 1103 deletions(-) create mode 100644 CHDataManagement/Extensions/ConvertThrowing.swift rename CHDataManagement/Generator/{PageContentAnomaly.swift => GenerationAnomaly.swift} (86%) create mode 100644 CHDataManagement/Generator/GenerationResults.swift delete mode 100644 CHDataManagement/Generator/LocalizedWebsiteGenerator.swift create mode 100644 CHDataManagement/Generator/Page Content/InlineLinkProcessor.swift create mode 100644 CHDataManagement/Model/FileType.swift delete mode 100644 CHDataManagement/Model/Types/CodeFileType.swift delete mode 100644 CHDataManagement/Model/Types/FileType.swift delete mode 100644 CHDataManagement/Model/Types/ImageFileType.swift delete mode 100644 CHDataManagement/Model/Types/ModelFileType.swift delete mode 100644 CHDataManagement/Model/Types/ResourceFileType.swift delete mode 100644 CHDataManagement/Model/Types/TextFileType.swift delete mode 100644 CHDataManagement/Model/Types/VideoFileType.swift delete mode 100644 CHDataManagement/Storage/Model/FileOnDisk.swift delete mode 100644 CHDataManagement/Storage/Model/PageOnDisk.swift create mode 100644 CHDataManagement/Views/Files/MultiFileSelectionView.swift rename CHDataManagement/Views/Settings/{ => Content}/GenerationContentView.swift (50%) diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 1f2ef50..03fd03a 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -9,15 +9,12 @@ /* Begin PBXBuildFile section */ E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850082CEE01BF0090B18B /* PagePickerView.swift */; }; E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500A2CEE02FA0090B18B /* Content+Mock.swift */; }; - E21850152CEE55D40090B18B /* FileOnDisk.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850142CEE55D40090B18B /* FileOnDisk.swift */; }; E21850172CEE55FC0090B18B /* FileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850162CEE55FB0090B18B /* FileType.swift */; }; - E21850192CEE561C0090B18B /* PageOnDisk.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850182CEE561B0090B18B /* PageOnDisk.swift */; }; E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218501C2CEE6CB30090B18B /* VerticalCenter.swift */; }; E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850222CF10C840090B18B /* TagSelectionView.swift */; }; E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850262CF3B42D0090B18B /* PostDetailView.swift */; }; E218502B2CF790B30090B18B /* PostContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502A2CF790AC0090B18B /* PostContentView.swift */; }; E218502D2CF791440090B18B /* PostImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502C2CF791440090B18B /* PostImagesView.swift */; }; - E218502F2CFAF69C0090B18B /* LocalizedWebsiteGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502E2CFAF6990090B18B /* LocalizedWebsiteGenerator.swift */; }; E21850332CFAFA2F0090B18B /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850322CFAFA200090B18B /* Settings.swift */; }; E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850342CFAFA570090B18B /* SettingsFile.swift */; }; E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */; }; @@ -65,7 +62,6 @@ E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */; }; E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */; }; E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5262CFF745200AEF16D /* URL+Extensions.swift */; }; - E25DA5292CFFBFBB00AEF16D /* ImageFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5282CFFBFB800AEF16D /* ImageFileType.swift */; }; E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */; }; E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */; }; E25DA5362D0041EB00AEF16D /* PostSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5352D0041E200AEF16D /* PostSettingsFile.swift */; }; @@ -81,7 +77,6 @@ E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */; }; E25DA57D2D01C67900AEF16D /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA57C2D01C67900AEF16D /* Ink */; }; E25DA5802D01C6AC00AEF16D /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA57F2D01C6AC00AEF16D /* Splash */; }; - E25DA5832D01C7A400AEF16D /* VideoFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5822D01C7A100AEF16D /* VideoFileType.swift */; }; E25DA5852D01C92700AEF16D /* ShorthandMarkdownKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */; }; E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */; }; E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58A2D020C9200AEF16D /* PageImage.swift */; }; @@ -119,10 +114,6 @@ E29D31532D0618740051B7F4 /* AddPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31522D0618700051B7F4 /* AddPageView.swift */; }; E29D31552D06D2CE0051B7F4 /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31542D06D2CB0051B7F4 /* TagListView.swift */; }; E29D31572D06D38B0051B7F4 /* AddTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31562D06D3880051B7F4 /* AddTagView.swift */; }; - E29D315B2D06D63E0051B7F4 /* ModelFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D315A2D06D63C0051B7F4 /* ModelFileType.swift */; }; - E29D315D2D06D6EA0051B7F4 /* TextFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D315C2D06D6EA0051B7F4 /* TextFileType.swift */; }; - E29D315F2D06D6F30051B7F4 /* CodeFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D315E2D06D6F30051B7F4 /* CodeFileType.swift */; }; - E29D31612D06D95C0051B7F4 /* ResourceFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31602D06D9570051B7F4 /* ResourceFileType.swift */; }; E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */; }; E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31682D0702670051B7F4 /* TextFileContentView.swift */; }; E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */; }; @@ -137,7 +128,7 @@ E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31882D0AED1B0051B7F4 /* ModelViewer.swift */; }; E29D318B2D0B07EE0051B7F4 /* ContentBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D318A2D0B07E60051B7F4 /* ContentBox.swift */; }; E29D318E2D0B2E680051B7F4 /* PageSettingsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D318D2D0B2E640051B7F4 /* PageSettingsContentView.swift */; }; - E29D31902D0B34870051B7F4 /* PageContentAnomaly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D318F2D0B34870051B7F4 /* PageContentAnomaly.swift */; }; + E29D31902D0B34870051B7F4 /* GenerationAnomaly.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D318F2D0B34870051B7F4 /* GenerationAnomaly.swift */; }; E29D31942D0B7D280051B7F4 /* SvgImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31932D0B7D250051B7F4 /* SvgImage.swift */; }; E29D31962D0C186E0051B7F4 /* PathSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31952D0C18690051B7F4 /* PathSettings.swift */; }; E29D31982D0C19340051B7F4 /* PathSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31972D0C19300051B7F4 /* PathSettingsFile.swift */; }; @@ -198,20 +189,21 @@ E2DD047E2C276F32003BFF1F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */; }; E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFA2CA4A6570019C2AF /* Content.swift */; }; E2E06E002CA4A8F00019C2AF /* Page+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */; }; + E2FE0EE62D15A0B5002963B7 /* GenerationResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */; }; + E2FE0EE82D16D4A3002963B7 /* ConvertThrowing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */; }; + E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0EEB2D1C124E002963B7 /* MultiFileSelectionView.swift */; }; + E2FE0EEE2D1C22F3002963B7 /* InlineLinkProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0EED2D1C22EF002963B7 /* InlineLinkProcessor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ E21850082CEE01BF0090B18B /* PagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagePickerView.swift; sourceTree = ""; }; E218500A2CEE02FA0090B18B /* Content+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Mock.swift"; sourceTree = ""; }; - E21850142CEE55D40090B18B /* FileOnDisk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileOnDisk.swift; sourceTree = ""; }; E21850162CEE55FB0090B18B /* FileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileType.swift; sourceTree = ""; }; - E21850182CEE561B0090B18B /* PageOnDisk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageOnDisk.swift; sourceTree = ""; }; E218501C2CEE6CB30090B18B /* VerticalCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalCenter.swift; sourceTree = ""; }; E21850222CF10C840090B18B /* TagSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSelectionView.swift; sourceTree = ""; }; E21850262CF3B42D0090B18B /* PostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailView.swift; sourceTree = ""; }; E218502A2CF790AC0090B18B /* PostContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostContentView.swift; sourceTree = ""; }; E218502C2CF791440090B18B /* PostImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostImagesView.swift; sourceTree = ""; }; - E218502E2CFAF6990090B18B /* LocalizedWebsiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedWebsiteGenerator.swift; sourceTree = ""; }; E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; E21850342CFAFA570090B18B /* SettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFile.swift; sourceTree = ""; }; E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = ""; }; @@ -259,7 +251,6 @@ E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGenerator.swift; sourceTree = ""; }; E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+Scaling.swift"; sourceTree = ""; }; E25DA5262CFF745200AEF16D /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = ""; }; - E25DA5282CFFBFB800AEF16D /* ImageFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFileType.swift; sourceTree = ""; }; E25DA5352D0041E200AEF16D /* PostSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSettingsFile.swift; sourceTree = ""; }; E25DA5372D00420D00AEF16D /* LocalizedPostSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettingsFile.swift; sourceTree = ""; }; E25DA5402D00446700AEF16D /* PostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSettings.swift; sourceTree = ""; }; @@ -271,7 +262,6 @@ E25DA5742D018B6100AEF16D /* FileDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDetailView.swift; sourceTree = ""; }; E25DA5762D018B9500AEF16D /* File+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "File+Mock.swift"; sourceTree = ""; }; E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContentGenerator.swift; sourceTree = ""; }; - E25DA5822D01C7A100AEF16D /* VideoFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFileType.swift; sourceTree = ""; }; E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShorthandMarkdownKey.swift; sourceTree = ""; }; E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Generation.swift"; sourceTree = ""; }; E25DA58A2D020C9200AEF16D /* PageImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImage.swift; sourceTree = ""; }; @@ -309,10 +299,6 @@ E29D31522D0618700051B7F4 /* AddPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPageView.swift; sourceTree = ""; }; E29D31542D06D2CB0051B7F4 /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = ""; }; E29D31562D06D3880051B7F4 /* AddTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTagView.swift; sourceTree = ""; }; - E29D315A2D06D63C0051B7F4 /* ModelFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileType.swift; sourceTree = ""; }; - E29D315C2D06D6EA0051B7F4 /* TextFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFileType.swift; sourceTree = ""; }; - E29D315E2D06D6F30051B7F4 /* CodeFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeFileType.swift; sourceTree = ""; }; - E29D31602D06D9570051B7F4 /* ResourceFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceFileType.swift; sourceTree = ""; }; E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationIcon.swift; sourceTree = ""; }; E29D31682D0702670051B7F4 /* TextFileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFileContentView.swift; sourceTree = ""; }; E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListPageGenerator.swift; sourceTree = ""; }; @@ -327,7 +313,7 @@ E29D31882D0AED1B0051B7F4 /* ModelViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelViewer.swift; sourceTree = ""; }; E29D318A2D0B07E60051B7F4 /* ContentBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentBox.swift; sourceTree = ""; }; E29D318D2D0B2E640051B7F4 /* PageSettingsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsContentView.swift; sourceTree = ""; }; - E29D318F2D0B34870051B7F4 /* PageContentAnomaly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContentAnomaly.swift; sourceTree = ""; }; + E29D318F2D0B34870051B7F4 /* GenerationAnomaly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationAnomaly.swift; sourceTree = ""; }; E29D31932D0B7D250051B7F4 /* SvgImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SvgImage.swift; sourceTree = ""; }; E29D31952D0C18690051B7F4 /* PathSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathSettings.swift; sourceTree = ""; }; E29D31972D0C19300051B7F4 /* PathSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathSettingsFile.swift; sourceTree = ""; }; @@ -387,6 +373,10 @@ E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; E2E06DFA2CA4A6570019C2AF /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = ""; }; E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Mock.swift"; sourceTree = ""; }; + E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationResults.swift; sourceTree = ""; }; + E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConvertThrowing.swift; sourceTree = ""; }; + E2FE0EEB2D1C124E002963B7 /* MultiFileSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFileSelectionView.swift; sourceTree = ""; }; + E2FE0EED2D1C22EF002963B7 /* InlineLinkProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineLinkProcessor.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -424,9 +414,7 @@ children = ( E29D31292D039B050051B7F4 /* FileDescriptions.swift */, E25DA5322D0041C400AEF16D /* Settings */, - E21850142CEE55D40090B18B /* FileOnDisk.swift */, E2A37D102CE537670000979F /* PageFile.swift */, - E21850182CEE561B0090B18B /* PageOnDisk.swift */, E2A37D142CE68BEA0000979F /* PostFile.swift */, E2A37D162CE73F170000979F /* TagFile.swift */, E22990212D0ED129009F8D77 /* TagOverviewFile.swift */, @@ -461,38 +449,24 @@ E25DA5782D01C56200AEF16D /* Generator */ = { isa = PBXGroup; children = ( - E22990232D0EDBD0009F8D77 /* HeaderElement.swift */, E29D31B62D0DAC030051B7F4 /* Page Content */, - E29D318F2D0B34870051B7F4 /* PageContentAnomaly.swift */, + E22990232D0EDBD0009F8D77 /* HeaderElement.swift */, E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */, E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, E22990412D107A94009F8D77 /* ImageJob.swift */, - E218502E2CFAF6990090B18B /* LocalizedWebsiteGenerator.swift */, E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */, E29D316C2D07A4FF0051B7F4 /* PageGenerationResults.swift */, + E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */, E25DA5982D02401A00AEF16D /* PageGenerator.swift */, E2B85F3C2C4293F80047CD0C /* FeedPageGenerator.swift */, E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */, E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */, E29D31252D0370A50051B7F4 /* VideoOption.swift */, + E29D318F2D0B34870051B7F4 /* GenerationAnomaly.swift */, ); path = Generator; sourceTree = ""; }; - E25DA5812D01C79800AEF16D /* Types */ = { - isa = PBXGroup; - children = ( - E29D31602D06D9570051B7F4 /* ResourceFileType.swift */, - E29D315E2D06D6F30051B7F4 /* CodeFileType.swift */, - E29D315C2D06D6EA0051B7F4 /* TextFileType.swift */, - E29D315A2D06D63C0051B7F4 /* ModelFileType.swift */, - E21850162CEE55FB0090B18B /* FileType.swift */, - E25DA5282CFFBFB800AEF16D /* ImageFileType.swift */, - E25DA5822D01C7A100AEF16D /* VideoFileType.swift */, - ); - path = Types; - sourceTree = ""; - }; E29D311E2D0320D90051B7F4 /* ContentElements */ = { isa = PBXGroup; children = ( @@ -527,6 +501,7 @@ isa = PBXGroup; children = ( E29D31992D0C451B0051B7F4 /* Pages */, + E25DA5702D01015400AEF16D /* GenerationContentView.swift */, E29D318D2D0B2E640051B7F4 /* PageSettingsContentView.swift */, ); path = Content; @@ -557,6 +532,7 @@ E29D31B62D0DAC030051B7F4 /* Page Content */ = { isa = PBXGroup; children = ( + E2FE0EED2D1C22EF002963B7 /* InlineLinkProcessor.swift */, E29D31BD2D0DB8560051B7F4 /* AudioPlayerCommand.swift */, E29D31BB2D0DB5110051B7F4 /* CommandProcessor.swift */, E29D31B92D0DB4EF0051B7F4 /* LabelsCommand.swift */, @@ -593,7 +569,6 @@ children = ( E29D318C2D0B2E5E0051B7F4 /* Content */, E29D316E2D0822720051B7F4 /* SettingsListView.swift */, - E25DA5702D01015400AEF16D /* GenerationContentView.swift */, E29D31702D08234D0051B7F4 /* GenerationDetailView.swift */, E25DA5942D023BCC00AEF16D /* PageSettingsDetailView.swift */, E25DA5442D00952D00AEF16D /* SettingsSection.swift */, @@ -645,6 +620,7 @@ E29D314A2D04FC940051B7F4 /* FileToAdd.swift */, E29D314C2D04FCBF0051B7F4 /* FileToAddView.swift */, E29D31A42D0CD03A0051B7F4 /* FileSelectionView.swift */, + E2FE0EEB2D1C124E002963B7 /* MultiFileSelectionView.swift */, ); path = Files; sourceTree = ""; @@ -680,7 +656,6 @@ isa = PBXGroup; children = ( E229901A2D0E3F09009F8D77 /* Item */, - E25DA5812D01C79800AEF16D /* Types */, E25DA53B2D0042EA00AEF16D /* Settings */, E2E06DFA2CA4A6570019C2AF /* Content.swift */, E29D31A02D0C75C50051B7F4 /* Content+Validation.swift */, @@ -689,6 +664,7 @@ E25DA5142CFF00B900AEF16D /* Content+Load.swift */, E24252092C52C9260029FF16 /* ContentLanguage.swift */, E25DA59A2D024A2900AEF16D /* DateItem.swift */, + E21850162CEE55FB0090B18B /* FileType.swift */, E2A21C502CBBD53C0060935B /* FileResource.swift */, E2B85F3A2C428F0D0047CD0C /* Post.swift */, E2A37D1C2CEA922A0000979F /* LocalizedPost.swift */, @@ -760,6 +736,7 @@ E2B85F552C4BD0AD0047CD0C /* Extensions */ = { isa = PBXGroup; children = ( + E2FE0EE72D16D4A3002963B7 /* ConvertThrowing.swift */, E25DA5182CFF035200AEF16D /* Array+Split.swift */, E2B85F562C4BD0BB0047CD0C /* Binding+Extension.swift */, E2A21C0D2CB189D70060935B /* Color+RGB.swift */, @@ -913,6 +890,7 @@ files = ( E29D31242D0366860051B7F4 /* TagList.swift in Sources */, E22990282D0F596C009F8D77 /* IntegerPropertyView.swift in Sources */, + E2FE0EE62D15A0B5002963B7 /* GenerationResults.swift in Sources */, E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */, E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */, E229904C2D10BE5D009F8D77 /* InitialSetupView.swift in Sources */, @@ -926,7 +904,7 @@ E25DA5412D00446C00AEF16D /* PostSettings.swift in Sources */, E2A37D192CEA36A90000979F /* LocalizedTag.swift in Sources */, E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */, - E29D31902D0B34870051B7F4 /* PageContentAnomaly.swift in Sources */, + E29D31902D0B34870051B7F4 /* GenerationAnomaly.swift in Sources */, E25DA5172CFF00F500AEF16D /* Content+Save.swift in Sources */, E229902E2D0F7280009F8D77 /* IdPropertyView.swift in Sources */, E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */, @@ -935,7 +913,6 @@ E2A37D1D2CEA922D0000979F /* LocalizedPost.swift in Sources */, E21850172CEE55FC0090B18B /* FileType.swift in Sources */, E29D31B12D0DA5510051B7F4 /* ContentIcon.swift in Sources */, - E29D31612D06D95C0051B7F4 /* ResourceFileType.swift in Sources */, E2A37D112CE537800000979F /* PageFile.swift in Sources */, E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */, E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */, @@ -950,10 +927,8 @@ E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */, E2A37D2B2CED2CC30000979F /* TagDetailView.swift in Sources */, E2A21C282CB29B2A0060935B /* FeedEntryData.swift in Sources */, - E218502F2CFAF69C0090B18B /* LocalizedWebsiteGenerator.swift in Sources */, E29D31942D0B7D280051B7F4 /* SvgImage.swift in Sources */, E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */, - E25DA5832D01C7A400AEF16D /* VideoFileType.swift in Sources */, E22990462D10B7A7009F8D77 /* SecurityScopeStatus.swift in Sources */, E29D31512D06168E0051B7F4 /* PostListView.swift in Sources */, E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */, @@ -971,7 +946,6 @@ E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */, E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */, E2581DED2C75202400F1F079 /* Tag.swift in Sources */, - E29D315B2D06D63E0051B7F4 /* ModelFileType.swift in Sources */, E29D31302D03A2C50051B7F4 /* DescriptionField.swift in Sources */, E29D31BE2D0DB85A0051B7F4 /* AudioPlayerCommand.swift in Sources */, E29D31552D06D2CE0051B7F4 /* TagListView.swift in Sources */, @@ -994,8 +968,6 @@ E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */, E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */, E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */, - E29D315D2D06D6EA0051B7F4 /* TextFileType.swift in Sources */, - E21850152CEE55D40090B18B /* FileOnDisk.swift in Sources */, E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */, E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */, E21850332CFAFA2F0090B18B /* Settings.swift in Sources */, @@ -1003,8 +975,8 @@ E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */, E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */, E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */, - E21850192CEE561C0090B18B /* PageOnDisk.swift in Sources */, E2A21C202CB28ED20060935B /* MockImage.swift in Sources */, + E2FE0EEE2D1C22F3002963B7 /* InlineLinkProcessor.swift in Sources */, E29D313F2D04822C0051B7F4 /* AddPostView.swift in Sources */, E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */, E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */, @@ -1020,6 +992,7 @@ E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */, E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */, E22990382D0F7B32009F8D77 /* OptionalImagePropertyView.swift in Sources */, + E2FE0EEC2D1C1253002963B7 /* MultiFileSelectionView.swift in Sources */, E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */, E29D319D2D0C45B90051B7F4 /* PageIssueView.swift in Sources */, E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */, @@ -1047,6 +1020,7 @@ E29D319F2D0C46310051B7F4 /* PageIssueChecker.swift in Sources */, E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */, E29D31322D03B5680051B7F4 /* LocalizedPostDetailView.swift in Sources */, + E2FE0EE82D16D4A3002963B7 /* ConvertThrowing.swift in Sources */, E29D31712D08234D0051B7F4 /* GenerationDetailView.swift in Sources */, E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */, E29D31C32D0DBEF20051B7F4 /* Song.swift in Sources */, @@ -1054,14 +1028,12 @@ E29D31532D0618740051B7F4 /* AddPageView.swift in Sources */, E22990202D0ECBE5009F8D77 /* TagOverviewDetailView.swift in Sources */, E29D31C02D0DB9F20051B7F4 /* AudioPlayerContent.swift in Sources */, - E25DA5292CFFBFBB00AEF16D /* ImageFileType.swift in Sources */, E22990192D0E3546009F8D77 /* ItemType.swift in Sources */, E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */, E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */, E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */, - E29D315F2D06D6F30051B7F4 /* CodeFileType.swift in Sources */, E2A37D0E2CE527070000979F /* Storage.swift in Sources */, E2E06E002CA4A8F00019C2AF /* Page+Mock.swift in Sources */, E29D314D2D04FCBF0051B7F4 /* FileToAddView.swift in Sources */, diff --git a/CHDataManagement/Extensions/Array+Split.swift b/CHDataManagement/Extensions/Array+Split.swift index bb08208..b63f3be 100644 --- a/CHDataManagement/Extensions/Array+Split.swift +++ b/CHDataManagement/Extensions/Array+Split.swift @@ -6,4 +6,8 @@ extension Array { Array(self[$0.. + +prefix func ~> (operation: () throws -> Void) -> Bool { + do { + try operation() + return true + } catch { + return false + } +} diff --git a/CHDataManagement/Extensions/Sequence+Sorted.swift b/CHDataManagement/Extensions/Sequence+Sorted.swift index a962eb5..414b4c6 100644 --- a/CHDataManagement/Extensions/Sequence+Sorted.swift +++ b/CHDataManagement/Extensions/Sequence+Sorted.swift @@ -27,3 +27,20 @@ extension Collection { } } + +extension Collection where Element: Collection, Element.Element: Hashable { + + func union() -> Set { + reduce(into: []) { $0.formUnion($1) } + } +} + + +extension RangeReplaceableCollection where Element: Comparable { + + + mutating func insertSorted(_ element: Element) { + let index = firstIndex(where: { $0 > element }) ?? endIndex + insert(element, at: index) + } +} diff --git a/CHDataManagement/Generator/PageContentAnomaly.swift b/CHDataManagement/Generator/GenerationAnomaly.swift similarity index 86% rename from CHDataManagement/Generator/PageContentAnomaly.swift rename to CHDataManagement/Generator/GenerationAnomaly.swift index c1044a5..125a9ae 100644 --- a/CHDataManagement/Generator/PageContentAnomaly.swift +++ b/CHDataManagement/Generator/GenerationAnomaly.swift @@ -1,5 +1,5 @@ -enum PageContentAnomaly { +enum GenerationAnomaly { case failedToLoadContent case failedToParseContent case missingFile(file: String, markdown: String) @@ -9,7 +9,7 @@ enum PageContentAnomaly { case warning(String) } -extension PageContentAnomaly: Identifiable { +extension GenerationAnomaly: Identifiable { var id: String { switch self { @@ -31,21 +31,21 @@ extension PageContentAnomaly: Identifiable { } } -extension PageContentAnomaly: Equatable { +extension GenerationAnomaly: Equatable { - static func == (lhs: PageContentAnomaly, rhs: PageContentAnomaly) -> Bool { + static func == (lhs: GenerationAnomaly, rhs: GenerationAnomaly) -> Bool { lhs.id == rhs.id } } -extension PageContentAnomaly: Hashable { +extension GenerationAnomaly: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } } -extension PageContentAnomaly { +extension GenerationAnomaly { enum Severity: String, CaseIterable { case warning @@ -62,7 +62,7 @@ extension PageContentAnomaly { } } -extension PageContentAnomaly: CustomStringConvertible { +extension GenerationAnomaly: CustomStringConvertible { var description: String { switch self { diff --git a/CHDataManagement/Generator/GenerationResults.swift b/CHDataManagement/Generator/GenerationResults.swift new file mode 100644 index 0000000..128bf16 --- /dev/null +++ b/CHDataManagement/Generator/GenerationResults.swift @@ -0,0 +1,199 @@ +import Foundation + +struct LocalizedPageId: Hashable { + + let language: ContentLanguage + + let pageId: String +} + +final class GenerationResults: ObservableObject { + + /// The files that could not be accessed + @Published + var inaccessibleFiles: Set = [] + + /// The files that could not be parsed, with the error message produced + @Published + var unparsableFiles: Set = [] + + @Published + var missingFiles: Set = [] + + @Published + var missingTags: Set = [] + + @Published + var missingPages: Set = [] + + @Published + var externalLinks: Set = [] + + @Published + var requiredFiles: Set = [] + + @Published + var imagesToGenerate: Set = [] + + @Published + var invalidCommands: Set = [] + + @Published + var warnings: Set = [] + + @Published + var unsavedOutputFiles: Set = [] + + @Published + var failedImages: Set = [] + + @Published + var emptyPages: Set = [] + + /// The cache of previously used GenerationResults + private var cache: [ItemId : PageGenerationResults] = [:] + + private(set) var general: PageGenerationResults! + + var resultCount: Int { + cache.count + } + + // MARK: Life cycle + + init() { + let id = ItemId(language: .english, itemType: .general) + let general = PageGenerationResults(itemId: id, delegate: self) + self.general = general + cache[id] = general + } + + func makeResults(_ itemId: ItemId) -> PageGenerationResults { + guard let result = cache[itemId] else { + let result = PageGenerationResults(itemId: itemId, delegate: self) + cache[itemId] = result + return result + } + return result + } + + func makeResults(for type: ItemType, in language: ContentLanguage) -> PageGenerationResults { + let itemId = ItemId(language: language, itemType: type) + return makeResults(itemId) + } + + func makeResults(for page: Page, in language: ContentLanguage) -> PageGenerationResults { + let itemId = ItemId(language: language, itemType: .page(page)) + return makeResults(itemId) + } + + func makeResults(for tag: Tag, in language: ContentLanguage) -> PageGenerationResults { + let itemId = ItemId(language: language, itemType: .tagPage(tag)) + return makeResults(itemId) + } + + func recalculate() { + let inaccessibleFiles = cache.values.map { $0.inaccessibleFiles }.union() + update { self.inaccessibleFiles = inaccessibleFiles } + let unparsableFiles = cache.values.map { $0.unparsableFiles.keys }.union() + update { self.unparsableFiles = unparsableFiles } + let missingFiles = cache.values.map { $0.missingFiles.keys }.union() + update { self.missingFiles = missingFiles } + let missingTags = cache.values.map { $0.missingLinkedTags.keys }.union() + update { self.missingTags = missingTags } + let missingPages = cache.values.map { $0.missingLinkedPages.keys }.union() + update { self.missingPages = missingPages } + let externalLinks = cache.values.map { $0.externalLinks }.union() + update { self.externalLinks = externalLinks } + let requiredFiles = cache.values.map { $0.requiredFiles }.union() + update { self.requiredFiles = requiredFiles } + let imagesToGenerate = cache.values.map { $0.imagesToGenerate }.union() + update { self.imagesToGenerate = imagesToGenerate } + let invalidCommands = cache.values.map { $0.invalidCommands.map { $0.markdown }}.union() + update { self.invalidCommands = invalidCommands } + let warnings = cache.values.map { $0.warnings }.union() + update { self.warnings = warnings } + let unsavedOutputFiles = cache.values.map { $0.unsavedOutputFiles.keys }.union() + update { self.unsavedOutputFiles = unsavedOutputFiles } + } + + private func update(_ operation: @escaping () -> Void) { + DispatchQueue.main.async { + operation() + } + } + + + // MARK: Adding entries + + func inaccessibleContent(file: FileResource) { + update { self.inaccessibleFiles.insert(file) } + } + + func unparsable(file: FileResource) { + update { self.unparsableFiles.insert(file) } + } + + func missing(file: String) { + update { self.missingFiles.insert(file) } + } + + func missing(tag: String) { + update { self.missingTags.insert(tag) } + } + + func missing(page: String) { + update { self.missingPages.insert(page) } + } + + func externalLink(_ url: String) { + update { self.externalLinks.insert(url) } + } + + func require(file: FileResource) { + update { self.requiredFiles.insert(file) } + } + + func require(files: S) where S: Sequence, S.Element == FileResource { + update { self.requiredFiles.formUnion(files) } + } + + func generate(_ image: ImageGenerationJob) { + update { self.imagesToGenerate.insert(image) } + } + + func generate(_ images: S) where S: Sequence, S.Element == ImageGenerationJob { + update { self.imagesToGenerate.formUnion(images) } + } + + func invalidCommand(_ markdown: String) { + update { self.invalidCommands.insert(markdown) } + } + + func warning(_ warning: String) { + update { self.warnings.insert(warning) } + } + + func failed(image: ImageGenerationJob) { + update { self.failedImages.insert(image) } + } + + func unsaved(_ path: String) { + update { self.unsavedOutputFiles.insert(path) } + } +} + +private extension Dictionary where Value == Set { + + mutating func remove(keys: S, of item: ItemId) where S: Sequence, S.Element == Key { + for key in keys { + guard var value = self[key] else { continue } + value.remove(item) + if value.isEmpty { + self[key] = nil + } else { + self[key] = value + } + } + } +} diff --git a/CHDataManagement/Generator/HeaderElement.swift b/CHDataManagement/Generator/HeaderElement.swift index e862f66..2ab8f5d 100644 --- a/CHDataManagement/Generator/HeaderElement.swift +++ b/CHDataManagement/Generator/HeaderElement.swift @@ -97,14 +97,14 @@ extension HeaderElement { var content: String { switch self { case .icon(let file, let size, let rel): - return "" + return "" case .css(let file, _): - return "" + return "" case .js(let file, let deferred): let deferText = deferred ? " defer" : "" - return "" + return "" case .jsModule(let file): - return "" + return "" case .author(let author): return "" case .title(let title): diff --git a/CHDataManagement/Generator/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift index 6af20f2..f9f81b8 100644 --- a/CHDataManagement/Generator/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -11,8 +11,6 @@ final class ImageGenerator { private var generatedImages: [String : Set] = [:] - private var jobs: [ImageGenerationJob] = [] - init(storage: Storage, settings: Settings) { self.storage = storage self.settings = settings @@ -23,20 +21,6 @@ final class ImageGenerator { settings.paths.imagesOutputFolderPath } - func runJobs(callback: (String) -> Void) -> Bool { - guard !jobs.isEmpty else { - return true - } - print("Generating \(jobs.count) images...") - while let job = jobs.popLast() { - callback("Generating image \(job.version)") - guard generate(job: job) else { - return false - } - } - return true - } - func save() -> Bool { guard storage.save(listOfGeneratedImages: generatedImages) else { print("Failed to save list of generated images") @@ -45,50 +29,6 @@ final class ImageGenerator { return true } - private func versionFileName(image: String, type: ImageFileType, width: CGFloat, height: CGFloat) -> String { - let fileName = image.fileNameAndExtension.fileName - let prefix = "\(fileName)@\(Int(width))x\(Int(height))" - return "\(prefix).\(type.fileExtension)" - } - - func generateImageSet(for image: String, maxWidth: CGFloat, maxHeight: CGFloat) { - let type = ImageFileType(fileExtension: image.fileExtension!)! - - let width2x = maxWidth * 2 - let height2x = maxHeight * 2 - - generateVersion(for: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight) - generateVersion(for: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x) - - generateVersion(for: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight) - generateVersion(for: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x) - - generateVersion(for: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight) - generateVersion(for: image, type: type, maximumWidth: width2x, maximumHeight: height2x) - } - - func generateVersion(for image: String, type: ImageFileType, maximumWidth: CGFloat, maximumHeight: CGFloat) { - let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight) - guard needsToGenerate(version: version, for: image) else { - // Image already present - return - } - guard !jobs.contains(where: { $0.version == version }) else { - // Job already in queue - return - } - - let job = ImageGenerationJob( - image: image, - version: version, - maximumWidth: maximumWidth, - maximumHeight: maximumHeight, - quality: 0.7, - type: type) - - jobs.append(job) - } - /** Remove all versions of an image, so that they will be recreated on the next run. @@ -105,6 +45,9 @@ final class ImageGenerator { } private func needsToGenerate(version: String, for image: String) -> Bool { + if exists(version) { + return false + } guard let versions = generatedImages[image] else { return true } @@ -143,7 +86,7 @@ final class ImageGenerator { // MARK: Image operations - private func generate(job: ImageGenerationJob) -> Bool { + func generate(job: ImageGenerationJob) -> Bool { guard needsToGenerate(version: job.version, for: job.image) else { return true } @@ -158,7 +101,7 @@ final class ImageGenerator { return false } - let representation = create(image: originalImage, width: job.maximumWidth, height: job.maximumHeight) + let representation = create(image: originalImage, width: CGFloat(job.maximumWidth), height: CGFloat(job.maximumHeight)) guard let data = create(image: representation, type: job.type, quality: job.quality) else { print("Failed to get data for type \(job.type)") @@ -209,7 +152,7 @@ final class ImageGenerator { // MARK: Avif images - private func create(image: NSBitmapImageRep, type: ImageFileType, quality: CGFloat) -> Data? { + private func create(image: NSBitmapImageRep, type: FileType, quality: CGFloat) -> Data? { switch type { case .jpg: return image.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: 0.6)]) @@ -225,6 +168,8 @@ final class ImageGenerator { return nil case .tiff: return nil + default: + return nil } } diff --git a/CHDataManagement/Generator/ImageJob.swift b/CHDataManagement/Generator/ImageJob.swift index 1a12816..7368536 100644 --- a/CHDataManagement/Generator/ImageJob.swift +++ b/CHDataManagement/Generator/ImageJob.swift @@ -4,13 +4,70 @@ struct ImageGenerationJob { let image: String - let version: String + let type: FileType - let maximumWidth: CGFloat + let maximumWidth: Int - let maximumHeight: CGFloat + let maximumHeight: Int let quality: CGFloat - let type: ImageFileType + init(image: String, type: FileType, maximumWidth: CGFloat, maximumHeight: CGFloat, quality: CGFloat = 0.7) { + self.image = image + self.type = type + self.maximumWidth = Int(maximumWidth) + self.maximumHeight = Int(maximumHeight) + self.quality = quality + } + + init(image: String, type: FileType, maximumWidth: Int, maximumHeight: Int, quality: CGFloat = 0.7) { + self.image = image + self.type = type + self.maximumWidth = maximumWidth + self.maximumHeight = maximumHeight + self.quality = quality + } + + var version: String { + let fileName = image.fileNameAndExtension.fileName + let prefix = "\(fileName)@\(maximumWidth)x\(maximumHeight)" + return "\(prefix).\(type.fileExtension)" + } + + static func imageSet(for image: String, maxWidth: Int, maxHeight: Int, quality: CGFloat = 0.7) -> [ImageGenerationJob] { + let type = FileType(fileExtension: image.fileExtension) + + let width2x = maxWidth * 2 + let height2x = maxHeight * 2 + + return [ + .init(image: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality), + .init(image: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x, quality: quality), + .init(image: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality), + .init(image: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x, quality: quality), + .init(image: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality), + .init(image: image, type: type, maximumWidth: width2x, maximumHeight: height2x, quality: quality) + ] + } +} + +extension ImageGenerationJob: Equatable { + + static func == (lhs: ImageGenerationJob, rhs: ImageGenerationJob) -> Bool { + lhs.version == rhs.version + } +} + +extension ImageGenerationJob: Hashable { + + func hash(into hasher: inout Hasher) { + hasher.combine(version) + } +} + +extension ImageGenerationJob: Comparable { + + static func < (lhs: ImageGenerationJob, rhs: ImageGenerationJob) -> Bool { + lhs.version < rhs.version + } } diff --git a/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift b/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift deleted file mode 100644 index e0318c4..0000000 --- a/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation - -final class LocalizedWebsiteGenerator { - - private let content: Content - - let language: ContentLanguage - - private let imageGenerator: ImageGenerator - - private let localizedPostSettings: LocalizedPostSettings - - init(content: Content, language: ContentLanguage) { - self.language = language - self.content = content - self.localizedPostSettings = content.settings.localized(in: language) - self.imageGenerator = ImageGenerator( - storage: content.storage, - settings: content.settings) - } - - private var postsPerPage: Int { - content.settings.posts.postsPerPage - } - - private var mainContentMaximumWidth: CGFloat { - CGFloat(content.settings.posts.contentWidth) - } - - func generateWebsite(callback: (String) -> Void) -> Bool { - guard createMainPostFeedPages() else { - return false - } - #warning("Generate content pages") - #warning("Generate tag overview page") - guard generateTagPages() else { - return false - } - guard imageGenerator.runJobs(callback: callback) else { - return false - } - return imageGenerator.save() - } - - private func createMainPostFeedPages() -> Bool { - let generator = PostListPageGenerator( - language: language, - content: content, - imageGenerator: imageGenerator, - showTitle: false, - pageTitle: localizedPostSettings.title, - pageDescription: localizedPostSettings.description, - pageUrlPrefix: localizedPostSettings.feedUrlPrefix) - return generator.createPages(for: content.posts) - } - - private func generateTagPages() -> Bool { - for tag in content.tags { - let posts = content.posts.filter { $0.tags.contains(tag) } - guard posts.count > 0 else { continue } - - let localized = tag.localized(in: language) - - let urlPrefix = content.absoluteUrlPrefixForTag(tag, language: language) - - let generator = PostListPageGenerator( - language: language, - content: content, - imageGenerator: imageGenerator, - showTitle: true, - pageTitle: localized.name, - pageDescription: localized.description ?? "", - pageUrlPrefix: urlPrefix) - guard generator.createPages(for: posts) else { - return false - } - } - return true - } - - private func copy(requiredFiles: Set) -> Bool { - //print("Copying \(requiredVideoFiles.count) files...") - for file in requiredFiles { - guard !file.isExternallyStored else { - continue - } - guard content.storage.copy(file: file.id, to: file.absoluteUrl) else { - return false - } - } - return true - } - - private func save(_ content: String, to relativePath: String) -> Bool { - self.content.storage.write(content, to: relativePath) - } -} diff --git a/CHDataManagement/Generator/Page Content/AudioPlayerCommand.swift b/CHDataManagement/Generator/Page Content/AudioPlayerCommand.swift index c5c308f..2d33ba7 100644 --- a/CHDataManagement/Generator/Page Content/AudioPlayerCommand.swift +++ b/CHDataManagement/Generator/Page Content/AudioPlayerCommand.swift @@ -27,18 +27,18 @@ struct AudioPlayerCommandProcessor: CommandProcessor { } guard let file = content.file(fileId) else { - results.missingFiles.insert(fileId) + results.missing(file: fileId, source: "Audio player song list") return "" } guard let data = file.dataContent() else { - results.issues.insert(.failedToLoadContent) + results.inaccessibleContent(file: file) return "" } let songs: [Song] do { songs = try JSONDecoder().decode([Song].self, from: data) } catch { - results.issues.insert(.failedToParseContent) + results.invalidFormat(file: file, error: "Not valid JSON containing [Song]: \(error)") return "" } @@ -47,12 +47,12 @@ struct AudioPlayerCommandProcessor: CommandProcessor { for song in songs { guard let image = content.image(song.cover) else { - results.missing(file: song.cover, markdown: "Missing cover image \(song.cover) in \(file.id)") + results.missing(file: song.cover, containedIn: file) continue } guard let audioFile = content.file(song.file) else { - results.missing(file: song.file, markdown: "Missing audio file \(song.file) in \(file.id)") + results.missing(file: song.cover, containedIn: file) continue } #warning("Check if file is audio") @@ -79,18 +79,17 @@ struct AudioPlayerCommandProcessor: CommandProcessor { } let footerScript = AudioPlayerScript(items: amplitude).content - results.requiredFooters.insert(footerScript) - results.requiredHeaders.insert(.audioPlayerCss) - results.requiredHeaders.insert(.audioPlayerJs) + results.require(footer: footerScript) + results.require(headers: .audioPlayerCss, .audioPlayerJs) - results.requiredIcons.formUnion([ + results.require(icons: .audioPlayerClose, .audioPlayerPlaylist, .audioPlayerNext, .audioPlayerPrevious, .audioPlayerPlay, .audioPlayerPause - ]) + ) return AudioPlayer(playingText: titleText, items: playlist).content } diff --git a/CHDataManagement/Generator/Page Content/ButtonCommand.swift b/CHDataManagement/Generator/Page Content/ButtonCommand.swift index 06b5b8b..8ebbe4c 100644 --- a/CHDataManagement/Generator/Page Content/ButtonCommand.swift +++ b/CHDataManagement/Generator/Page Content/ButtonCommand.swift @@ -57,11 +57,11 @@ struct ButtonCommandProcessor: CommandProcessor { let downloadName = arguments.count > 2 ? arguments[2].trimmed : nil guard let file = content.file(fileId) else { - results.missing(file: fileId, markdown: markdown) + results.missing(file: fileId, source: "Download button") return nil } - results.files.insert(file) - results.requiredIcons.insert(.buttonDownload) + results.require(file: file) + results.require(icon: .buttonDownload) return ContentButtons.Item( icon: .buttonDownload, filePath: file.absoluteUrl, @@ -80,8 +80,8 @@ struct ButtonCommandProcessor: CommandProcessor { return nil } - results.externalLinks.insert(rawUrl) - results.requiredIcons.insert(icon) + results.externalLink(to: rawUrl) + results.require(icon: icon) let title = arguments[1].trimmed @@ -96,7 +96,7 @@ struct ButtonCommandProcessor: CommandProcessor { let text = arguments[0].trimmed let event = arguments[1].trimmed - results.requiredIcons.insert(.buttonPlay) + results.require(icon: .buttonPlay) return .init(icon: .buttonPlay, filePath: nil, text: text, onClickText: event) } diff --git a/CHDataManagement/Generator/Page Content/InlineLinkProcessor.swift b/CHDataManagement/Generator/Page Content/InlineLinkProcessor.swift new file mode 100644 index 0000000..c69ffb7 --- /dev/null +++ b/CHDataManagement/Generator/Page Content/InlineLinkProcessor.swift @@ -0,0 +1,75 @@ + +struct InlineLinkProcessor { + + private let pageLinkMarker = "page:" + + private let tagLinkMarker = "tag:" + + private let fileLinkMarker = "file:" + + let content: Content + + let results: PageGenerationResults + + let language: ContentLanguage + + func handleLink(html: String, markdown: Substring) -> String { + let url = markdown.between("(", and: ")") + if url.hasPrefix(pageLinkMarker) { + return handleInlinePageLink(url: url, html: html, markdown: markdown) + } + if url.hasPrefix(tagLinkMarker) { + return handleInlineTagLink(url: url, html: html, markdown: markdown) + } + if url.hasPrefix(fileLinkMarker) { + return handleInlineFileLink(url: url, html: html, markdown: markdown) + } + results.externalLink(to: url) + return html + } + + private func handleInlinePageLink(url: String, html: String, markdown: Substring) -> String { + // Retain links pointing to elements within a page + let textToChange = url.dropAfterFirst("#") + let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "") + guard let page = content.page(pageId) else { + results.missing(page: pageId, source: "Inline page link") + // Remove link since the page can't be found + return markdown.between("[", and: "]") + } + guard !page.isDraft else { + return markdown.between("[", and: "]") + } + + results.linked(to: page) + let pagePath = page.absoluteUrl(in: language) + return html.replacingOccurrences(of: textToChange, with: pagePath) + } + + private func handleInlineTagLink(url: String, html: String, markdown: Substring) -> String { + // Retain links pointing to elements within a page + let textToChange = url.dropAfterFirst("#") + let tagId = textToChange.replacingOccurrences(of: tagLinkMarker, with: "") + guard let tag = content.tag(tagId) else { + results.missing(tag: tagId, source: "Inline tag link") + // Remove link since the tag can't be found + return markdown.between("[", and: "]") + } + results.linked(to: tag) + let tagPath = content.absoluteUrlToTag(tag, language: language) + return html.replacingOccurrences(of: textToChange, with: tagPath) + } + + private func handleInlineFileLink(url: String, html: String, markdown: Substring) -> String { + // Retain links pointing to elements within a page + let fileId = url.replacingOccurrences(of: fileLinkMarker, with: "") + guard let file = content.file(fileId) else { + results.missing(file: fileId, source: "Inline file link") + // Remove link since the file can't be found + return markdown.between("[", and: "]") + } + results.require(file: file) + let filePath = file.absoluteUrl + return html.replacingOccurrences(of: url, with: filePath) + } +} diff --git a/CHDataManagement/Generator/Page Content/LabelsCommand.swift b/CHDataManagement/Generator/Page Content/LabelsCommand.swift index 5502d20..ec0ca40 100644 --- a/CHDataManagement/Generator/Page Content/LabelsCommand.swift +++ b/CHDataManagement/Generator/Page Content/LabelsCommand.swift @@ -23,7 +23,7 @@ struct LabelsCommandProcessor: CommandProcessor { results.invalid(command: .labels, markdown) return nil } - results.requiredIcons.insert(icon) + results.require(icon: icon) return .init(icon: icon, value: parts[1]) } return ContentLabels(labels: labels).content diff --git a/CHDataManagement/Generator/PageContentGenerator.swift b/CHDataManagement/Generator/PageContentGenerator.swift index be7588c..614e713 100644 --- a/CHDataManagement/Generator/PageContentGenerator.swift +++ b/CHDataManagement/Generator/PageContentGenerator.swift @@ -3,29 +3,25 @@ import Ink import Splash import SwiftSoup -typealias VideoSource = (url: String, type: VideoFileType) - final class PageContentParser { - private let pageLinkMarker = "page:" - - private let tagLinkMarker = "tag:" - private static let codeHighlightFooter = "" private let swift = SyntaxHighlighter(format: HTMLOutputFormat()) - let results = PageGenerationResults() + private let language: ContentLanguage private let content: Content + private let results: PageGenerationResults + private let buttonHandler: ButtonCommandProcessor private let labelHandler: LabelsCommandProcessor private let audioPlayer: AudioPlayerCommandProcessor - let language: ContentLanguage + private let inlineLink: InlineLinkProcessor var largeImageWidth: Int { content.settings.pages.largeImageWidth @@ -35,33 +31,21 @@ final class PageContentParser { content.settings.pages.contentWidth } - init(content: Content, language: ContentLanguage) { + init(content: Content, language: ContentLanguage, results: PageGenerationResults) { self.content = content + self.results = results self.language = language self.buttonHandler = .init(content: content, results: results) self.labelHandler = .init(content: content, results: results) self.audioPlayer = .init(content: content, results: results) - } - - func requestImages(_ generator: ImageGenerator) { - for request in results.imagesToGenerate { - generator.generateImageSet( - for: request.image.id, - maxWidth: CGFloat(request.size), - maxHeight: CGFloat(request.size)) - } - } - - func reset() { - results.reset() + self.inlineLink = .init(content: content, results: results, language: language) } func generatePage(from content: String) -> String { - reset() let parser = MarkdownParser(modifiers: [ Modifier(target: .images, closure: processMarkdownImage), Modifier(target: .codeBlocks, closure: handleCode), - Modifier(target: .links, closure: handleLink), + Modifier(target: .links, closure: inlineLink.handleLink), Modifier(target: .html, closure: handleHTML), Modifier(target: .headings, closure: handleHeadlines) ]) @@ -70,8 +54,8 @@ final class PageContentParser { private func handleCode(html: String, markdown: Substring) -> String { guard markdown.starts(with: "```swift") else { - results.requiredHeaders.insert(.codeHightlighting) - results.requiredFooters.insert(PageContentParser.codeHighlightFooter) + results.require(header: .codeHightlighting) + results.require(footer: PageContentParser.codeHighlightFooter) return html // Just use normal code highlighting } // Highlight swift code using Splash @@ -79,46 +63,6 @@ final class PageContentParser { return "
" + swift.highlight(code) + "
" } - private func handleLink(html: String, markdown: Substring) -> String { - let file = markdown.between("(", and: ")") - if file.hasPrefix(pageLinkMarker) { - return handlePageLink(file: file, html: html, markdown: markdown) - } - if file.hasPrefix(tagLinkMarker) { - return handleTagLink(file: file, html: html, markdown: markdown) - } - results.externalLinks.insert(file) - return html - } - - private func handlePageLink(file: String, html: String, markdown: Substring) -> String { - // Retain links pointing to elements within a page - let textToChange = file.dropAfterFirst("#") - let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "") - guard let page = content.page(pageId) else { - results.missing(page: pageId, markdown: markdown) - // Remove link since the page can't be found - return markdown.between("[", and: "]") - } - results.linkedPages.insert(page) - let pagePath = page.absoluteUrl(in: language) - return html.replacingOccurrences(of: textToChange, with: pagePath) - } - - private func handleTagLink(file: String, html: String, markdown: Substring) -> String { - // Retain links pointing to elements within a page - let textToChange = file.dropAfterFirst("#") - let tagId = textToChange.replacingOccurrences(of: tagLinkMarker, with: "") - guard let tag = content.tag(tagId) else { - results.missing(tag: tagId, markdown: markdown) - // Remove link since the tag can't be found - return markdown.between("[", and: "]") - } - results.linkedTags.insert(tag) - let tagPath = content.absoluteUrlToTag(tag, language: language) - return html.replacingOccurrences(of: textToChange, with: tagPath) - } - private func handleHTML(html: String, _: Substring) -> String { findResourcesInHtml(html: html) return html @@ -144,7 +88,7 @@ final class PageContentParser { .filter { !$0.trimmed.isEmpty } for src in srcAttributes { - results.issues.insert(.warning("Found image in html: \(src)")) + results.warning("Found image in html: \(src)") } } catch { print("Error parsing HTML: \(error)") @@ -166,9 +110,9 @@ final class PageContentParser { for url in srcAttributes { if url.hasPrefix("http://") || url.hasPrefix("https://") { - results.externalLinks.insert(url) + results.externalLink(to: url) } else { - results.issues.insert(.warning("Relative link in HTML: \(url)")) + results.warning("Relative link in HTML: \(url)") } } } catch { @@ -190,7 +134,7 @@ final class PageContentParser { .filter { !$0.trimmed.isEmpty } for src in srcsetAttributes { - results.issues.insert(.warning("Found source set in html: \(src)")) + results.warning("Found source set in html: \(src)") } let srcAttributes = try linkElements.array() @@ -199,14 +143,15 @@ final class PageContentParser { for src in srcAttributes { guard content.isValidIdForFile(src) else { - results.issues.insert(.warning("Found source in html: \(src)")) + results.warning("Found source in html: \(src)") continue } guard let file = content.file(src) else { - results.issues.insert(.warning("Found source in html: \(src)")) + results.warning("Found source in html: \(src)") continue } - results.files.insert(file) + #warning("Either find files by their full path, or replace file id with full path") + results.require(file: file) } } catch { print("Error parsing HTML: \(error)") @@ -285,7 +230,7 @@ final class PageContentParser { } /** - Format: `[image](;]` + Format: `![image](;]` */ private func handleImage(_ arguments: [String], markdown: Substring) -> String { guard (1...2).contains(arguments.count) else { @@ -295,10 +240,10 @@ final class PageContentParser { let imageId = arguments[0] guard let image = content.image(imageId) else { - results.missing(file: imageId, markdown: markdown) + results.missing(file: imageId, source: "Image command") return "" } - results.files.insert(image) + results.used(file: image) let caption = arguments.count == 2 ? arguments[1] : nil let altText = image.localized(in: language) @@ -314,14 +259,14 @@ final class PageContentParser { width: thumbnailWidth, height: thumbnailWidth, altText: altText) - results.imagesToGenerate.insert(.init(size: thumbnailWidth, image: image)) + results.requireImageSet(for: image, size: thumbnailWidth) let largeImage = FeedEntryData.Image( rawImagePath: path, width: largeImageWidth, height: largeImageWidth, altText: altText) - results.imagesToGenerate.insert(.init(size: largeImageWidth, image: image)) + results.requireImageSet(for: image, size: largeImageWidth) return PageImage( imageId: imageId.replacingOccurrences(of: ".", with: "-"), @@ -343,12 +288,13 @@ final class PageContentParser { let options = arguments.dropFirst().compactMap { convertVideoOption($0, markdown: markdown) } guard let file = content.file(fileId) else { - results.missing(file: fileId, markdown: markdown) + results.missing(file: fileId, source: "Video command") return "" } - results.files.insert(file) + #warning("Create/specify video alternatives") + results.require(file: file) - guard let videoType = file.type.videoType?.htmlType else { + guard let videoType = file.type.htmlType else { results.invalid(command: .video, markdown) return "" } @@ -370,23 +316,22 @@ final class PageContentParser { } if case let .poster(imageId) = option { if let image = content.image(imageId) { - results.files.insert(image) + results.used(file: image) let width = 2*thumbnailWidth let fullLink = WebsiteImage.imagePath(source: image.absoluteUrl, width: width, height: width) return .poster(image: fullLink) } else { - results.missing(file: imageId, markdown: markdown) + results.missing(file: imageId, source: "Video command poster") return nil // Image file not present, so skip the option } } if case let .src(videoId) = option { if let video = content.video(videoId) { - results.files.insert(video) + results.used(file: video) let link = video.absoluteUrl - // TODO: Set correct video path? return .src(link) } else { - results.missing(file: videoId, markdown: markdown) + results.missing(file: videoId, source: "Video command source") return nil // Video file not present, so skip the option } } @@ -403,7 +348,7 @@ final class PageContentParser { } let fileId = arguments[0] guard let file = content.file(fileId) else { - results.missing(file: fileId, markdown: markdown) + results.missing(file: fileId, source: "External HTML command") return "" } let content = file.textContent() @@ -435,7 +380,7 @@ final class PageContentParser { let pageId = arguments[0] guard let page = content.page(pageId) else { - results.missing(page: pageId, markdown: markdown) + results.missing(page: pageId, source: "Page link command") return "" } guard !page.isDraft else { @@ -443,6 +388,8 @@ final class PageContentParser { return "" } + results.linked(to: page) + let localized = page.localized(in: language) let url = page.absoluteUrl(in: language) let title = localized.linkPreviewTitle ?? localized.title @@ -450,8 +397,8 @@ final class PageContentParser { let image = localized.linkPreviewImage.map { image in let size = content.settings.pages.pageLinkImageSize - results.files.insert(image) - results.imagesToGenerate.insert(.init(size: size, image: image)) + results.used(file: image) + results.requireImageSet(for: image, size: size) return RelatedPageLink.Image( url: image.absoluteUrl, @@ -478,7 +425,7 @@ final class PageContentParser { let tagId = arguments[0] guard let tag = content.tag(tagId) else { - results.missing(tag: tagId, markdown: markdown) + results.missing(tag: tagId, source: "Tag link command") return "" } @@ -489,8 +436,7 @@ final class PageContentParser { let image = localized.linkPreviewImage.map { image in let size = content.settings.pages.pageLinkImageSize - results.files.insert(image) - results.imagesToGenerate.insert(.init(size: size, image: image)) + results.requireImageSet(for: image, size: size) return RelatedPageLink.Image( url: image.absoluteUrl, @@ -521,11 +467,11 @@ final class PageContentParser { } guard let file = content.file(fileId) else { - results.missing(file: fileId, markdown: markdown) + results.missing(file: fileId, source: "Model command") return "" } - results.files.insert(file) - results.requiredHeaders.insert(.modelViewer) + results.require(file: file) + results.require(header: .modelViewer) let description = file.localized(in: language) return ModelViewer(file: file.absoluteUrl, description: description).content @@ -548,11 +494,10 @@ final class PageContentParser { let imageId = arguments[0] guard let image = content.image(imageId) else { - results.missing(file: imageId, markdown: markdown) + results.missing(file: imageId, source: "SVG command") return "" } - guard case .image(let imageType) = image.type, - imageType == .svg else { + guard image.type.isSvg else { results.invalid(command: .svg, markdown) return "" } diff --git a/CHDataManagement/Generator/PageGenerationResults.swift b/CHDataManagement/Generator/PageGenerationResults.swift index 6785373..39f1419 100644 --- a/CHDataManagement/Generator/PageGenerationResults.swift +++ b/CHDataManagement/Generator/PageGenerationResults.swift @@ -17,82 +17,219 @@ extension ImageToGenerate: Hashable { final class PageGenerationResults: ObservableObject { - @Published - var linkedPages: Set = [] + let itemId: ItemId - @Published - var linkedTags: Set = [] + private unowned let delegate: GenerationResults - @Published - var externalLinks: Set = [] + /// The files that could not be accessed + private(set) var inaccessibleFiles: Set - @Published - var files: Set = [] + /// The files that could not be parsed, with the error message produced + private(set) var unparsableFiles: [FileResource : Set] - @Published - var assets: Set = [] + /// The missing files directly used by this page, and the source of the file + private(set) var missingFiles: [String: Set] - @Published - var imagesToGenerate: Set = [] + /// The missing files linked to from other files. + private(set) var missingLinkedFiles: [String : Set] - @Published - var missingPages: Set = [] + /// The missing tags linked to by this page, and the source of the link + private(set) var missingLinkedTags: [String : Set] - @Published - var missingFiles: Set = [] + /// The missing pages linked to by this page, and the source of the link + private(set) var missingLinkedPages: [String : Set] - @Published - var missingTags: Set = [] + /// The footer scripts or html to add to the end of the body + private(set) var requiredFooters: Set - @Published - var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = [] + /// The known header elements to include in the page + private(set) var requiredHeaders: Set - @Published - var requiredHeaders: Set = [] + /// The known icons that need to be included as hidden SVGs + private(set) var requiredIcons: Set - @Published - var requiredFooters: Set = [] + /// The pages linked to by the page + private(set) var linkedPages: Set - @Published - var requiredIcons: Set = [] + /// The tags linked to by this page + private(set) var linkedTags: Set - @Published - var issues: Set = [] + /// The links to external content in this page + private(set) var externalLinks: Set - func reset() { - linkedPages = [] - linkedTags = [] - externalLinks = [] - files = [] - assets = [] - imagesToGenerate = [] - missingPages = [] - missingFiles = [] - missingTags = [] - invalidCommands = [] + /// The files used by this page, but not necessarily required in the output folder + private(set) var usedFiles: Set + + /// The files that need to be copied + private(set) var requiredFiles: Set + + /// The image versions required for this page + private(set) var imagesToGenerate: Set + + private(set) var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = [] + + private(set) var warnings: Set + + /// The files that could not be saved to the output folder + private(set) var unsavedOutputFiles: [String: Set] = [:] + + init(itemId: ItemId, delegate: GenerationResults) { + self.itemId = itemId + self.delegate = delegate + inaccessibleFiles = [] + unparsableFiles = [:] + missingFiles = [:] + missingLinkedFiles = [:] + missingLinkedTags = [:] + missingLinkedPages = [:] requiredHeaders = [] requiredFooters = [] requiredIcons = [] - issues = [] + linkedPages = [] + linkedTags = [] + externalLinks = [] + usedFiles = [] + requiredFiles = [] + imagesToGenerate = [] + invalidCommands = [] + warnings = [] + unsavedOutputFiles = [:] + } + + private init(other: PageGenerationResults) { + self.itemId = other.itemId + self.delegate = other.delegate + inaccessibleFiles = other.inaccessibleFiles + unparsableFiles = other.unparsableFiles + missingFiles = other.missingFiles + missingLinkedFiles = other.missingLinkedFiles + missingLinkedTags = other.missingLinkedTags + missingLinkedPages = other.missingLinkedPages + requiredHeaders = other.requiredHeaders + requiredFooters = other.requiredFooters + requiredIcons = other.requiredIcons + linkedPages = other.linkedPages + linkedTags = other.linkedTags + externalLinks = other.externalLinks + usedFiles = other.usedFiles + requiredFiles = other.requiredFiles + imagesToGenerate = other.imagesToGenerate + invalidCommands = other.invalidCommands + warnings = other.warnings + unsavedOutputFiles = other.unsavedOutputFiles + } + + func copy() -> PageGenerationResults { + .init(other: self) + } + + // MARK: Adding entries + + func inaccessibleContent(file: FileResource) { + inaccessibleFiles.insert(file) + delegate.inaccessibleContent(file: file) } func invalid(command: ShorthandMarkdownKey?, _ markdown: Substring) { - invalidCommands.append((command, String(markdown))) - issues.insert(.invalidCommand(command: command, markdown: String(markdown))) + let markdown = String(markdown) + invalidCommands.append((command, markdown)) + delegate.invalidCommand(markdown) } - func missing(page: String, markdown: Substring) { - missingPages.insert(page) - issues.insert(.missingPage(page: page, markdown: String(markdown))) + func missing(page: String, source: String) { + missingLinkedPages[page, default: []].insert(source) + delegate.missing(page: page) } - func missing(tag: String, markdown: Substring) { - missingTags.insert(tag) - issues.insert(.missingTag(tag: tag, markdown: String(markdown))) + func missing(tag: String, source: String) { + missingLinkedTags[tag, default: []].insert(source) + delegate.missing(tag: tag) } - func missing(file: String, markdown: Substring) { - missingFiles.insert(file) - issues.insert(.missingFile(file: file, markdown: String(markdown))) + func missing(file: String, source: String) { + missingFiles[file, default: []].insert(source) + delegate.missing(file: file) + } + + func requireImageSet(for image: FileResource, size: Int) { + let jobs = ImageGenerationJob.imageSet(for: image.id, maxWidth: size, maxHeight: size) + imagesToGenerate.formUnion(jobs) + used(file: image) + delegate.generate(jobs) + } + + func invalidFormat(file: FileResource, error: String) { + unparsableFiles[file, default: []].insert(error) + delegate.unparsable(file: file) + } + + func missing(file: String, containedIn sourceFile: FileResource) { + missingLinkedFiles[file, default: []].insert(sourceFile) + delegate.missing(file: file) + } + + func used(file: FileResource) { + usedFiles.insert(file) + // TODO: Notify delegate + } + + func require(file: FileResource) { + requiredFiles.insert(file) + usedFiles.insert(file) + delegate.require(file: file) + } + + func require(files: [FileResource]) { + requiredFiles.formUnion(files) + usedFiles.formUnion(files) + delegate.require(files: files) + } + + func require(footer: String) { + requiredFooters.insert(footer) + } + + func require(header: KnownHeaderElement) { + requiredHeaders.insert(header) + } + + func require(headers: KnownHeaderElement...) { + requiredHeaders.formUnion(headers) + } + + func require(icon: PageIcon) { + requiredIcons.insert(icon) + } + + func require(icons: PageIcon...) { + requiredIcons.formUnion(icons) + } + + func require(icons: [PageIcon]) { + requiredIcons.formUnion(icons) + } + + func linked(to page: Page) { + linkedPages.insert(page) + } + + func linked(to tag: Tag) { + linkedTags.insert(tag) + } + + func externalLink(to url: String) { + externalLinks.insert(url) + delegate.externalLink(url) + } + + func warning(_ warning: String) { + warnings.insert(warning) + delegate.warning(warning) + } + + func unsavedOutput(_ path: String, source: ItemType) { + unsavedOutputFiles[path, default: []].insert(source) + delegate.unsaved(path) } } + diff --git a/CHDataManagement/Generator/PageGenerator.swift b/CHDataManagement/Generator/PageGenerator.swift index d8433c3..71a4c91 100644 --- a/CHDataManagement/Generator/PageGenerator.swift +++ b/CHDataManagement/Generator/PageGenerator.swift @@ -2,11 +2,8 @@ final class PageGenerator { private let content: Content - private let imageGenerator: ImageGenerator - - init(content: Content, imageGenerator: ImageGenerator) { + init(content: Content) { self.content = content - self.imageGenerator = imageGenerator } private func makeHeaders(requiredItems: Set) -> Set { @@ -22,10 +19,10 @@ final class PageGenerator { return result } - func generate(page: Page, language: ContentLanguage) -> (page: String, results: PageGenerationResults)? { + func generate(page: Page, language: ContentLanguage, results: PageGenerationResults) -> String? { let contentGenerator = PageContentParser( content: content, - language: language) + language: language, results: results) guard let rawPageContent = content.storage.pageContent(for: page.id, language: language) else { return nil @@ -33,8 +30,6 @@ final class PageGenerator { let pageContent = contentGenerator.generatePage(from: rawPageContent) - contentGenerator.requestImages(imageGenerator) - let localized = page.localized(in: language) let tags: [FeedEntryData.Tag] = page.tags.map { tag in @@ -42,8 +37,8 @@ final class PageGenerator { url: content.absoluteUrlToTag(tag, language: language)) } - let headers = makeHeaders(requiredItems: contentGenerator.results.requiredHeaders) - contentGenerator.results.assets.formUnion(headers.compactMap { $0.file }) + let headers = makeHeaders(requiredItems: results.requiredHeaders) + results.require(files: headers.compactMap { $0.file }) let fullPage = ContentPage( language: language, @@ -55,10 +50,10 @@ final class PageGenerator { navigationBarLinks: content.navigationBar(in: language), pageContent: pageContent, headers: headers, - footers: contentGenerator.results.requiredFooters.sorted(), - icons: contentGenerator.results.requiredIcons) + footers: results.requiredFooters.sorted(), + icons: results.requiredIcons) .content - return (fullPage, contentGenerator.results) + return fullPage } } diff --git a/CHDataManagement/Generator/PostListPageGenerator.swift b/CHDataManagement/Generator/PostListPageGenerator.swift index 89fe471..5f8f8ca 100644 --- a/CHDataManagement/Generator/PostListPageGenerator.swift +++ b/CHDataManagement/Generator/PostListPageGenerator.swift @@ -6,7 +6,7 @@ final class PostListPageGenerator { private let content: Content - private let imageGenerator: ImageGenerator + private let results: PageGenerationResults private let showTitle: Bool @@ -17,28 +17,33 @@ final class PostListPageGenerator { /// The url of the page, excluding the extension private let pageUrlPrefix: String - init(language: ContentLanguage, content: Content, imageGenerator: ImageGenerator, showTitle: Bool, pageTitle: String, pageDescription: String, pageUrlPrefix: String) { + init(language: ContentLanguage, + content: Content, + results: PageGenerationResults, + showTitle: Bool, pageTitle: String, + pageDescription: String, + pageUrlPrefix: String) { self.language = language self.content = content - self.imageGenerator = imageGenerator + self.results = results self.showTitle = showTitle self.pageTitle = pageTitle self.pageDescription = pageDescription self.pageUrlPrefix = pageUrlPrefix } - private var mainContentMaximumWidth: CGFloat { - CGFloat(content.settings.posts.contentWidth) + private var mainContentMaximumWidth: Int { + content.settings.posts.contentWidth } private var postsPerPage: Int { content.settings.posts.postsPerPage } - func createPages(for posts: [Post]) -> Bool { + func createPages(for posts: [Post]) { let totalCount = posts.count guard totalCount > 0 else { - return true + return } let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up @@ -46,14 +51,11 @@ final class PostListPageGenerator { let startIndex = (pageIndex - 1) * postsPerPage let endIndex = min(pageIndex * postsPerPage, totalCount) let postsOnPage = posts[startIndex..) -> Bool { + private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice) { let posts: [FeedEntryData] = posts.map { post in let localized: LocalizedPost = post.localized(in: language) @@ -68,6 +70,8 @@ final class PostListPageGenerator { url: content.absoluteUrlToTag(tag, language: language)) } + let images = localized.images.map(createFeedImage) + return FeedEntryData( entryId: post.id, title: localized.title, @@ -75,7 +79,7 @@ final class PostListPageGenerator { link: linkUrl, tags: tags, text: localized.text.components(separatedBy: "\n"), - images: localized.images.map(createImageSet)) + images: images) } let feedPageGenerator = FeedPageGenerator(content: content) @@ -88,23 +92,19 @@ final class PostListPageGenerator { showTitle: showTitle, pageNumber: pageIndex, totalPages: pageCount) - - if pageIndex == 1 { - return save(fileContent, to: "\(pageUrlPrefix).html") - } else { - return save(fileContent, to: "\(pageUrlPrefix)-\(pageIndex).html") + let filePath = "\(pageUrlPrefix)/\(pageIndex).html" + guard save(fileContent, to: filePath) else { + results.unsavedOutput(filePath, source: .feed) + return } } - private func createImageSet(for image: FileResource) -> FeedEntryData.Image { - imageGenerator.generateImageSet( - for: image.id, - maxWidth: mainContentMaximumWidth, - maxHeight: mainContentMaximumWidth) + private func createFeedImage(for image: FileResource) -> FeedEntryData.Image { + results.requireImageSet(for: image, size: mainContentMaximumWidth) return .init( rawImagePath: image.absoluteUrl, - width: Int(mainContentMaximumWidth), - height: Int(mainContentMaximumWidth), + width: mainContentMaximumWidth, + height: mainContentMaximumWidth, altText: image.localized(in: language)) } diff --git a/CHDataManagement/Main/MainView.swift b/CHDataManagement/Main/MainView.swift index 81f80c0..1f21905 100644 --- a/CHDataManagement/Main/MainView.swift +++ b/CHDataManagement/Main/MainView.swift @@ -1,9 +1,12 @@ import SwiftUI import SFSafeSymbols +#warning("Fix podcast") +#warning("Fix CV") +#warning("Fix endeavor basics (image compare)") + #warning("Show all warnings on page content") #warning("Button to delete file") -#warning("Fix podcast") #warning("Add link to other language") #warning("Transfer images of posts to other language") #warning("Show tag selection view for pages") @@ -17,6 +20,7 @@ import SFSafeSymbols #warning("Clean up mock content") #warning("Show posts linking to a page") #warning("Add author to settings and page headers") +#warning("Mark changed images for generation") @main struct MainView: App { diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift index bdfecb5..755ab78 100644 --- a/CHDataManagement/Model/Content+Generation.swift +++ b/CHDataManagement/Model/Content+Generation.swift @@ -2,70 +2,97 @@ import Foundation extension Content { - func generateFeed() -> Bool { - #warning("Implement feed generation") - return false + func generateWebsiteInAllLanguages() { + performGenerationIfIdle { + self.generatePagesInternal() + self.generatePostFeedPagesInternal() + self.generateTagPagesInternal() + self.generateTagOverviewPagesInternal() + + self.copyRequiredFiles() + self.generateRequiredImages() + self.status("Generation completed") + } } - func generateAllPages() -> Bool { - guard startGenerating() else { return false } - defer { endGenerating() } + func generatePostFeedPages() { + performGenerationIfIdle { + self.generatePostFeedPagesInternal() + } + } - for page in pages { + func check(content: String, of page: Page, for language: ContentLanguage, onComplete: @escaping (PageGenerationResults) -> Void) { + performGenerationIfIdle { + let results = self.results.makeResults(for: page, in: language) + let generator = PageContentParser(content: page.content, language: language, results: results) + _ = generator.generatePage(from: content) + DispatchQueue.main.async { + onComplete(results) + } + } + } + + private func copyRequiredFiles() { + let count = results.requiredFiles.count + var completed = 0 + for file in results.requiredFiles { + defer { + completed += 1 + status("Copying required files: \(completed) / \(count)") + } + guard !file.isExternallyStored else { + continue + } + let path = file.absoluteUrl + if !storage.copy(file: file.id, to: path) { + results.general.unsavedOutput(path, source: .general) + } + } + } + + private func generateRequiredImages() { + let imageGenerator = ImageGenerator( + storage: storage, + settings: settings) + + let images = results.imagesToGenerate.sorted() + let count = images.count + var completed = 0 + for image in images { + defer { + completed += 1 + status("Generating required images: \(completed) / \(count)") + } + if imageGenerator.generate(job: image) { + continue + } + results.failed(image: image) + } + + //let images = Set(self.images.map { $0.id }) + //imageGenerator.recalculateGeneratedImages(by: images) + } + + func generateAllPages() { + performGenerationIfIdle { + self.generatePagesInternal() + } + } + + func generatePage(_ page: Page) { + performGenerationIfIdle { for language in ContentLanguage.allCases { - guard generateInternal(page, in: language) else { - return false - } + self.generateInternal(page, in: language) } + self.copyRequiredFiles() + self.generateRequiredImages() } - - let failedAssetCopies = results.values - .reduce(Set()) { $0.union($1.assets) } - .filter { !$0.isExternallyStored } - .filter { !storage.copy(file: $0.id, to: $0.assetUrl) } - - let failedFileCopies = results.values - .reduce(Set()) { $0.union($1.files) } - .filter { !$0.isExternallyStored } - .filter { !storage.copy(file: $0.id, to: $0.absoluteUrl) } - - - guard imageGenerator.runJobs(callback: { _ in }) else { - return false - } - return true } - func generatePage(_ page: Page) -> Bool { - guard startGenerating() else { return false } - defer { endGenerating() } - - for language in ContentLanguage.allCases { - guard generateInternal(page, in: language) else { - return false - } + func generatePage(_ page: Page, in language: ContentLanguage) { + performGenerationIfIdle { + self.generateInternal(page, in: language) } - guard imageGenerator.runJobs(callback: { _ in }) else { - return false - } - - let failedAssetCopies = results.values - .reduce(Set()) { $0.union($1.assets) } - .filter { !$0.isExternallyStored } - .filter { !storage.copy(file: $0.id, to: $0.assetUrl) } - - let failedFileCopies = results.values - .reduce(Set()) { $0.union($1.files) } - .filter { !$0.isExternallyStored } - .filter { !storage.copy(file: $0.id, to: $0.absoluteUrl) } - - return true - } - - func generatePage(_ page: Page, in language: ContentLanguage) -> Bool { - guard startGenerating() else { return false } - defer { endGenerating() } - return generateInternal(page, in: language) } // MARK: Paths to items @@ -121,60 +148,134 @@ extension Content { return result } - // MARK: Images - - func recalculateGeneratedImages() { - let images = Set(self.images.map { $0.id }) - imageGenerator.recalculateGeneratedImages(by: images) - } - // MARK: Generation - private func startGenerating() -> Bool { - guard !isGeneratingWebsite else { - return false - } - // TODO: Fix bug where multiple generating operations can be started - // due to dispatch of locking property on main queue - self.set(isGenerating: true) - return true - } - - private func endGenerating() { - set(isGenerating: false) - } - - private func generateInternal(_ page: Page, in language: ContentLanguage) -> Bool { - let pageGenerator = PageGenerator( - content: self, - imageGenerator: imageGenerator) - - guard let (content, results) = pageGenerator.generate(page: page, language: language) else { - print("Failed to generate page \(page.id) in language \(language)") - return false - } - + private func performGenerationIfIdle(_ operation: @escaping () -> ()) { DispatchQueue.main.async { - let id = ItemId(itemId: page.id, language: language, itemType: .page) - self.results[id] = results + guard !self.isGeneratingWebsite else { + return + } + self.set(isGenerating: true) + DispatchQueue.global(qos: .userInitiated).async { + operation() + DispatchQueue.main.async { + self.set(isGenerating: false) + } + } + } + } + + private func status(_ message: String) { + DispatchQueue.main.async { + self.generationStatus = message + } + } + + /** + - Note: Run on background thread + */ + private func generatePagesInternal() { + let count = pages.count + for index in pages.indices { + let page = pages[index] + status("Generating pages: \(index) / \(count)") + guard !page.isExternalUrl else { + continue + } + for language in ContentLanguage.allCases { + guard page.hasContent(in: language) else { + continue + } + generateInternal(page, in: language) + } + } + } + + /** + - Note: Run on background thread + */ + private func generatePostFeedPagesInternal() { + status("Generating post feed") + for language in ContentLanguage.allCases { + let results = results.makeResults(for: .feed, in: language) + let generator = PostListPageGenerator( + language: language, + content: self, + results: results, + showTitle: false, + pageTitle: settings.localized(in: language).title, + pageDescription: settings.localized(in: language).description, + pageUrlPrefix: settings.localized(in: language).feedUrlPrefix) + generator.createPages(for: posts) + } + + } + + /** + - Note: Run on background thread + */ + private func generateTagPagesInternal() { + let count = tags.count + for index in tags.indices { + let tag = tags[index] + status("Generating tag pages: \(index) / \(count)") + generatePagesInternal(for: tag) + } + } + + /** + - Note: Run on background thread + */ + private func generatePagesInternal(for tag: Tag) { + for language in ContentLanguage.allCases { + let results = results.makeResults(for: tag, in: language) + + let posts = posts.filter { $0.tags.contains(tag) } + guard posts.count > 0 else { continue } + + let localized = tag.localized(in: language) + let urlPrefix = absoluteUrlPrefixForTag(tag, language: language) + let generator = PostListPageGenerator( + language: language, + content: self, + results: results, + showTitle: true, + pageTitle: localized.name, + pageDescription: localized.description ?? "", + pageUrlPrefix: urlPrefix) + generator.createPages(for: posts) + } + } + + /** + - Note: Run on background thread + */ + private func generateTagOverviewPagesInternal() { + status("Generating tag overview page") + for language in ContentLanguage.allCases { + let results = results.makeResults(for: .tagOverview, in: language) + #warning("Create layout for tag overview page") + } + } + + /** + - Note: Run on background thread + */ + private func generateInternal(_ page: Page, in language: ContentLanguage) { + let results = results.makeResults(for: page, in: language) + let pageGenerator = PageGenerator(content: self) + + results.require(files: page.requiredFiles) + + guard let content = pageGenerator.generate(page: page, language: language, results: results) else { + print("Failed to generate page \(page.id) in language \(language)") + return } let path = page.absoluteUrl(in: language) + ".html" guard storage.write(content, to: path) else { print("Failed to save page \(page.id)") - return false + return } - return true - } -} - -prefix operator ~> - -prefix func ~> (operation: () throws -> Void) -> Bool { - do { - try operation() - return true - } catch { - return false } } diff --git a/CHDataManagement/Model/Content+Load.swift b/CHDataManagement/Model/Content+Load.swift index 8fb495f..7574924 100644 --- a/CHDataManagement/Model/Content+Load.swift +++ b/CHDataManagement/Model/Content+Load.swift @@ -32,9 +32,6 @@ extension Content { title: page.title, lastModified: page.lastModifiedDate, originalUrl: page.originalURL, - files: Set(page.files), - externalFiles: Set(page.externalFiles), - requiredFiles: Set(page.requiredFiles), linkPreviewImage: page.linkPreviewImage.map { images[$0] }, linkPreviewTitle: page.linkPreviewTitle, linkPreviewDescription: page.linkPreviewDescription) @@ -115,14 +112,15 @@ extension Content { english: convert(data.value.english, images: images)) } - let pages: [String : Page] = loadPages(pagesData, tags: tags, images: images) + let pages: [String : Page] = loadPages(pagesData, tags: tags, files: files) - let posts = postsData.map { postId, post in + let posts: [String : Post] = postsData.reduce(into: [:]) { dict, data in + let (postId, post) = data let linkedPage = post.linkedPageId.map { pages[$0] } let german = convert(post.german, images: images) let english = convert(post.english, images: images) - return Post( + dict[postId] = Post( content: self, id: postId, isDraft: post.isDraft, @@ -145,25 +143,36 @@ extension Content { self.tags = tags.values.sorted() self.pages = pages.values.sorted(ascending: false) { $0.startDate } self.files = files.values.sorted { $0.id } - self.posts = posts.sorted(ascending: false) { $0.startDate } + self.posts = posts.values.sorted(ascending: false) { $0.startDate } self.tagOverview = tagOverview - self.settings = makeSettings(settings, tags: tags, pages: pages, files: files) + self.settings = makeSettings(settings, tags: tags, pages: pages, files: files, posts: posts) print("Content loaded") } - private func makeSettings(_ settings: SettingsFile, tags: [String : Tag], pages: [String : Page], files: [String : FileResource]) -> Settings { + private func makeSettings(_ settings: SettingsFile, + tags: [String : Tag], + pages: [String : Page], + files: [String : FileResource], + posts: [String : Post]) -> Settings { #warning("Notify about missing links") - let navigationItems: [Item] = settings.navigationItems.compactMap { - switch $0.type { - case .tag: - return tags[$0.id] - case .page: - return pages[$0.id] + let navigationItems: [Item] = settings.navigationItems.compactMap { raw in + guard let type = ItemType(rawValue: raw, posts: posts, pages: pages, tags: tags) else { + return nil + } + switch type { + case .general: + return nil + case .post(let post): + return post + case .feed: + return nil // TODO: Provide feed object + case .page(let page): + return page + case .tagPage(let tag): + return tag case .tagOverview: return tagOverview - default: - return nil } } @@ -182,7 +191,7 @@ extension Content { english: .init(file: settings.english)) } - private func loadPages(_ pagesData: [String : PageFile], tags: [String : Tag], images: [String : FileResource]) -> [String : Page] { + private func loadPages(_ pagesData: [String : PageFile], tags: [String : Tag], files: [String : FileResource]) -> [String : Page] { pagesData.reduce(into: [:]) { pages, data in let (pageId, page) = data pages[pageId] = Page( @@ -193,9 +202,10 @@ extension Content { createdDate: page.createdDate, startDate: page.startDate, endDate: page.endDate, - german: convert(page.german, images: images), - english: convert(page.english, images: images), - tags: page.tags.map { tags[$0]! }) + german: convert(page.german, images: files), + english: convert(page.english, images: files), + tags: page.tags.map { tags[$0]! }, + requiredFiles: page.requiredFiles?.map { files[$0]! } ?? []) } } diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index 7f4a1d5..da28b87 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -63,7 +63,8 @@ private extension Page { startDate: startDate, endDate: hasEndDate ? endDate : nil, german: german.pageFile, - english: english.pageFile) + english: english.pageFile, + requiredFiles: requiredFiles.nonEmpty?.map { $0.id }.sorted()) } } @@ -71,9 +72,6 @@ private extension LocalizedPage { var pageFile: LocalizedPageFile { .init(url: urlString, - files: files.sorted(), - externalFiles: externalFiles.sorted(), - requiredFiles: requiredFiles.sorted(), title: title, linkPreviewImage: linkPreviewImage?.id, linkPreviewTitle: linkPreviewTitle, @@ -140,7 +138,7 @@ extension Settings { var file: SettingsFile { .init( paths: paths.file, - navigationItems: navigationItems.map { .init(type: $0.itemType, id: $0.id) }, + navigationItems: navigationItems.map { $0.itemType.id }, posts: posts.file, pages: pages.file, german: german.file, diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index 2095921..8dcf224 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -26,13 +26,14 @@ final class Content: ObservableObject { var tagOverview: TagOverviewPage? @Published - var results: [ItemId : PageGenerationResults] + var results: GenerationResults + + @Published + var generationStatus: String = "Ready to generate" @Published private(set) var isGeneratingWebsite = false - let imageGenerator: ImageGenerator - init(settings: Settings, posts: [Post], pages: [Page], @@ -45,13 +46,10 @@ final class Content: ObservableObject { self.tags = tags self.files = files self.tagOverview = tagOverview - self.results = [:] + self.results = .init() let storage = Storage() self.storage = storage - self.imageGenerator = ImageGenerator( - storage: storage, - settings: settings) } init() { @@ -62,13 +60,10 @@ final class Content: ObservableObject { self.tags = [] self.files = [] self.tagOverview = nil - self.results = [:] + self.results = .init() let storage = Storage() self.storage = storage - self.imageGenerator = ImageGenerator( - storage: storage, - settings: settings) } private func clear() { @@ -78,7 +73,7 @@ final class Content: ObservableObject { self.tags = [] self.files = [] self.tagOverview = nil - self.results = [:] + self.results = .init() } var images: [FileResource] { @@ -86,9 +81,7 @@ final class Content: ObservableObject { } func set(isGenerating: Bool) { - DispatchQueue.main.async { - self.isGeneratingWebsite = isGenerating - } + self.isGeneratingWebsite = isGenerating } func add(_ file: FileResource) { diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index c8eefd6..79da1ce 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -28,8 +28,8 @@ final class FileResource: Item { /** Only for bundle images */ - init(resourceImage: String, type: ImageFileType) { - self.type = .image(type) + init(resourceImage: String, type: FileType) { + self.type = type self.english = "A test image included in the bundle" self.german = "Ein Testbild aus dem Bundle" self.isExternallyStored = true @@ -87,18 +87,20 @@ final class FileResource: Item { return makeCleanAbsolutePath(path) } - var assetUrl: String { - let path = content.settings.paths.assetsOutputFolderPath + "/" + id - return makeCleanAbsolutePath(path) - } - - private var pathPrefix: String { - switch type { - case .image: return content.settings.paths.imagesOutputFolderPath - case .video: return content.settings.paths.videosOutputFolderPath - default: return content.settings.paths.filesOutputFolderPath + if type.isImage { + return content.settings.paths.imagesOutputFolderPath } + if type.isVideo { + return content.settings.paths.videosOutputFolderPath + } + if type.isAudio { + + } + if type.isAsset { + return content.settings.paths.assetsOutputFolderPath + } + return content.settings.paths.filesOutputFolderPath } // MARK: File diff --git a/CHDataManagement/Model/FileType.swift b/CHDataManagement/Model/FileType.swift new file mode 100644 index 0000000..9604966 --- /dev/null +++ b/CHDataManagement/Model/FileType.swift @@ -0,0 +1,240 @@ +import Foundation + +enum FileTypeCategory: String, CaseIterable { + case image + case code + case model + case text + case video + case resource + case asset + case audio + + var text: String { + switch self { + case .image: return "Images" + case .code: return "Code" + case .model: return "Models" + case .text: return "Text" + case .video: return "Videos" + case .asset: return "Assets" + case .resource: return "Other" + case .audio: return "Audio" + } + } +} + +extension FileTypeCategory: Hashable { + +} + +extension FileTypeCategory: Identifiable { + + var id: String { + rawValue + } +} + +enum FileType: String { + + // MARK: Images + + case jpg + + case png + + case avif + + case webp + + case gif + + case svg + + case tiff + + // MARK: Code + + case html + + case cpp + + case swift + + // MARK: Assets + + case css + + case js + + // MARK: Text + + case json + + case conf + + case yaml + + // MARK: Model + + case stl + + case f3d + + case step + + case glb + + case f3z + + // MARK: Video + + case mp4 + + case m4v + + case webm + + // MARK: Audio + + case mp3 + + case aac + + // MARK: Other + + case noExtension + + case zip + + case cddx + + case pdf + + case key + + case psd + + // MARK: Unknown + + case unknown + + init(fileExtension: String?) { + guard let lower = fileExtension?.lowercased() else { + self = .noExtension + return + } + if lower == "jpeg" { + self = .jpg + return + } + guard let type = FileType(rawValue: lower) else { + self = .unknown + return + } + self = type + } + + var category: FileTypeCategory { + switch self { + case .jpg, .png, .avif, .webp, .gif, .svg, .tiff: + return .image + case .mp4, .m4v, .webm: + return .video + case .mp3, .aac: + return .audio + case .js, .css: + return .asset + case .json, .conf, .yaml: + return .text + case .html, .cpp, .swift: + return .code + case .stl, .f3d, .step, .glb, .f3z: + return .model + case .zip, .cddx, .pdf, .key, .psd: + return .resource + case .noExtension, .unknown: + return .resource + } + } + + var fileExtension: String { + switch self { + case .noExtension, .unknown: return "" + default: + return rawValue + } + } + + var isImage: Bool { + switch self { + case .jpg, .png, .avif, .webp, .gif, .svg, .tiff: + return true + default: + return false + } + } + + var isVideo: Bool { + switch self { + case .mp4, .m4v, .webm: + return true + default: + return false + } + } + + var isAudio: Bool { + switch self { + case .mp3, .aac: + return true + default: + return false + } + } + + var isAsset: Bool { + switch self { + case .js, .css: + return true + default: + return false + } + } + + var isTextFile: Bool { + switch self { + case .html, .cpp, .swift, .css, .js, .json, .conf, .yaml: + return true + default: + return false + } + } + + var isOtherFile: Bool { + switch self { + case .noExtension, .zip, .cddx, .pdf, .key, .psd: + return true + default: + return false + } + } + + var htmlType: String? { + switch self { + case .mp4, .m4v: + return "video/mp4" + case .webm: + return "video/webm" + default: + return nil + } + } + + var isSvg: Bool { + guard case .svg = self else { + return false + } + return true + } +} diff --git a/CHDataManagement/Model/Item/ItemId.swift b/CHDataManagement/Model/Item/ItemId.swift index 5a86f7c..f83ccce 100644 --- a/CHDataManagement/Model/Item/ItemId.swift +++ b/CHDataManagement/Model/Item/ItemId.swift @@ -1,8 +1,6 @@ struct ItemId { - let itemId: String - let language: ContentLanguage let itemType: ItemType @@ -11,16 +9,16 @@ struct ItemId { extension ItemId: Equatable { static func == (lhs: ItemId, rhs: ItemId) -> Bool { - lhs.itemId == rhs.itemId && lhs.language == rhs.language && lhs.itemType == rhs.itemType + lhs.language == rhs.language && + lhs.itemType == rhs.itemType } } extension ItemId: Hashable { func hash(into hasher: inout Hasher) { - hasher.combine(itemId) hasher.combine(language) - hasher.combine(itemType) + hasher.combine(itemType.id) } } @@ -30,9 +28,6 @@ extension ItemId: Comparable { guard lhs.itemType == rhs.itemType else { return lhs.itemType < rhs.itemType } - guard lhs.itemId == rhs.itemId else { - return lhs.itemId < rhs.itemId - } return lhs.language < rhs.language } } diff --git a/CHDataManagement/Model/Item/ItemType.swift b/CHDataManagement/Model/Item/ItemType.swift index a3f3b12..b0c8211 100644 --- a/CHDataManagement/Model/Item/ItemType.swift +++ b/CHDataManagement/Model/Item/ItemType.swift @@ -1,15 +1,17 @@ -enum ItemType: String, Codable { +enum ItemType { - case post + case general - case tag + case post(Post) - case page + case feed + + case page(Page) + + case tagPage(Tag) case tagOverview - - case file } extension ItemType: Equatable { @@ -23,13 +25,52 @@ extension ItemType: Hashable { extension ItemType: Identifiable { var id: String { - rawValue + switch self { + case .general: + return "0-general" + case .feed: + return "1-feed" + case .post(let post): + return "2-post-\(post.id)" + case .page(let page): + return "3-page-\(page.id)" + case .tagPage(let tag): + return "5-tag-\(tag.id)" + case .tagOverview: + return "4-tag-overview" + } + } + + init?(rawValue: String, posts: [String : Post], pages: [String : Page], tags: [String : Tag]) { + if rawValue == "0-general" { + self = .general + } else if rawValue == "1-feed" { + self = .feed + } else if rawValue == "4-tag-overview" { + self = .tagOverview + } else if let id = rawValue.removingPrefix("3-page-"), let page = pages[id] { + self = .page(page) + } else if let id = rawValue.removingPrefix("2-post-"), let post = posts[id] { + self = .post(post) + } else if let id = rawValue.removingPrefix("5-tag-"), let tag = tags[id] { + self = .tagPage(tag) + } else { + return nil + } } } extension ItemType: Comparable { static func < (lhs: ItemType, rhs: ItemType) -> Bool { - lhs.rawValue < rhs.rawValue + lhs.id < rhs.id + } +} + +extension String { + + func removingPrefix(_ prefix: String) -> String? { + guard self.hasPrefix(prefix) else { return nil } + return String(self.dropFirst(prefix.count)) } } diff --git a/CHDataManagement/Model/LocalizedPage.swift b/CHDataManagement/Model/LocalizedPage.swift index 469772c..4cac77a 100644 --- a/CHDataManagement/Model/LocalizedPage.swift +++ b/CHDataManagement/Model/LocalizedPage.swift @@ -34,29 +34,6 @@ final class LocalizedPage: ObservableObject { */ let originalUrl: String? - /** - All files which occur in the content and are stored. - - Note: This property defaults to an empty set. - */ - @Published - var files: Set = [] - - /** - All files which may occur in the content but are stored externally. - - Missing files which would otherwise produce a warning are ignored when included here. - - Note: This property defaults to an empty set. - */ - @Published - var externalFiles: Set = [] - - /** - Specifies additional files which should be copied to the destination when generating the content. - - Note: This property defaults to an empty set. - */ - @Published - var requiredFiles: Set = [] - @Published var linkPreviewImage: FileResource? @@ -71,9 +48,6 @@ final class LocalizedPage: ObservableObject { title: String, lastModified: Date? = nil, originalUrl: String? = nil, - files: Set = [], - externalFiles: Set = [], - requiredFiles: Set = [], linkPreviewImage: FileResource? = nil, linkPreviewTitle: String? = nil, linkPreviewDescription: String? = nil) { @@ -82,9 +56,6 @@ final class LocalizedPage: ObservableObject { self.title = title self.lastModified = lastModified self.originalUrl = originalUrl - self.files = files - self.externalFiles = externalFiles - self.requiredFiles = requiredFiles self.linkPreviewImage = linkPreviewImage self.linkPreviewTitle = linkPreviewTitle self.linkPreviewDescription = linkPreviewDescription diff --git a/CHDataManagement/Model/Page.swift b/CHDataManagement/Model/Page.swift index 05ab46e..8c58b88 100644 --- a/CHDataManagement/Model/Page.swift +++ b/CHDataManagement/Model/Page.swift @@ -36,12 +36,10 @@ final class Page: Item { var tags: [Tag] /** - Additional images required by the element. - - These images are specified as: `source_name destination_name width (height)`. + Additional files to copy, because the page content references them */ @Published - var images: Set = [] + var requiredFiles: [FileResource] init(content: Content, id: String, @@ -52,7 +50,8 @@ final class Page: Item { endDate: Date?, german: LocalizedPage, english: LocalizedPage, - tags: [Tag]) { + tags: [Tag], + requiredFiles: [FileResource]) { self.externalLink = externalLink self.isDraft = isDraft self.createdDate = createdDate @@ -62,6 +61,7 @@ final class Page: Item { self.german = german self.english = english self.tags = tags + self.requiredFiles = requiredFiles super.init(content: content, id: id) } @@ -109,12 +109,20 @@ final class Page: Item { } override var itemType: ItemType { - .page + .page(self) } func contains(urlComponent: String) -> Bool { english.urlString == urlComponent || german.urlString == urlComponent } + + func pageContent(in language: ContentLanguage) -> String? { + content.storage.pageContent(for: id, language: language) + } + + func hasContent(in language: ContentLanguage) -> Bool { + content.storage.hasPageContent(for: id, language: language) + } } extension Page: DateItem { diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift index e08015f..8f75c04 100644 --- a/CHDataManagement/Model/Post.swift +++ b/CHDataManagement/Model/Post.swift @@ -1,11 +1,6 @@ import Foundation -final class Post: ObservableObject { - - unowned let content: Content - - @Published - var id: String +final class Post: Item { @Published var isDraft: Bool @@ -45,8 +40,6 @@ final class Post: ObservableObject { german: LocalizedPost, english: LocalizedPost, linkedPage: Page? = nil) { - self.content = content - self.id = id self.isDraft = isDraft self.createdDate = createdDate self.startDate = startDate @@ -56,6 +49,7 @@ final class Post: ObservableObject { self.german = german self.english = english self.linkedPage = linkedPage + super.init(content: content, id: id) } func localized(in language: ContentLanguage) -> LocalizedPost { @@ -82,24 +76,6 @@ final class Post: ObservableObject { } } -extension Post: Identifiable { - -} - -extension Post: Equatable { - - static func == (lhs: Post, rhs: Post) -> Bool { - lhs.id == rhs.id - } -} - -extension Post: Hashable { - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - extension Post: DateItem { } diff --git a/CHDataManagement/Model/Tag.swift b/CHDataManagement/Model/Tag.swift index c6d4618..ea8f4f5 100644 --- a/CHDataManagement/Model/Tag.swift +++ b/CHDataManagement/Model/Tag.swift @@ -53,7 +53,7 @@ final class Tag: Item { } override var itemType: ItemType { - .tag + .tagPage(self) } func contains(urlComponent: String) -> Bool { diff --git a/CHDataManagement/Model/Types/CodeFileType.swift b/CHDataManagement/Model/Types/CodeFileType.swift deleted file mode 100644 index 4c5e27e..0000000 --- a/CHDataManagement/Model/Types/CodeFileType.swift +++ /dev/null @@ -1,21 +0,0 @@ - -enum CodeFileType: String { - - case html - - case css - - case js - - case cpp - - case swift - - init?(fileExtension: String) { - self.init(rawValue: fileExtension) - } - - var fileExtension: String { - rawValue - } -} diff --git a/CHDataManagement/Model/Types/FileType.swift b/CHDataManagement/Model/Types/FileType.swift deleted file mode 100644 index 4764488..0000000 --- a/CHDataManagement/Model/Types/FileType.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation - -enum FileTypeCategory: String, CaseIterable { - case image - case code - case model - case text - case video - case resource - - var text: String { - switch self { - case .image: return "Images" - case .code: return "Code" - case .model: return "Models" - case .text: return "Text" - case .video: return "Videos" - case .resource: return "Other" - } - } -} - -extension FileTypeCategory: Hashable { - -} - -extension FileTypeCategory: Identifiable { - - var id: String { - rawValue - } -} - -enum FileType { - case image(ImageFileType) - case code(CodeFileType) - case model(ModelFileType) - case text(TextFileType) - case video(VideoFileType) - case other(ResourceFileType) - - init(fileExtension: String?) { - let ext = fileExtension?.lowercased() ?? "" - - if let image = ImageFileType(fileExtension: ext) { - self = .image(image) - } else if let code = CodeFileType(fileExtension: ext) { - self = .code(code) - } else if let model = ModelFileType(fileExtension: ext) { - self = .model(model) - } else if let text = TextFileType(fileExtension: ext) { - self = .text(text) - } else if let video = VideoFileType(fileExtension: ext) { - self = .video(video) - } else { - let resource = ResourceFileType(fileExtension: ext) - self = .other(resource) - } - } - - var fileExtension: String { - switch self { - case .image(let type): return type.fileExtension - case .code(let type): return type.fileExtension - case .model(let type): return type.fileExtension - case .text(let type): return type.fileExtension - case .video(let type): return type.fileExtension - case .other(let type): return type.fileExtension - } - } - - var isImage: Bool { - if case .image = self { - return true - } - return false - } - - var isVideo: Bool { - if case .video = self { - return true - } - return false - } - - var isTextFile: Bool { - switch self { - case .code, .text: return true - default: return false - } - } - - var isOtherFile: Bool { - switch self { - case .model, .other: return true - default: return false - } - } - - var videoType: VideoFileType? { - if case .video(let videoType) = self { - return videoType - } - return nil - } - - var isSvg: Bool { - guard case .image(let imageFileType) = self else { - return false - } - guard case .svg = imageFileType else { - return false - } - return true - } -} diff --git a/CHDataManagement/Model/Types/ImageFileType.swift b/CHDataManagement/Model/Types/ImageFileType.swift deleted file mode 100644 index 0634151..0000000 --- a/CHDataManagement/Model/Types/ImageFileType.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import AppKit - -enum ImageFileType: String { - - case jpg - case png - case avif - case webp - case gif - case svg - case tiff - - init?(fileExtension: String) { - if fileExtension == "jpeg" { - self = .jpg - return - } - self.init(rawValue: fileExtension) - } - - var fileExtension: String { - rawValue - } - - var fileType: NSBitmapImageRep.FileType? { - switch self { - case .jpg: - return .jpeg - case .png, .avif, .webp: - return .png - case .gif: return .gif - case .tiff: return .tiff - case .svg: return nil - } - } -} - -extension ImageFileType: CaseIterable { - -} diff --git a/CHDataManagement/Model/Types/ModelFileType.swift b/CHDataManagement/Model/Types/ModelFileType.swift deleted file mode 100644 index 5b42a2a..0000000 --- a/CHDataManagement/Model/Types/ModelFileType.swift +++ /dev/null @@ -1,21 +0,0 @@ - -enum ModelFileType: String { - - case stl - - case f3d - - case step - - case glb - - case f3z - - init?(fileExtension: String) { - self.init(rawValue: fileExtension) - } - - var fileExtension: String { - rawValue - } -} diff --git a/CHDataManagement/Model/Types/ResourceFileType.swift b/CHDataManagement/Model/Types/ResourceFileType.swift deleted file mode 100644 index 3e48239..0000000 --- a/CHDataManagement/Model/Types/ResourceFileType.swift +++ /dev/null @@ -1,54 +0,0 @@ - -enum ResourceFileType { - - case noExtension - - case zip - - case cddx - - case mp3 - - case pdf - - case key - - case psd - - case other(String) - - init(fileExtension: String) { - switch fileExtension { - case "": self = .noExtension - case "zip": self = .zip - case "cddx": self = .cddx - case "mp3": self = .mp3 - case "pdf": self = .pdf - case "key": self = .key - case "psd": self = .psd - default: - self = .other(fileExtension) - } - } - - var fileExtension: String { - switch self { - case .noExtension: - return "" - case .zip: - return "zip" - case .cddx: - return "cddx" - case .mp3: - return "mp3" - case .pdf: - return "pdf" - case .key: - return "key" - case .psd: - return "psd" - case .other(let ext): - return ext - } - } -} diff --git a/CHDataManagement/Model/Types/TextFileType.swift b/CHDataManagement/Model/Types/TextFileType.swift deleted file mode 100644 index 3e919c8..0000000 --- a/CHDataManagement/Model/Types/TextFileType.swift +++ /dev/null @@ -1,18 +0,0 @@ - -enum TextFileType: String { - - case json - - case conf - - case yaml - - init?(fileExtension: String) { - self.init(rawValue: fileExtension) - } - - var fileExtension: String { - rawValue - } -} - diff --git a/CHDataManagement/Model/Types/VideoFileType.swift b/CHDataManagement/Model/Types/VideoFileType.swift deleted file mode 100644 index 2fe4d44..0000000 --- a/CHDataManagement/Model/Types/VideoFileType.swift +++ /dev/null @@ -1,30 +0,0 @@ - -enum VideoFileType: String { - - case mp4 - - case m4v - - case webm - - init?(fileExtension: String) { - self.init(rawValue: fileExtension) - } - - var fileExtension: String { - rawValue - } - - var htmlType: String { - switch self { - case .mp4, .m4v: - return "video/mp4" - case .webm: - return "video/webm" - } - } -} - -extension VideoFileType: CaseIterable { - -} diff --git a/CHDataManagement/Preview Content/Page+Mock.swift b/CHDataManagement/Preview Content/Page+Mock.swift index 3c3d2e7..af956f4 100644 --- a/CHDataManagement/Preview Content/Page+Mock.swift +++ b/CHDataManagement/Preview Content/Page+Mock.swift @@ -13,7 +13,8 @@ extension Page { endDate: nil, german: .german, english: .english, - tags: [.mock]) + tags: [.mock], + requiredFiles: []) } } @@ -24,18 +25,12 @@ extension LocalizedPage { urlString: "my-project", title: "My First Project", lastModified: nil, - originalUrl: "projects/electronics/my-first-project/en.html", - files: [], - externalFiles: [], - requiredFiles: []) + originalUrl: "projects/electronics/my-first-project/en.html") static let german = LocalizedPage( content: .mock, urlString: "mein-projekt", title: "Mein Erstes Projekt", lastModified: nil, - originalUrl: "projects/electronics/my-first-project/de.html", - files: [], - externalFiles: [], - requiredFiles: []) + originalUrl: "projects/electronics/my-first-project/de.html") } diff --git a/CHDataManagement/Storage/Model/FileOnDisk.swift b/CHDataManagement/Storage/Model/FileOnDisk.swift deleted file mode 100644 index c83dff8..0000000 --- a/CHDataManagement/Storage/Model/FileOnDisk.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -struct FileOnDisk { - - let type: FileType - - let url: URL - - let name: String - - init(image: String, url: URL) { - let ext = image.fileExtension! - let type = ImageFileType(fileExtension: ext)! - self.type = .image(type) - self.url = url - self.name = image - } - - init(type: FileType, url: URL, name: String) { - self.type = type - self.url = url - self.name = name - } -} - diff --git a/CHDataManagement/Storage/Model/PageFile.swift b/CHDataManagement/Storage/Model/PageFile.swift index ba74e2e..29bf113 100644 --- a/CHDataManagement/Storage/Model/PageFile.swift +++ b/CHDataManagement/Storage/Model/PageFile.swift @@ -17,6 +17,12 @@ struct PageFile { let german: LocalizedPageFile let english: LocalizedPageFile + + /** + Specifies additional files which should be copied to the destination when generating the content. + - Note: This property defaults to an empty set. + */ + let requiredFiles: [String]? } extension PageFile: Codable { @@ -30,22 +36,6 @@ struct LocalizedPageFile { let url: String - /** - The files (images, videos, other files) used in the page. - */ - let files: [String] - - /** - The additional files required for the page to function correctly, but which are not stored with the content. - */ - let externalFiles: [String] - - /** - Specifies additional files which should be copied to the destination when generating the content. - - Note: This property defaults to an empty set. - */ - let requiredFiles: [String] - let title: String let linkPreviewImage: String? diff --git a/CHDataManagement/Storage/Model/PageOnDisk.swift b/CHDataManagement/Storage/Model/PageOnDisk.swift deleted file mode 100644 index 59a642c..0000000 --- a/CHDataManagement/Storage/Model/PageOnDisk.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -struct PageOnDisk { - - let page: PageFile - - let deContentUrl: URL - - let enContentUrl: URL -} - diff --git a/CHDataManagement/Storage/Model/Settings/SettingsFile.swift b/CHDataManagement/Storage/Model/Settings/SettingsFile.swift index c1d2cfb..4b82b99 100644 --- a/CHDataManagement/Storage/Model/Settings/SettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/SettingsFile.swift @@ -1,18 +1,11 @@ import Foundation -struct NavigationItemReference: Codable { - - let type: ItemType - - let id: String -} - struct SettingsFile { let paths: PathSettingsFile /// The tags to show in the navigation bar - let navigationItems: [NavigationItemReference] + let navigationItems: [String] let posts: PostSettingsFile diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 20084a1..d376d78 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -95,6 +95,12 @@ final class Storage: ObservableObject { return contentScope.readString(at: path) } + func hasPageContent(for pageId: String, language: ContentLanguage) -> Bool { + guard let contentScope else { return false } + let path = pageContentPath(page: pageId, language: language) + return contentScope.hasFile(at: path) + } + /** Delete all files associated with pages that are not in the given set - Note: This function requires a security scope for the content path diff --git a/CHDataManagement/Views/Files/FileContentView.swift b/CHDataManagement/Views/Files/FileContentView.swift index 5aeb448..31c0b9a 100644 --- a/CHDataManagement/Views/Files/FileContentView.swift +++ b/CHDataManagement/Views/Files/FileContentView.swift @@ -24,7 +24,7 @@ struct FileContentView: View { } .foregroundStyle(.secondary) } else { - switch file.type { + switch file.type.category { case .image: file.imageToDisplay .resizable() @@ -39,7 +39,7 @@ struct FileContentView: View { .font(.title) } .foregroundStyle(.secondary) - case .text, .code: + case .text, .code, .asset: TextFileContentView(file: file) .id(file.id) case .video: @@ -52,7 +52,7 @@ struct FileContentView: View { .font(.title) } .foregroundStyle(.secondary) - case .other: + case .resource: VStack { Image(systemSymbol: .docQuestionmark) .resizable() @@ -62,6 +62,16 @@ struct FileContentView: View { .font(.title) } .foregroundStyle(.secondary) + case .audio: + VStack { + Image(systemSymbol: .waveformPath) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: iconSize) + Text("No preview available") + .font(.title) + } + .foregroundStyle(.secondary) } } }.padding() diff --git a/CHDataManagement/Views/Files/MultiFileSelectionView.swift b/CHDataManagement/Views/Files/MultiFileSelectionView.swift new file mode 100644 index 0000000..186c46a --- /dev/null +++ b/CHDataManagement/Views/Files/MultiFileSelectionView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct MultiFileSelectionView: View { + + @Environment(\.dismiss) + private var dismiss + + @EnvironmentObject + private var content: Content + + @Binding + private var selectedFiles: [FileResource] + + let allowedType: FileFilterType? + + let insertSorted: Bool + + @State + private var selectedFileType: FileFilterType + + @State + private var searchString = "" + + @State + private var newSelection: [FileResource] + + init(selectedFiles: Binding<[FileResource]>, allowedType: FileFilterType? = nil, insertSorted: Bool = false) { + self._selectedFiles = selectedFiles + self.newSelection = selectedFiles.wrappedValue + self.allowedType = allowedType + self.selectedFileType = allowedType ?? .images + self.insertSorted = insertSorted + } + + private var filesBySelectedType: [FileResource] { + content.files.filter { selectedFileType.matches($0.type) } + } + + private var filteredFiles: [FileResource] { + guard !searchString.isEmpty else { + return filesBySelectedType + } + return filesBySelectedType.filter { $0.id.contains(searchString) } + } + + var body: some View { + HStack { + VStack { + Text("Selected files") + .font(.title) + List { + ForEach(newSelection) { file in + HStack { + Image(systemSymbol: .minusCircleFill) + .foregroundStyle(.red) + .contentShape(Rectangle()) + .onTapGesture { deselect(file: file) } + Text(file.id) + Spacer() + } + } + .onMove(perform: moveSelectedFile) + } + HStack { + Button("Cancel") { + DispatchQueue.main.async { + dismiss() + } + } + Button("Save") { + selectedFiles = newSelection + dismiss() + } + } + } + VStack { + Picker("", selection: $selectedFileType) { + ForEach(FileFilterType.allCases) { type in + Text(type.text).tag(type) + } + } + .pickerStyle(.segmented) + .padding(.trailing, 7) + .disabled(allowedType != nil) + TextField("", text: $searchString, prompt: Text("Search")) + .textFieldStyle(.roundedBorder) + .padding(.horizontal, 8) + List(filteredFiles) { file in + HStack { + if newSelection.contains(file) { + Image(systemSymbol: .checkmarkCircleFill) + .foregroundStyle(.gray) + } else { + Image(systemSymbol: .plusCircleFill) + .foregroundStyle(.green) + } + Text(file.id) + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { select(file: file) } + } + } + } + .frame(minHeight: 500, idealHeight: 600) + .padding() + } + + private func deselect(file: FileResource) { + guard let index = newSelection.firstIndex(of: file) else { + return + } + newSelection.remove(at: index) + } + + private func select(file: FileResource) { + guard !newSelection.contains(file) else { + return + } + guard insertSorted else { + newSelection.append(file) + return + } + newSelection.insertSorted(file) + } + + private func moveSelectedFile(from source: IndexSet, to destination: Int) { + newSelection.move(fromOffsets: source, toOffset: destination) + } +} diff --git a/CHDataManagement/Views/Generic/IdPropertyView.swift b/CHDataManagement/Views/Generic/IdPropertyView.swift index 4f394b3..d7380ba 100644 --- a/CHDataManagement/Views/Generic/IdPropertyView.swift +++ b/CHDataManagement/Views/Generic/IdPropertyView.swift @@ -30,7 +30,7 @@ struct IdPropertyView: View { } private var isValid: Bool { - validation(id) + validation(newId) } var body: some View { diff --git a/CHDataManagement/Views/Pages/AddPageView.swift b/CHDataManagement/Views/Pages/AddPageView.swift index 968ae46..f202ede 100644 --- a/CHDataManagement/Views/Pages/AddPageView.swift +++ b/CHDataManagement/Views/Pages/AddPageView.swift @@ -80,7 +80,8 @@ struct AddPageView: View { english: .init(content: content, urlString: "page", title: "A Title"), - tags: []) + tags: [], + requiredFiles: []) content.add(page) selectedPage = page dismissSheet() diff --git a/CHDataManagement/Views/Pages/LocalizedPageContentView.swift b/CHDataManagement/Views/Pages/LocalizedPageContentView.swift index 2960934..9decda2 100644 --- a/CHDataManagement/Views/Pages/LocalizedPageContentView.swift +++ b/CHDataManagement/Views/Pages/LocalizedPageContentView.swift @@ -4,6 +4,9 @@ import HighlightedTextEditor struct LocalizedPageContentView: View { + @EnvironmentObject + var content: Content + let pageId: String let language: ContentLanguage @@ -11,9 +14,6 @@ struct LocalizedPageContentView: View { @ObservedObject var page: LocalizedPage - @State - private var isGeneratingWebsite = false - @State private var pageContent: String = "" @@ -21,7 +21,7 @@ struct LocalizedPageContentView: View { private var pageContentUsedForGeneration: String = "" @State - private var generationResults = PageGenerationResults() + private var generationResults: PageGenerationResults? @State private var didChangeContent = false @@ -47,10 +47,16 @@ struct LocalizedPageContentView: View { } Button(action: checkContent) { Text("Check") + }.disabled(content.isGeneratingWebsite) + if content.isGeneratingWebsite { + ProgressView() + .frame(height: 15) } Spacer() } - PageContentResultsView(results: generationResults) + if let generationResults { + PageContentResultsView(results: generationResults) + } HighlightedTextEditor( text: $pageContent, highlightRules: .markdown) @@ -65,9 +71,19 @@ struct LocalizedPageContentView: View { private func loadContent() { let language = language + guard page.content.storage.hasPageContent(for: pageId, language: language) else { + pageContent = "New file" + DispatchQueue.main.async { + didChangeContent = false + } + return + } guard let content = page.content.storage.pageContent(for: pageId, language: language) else { print("Failed to load page content") pageContent = "Failed to load" + DispatchQueue.main.async { + didChangeContent = false + } return } guard content != "" else { @@ -105,15 +121,14 @@ struct LocalizedPageContentView: View { guard content != pageContentUsedForGeneration else { return } - isGeneratingWebsite = true - DispatchQueue.global(qos: .background).async { - let generator = PageContentParser(content: page.content, language: language) - _ = generator.generatePage(from: content) - - DispatchQueue.main.async { - self.generationResults = generator.results - isGeneratingWebsite = false - } + guard let page = self.content.page(pageId) else { + return + } + guard !self.content.isGeneratingWebsite else { + return + } + self.content.check(content: content, of: page, for: language) { + self.generationResults = $0 } } } diff --git a/CHDataManagement/Views/Pages/PageContentResultsView.swift b/CHDataManagement/Views/Pages/PageContentResultsView.swift index 652373b..fa771a5 100644 --- a/CHDataManagement/Views/Pages/PageContentResultsView.swift +++ b/CHDataManagement/Views/Pages/PageContentResultsView.swift @@ -67,17 +67,22 @@ struct PageContentResultsView: View { @ObservedObject var results: PageGenerationResults + #warning("Rework to only show a single popup will all files, and indicate missing ones") + private var totalFileCount: Int { + results.usedFiles.count + results.missingFiles.count + results.missingLinkedFiles.count + } + var body: some View { HStack { TextWithPopup( symbol: .photoOnRectangleAngled, - text: "\(results.files.count + results.missingFiles.count) images and files", - items: results.files.sorted().map { $0.id }) + text: "\(totalFileCount) images and files", + items: results.usedFiles.sorted().map { $0.id }) .foregroundStyle(.secondary) TextWithPopup( symbol: .docBadgePlus, - text: "\(results.linkedPages.count + results.missingPages.count) page links", + text: "\(results.linkedPages.count + results.missingLinkedPages.count) page links", items: results.linkedPages.sorted().map { $0.localized(in: language).title }) .foregroundStyle(.secondary) @@ -87,18 +92,18 @@ struct PageContentResultsView: View { items: results.externalLinks.sorted()) .foregroundStyle(.secondary) - if !results.missingPages.isEmpty { + if !results.missingLinkedPages.isEmpty { TextWithPopup( symbol: .exclamationmarkTriangleFill, - text: "\(results.missingPages.count) missing pages", - items: results.missingPages.sorted()) + text: "\(results.missingLinkedPages.count) missing pages", + items: results.missingLinkedPages.keys.sorted()) .foregroundStyle(.red) } if !results.missingFiles.isEmpty { TextWithPopup( symbol: .exclamationmarkTriangleFill, text: "\(results.missingFiles.count) missing files", - items: results.missingFiles.sorted()) + items: results.missingFiles.keys.sorted()) .foregroundStyle(.red) } if !results.invalidCommands.isEmpty { @@ -111,7 +116,3 @@ struct PageContentResultsView: View { } } } - -#Preview { - PageContentResultsView(results: .init()) -} diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift index 63bfdbf..9ecc9aa 100644 --- a/CHDataManagement/Views/Pages/PageDetailView.swift +++ b/CHDataManagement/Views/Pages/PageDetailView.swift @@ -13,12 +13,21 @@ struct PageDetailView: View { private var page: Page @State - private var didGenerateWebsite: Bool? + private var showFileSelectionSheet = false init(page: Page) { self.page = page } + private var requiredFilesText: String { + switch page.requiredFiles.count { + case 0: return "No files" + case 1: return "1 file" + default: return "\(page.requiredFiles.count) files" + } + } + + #warning("Show info on page generation") var body: some View { ScrollView { VStack(alignment: .leading) { @@ -30,17 +39,17 @@ struct PageDetailView: View { Text("Generate") } .disabled(content.isGeneratingWebsite) - switch didGenerateWebsite { - case .none: - Image(systemSymbol: .questionmarkCircleFill) - .foregroundStyle(.gray) - case .some(true): - Image(systemSymbol: .checkmarkCircleFill) - .foregroundStyle(.green) - case .some(false): - Image(systemSymbol: .xmarkCircleFill) - .foregroundStyle(.red) - } +// switch didGenerateWebsite { +// case .none: +// Image(systemSymbol: .questionmarkCircleFill) +// .foregroundStyle(.gray) +// case .some(true): +// Image(systemSymbol: .checkmarkCircleFill) +// .foregroundStyle(.green) +// case .some(false): +// Image(systemSymbol: .xmarkCircleFill) +// .foregroundStyle(.red) +// } } IdPropertyView( id: $page.id, @@ -72,6 +81,24 @@ struct PageDetailView: View { footer: "The date when the page content ended") .disabled(page.isExternalUrl) + GenericPropertyView( + title: "Required files", + footer: "The additional files required by the page") { + HStack { + Image(systemSymbol: .squareAndPencilCircleFill) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 20) + Text(requiredFilesText) + Spacer() + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { + showFileSelectionSheet = true + } + } + LocalizedPageDetailView( isExternalPage: page.isExternalUrl, page: page.localized(in: language)) @@ -79,14 +106,14 @@ struct PageDetailView: View { } .padding() } + .sheet(isPresented: $showFileSelectionSheet) { + MultiFileSelectionView(selectedFiles: $page.requiredFiles, insertSorted: true) + } } private func generate() { DispatchQueue.global(qos: .userInitiated).async { - let success = content.generateFeed() - DispatchQueue.main.async { - didGenerateWebsite = success - } + content.generatePage(page) } } } diff --git a/CHDataManagement/Views/Posts/PostImagesView.swift b/CHDataManagement/Views/Posts/PostImagesView.swift index ffdcd6b..a45ae00 100644 --- a/CHDataManagement/Views/Posts/PostImagesView.swift +++ b/CHDataManagement/Views/Posts/PostImagesView.swift @@ -50,6 +50,9 @@ struct PostImagesView: View { .padding() } } + .sheet(isPresented: $showImagePicker) { + MultiFileSelectionView(selectedFiles: $post.images, allowedType: .images) + } } private func shiftLeft(_ image: FileResource) { diff --git a/CHDataManagement/Views/Settings/GenerationContentView.swift b/CHDataManagement/Views/Settings/Content/GenerationContentView.swift similarity index 50% rename from CHDataManagement/Views/Settings/GenerationContentView.swift rename to CHDataManagement/Views/Settings/Content/GenerationContentView.swift index c7f4ce4..efea01c 100644 --- a/CHDataManagement/Views/Settings/GenerationContentView.swift +++ b/CHDataManagement/Views/Settings/Content/GenerationContentView.swift @@ -32,7 +32,6 @@ struct GenerationContentView: View { @ViewBuilder private var generationView: some View { - ScrollView { VStack(alignment: .leading) { Text("Website Generation") .font(.largeTitle) @@ -42,32 +41,75 @@ struct GenerationContentView: View { .padding(.bottom, 30) HStack { - Button(action: generateFeed) { + Button(action: generateFullWebsite) { Text("Generate") } - .disabled(isGeneratingWebsite) + Text(generatorText) + Spacer() if isGeneratingWebsite { ProgressView() .progressViewStyle(.circular) .frame(height: 25) } - Button(action: updateGeneratedImages) { - Text("Update images") + } + .disabled(isGeneratingWebsite) + Text(content.generationStatus) + .font(.subheadline) + .padding() + HStack(spacing: 8) { + Text("\(content.results.imagesToGenerate.count) images") + Text("\(content.results.externalLinks.count) external links") + Text("\(content.results.resultCount) items processed") + Text("\(content.results.requiredFiles.count) files") + } + List { + Section("Inaccessible files") { + ForEach(content.results.inaccessibleFiles.sorted()) { file in + Text(file.id) + } + } + Section("Unparsable files") { + ForEach(content.results.unparsableFiles.sorted()) { file in + Text(file.id) + } + } + Section("Missing files") { + ForEach(content.results.missingFiles.sorted(), id: \.self) { file in + Text(file) + } + } + Section("Missing tags") { + ForEach(content.results.missingTags.sorted(), id: \.self) { tag in + Text(tag) + } + } + Section("Missing pages") { + ForEach(content.results.missingPages.sorted(), id: \.self) { page in + Text(page) + } + } + Section("Invalid commands") { + ForEach(content.results.invalidCommands.sorted(), id: \.self) { markdown in + Text(markdown) + } + } + Section("Warnings") { + ForEach(content.results.warnings.sorted(), id: \.self) { warning in + Text(warning) + } + } + Section("Unsaved output files") { + ForEach(content.results.unsavedOutputFiles.sorted(), id: \.self) { file in + Text(file) + } } - Text(generatorText) - Spacer() } }.padding() - } } - private func updateGeneratedImages() { - content.recalculateGeneratedImages() - } - - private func generateFeed() { + private func generateFullWebsite() { DispatchQueue.main.async { - _ = content.generateFeed() + content.generateWebsiteInAllLanguages() } #warning("Update feed generation") /* diff --git a/CHDataManagement/Views/Settings/Content/Pages/PageIssue.swift b/CHDataManagement/Views/Settings/Content/Pages/PageIssue.swift index 8df6cfd..fb33b1c 100644 --- a/CHDataManagement/Views/Settings/Content/Pages/PageIssue.swift +++ b/CHDataManagement/Views/Settings/Content/Pages/PageIssue.swift @@ -5,9 +5,9 @@ struct PageIssue { let language: ContentLanguage - let message: PageContentAnomaly + let message: GenerationAnomaly - init(page: Page, language: ContentLanguage, message: PageContentAnomaly) { + init(page: Page, language: ContentLanguage, message: GenerationAnomaly) { self.page = page self.language = language self.message = message diff --git a/CHDataManagement/Views/Settings/Content/Pages/PageIssueChecker.swift b/CHDataManagement/Views/Settings/Content/Pages/PageIssueChecker.swift index 1ef8e84..70a7d3e 100644 --- a/CHDataManagement/Views/Settings/Content/Pages/PageIssueChecker.swift +++ b/CHDataManagement/Views/Settings/Content/Pages/PageIssueChecker.swift @@ -50,24 +50,23 @@ final class PageIssueChecker: ObservableObject { } private func analyze(page: Page, in language: ContentLanguage) { - let parser = PageContentParser(content: page.content, language: language) + let results = page.content.results.makeResults(for: page, in: language) + let parser = PageContentParser(content: page.content, language: language, results: results) let hasPreviousIssues = issues.contains { $0.page == page && $0.language == language } let pageIssues: [PageIssue] if let rawPageContent = page.content.storage.pageContent(for: page.id, language: language) { _ = parser.generatePage(from: rawPageContent) - pageIssues = parser.results.issues.map { - PageIssue(page: page, language: language, message: $0) - } + pageIssues = [] } else { - let message = PageContentAnomaly.failedToLoadContent + let message = GenerationAnomaly.failedToLoadContent let error = PageIssue(page: page, language: language, message: message) pageIssues = [error] } guard hasPreviousIssues || !pageIssues.isEmpty else { return } - update(issues: pageIssues, for: page, in: parser.language) + update(issues: pageIssues, for: page, in: language) } private func update(issues: [PageIssue], for page: Page, in language: ContentLanguage) { diff --git a/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift b/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift index 9701f6d..c0ffb7d 100644 --- a/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift +++ b/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift @@ -231,7 +231,8 @@ struct PageIssueView: View { english: .init(content: content, urlString: pageId, title: pageId), - tags: []) + tags: [], + requiredFiles: []) content.pages.insert(page, at: 0) retryPageCheck()