diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index f8b63d5..43895a8 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -19,7 +19,7 @@ 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 /* WebsiteGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502E2CFAF6990090B18B /* WebsiteGenerator.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 */; }; @@ -31,7 +31,6 @@ E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.swift */; }; E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */; }; E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50C2CFD9B9C00AEF16D /* PostTagAssignmentView.swift */; }; - E25DA50F2CFDD76B00AEF16D /* ImageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50E2CFDD76B00AEF16D /* ImageContentView.swift */; }; E25DA5152CFF00C100AEF16D /* Content+Load.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5142CFF00B900AEF16D /* Content+Load.swift */; }; E25DA5172CFF00F500AEF16D /* Content+Save.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5162CFF00F200AEF16D /* Content+Save.swift */; }; E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5182CFF035200AEF16D /* Array+Split.swift */; }; @@ -44,7 +43,6 @@ 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 */; }; - E25DA5312D003FCB00AEF16D /* SectionedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5302D003FC000AEF16D /* SectionedSettingsView.swift */; }; E25DA5342D0041CB00AEF16D /* NavigationBarSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5332D0041CB00AEF16D /* NavigationBarSettingsFile.swift */; }; E25DA5362D0041EB00AEF16D /* PostSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5352D0041E200AEF16D /* PostSettingsFile.swift */; }; E25DA5382D00420E00AEF16D /* LocalizedPostSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5372D00420D00AEF16D /* LocalizedPostSettingsFile.swift */; }; @@ -52,11 +50,10 @@ E25DA53D2D0043E600AEF16D /* LocalizedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA53C2D0043E200AEF16D /* LocalizedSettings.swift */; }; E25DA53F2D00441F00AEF16D /* NavigationBarSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA53E2D00441C00AEF16D /* NavigationBarSettings.swift */; }; E25DA5412D00446C00AEF16D /* PostSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5402D00446700AEF16D /* PostSettings.swift */; }; - E25DA5432D0094A900AEF16D /* SettingsSidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5422D0094A400AEF16D /* SettingsSidebar.swift */; }; E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5442D00952D00AEF16D /* SettingsSection.swift */; }; E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA56C2D00EBC900AEF16D /* NavigationBarSettingsView.swift */; }; E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA56E2D00F99900AEF16D /* PostFeedSettingsView.swift */; }; - E25DA5712D01015400AEF16D /* GenerationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5702D01015400AEF16D /* GenerationSettingsView.swift */; }; + E25DA5712D01015400AEF16D /* GenerationContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5702D01015400AEF16D /* GenerationContentView.swift */; }; E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5722D018AA100AEF16D /* FileContentView.swift */; }; E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5742D018B6100AEF16D /* FileDetailView.swift */; }; E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5762D018B9500AEF16D /* File+Mock.swift */; }; @@ -77,7 +74,7 @@ E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5982D02401A00AEF16D /* PageGenerator.swift */; }; E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA59A2D024A2900AEF16D /* DateItem.swift */; }; E29D31202D0320E70051B7F4 /* HikingStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D311F2D0320E20051B7F4 /* HikingStatistics.swift */; }; - E29D31222D0363FD0051B7F4 /* DownloadButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31212D0363FA0051B7F4 /* DownloadButtons.swift */; }; + E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31212D0363FA0051B7F4 /* ContentButtons.swift */; }; E29D31242D0366860051B7F4 /* TagList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31232D0366820051B7F4 /* TagList.swift */; }; E29D31262D0370A80051B7F4 /* VideoOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31252D0370A50051B7F4 /* VideoOption.swift */; }; E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31272D0371870051B7F4 /* ContentPageVideo.swift */; }; @@ -109,6 +106,12 @@ 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 */; }; + E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D316C2D07A4FF0051B7F4 /* PageGenerationResults.swift */; }; + E29D316F2D0822770051B7F4 /* SettingsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D316E2D0822720051B7F4 /* SettingsListView.swift */; }; + E29D31712D08234D0051B7F4 /* GenerationDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31702D08234D0051B7F4 /* GenerationDetailView.swift */; }; + E29D31792D083DE50051B7F4 /* PageContentResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31782D083DDA0051B7F4 /* PageContentResultsView.swift */; }; + E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D317C2D086AAE0051B7F4 /* Int+Random.swift */; }; + E29D317F2D086F4C0051B7F4 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D317E2D086F490051B7F4 /* Icons.swift */; }; E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; }; E2A21C082CB17B870060935B /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C072CB17B810060935B /* TagView.swift */; }; E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.swift */; }; @@ -164,7 +167,7 @@ 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 /* WebsiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteGenerator.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 = ""; }; @@ -176,7 +179,6 @@ E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = ""; }; E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTagAssignmentView.swift; sourceTree = ""; }; E25DA50C2CFD9B9C00AEF16D /* PostTagAssignmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostTagAssignmentView.swift; sourceTree = ""; }; - E25DA50E2CFDD76B00AEF16D /* ImageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContentView.swift; sourceTree = ""; }; E25DA5142CFF00B900AEF16D /* Content+Load.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Load.swift"; sourceTree = ""; }; E25DA5162CFF00F200AEF16D /* Content+Save.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Save.swift"; sourceTree = ""; }; E25DA5182CFF035200AEF16D /* Array+Split.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Split.swift"; sourceTree = ""; }; @@ -187,7 +189,6 @@ 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 = ""; }; - E25DA5302D003FC000AEF16D /* SectionedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionedSettingsView.swift; sourceTree = ""; }; E25DA5332D0041CB00AEF16D /* NavigationBarSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarSettingsFile.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 = ""; }; @@ -195,11 +196,10 @@ E25DA53C2D0043E200AEF16D /* LocalizedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSettings.swift; sourceTree = ""; }; E25DA53E2D00441C00AEF16D /* NavigationBarSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarSettings.swift; sourceTree = ""; }; E25DA5402D00446700AEF16D /* PostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSettings.swift; sourceTree = ""; }; - E25DA5422D0094A400AEF16D /* SettingsSidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSidebar.swift; sourceTree = ""; }; E25DA5442D00952D00AEF16D /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; E25DA56C2D00EBC900AEF16D /* NavigationBarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarSettingsView.swift; sourceTree = ""; }; E25DA56E2D00F99900AEF16D /* PostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedSettingsView.swift; sourceTree = ""; }; - E25DA5702D01015400AEF16D /* GenerationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationSettingsView.swift; sourceTree = ""; }; + E25DA5702D01015400AEF16D /* GenerationContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationContentView.swift; sourceTree = ""; }; E25DA5722D018AA100AEF16D /* FileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileContentView.swift; sourceTree = ""; }; 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 = ""; }; @@ -218,7 +218,7 @@ E25DA5982D02401A00AEF16D /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = ""; }; E25DA59A2D024A2900AEF16D /* DateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateItem.swift; sourceTree = ""; }; E29D311F2D0320E20051B7F4 /* HikingStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HikingStatistics.swift; sourceTree = ""; }; - E29D31212D0363FA0051B7F4 /* DownloadButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadButtons.swift; sourceTree = ""; }; + E29D31212D0363FA0051B7F4 /* ContentButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentButtons.swift; sourceTree = ""; }; E29D31232D0366820051B7F4 /* TagList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagList.swift; sourceTree = ""; }; E29D31252D0370A50051B7F4 /* VideoOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoOption.swift; sourceTree = ""; }; E29D31272D0371870051B7F4 /* ContentPageVideo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPageVideo.swift; sourceTree = ""; }; @@ -250,6 +250,12 @@ 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 = ""; }; + E29D316C2D07A4FF0051B7F4 /* PageGenerationResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerationResults.swift; sourceTree = ""; }; + E29D316E2D0822720051B7F4 /* SettingsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsListView.swift; sourceTree = ""; }; + E29D31702D08234D0051B7F4 /* GenerationDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationDetailView.swift; sourceTree = ""; }; + E29D31782D083DDA0051B7F4 /* PageContentResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContentResultsView.swift; sourceTree = ""; }; + E29D317C2D086AAE0051B7F4 /* Int+Random.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Random.swift"; sourceTree = ""; }; + E29D317E2D086F490051B7F4 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; }; E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = ""; }; E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = ""; }; @@ -352,14 +358,15 @@ E25DA5782D01C56200AEF16D /* Generator */ = { isa = PBXGroup; children = ( - E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */, - E29D31252D0370A50051B7F4 /* VideoOption.swift */, E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */, - E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */, - E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */, - E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */, - E25DA5982D02401A00AEF16D /* PageGenerator.swift */, E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, + E218502E2CFAF6990090B18B /* LocalizedWebsiteGenerator.swift */, + E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */, + E29D316C2D07A4FF0051B7F4 /* PageGenerationResults.swift */, + E25DA5982D02401A00AEF16D /* PageGenerator.swift */, + E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */, + E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */, + E29D31252D0370A50051B7F4 /* VideoOption.swift */, ); path = Generator; sourceTree = ""; @@ -381,9 +388,10 @@ E29D311E2D0320D90051B7F4 /* ContentElements */ = { isa = PBXGroup; children = ( + E29D317E2D086F490051B7F4 /* Icons.swift */, E29D31272D0371870051B7F4 /* ContentPageVideo.swift */, E29D31232D0366820051B7F4 /* TagList.swift */, - E29D31212D0363FA0051B7F4 /* DownloadButtons.swift */, + E29D31212D0363FA0051B7F4 /* ContentButtons.swift */, E29D311F2D0320E20051B7F4 /* HikingStatistics.swift */, ); path = ContentElements; @@ -404,6 +412,7 @@ E2A21C322CB5BCAC0060935B /* Pages */ = { isa = PBXGroup; children = ( + E29D31782D083DDA0051B7F4 /* PageContentResultsView.swift */, E2A37D242CEBD7A10000979F /* PageListView.swift */, E2A21C312CB5BCAC0060935B /* PageContentView.swift */, E29D312B2D039DB30051B7F4 /* PageDetailView.swift */, @@ -417,15 +426,15 @@ E2A21C342CB9A3CA0060935B /* Settings */ = { isa = PBXGroup; children = ( + E29D316E2D0822720051B7F4 /* SettingsListView.swift */, + E25DA5702D01015400AEF16D /* GenerationContentView.swift */, + E29D31702D08234D0051B7F4 /* GenerationDetailView.swift */, E25DA5942D023BCC00AEF16D /* PageSettingsView.swift */, E25DA5442D00952D00AEF16D /* SettingsSection.swift */, - E25DA5422D0094A400AEF16D /* SettingsSidebar.swift */, - E25DA5302D003FC000AEF16D /* SectionedSettingsView.swift */, E2A21C352CB9A3D70060935B /* FolderSettingsView.swift */, E25DA56C2D00EBC900AEF16D /* NavigationBarSettingsView.swift */, E25DA56E2D00F99900AEF16D /* PostFeedSettingsView.swift */, E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */, - E25DA5702D01015400AEF16D /* GenerationSettingsView.swift */, ); path = Settings; sourceTree = ""; @@ -445,14 +454,6 @@ path = Generic; sourceTree = ""; }; - E2A21C492CBB168F0060935B /* Images */ = { - isa = PBXGroup; - children = ( - E25DA50E2CFDD76B00AEF16D /* ImageContentView.swift */, - ); - path = Images; - sourceTree = ""; - }; E2A21C522CBBF86D0060935B /* Files */ = { isa = PBXGroup; children = ( @@ -547,7 +548,6 @@ E2A21C322CB5BCAC0060935B /* Pages */, E2A9CB7F2C7E686C005C89CC /* Tags */, E2A21C522CBBF86D0060935B /* Files */, - E2A21C492CBB168F0060935B /* Images */, E2A21C342CB9A3CA0060935B /* Settings */, ); path = Views; @@ -575,6 +575,7 @@ E2B85F552C4BD0AD0047CD0C /* Extensions */ = { isa = PBXGroup; children = ( + E29D317C2D086AAE0051B7F4 /* Int+Random.swift */, E25DA5262CFF745200AEF16D /* URL+Extensions.swift */, E25DA5242CFF73A600AEF16D /* NSSize+Scaling.swift */, E25DA5182CFF035200AEF16D /* Array+Split.swift */, @@ -727,6 +728,7 @@ E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */, E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */, E218502B2CF790B30090B18B /* PostContentView.swift in Sources */, + E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */, E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */, E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */, E2A37D212CEA94EC0000979F /* Sequence+Sorted.swift in Sources */, @@ -747,13 +749,14 @@ E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */, E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */, E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */, + E29D317F2D086F4C0051B7F4 /* Icons.swift in Sources */, E2A21C082CB17B870060935B /* TagView.swift in Sources */, E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */, E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */, E2A37D2B2CED2CC30000979F /* TagDetailView.swift in Sources */, E2A21C282CB29B2A0060935B /* FeedEntryData.swift in Sources */, E25DA53F2D00441F00AEF16D /* NavigationBarSettings.swift in Sources */, - E218502F2CFAF69C0090B18B /* WebsiteGenerator.swift in Sources */, + E218502F2CFAF69C0090B18B /* LocalizedWebsiteGenerator.swift in Sources */, E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */, E25DA5832D01C7A400AEF16D /* VideoFileType.swift in Sources */, E29D31512D06168E0051B7F4 /* PostListView.swift in Sources */, @@ -799,12 +802,12 @@ E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */, E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */, E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */, + E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */, E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */, E2B85F412C4294790047CD0C /* PageHead.swift in Sources */, E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */, E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */, E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */, - E25DA5432D0094A900AEF16D /* SettingsSidebar.swift in Sources */, E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, E29D31202D0320E70051B7F4 /* HikingStatistics.swift in Sources */, @@ -821,9 +824,9 @@ E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */, E29D31452D0488CB0051B7F4 /* SelectedContentView.swift in Sources */, E2A37D1B2CEA45560000979F /* Tag+Mock.swift in Sources */, - E25DA50F2CFDD76B00AEF16D /* ImageContentView.swift in Sources */, E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */, E29D31322D03B5680051B7F4 /* LocalizedPostDetailView.swift in Sources */, + E29D31712D08234D0051B7F4 /* GenerationDetailView.swift in Sources */, E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */, E29D31532D0618740051B7F4 /* AddPageView.swift in Sources */, E25DA5292CFFBFBB00AEF16D /* ImageFileType.swift in Sources */, @@ -842,20 +845,21 @@ E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */, E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */, E29D31342D03B5D50051B7F4 /* IconButton.swift in Sources */, - E25DA5712D01015400AEF16D /* GenerationSettingsView.swift in Sources */, + E25DA5712D01015400AEF16D /* GenerationContentView.swift in Sources */, E25DA5872D01CA9300AEF16D /* GenerationResultsHandler.swift in Sources */, + E29D316F2D0822770051B7F4 /* SettingsListView.swift in Sources */, E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */, E25DA5852D01C92700AEF16D /* ShorthandMarkdownKey.swift in Sources */, E25DA5152CFF00C100AEF16D /* Content+Load.swift in Sources */, - E25DA5312D003FCB00AEF16D /* SectionedSettingsView.swift in Sources */, E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */, E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */, E25DA5932D023B3C00AEF16D /* PageSettingsFile.swift in Sources */, - E29D31222D0363FD0051B7F4 /* DownloadButtons.swift in Sources */, + E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */, E2A21C362CB9A3D70060935B /* FolderSettingsView.swift in Sources */, E29D31362D0435430051B7F4 /* TabSelection.swift in Sources */, E29D31572D06D38B0051B7F4 /* AddTagView.swift in Sources */, E25DA5362D0041EB00AEF16D /* PostSettingsFile.swift in Sources */, + E29D31792D083DE50051B7F4 /* PageContentResultsView.swift in Sources */, E25DA5912D023A8400AEF16D /* IntegerField.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/CHDataManagement/Extensions/Int+Random.swift b/CHDataManagement/Extensions/Int+Random.swift new file mode 100644 index 0000000..cbe7af5 --- /dev/null +++ b/CHDataManagement/Extensions/Int+Random.swift @@ -0,0 +1,7 @@ + +extension Int { + + static func random() -> Int { + random(in: Int.min...Int.max) + } +} diff --git a/CHDataManagement/Extensions/Sequence+Sorted.swift b/CHDataManagement/Extensions/Sequence+Sorted.swift index a2ff71b..12da12a 100644 --- a/CHDataManagement/Extensions/Sequence+Sorted.swift +++ b/CHDataManagement/Extensions/Sequence+Sorted.swift @@ -1,6 +1,6 @@ import Foundation -extension Sequence { +extension Collection { func sorted(ascending: Bool = true, using conversion: (Element) -> T) -> [Element] where T: Comparable { guard ascending else { diff --git a/CHDataManagement/Extensions/String+Extensions.swift b/CHDataManagement/Extensions/String+Extensions.swift index 921404f..a579467 100644 --- a/CHDataManagement/Extensions/String+Extensions.swift +++ b/CHDataManagement/Extensions/String+Extensions.swift @@ -9,6 +9,16 @@ extension String { .replacingOccurrences(of: ">", with: ">") } + var removingSurroundingQuotes: String { + if hasPrefix("\"") && hasSuffix("\"") { + return dropBeforeFirst("\"").dropAfterLast("\"") + } + if hasPrefix("'") && hasSuffix("'") { + return dropBeforeFirst("'").dropAfterLast("'") + } + return self + } + var nonEmpty: String? { isEmpty ? nil : self } @@ -30,7 +40,7 @@ extension String { /** Remove the part after the last occurence of the separator (including the separator itself). - The string is left unchanges, if it does not contain the separator. + The string is left unchanged, if it does not contain the separator. */ func dropAfterLast(_ separator: String) -> String { guard contains(separator) else { diff --git a/CHDataManagement/Generator/GenerationResultsHandler.swift b/CHDataManagement/Generator/GenerationResultsHandler.swift index b31ea33..1d1dbc8 100644 --- a/CHDataManagement/Generator/GenerationResultsHandler.swift +++ b/CHDataManagement/Generator/GenerationResultsHandler.swift @@ -7,6 +7,8 @@ final class GenerationResultsHandler { /// Generic warnings for pages private var pageWarnings: [(message: String, source: String)] = [] + private var missingPages: [String : [String]] = [:] + func warning(_ message: String, page: Page) { pageWarnings.append((message, page.id)) print("Page: \(page.id): \(message)") @@ -15,4 +17,8 @@ final class GenerationResultsHandler { func addRequiredVideoFile(fileId: String) { requiredVideoFiles.insert(fileId) } + + func missing(page: String, linkedBy source: String) { + missingPages[page, default: []].append(source) + } } diff --git a/CHDataManagement/Generator/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift index 7b0e8e4..107718e 100644 --- a/CHDataManagement/Generator/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -54,6 +54,9 @@ final class ImageGenerator { } func runJobs(callback: (String) -> Void) -> Bool { + guard !jobs.isEmpty else { + return true + } print("Generating \(jobs.count) images...") for job in jobs { callback("Generating image \(job.version)") @@ -80,7 +83,7 @@ final class ImageGenerator { return "\(prefix).\(type.fileExtension)" } - func generateImageSet(for image: String, maxWidth: CGFloat, maxHeight: CGFloat, altText: String) -> FeedEntryData.Image { + func generateImageSet(for image: String, maxWidth: CGFloat, maxHeight: CGFloat) { let type = ImageFileType(fileExtension: image.fileExtension!)! let width2x = maxWidth * 2 @@ -94,12 +97,6 @@ final class ImageGenerator { _ = generateVersion(for: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight) _ = generateVersion(for: image, type: type, maximumWidth: width2x, maximumHeight: height2x) - - let path = "/" + relativeImageOutputPath + "/" + image - return .init(rawImagePath: path, - width: Int(maxWidth), - height: Int(maxHeight), - altText: altText) } func generateVersion(for image: String, type: ImageFileType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String { @@ -133,6 +130,10 @@ final class ImageGenerator { return versions.contains(version) } + private func exists(imageVersion version: String) -> Bool { + inOutputImagesFolder { $0.appendingPathComponent(version).exists } + } + private func hasNowGenerated(version: String, for image: String) { guard var versions = generatedImages[image] else { generatedImages[image] = [version] @@ -149,7 +150,8 @@ final class ImageGenerator { // MARK: Image operations private func generate(job: ImageJob) -> Bool { - if hasPreviouslyGenerated(version: job.version, for: job.image), exists(job.version) { + if hasPreviouslyGenerated(version: job.version, for: job.image), exists(job.version), + exists(imageVersion: job.version) { return true } @@ -201,7 +203,7 @@ final class ImageGenerator { let url = folder.appendingPathComponent(job.version) if job.type == .avif { let out = url.path() - let input = out.replacingOccurrences(of: ".avif", with: ".jpg") + let input = url.deletingPathExtension().appendingPathExtension(job.image.fileExtension!).path() print("avifenc -q 70 \(input) \(out)") return true } diff --git a/CHDataManagement/Generator/WebsiteGenerator.swift b/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift similarity index 81% rename from CHDataManagement/Generator/WebsiteGenerator.swift rename to CHDataManagement/Generator/LocalizedWebsiteGenerator.swift index 5c14655..569f028 100644 --- a/CHDataManagement/Generator/WebsiteGenerator.swift +++ b/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift @@ -1,6 +1,6 @@ import Foundation -final class WebsiteGenerator { +final class LocalizedWebsiteGenerator { let language: ContentLanguage @@ -38,7 +38,7 @@ final class WebsiteGenerator { self.localizedSettings = content.settings.localized(in: language) self.imageGenerator = ImageGenerator( storage: content.storage, - relativeImageOutputPath: "images") + relativeImageOutputPath: "images") // TODO: Get from settings } func generateWebsite(callback: (String) -> Void) -> Bool { @@ -48,6 +48,7 @@ final class WebsiteGenerator { guard createMainPostFeedPages() else { return false } + #warning("Generate content pages") guard generateTagPages() else { return false } @@ -127,12 +128,17 @@ final class WebsiteGenerator { let pageGenerator = PageGenerator(content: content, imageGenerator: imageGenerator, navigationBarData: navigationBarData) let content: String + let results: PageGenerationResults do { - content = try pageGenerator.generate(page: page, language: language) + (content, results) = try pageGenerator.generate(page: page, language: language) } catch { print("Failed to generate page \(page.id) in language \(language): \(error)") return false } + guard !content.trimmed.isEmpty else { + #warning("Generate page with placeholder content") + return true + } let path = self.content.pageLink(page, language: language) + ".html" guard save(content, to: path) else { @@ -142,22 +148,32 @@ final class WebsiteGenerator { guard imageGenerator.runJobs(callback: { _ in }) else { return false } - guard copy(requiredVideoFiles: pageGenerator.results.requiredVideoFiles) else { + guard copy(requiredFiles: results.files) else { return false } return true } - private func copy(requiredVideoFiles: Set) -> Bool { - print("Copying \(requiredVideoFiles.count) videos...") - for fileId in requiredVideoFiles { - guard let outputPath = content.pathToFile(fileId) else { - return false + private func copy(requiredFiles: Set) -> Bool { + //print("Copying \(requiredVideoFiles.count) files...") + for file in requiredFiles { + guard !file.isExternallyStored else { + continue + } + + let outputPath: String + switch file.type { + case .video: + outputPath = content.pathToVideo(file) + case .image: + outputPath = content.pathToImage(file) + default: + outputPath = content.pathToFile(file) } do { - try content.storage.copy(file: fileId, to: outputPath) + try content.storage.copy(file: file.id, to: outputPath) } catch { - print("Failed to copy video file: \(error)") + print("Failed to copy file \(file.id): \(error)") return false } } diff --git a/CHDataManagement/Generator/PageContentGenerator.swift b/CHDataManagement/Generator/PageContentGenerator.swift index 5a5a674..446cedb 100644 --- a/CHDataManagement/Generator/PageContentGenerator.swift +++ b/CHDataManagement/Generator/PageContentGenerator.swift @@ -8,68 +8,86 @@ final class PageContentParser { private let pageLinkMarker = "page:" - private let largeImageIndicator = "*large*" - private let swift = SyntaxHighlighter(format: HTMLOutputFormat()) - private let results: GenerationResultsHandler + let results = PageGenerationResults() private let content: Content - private let imageGenerator: ImageGenerator - - private let page: Page - private let language: ContentLanguage private var largeImageCount: Int = 0 - init(page: Page, content: Content, language: ContentLanguage, results: GenerationResultsHandler, imageGenerator: ImageGenerator) { - self.page = page + var largeImageWidth: Int { + content.settings.pages.largeImageWidth + } + + var thumbnailWidth: Int { + content.settings.pages.contentWidth + } + + init(content: Content, language: ContentLanguage) { self.content = content self.language = language - self.results = results - self.imageGenerator = imageGenerator + } + + func requestImages(_ generator: ImageGenerator) { + let thumbnailWidth = CGFloat(thumbnailWidth) + let largeImageWidth = CGFloat(largeImageWidth) + + for image in results.files { + guard case .image = image.type else { + continue + } + generator.generateImageSet( + for: image.id, + maxWidth: thumbnailWidth, maxHeight: thumbnailWidth) + + generator.generateImageSet( + for: image.id, + maxWidth: largeImageWidth, maxHeight: largeImageWidth) + } + } + + func reset() { + results.reset() + largeImageCount = 0 } func generatePage(from content: String) -> String { - - let imageModifier = Modifier(target: .images) { html, markdown in - self.processMarkdownImage(markdown: markdown, html: html) - } - let codeModifier = Modifier(target: .codeBlocks) { html, markdown in - if markdown.starts(with: "```swift") { - let code = markdown.between("```swift", and: "```").trimmed - return "
" + self.swift.highlight(code) + "
" - } - return html - } - let linkModifier = Modifier(target: .links) { html, markdown in - self.handleLink(html: html, markdown: markdown) - } - let htmlModifier = Modifier(target: .html) { html, markdown in - self.handleHTML(html: html, markdown: markdown) - } - let headlinesModifier = Modifier(target: .headings) { html, markdown in - self.handleHeadlines(html: html, markdown: markdown) - } - - let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier, htmlModifier, headlinesModifier]) + reset() + let parser = MarkdownParser(modifiers: [ + Modifier(target: .images, closure: processMarkdownImage), + Modifier(target: .codeBlocks, closure: handleCode), + Modifier(target: .links, closure: handleLink), + Modifier(target: .html, closure: handleHTML), + Modifier(target: .headings, closure: handleHeadlines) + ]) return parser.html(from: content) } + private func handleCode(html: String, markdown: Substring) -> String { + guard markdown.starts(with: "```swift") else { + return html // Just use normal code highlighting + } + // Highlight swift code using Splash + let code = markdown.between("```swift", and: "```").trimmed + return "
" + swift.highlight(code) + "
" + } + private func handleLink(html: String, markdown: Substring) -> String { let file = markdown.between("(", and: ")") if file.hasPrefix(pageLinkMarker) { // Retain links pointing to elements within a page let textToChange = file.dropAfterFirst("#") let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "") - guard let pagePath = content.pageLink(pageId: pageId, language: language) else { + guard let page = content.page(pageId) else { + results.missingPages.insert(pageId) // Remove link since the page can't be found return markdown.between("[", and: "]") } - // Adjust file path to get the page url - // TODO: Calculate relative links to make pages more portable + results.linkedPages.insert(page) + let pagePath = content.pageLink(page, language: language) return html.replacingOccurrences(of: textToChange, with: pagePath) } @@ -92,6 +110,13 @@ final class PageContentParser { return html } + /** + Modify headlines by extracting an id from the headline and adding it into the html element + + Format: ###<id> + + The id is created by lowercasing the string, removing all special characters, and replacing spaces with scores + */ private func handleHeadlines(html: String, markdown: Substring) -> String { let id = markdown .last(after: "#") @@ -101,18 +126,18 @@ final class PageContentParser { .components(separatedBy: " ") .filter { $0 != "" } .joined(separator: "-") - let parts = html.components(separatedBy: ">") + let parts = html.components(separatedBy: ">") return parts[0] + " id=\"\(id)\">" + parts.dropFirst().joined(separator: ">") } - private func processMarkdownImage(markdown: Substring, html: String) -> String { + private func processMarkdownImage(html: String, markdown: Substring) -> String { // First, check the content type, then parse the remaining arguments // Notation: // <abc?> -> Optional argument // <abc...> -> Repeated argument (0 or more) // ![url](<url>;<text>) // ![image](<imageId>;<caption?>] - // ![video](<fileId>;<alt>;<option1...>] + // ![video](<fileId>;<option1...>] // ![svg](<fileId>;<<x>;<y>;<width>;<height>?>) // ![download](<<fileId>,<text>,<download-filename?>;...) // ![box](<title>;<body>) @@ -120,12 +145,11 @@ final class PageContentParser { // ![page](<pageId>) // ![external](<<url>;<text>...> // ![html](<fileId>) - guard let argumentList = markdown.between(first: "](", andLast: ")").removingPercentEncoding else { - results.warning("Invalid percent encoding for markdown image", page: page) - return "" - } + + let argumentList = markdown.between(first: "](", andLast: ")").removingPercentEncoding ?? markdown.between(first: "](", andLast: ")") let arguments = argumentList.components(separatedBy: ";") + let rawCommand = markdown.between("![", and: "]").trimmed guard rawCommand != "" else { return handleImage(arguments) @@ -134,7 +158,7 @@ final class PageContentParser { guard let convertedCommand = rawCommand.removingPercentEncoding, let command = ShorthandMarkdownKey(rawValue: convertedCommand) else { // Treat unknown commands as normal links - print("Unknown markdown command: \(rawCommand)") + results.warnings.append("Unknown markdown command '\(rawCommand)'") return html } @@ -147,12 +171,9 @@ final class PageContentParser { return handleDownloadButtons(arguments) case .video: return handleVideo(arguments) - default: - print("Unhandled markdown command: \(command)") - return "" - /* case .externalLink: - return handleExternalButtons(content: content) + return handleExternalButtons(arguments) + /* case .includedHtml: return handleExternalHTML(file: content) case .box: @@ -162,35 +183,42 @@ final class PageContentParser { case .model: return handle3dModel(content: content) */ + default: + results.warnings.append("Unhandled command '\(command.rawValue)'") + return "" + } } private func handleImage(_ arguments: [String]) -> String { // [image](<imageId>;<caption?>] guard (1...2).contains(arguments.count) else { - results.warning("Invalid image arguments: \(arguments)", page: page) + results.invalidCommandArguments.append((.image , arguments)) return "" } let imageId = arguments[0] guard let image = content.image(imageId) else { - results.warning("Missing image \(imageId)", page: page) + results.missingFiles.insert(imageId) return "" } + results.files.insert(image) + let caption = arguments.count == 2 ? arguments[1] : nil let altText = image.getDescription(for: language) - let thumbnailWidth = CGFloat(content.settings.pages.contentWidth) - let thumbnail = imageGenerator.generateImageSet( - for: imageId, - maxWidth: thumbnailWidth, maxHeight: thumbnailWidth, + let path = content.pathToImage(image) + + let thumbnail = FeedEntryData.Image( + rawImagePath: path, + width: thumbnailWidth, + height: thumbnailWidth, altText: altText) - let largeImageWidth = CGFloat(1200) // TODO: Move to settings - - let largeImage = imageGenerator.generateImageSet( - for: imageId, - maxWidth: largeImageWidth, maxHeight: largeImageWidth, + let largeImage = FeedEntryData.Image( + rawImagePath: path, + width: largeImageWidth, + height: largeImageWidth, altText: altText) return PageImage( @@ -202,7 +230,7 @@ final class PageContentParser { private func handleHikingStatistics(_ arguments: [String]) -> String { guard (1...5).contains(arguments.count) else { - results.warning("Invalid hiking statistic arguments: \(arguments)", page: page) + results.invalidCommandArguments.append((.hikingStatistics, arguments)) return "" } @@ -222,10 +250,11 @@ final class PageContentParser { } private func handleDownloadButtons(_ arguments: [String]) -> String { - let buttons: [DownloadButtons.Item] = arguments.compactMap { button in + // ![download](<<fileId>,<text>,<download-filename?>;...) + let buttons: [ContentButtons.Item] = arguments.compactMap { button in let parts = button.components(separatedBy: ",") guard (2...3).contains(parts.count) else { - results.warning("Invalid download definition with \(parts)", page: page) + results.invalidCommandArguments.append((.downloadButtons, parts)) return nil } let file = parts[0].trimmed @@ -234,44 +263,36 @@ final class PageContentParser { // Ensure that file is available guard let filePath = content.pathToFile(file) else { - results.warning("Missing download file \(file)", page: page) + results.missingFiles.insert(file) return nil } - - return DownloadButtons.Item(filePath: filePath, text: title, downloadFileName: downloadName) + return ContentButtons.Item(icon: .download, filePath: filePath, text: title, downloadFileName: downloadName) } - return DownloadButtons(items: buttons).content + return ContentButtons(items: buttons).content } private func handleVideo(_ arguments: [String]) -> String { + // ![video](<fileId>;<option1...>] guard arguments.count >= 1 else { + results.invalidCommandArguments.append((.video, arguments)) return "" } let fileId = arguments[0].trimmed - let options: [VideoOption] = arguments.dropFirst().compactMap { optionText in - guard let optionText = optionText.trimmed.nonEmpty else { - return nil - } - guard let option = VideoOption(rawValue: optionText) else { - results.warning("Unknown video option \(optionText)", page: page) - return nil - } - return option - } + let options = arguments.dropFirst().compactMap(convertVideoOption) - guard let filePath = content.pathToFile(fileId), - let file = content.file(id: fileId) else { - results.warning("Missing video file \(fileId)", page: page) + guard let file = content.file(id: fileId) else { + results.missingFiles.insert(fileId) return "" } + results.files.insert(file) + guard let videoType = file.type.videoType?.htmlType else { - results.warning("Unknown video file type for \(fileId)", page: page) + results.warnings.append("Unknown video file type for \(fileId)") return "" } - results.addRequiredVideoFile(fileId: fileId) - + let filePath = content.pathToFile(file) return ContentPageVideo( filePath: filePath, videoType: videoType, @@ -279,6 +300,40 @@ final class PageContentParser { .content } + private func convertVideoOption(_ videoOption: String) -> VideoOption? { + guard let optionText = videoOption.trimmed.nonEmpty else { + return nil + } + guard let option = VideoOption(rawValue: optionText) else { + results.invalidCommandArguments.append((.video, [optionText])) + return nil + } + if case let .poster(imageId) = option { + if let image = content.image(imageId) { + results.files.insert(image) + let link = content.pathToImage(image) + let width = 2*thumbnailWidth + let fullLink = WebsiteImage.imagePath(source: link, width: width, height: width) + return .poster(image: fullLink) + } else { + results.missingFiles.insert(imageId) + 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) + let link = content.pathToVideo(video) + // TODO: Set correct video path? + return .src(link) + } else { + results.missingFiles.insert(videoId) + return nil // Video file not present, so skip the option + } + } + return option + } + /* private func handleGif(file: String, altText: String) -> String { @@ -334,27 +389,34 @@ final class PageContentParser { results.warning("Unhandled file \(file) with extension \(fileExtension)", source: page.path) return "" } - - private func handleExternalButtons(content: String) -> String { - let buttons = content - .components(separatedBy: ";") - .compactMap { button -> (url: String, text: String)? in - let parts = button.components(separatedBy: ",") - guard parts.count == 2 else { - results.warning("Invalid external link definition", page: page) - return nil - } - guard let url = parts[0].trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - results.warning("Invalid external link \(parts[0].trimmed)", source: page.path) - return nil - } - let title = parts[1].trimmed - - return (url, title) +*/ + private func handleExternalButtons(_ arguments: [String]) -> String { + // ![external](<<url>;<text>...> + guard arguments.count >= 1 else { + results.invalidCommandArguments.append((.externalLink, arguments)) + return "" + } + let buttons: [ContentButtons.Item] = arguments.compactMap { button in + let parts = button.components(separatedBy: ",") + guard parts.count == 2 else { + results.invalidCommandArguments.append((.externalLink, parts)) + return nil } - return factory.html.externalButtons(buttons) - } + let rawUrl = parts[0].trimmed + guard let url = rawUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + results.invalidCommandArguments.append((.externalLink, parts)) + return nil + } + let title = parts[1].trimmed + return .init( + icon: .externalLink, + filePath: url, + text: title) + } + return ContentButtons(items: buttons).content + } +/* private func handleExternalHTML(file: String) -> String { let path = page.pathRelativeToRootForContainedInputFile(file) return results.getContentOfRequiredFile(at: path, source: page.path) ?? "" diff --git a/CHDataManagement/Generator/PageGenerationResults.swift b/CHDataManagement/Generator/PageGenerationResults.swift new file mode 100644 index 0000000..4c4a466 --- /dev/null +++ b/CHDataManagement/Generator/PageGenerationResults.swift @@ -0,0 +1,32 @@ +import Foundation + +final class PageGenerationResults: ObservableObject { + + @Published + var linkedPages: Set<Page> = [] + + @Published + var files: Set<FileResource> = [] + + @Published + var missingPages: Set<String> = [] + + @Published + var missingFiles: Set<String> = [] + + @Published + var invalidCommandArguments: [(command: ShorthandMarkdownKey, arguments: [String])] = [] + + @Published + var warnings: [String] = [] + + + func reset() { + linkedPages = [] + files = [] + missingPages = [] + missingFiles = [] + invalidCommandArguments = [] + warnings = [] + } +} diff --git a/CHDataManagement/Generator/PageGenerator.swift b/CHDataManagement/Generator/PageGenerator.swift index e477a67..c501407 100644 --- a/CHDataManagement/Generator/PageGenerator.swift +++ b/CHDataManagement/Generator/PageGenerator.swift @@ -6,26 +6,23 @@ final class PageGenerator { private let navigationBarData: NavigationBarData - let results = GenerationResultsHandler() - init(content: Content, imageGenerator: ImageGenerator, navigationBarData: NavigationBarData) { self.content = content self.imageGenerator = imageGenerator self.navigationBarData = navigationBarData } - func generate(page: Page, language: ContentLanguage) throws -> String { + func generate(page: Page, language: ContentLanguage) throws -> (page: String, results: PageGenerationResults) { let contentGenerator = PageContentParser( - page: page, content: content, - language: language, - results: results, - imageGenerator: imageGenerator) + language: language) let rawPageContent = try content.storage.pageContent(for: page.id, language: language) let pageContent = contentGenerator.generatePage(from: rawPageContent) + contentGenerator.requestImages(imageGenerator) + let localized = page.localized(in: language) let tags: [FeedEntryData.Tag] = page.tags.map { tag in @@ -33,7 +30,7 @@ final class PageGenerator { url: content.tagLink(tag, language: language)) } - return ContentPage( + let fullPage = ContentPage( language: language, dateString: page.dateText(in: language), title: localized.title, @@ -43,5 +40,7 @@ final class PageGenerator { navigationBarData: navigationBarData, pageContent: pageContent) .content + + return (fullPage, contentGenerator.results) } } diff --git a/CHDataManagement/Generator/PostListPageGenerator.swift b/CHDataManagement/Generator/PostListPageGenerator.swift index 0774c2e..f1f5250 100644 --- a/CHDataManagement/Generator/PostListPageGenerator.swift +++ b/CHDataManagement/Generator/PostListPageGenerator.swift @@ -66,7 +66,7 @@ final class PostListPageGenerator { text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings } - let tags: [FeedEntryData.Tag] = post.tags.map { tag in + let tags: [FeedEntryData.Tag] = post.tags.filter { $0.isVisible }.map { tag in .init(name: tag.localized(in: language).name, url: content.tagLink(tag, language: language)) } @@ -102,7 +102,11 @@ final class PostListPageGenerator { imageGenerator.generateImageSet( for: image.id, maxWidth: mainContentMaximumWidth, - maxHeight: mainContentMaximumWidth, + maxHeight: mainContentMaximumWidth) + return .init( + rawImagePath: content.pathToImage(image), + width: Int(mainContentMaximumWidth), + height: Int(mainContentMaximumWidth), altText: image.getDescription(for: language)) } diff --git a/CHDataManagement/Generator/ShorthandMarkdownKey.swift b/CHDataManagement/Generator/ShorthandMarkdownKey.swift index 7ad3a54..9cc0f31 100644 --- a/CHDataManagement/Generator/ShorthandMarkdownKey.swift +++ b/CHDataManagement/Generator/ShorthandMarkdownKey.swift @@ -18,7 +18,7 @@ enum ShorthandMarkdownKey: String { case hikingStatistics = "hiking-stats" /// A video - /// Format: `![video](<fileId>;<alt>;<option1...>]` + /// Format: `![video](<fileId>;<option1...>]` case video /// An SVG image diff --git a/CHDataManagement/Generator/VideoOption.swift b/CHDataManagement/Generator/VideoOption.swift index 296c588..9e329a8 100644 --- a/CHDataManagement/Generator/VideoOption.swift +++ b/CHDataManagement/Generator/VideoOption.swift @@ -1,11 +1,123 @@ /// HTML video options -enum VideoOption: String { +enum VideoOption { + + /// Specifies that video controls should be displayed (such as a play/pause button etc). case controls + + /// Specifies that the video will start playing as soon as it is ready case autoplay - case muted + + /// Specifies that the video will start over again, every time it is finished case loop + + /// Specifies that the audio output of the video should be muted + case muted + + /// Mobile browsers will play the video right where it is instead of the default, which is to open it up fullscreen while it plays case playsinline - case poster - case preload + + /// Sets the height of the video player + case height(Int) + + /// Sets the width of the video player + case width(Int) + + /// Specifies if and how the author thinks the video should be loaded when the page loads + case preload(VideoPreloadOption) + + /// Specifies an image to be shown while the video is downloading, or until the user hits the play button + case poster(image: String) + + /// Specifies the URL of the video file + case src(String) + + init?(rawValue: String) { + switch rawValue { + case "controls": + self = .controls + return + case "autoplay": + self = .autoplay + return + case "muted": + self = .muted + return + case "loop": + self = .loop + return + case "playsinline": + self = .playsinline + return + default: break + } + + let parts = rawValue.components(separatedBy: "=") + guard parts.count == 2 else { + return nil + } + + let optionName = parts[0] + let value = parts[1].removingSurroundingQuotes + + switch optionName { + case "height": + guard let height = Int(value) else { + return nil + } + self = .height(height) + case "width": + guard let width = Int(value) else { + return nil + } + self = .width(width) + case "preload": + guard let preloadOption = VideoPreloadOption(rawValue: value) else { + return nil + } + self = .preload(preloadOption) + case "poster": + self = .poster(image: value) + case "src": + self = .src(value) + default: + return nil + } + return + } + + var rawValue: String { + switch self { + case .controls: return "controls" + case .autoplay: return "autoplay" + case .muted: return "muted" + case .loop: return "loop" + case .playsinline: return "playsinline" + case .height(let height): return "height='\(height)'" + case .width(let width): return "width='\(width)'" + case .preload(let option): return "preload='\(option)'" + case .poster(let image): return "poster='\(image)'" + case .src(let url): return "src='\(url)'" + } + } +} + +/** + The `preload` attribute specifies if and how the author thinks that the video should be loaded when the page loads. + + The `preload` attribute allows the author to provide a hint to the browser about what he/she thinks will lead to the best user experience. + This attribute may be ignored in some instances. + + Note: The `preload` attribute is ignored if `autoplay` is present. + */ +enum VideoPreloadOption: String { + + /// The author thinks that the browser should load the entire video when the page loads + case auto + + /// The author thinks that the browser should load only metadata when the page loads + case metadata + + /// The author thinks that the browser should NOT load the video when the page loads + case none } diff --git a/CHDataManagement/Main/MainView.swift b/CHDataManagement/Main/MainView.swift index 0c608a8..577e32b 100644 --- a/CHDataManagement/Main/MainView.swift +++ b/CHDataManagement/Main/MainView.swift @@ -1,18 +1,9 @@ import SwiftUI import SFSafeSymbols -/* - Page: One page -> One post with overview - Post: One post -> No page - Page update: One page -> Multiple posts - - - - */ - -#warning("Consolidate images and files") #warning("Allow selection of pages as navigation bar items") #warning("Transfer images of posts to other language") +#warning("Show tag selection view for pages") @main struct MainView: App { @@ -46,7 +37,7 @@ struct MainView: App { private var selectedFile: FileResource? @State - private var selectedSection: SettingsSection? = .generation + private var selectedSection: SettingsSection = .folders @State private var showAddSheet = false @@ -63,9 +54,7 @@ struct MainView: App { case .files: FileListView(selectedFile: $selectedFile) case .generation: - List(SettingsSection.allCases, selection: $selectedSection) { item in - Label(item.rawValue, systemSymbol: item.icon).tag(item) - } + SettingsListView(selectedSection: $selectedSection) } } @@ -81,7 +70,7 @@ struct MainView: App { case .files: SelectedContentView<FileContentView>(selected: $selectedFile) case .generation: - GenerationDetailView(section: selectedSection) + GenerationContentView() } } @@ -97,7 +86,7 @@ struct MainView: App { case .files: SelectedDetailView<FileDetailView>(selected: $selectedFile) case .generation: - Text("") + GenerationDetailView(section: selectedSection) } } diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift index 51e418f..55961ed 100644 --- a/CHDataManagement/Model/Content+Generation.swift +++ b/CHDataManagement/Model/Content+Generation.swift @@ -18,6 +18,10 @@ extension Content { return prefix + page.localized(in: language).urlString } + func page(_ pageId: String) -> Page? { + pages.first { $0.id == pageId } + } + func pageLink(pageId: String, language: ContentLanguage) -> String? { guard let page = pages.first(where: { $0.id == pageId }) else { // TODO: Note missing link @@ -31,18 +35,42 @@ extension Content { guard let file = file(id: fileId) else { return nil } + switch file.type { + case .image: return pathToImage(file) + case .video: return pathToVideo(file) + default: return pathToFile(file) + } + } + + func pathToFile(_ file: FileResource) -> String { #warning("Add files path to settings") return "/files/\(file.id)" } - func image(_ imageId: String) -> FileResource? { - files.first { $0.id == imageId } + func pathToImage(_ image: FileResource) -> String { + return "/images/\(image.id)" } - func imageLink(imageId: String) { - + func image(_ imageId: String) -> FileResource? { + files.first { $0.id == imageId && $0.type.isImage } } + func video(_ videoId: String) -> FileResource? { + files.first { $0.id == videoId && $0.type.isVideo } + } + + func pathToVideo(_ videoId: String) -> String? { + guard let video = video(videoId) else { + return nil + } + return pathToVideo(video) + } + + func pathToVideo(_ video: FileResource) -> String { + "/videos/\(video.id)" + } + + func file(id: String) -> FileResource? { files.first { $0.id == id } } diff --git a/CHDataManagement/Model/Content+Load.swift b/CHDataManagement/Model/Content+Load.swift index 704edf0..8a10fe5 100644 --- a/CHDataManagement/Model/Content+Load.swift +++ b/CHDataManagement/Model/Content+Load.swift @@ -25,6 +25,7 @@ extension Content { private func convert(_ page: LocalizedPageFile, images: [String : FileResource]) -> LocalizedPage { LocalizedPage( + content: self, urlString: page.url, title: page.title, lastModified: page.lastModifiedDate, @@ -57,12 +58,24 @@ extension Content { let pagesData = try storage.loadAllPages() let postsData = try storage.loadAllPosts() let fileList = try storage.loadAllFiles() + let externalFiles = try storage.loadExternalFileList() - let files: [String : FileResource] = fileList.reduce(into: [:]) { files, fileId in + var files: [String : FileResource] = fileList.reduce(into: [:]) { files, fileId in let descriptions = imageDescriptions[fileId] files[fileId] = FileResource( content: self, id: fileId, + isExternallyStored: false, + en: descriptions?.english ?? "", + de: descriptions?.german ?? "") + } + + for fileId in externalFiles { + let descriptions = imageDescriptions[fileId] + files[fileId] = FileResource( + content: self, + id: fileId, + isExternallyStored: true, en: descriptions?.english ?? "", de: descriptions?.german ?? "") } @@ -115,7 +128,8 @@ extension Content { let pages = PageSettings( pageUrlPrefix: settings.pages.pageUrlPrefix, - contentWidth: settings.pages.contentWidth) + contentWidth: settings.pages.contentWidth, + largeImageWidth: settings.pages.largeImageWidth) return Settings( outputDirectoryPath: settings.outputDirectoryPath, diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index e0403c4..dda1393 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -29,6 +29,9 @@ extension Content { try storage.save(fileDescriptions: fileDescriptions) + let externalFileList = files.filter { $0.isExternallyStored }.map { $0.id } + try storage.save(externalFileList: externalFileList) + do { try storage.deletePostFiles(notIn: posts.map { $0.id }) try storage.deletePageFiles(notIn: pages.map { $0.id }) @@ -116,7 +119,7 @@ private extension LocalizedTag { name: name, subtitle: subtitle, description: description, - thumbnail: thumbnail?.id, + thumbnail: linkPreviewImage?.id, originalURL: originalUrl) } } @@ -154,7 +157,8 @@ private extension PageSettings { var file: PageSettingsFile { .init(pageUrlPrefix: pageUrlPrefix, - contentWidth: contentWidth) + contentWidth: contentWidth, + largeImageWidth: largeImageWidth) } } diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index 50cddc0..98e2506 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -11,6 +11,9 @@ final class FileResource: ObservableObject { @Published var id: String + @Published + var isExternallyStored: Bool + @Published var germanDescription: String @@ -20,12 +23,13 @@ final class FileResource: ObservableObject { @Published var size: CGSize = .zero - init(content: Content, id: String, en: String, de: String) { + init(content: Content, id: String, isExternallyStored: Bool, en: String, de: String) { self.content = content self.id = id self.type = FileType(fileExtension: id.fileExtension) self.englishDescription = en self.germanDescription = de + self.isExternallyStored = isExternallyStored } /** @@ -37,6 +41,7 @@ final class FileResource: ObservableObject { self.id = resourceImage self.englishDescription = "A test image included in the bundle" self.germanDescription = "Ein Testbild aus dem Bundle" + self.isExternallyStored = true } func getDescription(for language: ContentLanguage) -> String { diff --git a/CHDataManagement/Model/LocalizedPage.swift b/CHDataManagement/Model/LocalizedPage.swift index 68565a7..c727aa0 100644 --- a/CHDataManagement/Model/LocalizedPage.swift +++ b/CHDataManagement/Model/LocalizedPage.swift @@ -8,6 +8,8 @@ import SwiftUI */ final class LocalizedPage: ObservableObject { + unowned let content: Content + /** The string to use when creating the url for the page. @@ -64,7 +66,8 @@ final class LocalizedPage: ObservableObject { @Published var linkPreviewDescription: String? - init(urlString: String, + init(content: Content, + urlString: String, title: String, lastModified: Date? = nil, originalUrl: String? = nil, @@ -74,6 +77,7 @@ final class LocalizedPage: ObservableObject { linkPreviewImage: FileResource? = nil, linkPreviewTitle: String? = nil, linkPreviewDescription: String? = nil) { + self.content = content self.urlString = urlString self.title = title self.lastModified = lastModified diff --git a/CHDataManagement/Model/LocalizedTag.swift b/CHDataManagement/Model/LocalizedTag.swift index 48bc24b..397c8c3 100644 --- a/CHDataManagement/Model/LocalizedTag.swift +++ b/CHDataManagement/Model/LocalizedTag.swift @@ -17,7 +17,7 @@ final class LocalizedTag: ObservableObject { /// The image id of the thumbnail @Published - var thumbnail: FileResource? + var linkPreviewImage: FileResource? /// The original url in the previous site layout let originalUrl: String? @@ -32,7 +32,7 @@ final class LocalizedTag: ObservableObject { self.name = name self.subtitle = subtitle self.description = description - self.thumbnail = thumbnail + self.linkPreviewImage = thumbnail self.originalUrl = originalUrl } } diff --git a/CHDataManagement/Model/Page.swift b/CHDataManagement/Model/Page.swift index 5299cf1..e9fd87d 100644 --- a/CHDataManagement/Model/Page.swift +++ b/CHDataManagement/Model/Page.swift @@ -98,6 +98,13 @@ extension Page: Hashable { } } +extension Page: Comparable { + + static func < (lhs: Page, rhs: Page) -> Bool { + lhs.id < rhs.id + } +} + extension Page: DateItem { } diff --git a/CHDataManagement/Model/Settings/PageSettings.swift b/CHDataManagement/Model/Settings/PageSettings.swift index c1004e0..0773766 100644 --- a/CHDataManagement/Model/Settings/PageSettings.swift +++ b/CHDataManagement/Model/Settings/PageSettings.swift @@ -10,8 +10,12 @@ final class PageSettings: ObservableObject { @Published var contentWidth: Int - init(pageUrlPrefix: String, contentWidth: Int) { + @Published + var largeImageWidth: Int + + init(pageUrlPrefix: String, contentWidth: Int, largeImageWidth: Int) { self.pageUrlPrefix = pageUrlPrefix self.contentWidth = contentWidth + self.largeImageWidth = largeImageWidth } } diff --git a/CHDataManagement/Page Elements/ContentElements/DownloadButtons.swift b/CHDataManagement/Page Elements/ContentElements/ContentButtons.swift similarity index 51% rename from CHDataManagement/Page Elements/ContentElements/DownloadButtons.swift rename to CHDataManagement/Page Elements/ContentElements/ContentButtons.swift index 86ef8f1..d2a7e18 100644 --- a/CHDataManagement/Page Elements/ContentElements/DownloadButtons.swift +++ b/CHDataManagement/Page Elements/ContentElements/ContentButtons.swift @@ -1,13 +1,22 @@ -struct DownloadButtons { +struct ContentButtons { struct Item { + let icon: PageIcon + let filePath: String let text: String let downloadFileName: String? + + init(icon: PageIcon, filePath: String, text: String, downloadFileName: String? = nil) { + self.icon = icon + self.filePath = filePath + self.text = text + self.downloadFileName = downloadFileName + } } let items: [Item] @@ -17,7 +26,7 @@ struct DownloadButtons { } var content: String { - var result = "<p style='display: flex'>" + var result = "<p class='tags tag-buttons'>" for item in items { addButton(of: item, to: &result) } @@ -27,8 +36,8 @@ struct DownloadButtons { private func addButton(of item: Item, to result: inout String) { let downloadText = item.downloadFileName.map { " download='\($0)'" } ?? "" - result += "<a class='download-button' href='\(item.filePath)'\(downloadText)>" - result += "\(item.text)<span class='icon icon-download'></span>" + result += "<a class='tag' href='\(item.filePath)'\(downloadText)>" + result += "<svg><use href='#\(item.icon.name)'></use></svg>\(item.text)" result += "</a>" } } diff --git a/CHDataManagement/Page Elements/ContentElements/Icons.swift b/CHDataManagement/Page Elements/ContentElements/Icons.swift new file mode 100644 index 0000000..c407d35 --- /dev/null +++ b/CHDataManagement/Page Elements/ContentElements/Icons.swift @@ -0,0 +1,107 @@ + +enum PageIcon: CaseIterable { + + case time + + case elevationUp + + case elevationDown + + case distance + + case calories + + case download + + case externalLink + + case gitLink + + var icon: String { + switch self { + case .time: return PageIcon.timeIcon + case .elevationUp: return PageIcon.elevationUpIcon + case .elevationDown: return PageIcon.elevationDownIcon + case .distance: return PageIcon.distanceIcon + case .calories: return PageIcon.caloriesIcon + case .download: return PageIcon.downloadIcon + case .externalLink: return PageIcon.externalLinkIcon + case .gitLink: return PageIcon.gitLinkIcon + } + } + + var name: String { + switch self { + case .time: return "icon-clock" + case .elevationUp: return "icon-arrow-up" + case .elevationDown: return "icon-arrow-down" + case .distance: return "icon-sign" + case .calories: return "icon-flame" + case .download: return "icon-download" + case .externalLink: return "icon-external" + case .gitLink: return "icon-git" + } + } +} + +extension PageIcon { + + + private static let timeIcon = + """ + <svg id="icon-clock" width="16" height="16" viewBox="0 0 16 16"> + <path fill="currentColor" d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .3.4l3.5 2a.5.5 0 0 0 .4-.8L8 8.7V3.5z"/> + <path fill="currentColor" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/> + </svg> + """ + + private static let elevationUpIcon = + """ + <svg id="icon-arrow-up" width="16" height="16"> + <path fill="currentColor" d="m14 2.5a.5.5 0 0 0 -.5-.5h-6a.5.5 0 0 0 0 1h4.8l-10.16 10.15a.5.5 0 0 0 .7.7l10.16-10.14v4.79a.5.5 0 0 0 1 0z"/> + </svg> + """ + + private static let elevationDownIcon = + """ + <svg id="icon-arrow-down" width="16" height="16"> + <path fill="currentColor" fill-rule="evenodd" d="M14 13.5a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1 0-1h4.8L2.14 2.85a.5.5 0 1 1 .7-.7L13 12.29V7.5a.5.5 0 0 1 1 0v6z"/> + </svg> + """ + + private static let distanceIcon = + """ + <svg id="icon-sign" width="16" height="16"> + <path fill="currentColor" d="M7 1.4V4H2a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h5v6h2v-6h3.5a1 1 0 0 0 .8-.4l2-2.3a.5.5 0 0 0 0-.6l-2-2.3a1 1 0 0 0-.8-.4H9V1.4a1 1 0 0 0-2 0zM12.5 5l1.7 2-1.7 2H2V5h10.5z"/> + </svg> + + """ + + private static let caloriesIcon = + """ + <svg id="icon-flame" width="16" height="16"> + <path fill="currentColor" d="M8 16c3.3 0 6-2 6-5.5 0-1.5-.5-4-2.5-6 .3 1.5-1.3 2-1.3 2C11 4 9 .5 6 0c.4 2 .5 4-2 6-1.3 1-2 2.7-2 4.5C2 14 4.7 16 8 16Zm0-1c-1.7 0-3-1-3-2.8 0-.7.3-2 1.3-3-.2.8.7 1.3.7 1.3-.4-1.3.5-3.3 2-3.5-.2 1-.3 2 1 3a3 3 0 0 1 1 2.3C11 14 9.7 15 8 15Z"/> + </svg> + """ + + private static let downloadIcon: String = + """ + <svg id="icon-download" viewBox="0 0 40 40"> + <path fill="currentColor" fill-rule="evenodd" stroke="none" d="M20 40a20 20 0 1 1 20-20 20 20 0 0 1-20 20zm0-36.8A16.8 16.8 0 1 0 36.8 20 16.8 16.8 0 0 0 20 3.2zm.8 27a1 1 0 0 1-1.6 0L12.1 21c-.4-.4 0-1 .7-1H17v-8.7a.8.8 0 0 1 .8-.8h4.4a.8.8 0 0 1 .8.8V20h4.2c.6 0 1.1.5.7 1l-7.1 9.2z"/> + </svg> + """ + + private static let externalLinkIcon: String = + """ + <svg id="icon-external" viewBox="0 0 16 16"> + <path fill="currentColor" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/> + </svg> + """ + + private static let gitLinkIcon: String = + """ + <svg id="icon-git" viewBox="0 0 16 16"> + <path fill="currentColor" d="M15.698 7.287 8.712.302a1.03 1.03 0 0 0-1.457 0l-1.45 1.45 1.84 1.84a1.223 1.223 0 0 1 1.55 1.56l1.773 1.774a1.224 1.224 0 0 1 1.267 2.025 1.226 1.226 0 0 1-2.002-1.334L8.58 5.963v4.353a1.226 1.226 0 1 1-1.008-.036V5.887a1.226 1.226 0 0 1-.666-1.608L5.093 2.465l-4.79 4.79a1.03 1.03 0 0 0 0 1.457l6.986 6.986a1.03 1.03 0 0 0 1.457 0l6.953-6.953a1.03 1.03 0 0 0 0-1.457"/> + </svg> + """ +} diff --git a/CHDataManagement/Page Elements/WebsiteImage.swift b/CHDataManagement/Page Elements/WebsiteImage.swift index ee7673e..42e6013 100644 --- a/CHDataManagement/Page Elements/WebsiteImage.swift +++ b/CHDataManagement/Page Elements/WebsiteImage.swift @@ -1,6 +1,19 @@ struct WebsiteImage { + static func imagePath(prefix: String, width: Int, height: Int) -> String { + "\(prefix)@\(width)x\(height)" + } + + static func imagePath(prefix: String, extension fileExtension: String, width: Int, height: Int) -> String { + "\(prefix)@\(width)x\(height).\(fileExtension)" + } + + static func imagePath(source: String, width: Int, height: Int) -> String { + let (prefix, ext) = source.fileNameAndExtension + return imagePath(prefix: prefix, extension: ext ?? ".jpg", width: width, height: height) + } + private let prefix1x: String private let prefix2x: String @@ -18,8 +31,8 @@ struct WebsiteImage { init(rawImagePath: String, width: Int, height: Int, altText: String) { let (prefix, ext) = rawImagePath.fileNameAndExtension - self.prefix1x = "\(prefix)@\(width)x\(height)" - self.prefix2x = "\(prefix)@\(width*2)x\(height*2)" + self.prefix1x = WebsiteImage.imagePath(prefix: prefix, width: width, height: height) + self.prefix2x = WebsiteImage.imagePath(prefix: prefix, width: 2*width, height: 2*height) self.altText = altText.htmlEscaped() self.ext = ext ?? "jpg" } diff --git a/CHDataManagement/Pages/ContentPage.swift b/CHDataManagement/Pages/ContentPage.swift index 3603725..4b250e4 100644 --- a/CHDataManagement/Pages/ContentPage.swift +++ b/CHDataManagement/Pages/ContentPage.swift @@ -49,25 +49,13 @@ struct ContentPage: HtmlProducer { result += "</body></html>" // Close content } - private let symbols: String = - """ - <div style="display:none"> - <svg id="icon-clock" width="16" height="16" viewBox="0 0 16 16"> - <path fill="currentColor" d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .3.4l3.5 2a.5.5 0 0 0 .4-.8L8 8.7V3.5z"/> - <path fill="currentColor" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/> - </svg> - <svg id="icon-arrow-up" width="16" height="16"> - <path fill="currentColor" d="m14 2.5a.5.5 0 0 0 -.5-.5h-6a.5.5 0 0 0 0 1h4.8l-10.16 10.15a.5.5 0 0 0 .7.7l10.16-10.14v4.79a.5.5 0 0 0 1 0z"/> - </svg> - <svg id="icon-arrow-down" width="16" height="16"> - <path fill="currentColor" fill-rule="evenodd" d="M14 13.5a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1 0-1h4.8L2.14 2.85a.5.5 0 1 1 .7-.7L13 12.29V7.5a.5.5 0 0 1 1 0v6z"/> - </svg> - <svg id="icon-sign" width="16" height="16"> - <path fill="currentColor" d="M7 1.4V4H2a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h5v6h2v-6h3.5a1 1 0 0 0 .8-.4l2-2.3a.5.5 0 0 0 0-.6l-2-2.3a1 1 0 0 0-.8-.4H9V1.4a1 1 0 0 0-2 0zM12.5 5l1.7 2-1.7 2H2V5h10.5z"/> - </svg> - <svg id="icon-flame" width="16" height="16"> - <path fill="currentColor" d="M8 16c3.3 0 6-2 6-5.5 0-1.5-.5-4-2.5-6 .3 1.5-1.3 2-1.3 2C11 4 9 .5 6 0c.4 2 .5 4-2 6-1.3 1-2 2.7-2 4.5C2 14 4.7 16 8 16Zm0-1c-1.7 0-3-1-3-2.8 0-.7.3-2 1.3-3-.2.8.7 1.3.7 1.3-.4-1.3.5-3.3 2-3.5-.2 1-.3 2 1 3a3 3 0 0 1 1 2.3C11 14 9.7 15 8 15Z"/> - </svg> - </div> - """ + #warning("Select only required symbols") + private let symbols: String = { + var result = "<div style='display:none'>" + for icon in PageIcon.allCases { + result += icon.icon + } + result += "</div>" + return result + }() } diff --git a/CHDataManagement/Preview Content/File+Mock.swift b/CHDataManagement/Preview Content/File+Mock.swift index 1b3a4da..88f4a73 100644 --- a/CHDataManagement/Preview Content/File+Mock.swift +++ b/CHDataManagement/Preview Content/File+Mock.swift @@ -2,6 +2,6 @@ extension FileResource { static var mock: FileResource { - .init(content: .mock, id: "my-file.txt", en: "Some text file", de: "Eine Textdatei") + .init(content: .mock, id: "my-file.txt", isExternallyStored: true, en: "Some text file", de: "Eine Textdatei") } } diff --git a/CHDataManagement/Preview Content/Page+Mock.swift b/CHDataManagement/Preview Content/Page+Mock.swift index f7bee41..8392358 100644 --- a/CHDataManagement/Preview Content/Page+Mock.swift +++ b/CHDataManagement/Preview Content/Page+Mock.swift @@ -19,6 +19,7 @@ extension Page { extension LocalizedPage { static let english = LocalizedPage( + content: .mock, urlString: "my-project", title: "My First Project", lastModified: nil, @@ -28,6 +29,7 @@ extension LocalizedPage { requiredFiles: []) static let german = LocalizedPage( + content: .mock, urlString: "mein-projekt", title: "Mein Erstes Projekt", lastModified: nil, diff --git a/CHDataManagement/Preview Content/WebsiteData+Mock.swift b/CHDataManagement/Preview Content/WebsiteData+Mock.swift index 8d2d647..d9c92db 100644 --- a/CHDataManagement/Preview Content/WebsiteData+Mock.swift +++ b/CHDataManagement/Preview Content/WebsiteData+Mock.swift @@ -21,7 +21,7 @@ extension PostSettings { extension PageSettings { static var mock: PageSettings { - .init(pageUrlPrefix: "pages", contentWidth: 600) + .init(pageUrlPrefix: "pages", contentWidth: 600, largeImageWidth: 1200) } } diff --git a/CHDataManagement/Storage/Model/Settings/PageSettingsFile.swift b/CHDataManagement/Storage/Model/Settings/PageSettingsFile.swift index 0b578d2..7bfd32d 100644 --- a/CHDataManagement/Storage/Model/Settings/PageSettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/PageSettingsFile.swift @@ -4,6 +4,8 @@ struct PageSettingsFile { let pageUrlPrefix: String let contentWidth: Int + + let largeImageWidth: Int } extension PageSettingsFile: Codable { @@ -14,6 +16,7 @@ extension PageSettingsFile { static var `default`: PageSettingsFile { .init(pageUrlPrefix: "page", - contentWidth: 600) + contentWidth: 600, + largeImageWidth: 1200) } } diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 98611bb..87ac580 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -379,6 +379,18 @@ final class Storage { return try readExistingFile(at: path) } + // MARK: External file list + + private let externalFileListName = "external-files.json" + + func loadExternalFileList() throws -> [String] { + try read(at: externalFileListName, defaultValue: []) + } + + func save(externalFileList: [String]) throws { + try writeIfChanged(externalFileList.sorted(), to: externalFileListName) + } + // MARK: Website data private let settingsDataFileName: String = "settings.json" diff --git a/CHDataManagement/Views/Files/AddFileView.swift b/CHDataManagement/Views/Files/AddFileView.swift index 701de08..18d9c81 100644 --- a/CHDataManagement/Views/Files/AddFileView.swift +++ b/CHDataManagement/Views/Files/AddFileView.swift @@ -34,6 +34,7 @@ struct AddFileView: View { HStack { Button("Cancel", role: .cancel) { dismiss() } Button("Select more files", action: openFilePanel) + Button("Add placeholder", action: addPlaceholderFile) Button("Add selected", action: importSelectedFiles) .disabled(filesToAdd.isEmpty) } @@ -68,6 +69,11 @@ struct AddFileView: View { } } + private func addPlaceholderFile() { + let newFile = FileToAdd(content: content, externalFile: "placeholder") + filesToAdd.append(newFile) + } + private func delete(file: FileToAdd) { guard let index = filesToAdd.firstIndex(of: file) else { return @@ -85,16 +91,19 @@ struct AddFileView: View { print("Skipping existing file \(file.uniqueId)") continue } - do { - try content.storage.copyFile(at: file.url, fileId: file.uniqueId) - } catch { - print("Failed to import file '\(file.uniqueId)' at \(file.url.path()): \(error)") - return + if let url = file.url { + do { + try content.storage.copyFile(at: url, fileId: file.uniqueId) + } catch { + print("Failed to import file '\(file.uniqueId)' at \(url.path()): \(error)") + return + } } let resource = FileResource( content: content, id: file.uniqueId, + isExternallyStored: file.url == nil, en: "", de: "") // TODO: Insert at correct index? content.files.insert(resource, at: 0) diff --git a/CHDataManagement/Views/Files/FileContentView.swift b/CHDataManagement/Views/Files/FileContentView.swift index e748694..5aeb448 100644 --- a/CHDataManagement/Views/Files/FileContentView.swift +++ b/CHDataManagement/Views/Files/FileContentView.swift @@ -13,44 +13,56 @@ struct FileContentView: View { var body: some View { VStack { - switch file.type { - case .image: - file.imageToDisplay - .resizable() - .aspectRatio(contentMode: .fit) - case .model: + if file.isExternallyStored { VStack { - Image(systemSymbol: .cubeTransparent) + Image(systemSymbol: .squareDashed) .resizable() .aspectRatio(contentMode: .fit) .frame(width: iconSize) - Text("No preview available") + Text("External file") .font(.title) } .foregroundStyle(.secondary) - case .text, .code: - TextFileContentView(file: file) - .id(file.id) - case .video: - VStack { - Image(systemSymbol: .film) + } else { + switch file.type { + case .image: + file.imageToDisplay .resizable() .aspectRatio(contentMode: .fit) - .frame(width: iconSize) - Text("No preview available") - .font(.title) + case .model: + VStack { + Image(systemSymbol: .cubeTransparent) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: iconSize) + Text("No preview available") + .font(.title) + } + .foregroundStyle(.secondary) + case .text, .code: + TextFileContentView(file: file) + .id(file.id) + case .video: + VStack { + Image(systemSymbol: .film) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: iconSize) + Text("No preview available") + .font(.title) + } + .foregroundStyle(.secondary) + case .other: + VStack { + Image(systemSymbol: .docQuestionmark) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: iconSize) + Text("No preview available") + .font(.title) + } + .foregroundStyle(.secondary) } - .foregroundStyle(.secondary) - case .other: - VStack { - Image(systemSymbol: .docQuestionmark) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: iconSize) - Text("No preview available") - .font(.title) - } - .foregroundStyle(.secondary) } }.padding() } diff --git a/CHDataManagement/Views/Files/FileToAdd.swift b/CHDataManagement/Views/Files/FileToAdd.swift index ebb4ba6..82b83b3 100644 --- a/CHDataManagement/Views/Files/FileToAdd.swift +++ b/CHDataManagement/Views/Files/FileToAdd.swift @@ -2,9 +2,12 @@ import Foundation final class FileToAdd: ObservableObject { + let id: Int + unowned let content: Content - let url: URL + // The external path to the file, or nil if the file is just a placeholder + let url: URL? @Published var uniqueId: String @@ -13,11 +16,19 @@ final class FileToAdd: ObservableObject { var isSelected: Bool = true init(content: Content, url: URL) { + self.id = .random() self.content = content self.url = url self.uniqueId = url.lastPathComponent } + init(content: Content, externalFile: String) { + self.id = .random() + self.content = content + self.url = nil + self.uniqueId = externalFile + } + var idAlreadyExists: Bool { content.files.contains { $0.id == uniqueId } } @@ -25,9 +36,6 @@ final class FileToAdd: ObservableObject { extension FileToAdd: Identifiable { - var id: URL { - url - } } extension FileToAdd: Equatable { diff --git a/CHDataManagement/Views/Files/FileToAddView.swift b/CHDataManagement/Views/Files/FileToAddView.swift index d24c582..6e75f4f 100644 --- a/CHDataManagement/Views/Files/FileToAddView.swift +++ b/CHDataManagement/Views/Files/FileToAddView.swift @@ -30,7 +30,7 @@ struct FileToAddView: View { .frame(maxWidth: 200) } - Text(file.url.path()) + Text(file.url?.path() ?? "Placeholder file") .foregroundStyle(.secondary) } diff --git a/CHDataManagement/Views/Images/ImageContentView.swift b/CHDataManagement/Views/Images/ImageContentView.swift deleted file mode 100644 index d77e09e..0000000 --- a/CHDataManagement/Views/Images/ImageContentView.swift +++ /dev/null @@ -1,26 +0,0 @@ -import SwiftUI - -struct ImageContentView: View { - - @ObservedObject - var image: FileResource - - var body: some View { - image.imageToDisplay - .resizable() - .aspectRatio(contentMode: .fit) - } -} - -extension ImageContentView: MainContentView { - - init(item: FileResource) { - self.image = item - } - - static let itemDescription = "an image" -} - -#Preview { - ImageContentView(image: .init(resourceImage: "image1", type: .jpg)) -} diff --git a/CHDataManagement/Views/Pages/AddPageView.swift b/CHDataManagement/Views/Pages/AddPageView.swift index 7e6e7ff..b75c587 100644 --- a/CHDataManagement/Views/Pages/AddPageView.swift +++ b/CHDataManagement/Views/Pages/AddPageView.swift @@ -73,9 +73,11 @@ struct AddPageView: View { createdDate: .now, startDate: .now, endDate: nil, - german: .init(urlString: "seite", + german: .init(content: content, + urlString: "seite", title: "Ein Titel"), - english: .init(urlString: "page", + english: .init(content: content, + urlString: "page", title: "A Title"), tags: []) content.pages.insert(page, at: 0) diff --git a/CHDataManagement/Views/Pages/LocalizedPageContentView.swift b/CHDataManagement/Views/Pages/LocalizedPageContentView.swift index 90b73b8..82a342e 100644 --- a/CHDataManagement/Views/Pages/LocalizedPageContentView.swift +++ b/CHDataManagement/Views/Pages/LocalizedPageContentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols import HighlightedTextEditor struct LocalizedPageContentView: View { @@ -11,17 +12,21 @@ struct LocalizedPageContentView: View { @Environment(\.language) private var language - @EnvironmentObject - private var content: Content - @State private var isGeneratingWebsite = false + @State + private var loadedPageContentLanguage: ContentLanguage? + @State private var pageContent: String = "" @State - private var didLoadContent = false + private var pageContentUsedForGeneration: String = "" + + @State + private var generationResults = PageGenerationResults() + init(pageId: String, page: LocalizedPage) { self.pageId = pageId @@ -41,8 +46,12 @@ struct LocalizedPageContentView: View { Button(action: saveContent) { Text("Save") } + Button(action: checkContent) { + Text("Check") + } Spacer() } + PageContentResultsView(results: generationResults) HighlightedTextEditor( text: $pageContent, highlightRules: .markdown) @@ -53,32 +62,50 @@ struct LocalizedPageContentView: View { } private func loadContent() { + let language = language do { - let content = try content.storage.pageContent(for: pageId, language: language) + let content = try page.content.storage.pageContent(for: pageId, language: language) guard content != "" else { pageContent = "New file" - didLoadContent = false + loadedPageContentLanguage = nil return } pageContent = content - didLoadContent = true - + loadedPageContentLanguage = language + checkContent() } catch { print("Failed to load page content: \(error)") pageContent = "Failed to load" + loadedPageContentLanguage = nil } - } private func saveContent() { - guard didLoadContent else { + guard let loadedPageContentLanguage else { return } do { - try content.storage.save(pageContent: pageContent, for: pageId, language: language) + try page.content.storage.save(pageContent: pageContent, for: pageId, language: loadedPageContentLanguage) } catch { print("Failed to save content: \(error)") } } + + private func checkContent() { + let content = self.pageContent + 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 + } + } + } } diff --git a/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift b/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift index 1c171fe..0157648 100644 --- a/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift +++ b/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift @@ -4,22 +4,47 @@ import SFSafeSymbols struct LocalizedPageDetailView: View { @ObservedObject - private var item: LocalizedPage + private var page: LocalizedPage init(page: LocalizedPage, showImagePicker: Bool = false) { - self.item = page + self.page = page self.showImagePicker = showImagePicker + self.newUrlString = page.urlString } @State private var showImagePicker = false + @State + private var newUrlString: String + + private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted + + private var idExists: Bool { + page.content.pages.contains { + $0.german.urlString == newUrlString + || $0.english.urlString == newUrlString + } + } + + private var containsInvalidCharacters: Bool { + newUrlString.rangeOfCharacter(from: allowedCharactersInPostId) != nil + } + var body: some View { VStack(alignment: .leading) { + HStack { + TextField("", text: $newUrlString) + .textFieldStyle(.roundedBorder) + Button("Update", action: setNewId) + .disabled(newUrlString.isEmpty || containsInvalidCharacters || idExists) + } + .padding(.bottom) + Text("Link Preview Title") .font(.headline) - OptionalTextField("", text: $item.linkPreviewTitle, - prompt: item.title) + OptionalTextField("", text: $page.linkPreviewTitle, + prompt: page.title) .textFieldStyle(.roundedBorder) .padding(.bottom) @@ -35,13 +60,13 @@ struct LocalizedPageDetailView: View { IconButton(symbol: .trashCircleFill, size: 22, color: .red) { - item.linkPreviewImage = nil - }.disabled(item.linkPreviewImage == nil) + page.linkPreviewImage = nil + }.disabled(page.linkPreviewImage == nil) Spacer() } .buttonStyle(.plain) - if let image = item.linkPreviewImage { + if let image = page.linkPreviewImage { image.imageToDisplay .resizable() .aspectRatio(contentMode: .fit) @@ -52,16 +77,20 @@ struct LocalizedPageDetailView: View { Text("Link Preview Description") .font(.headline) .padding(.top) - OptionalDescriptionField(text: $item.linkPreviewDescription) + OptionalDescriptionField(text: $page.linkPreviewDescription) .textFieldStyle(.roundedBorder) .padding(.bottom) } .sheet(isPresented: $showImagePicker) { ImagePickerView(showImagePicker: $showImagePicker) { image in - item.linkPreviewImage = image + page.linkPreviewImage = image } } } + + private func setNewId() { + page.urlString = newUrlString + } } #Preview { diff --git a/CHDataManagement/Views/Pages/PageContentResultsView.swift b/CHDataManagement/Views/Pages/PageContentResultsView.swift new file mode 100644 index 0000000..14f0750 --- /dev/null +++ b/CHDataManagement/Views/Pages/PageContentResultsView.swift @@ -0,0 +1,118 @@ +import SwiftUI +import SFSafeSymbols + +private struct ListPopup: View { + + @Environment(\.dismiss) + var dismiss + + let items: [String] + + var body: some View { + VStack { + List { + ForEach(items, id: \.self) { page in + Text(page) + } + } + .frame(minHeight: min(CGFloat(items.count) * 31, 500)) + Button("Dismiss") { dismiss() } + } + .padding(.vertical) + .onTapGesture { + dismiss() + } + } +} + +private struct TextWithPopup: View { + + let symbol: SFSymbol + + let text: LocalizedStringKey + + let items: [String] + + @State + private var isHovering = false + + var body: some View { + HStack { + Image(systemSymbol: symbol) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + Text(text) + } + .contentShape(Rectangle()) + .onTapGesture { + if items.count > 0 { + isHovering.toggle() + } + } + .sheet(isPresented: $isHovering) { + ListPopup(items: items) + .onTapGesture { + isHovering.toggle() + } + } + } +} + +struct PageContentResultsView: View { + + @Environment(\.language) + private var language + + @ObservedObject + var results: PageGenerationResults + + 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 }) + .foregroundStyle(.secondary) + TextWithPopup( + symbol: .docBadgePlus, + text: "\(results.linkedPages.count + results.missingPages.count) page links", + items: results.linkedPages.sorted().map { $0.localized(in: language).title }) + .foregroundStyle(.secondary) + if !results.missingPages.isEmpty { + TextWithPopup( + symbol: .exclamationmarkTriangleFill, + text: "\(results.missingPages.count) missing pages", + items: results.missingPages.sorted()) + .foregroundStyle(.red) + } + if !results.missingFiles.isEmpty { + TextWithPopup( + symbol: .exclamationmarkTriangleFill, + text: "\(results.missingFiles.count) missing files", + items: results.missingFiles.sorted()) + .foregroundStyle(.red) + } + if !results.warnings.isEmpty { + TextWithPopup( + symbol: .exclamationmarkTriangleFill, + text: "\(results.warnings.count) errors", + items: results.warnings.sorted()) + .foregroundStyle(.red) + } + if !results.invalidCommandArguments.isEmpty { + TextWithPopup( + symbol: .exclamationmarkTriangleFill, + text: "\(results.invalidCommandArguments.count) errors", + items: results.invalidCommandArguments.map { + "\($0.command.rawValue): \($0.arguments.joined(separator: ";"))" + }) + .foregroundStyle(.red) + } + } + } +} + +#Preview { + PageContentResultsView(results: .init()) +} diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift index 52b5513..bd98b4a 100644 --- a/CHDataManagement/Views/Pages/PageDetailView.swift +++ b/CHDataManagement/Views/Pages/PageDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols struct PageDetailView: View { @@ -17,6 +18,9 @@ struct PageDetailView: View { @State private var newId: String + @State + private var didGenerateWebsite: Bool? + init(page: Page) { self.page = page self.newId = page.id @@ -35,10 +39,21 @@ struct PageDetailView: View { var body: some View { ScrollView { VStack(alignment: .leading) { - Button(action: generate) { - Text("Generate") + HStack { + Button(action: generate) { + Text("Generate") + } + .disabled(isGeneratingWebsite) + if let didGenerateWebsite { + if didGenerateWebsite { + Image(systemSymbol: .checkmarkCircleFill) + .foregroundStyle(.green) + } else { + Image(systemSymbol: .xmarkCircleFill) + .foregroundStyle(.red) + } + } } - .disabled(isGeneratingWebsite) HStack { TextField("", text: $newId) .textFieldStyle(.roundedBorder) @@ -86,6 +101,7 @@ struct PageDetailView: View { } LocalizedPageDetailView(page: page.localized(in: language)) + .id(page.id + language.rawValue) } .padding() @@ -106,11 +122,13 @@ struct PageDetailView: View { isGeneratingWebsite = true print("Generating page") DispatchQueue.global(qos: .userInitiated).async { - let generator = WebsiteGenerator( - content: content, - language: language) - if !generator.generate(page: page) { - print("Generation failed") + for language in ContentLanguage.allCases { + let generator = LocalizedWebsiteGenerator( + content: content, + language: language) + if !generator.generate(page: page) { + print("Generation failed") + } } DispatchQueue.main.async { isGeneratingWebsite = false diff --git a/CHDataManagement/Views/Settings/GenerationSettingsView.swift b/CHDataManagement/Views/Settings/GenerationContentView.swift similarity index 93% rename from CHDataManagement/Views/Settings/GenerationSettingsView.swift rename to CHDataManagement/Views/Settings/GenerationContentView.swift index ac9588b..731fec6 100644 --- a/CHDataManagement/Views/Settings/GenerationSettingsView.swift +++ b/CHDataManagement/Views/Settings/GenerationContentView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct GenerationSettingsView: View { +struct GenerationContentView: View { @Environment(\.language) private var language @@ -37,7 +37,7 @@ struct GenerationSettingsView: View { Text(generatorText) Spacer() } - } + }.padding() } } @@ -54,7 +54,7 @@ struct GenerationSettingsView: View { } isGeneratingWebsite = true DispatchQueue.global(qos: .userInitiated).async { - let generator = WebsiteGenerator( + let generator = LocalizedWebsiteGenerator( content: content, language: language) _ = generator.generateWebsite { text in @@ -71,7 +71,7 @@ struct GenerationSettingsView: View { } #Preview { - GenerationSettingsView() + GenerationContentView() .environmentObject(Content.mock) .padding() } diff --git a/CHDataManagement/Views/Settings/GenerationDetailView.swift b/CHDataManagement/Views/Settings/GenerationDetailView.swift new file mode 100644 index 0000000..ffcf01a --- /dev/null +++ b/CHDataManagement/Views/Settings/GenerationDetailView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct GenerationDetailView: View { + + let section: SettingsSection + + var body: some View { + Group { + switch section { + //case .generation: + // GenerationSettingsView() + case .folders: + FolderSettingsView() + case .navigationBar: + NavigationBarSettingsView() + case .postFeed: + PostFeedSettingsView() + case .pages: + PageSettingsView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + .navigationTitle("") + } +} + +#Preview { + GenerationDetailView(section: .folders) +} diff --git a/CHDataManagement/Views/Settings/NavigationBarSettingsView.swift b/CHDataManagement/Views/Settings/NavigationBarSettingsView.swift index 9eb7b33..d7e52ed 100644 --- a/CHDataManagement/Views/Settings/NavigationBarSettingsView.swift +++ b/CHDataManagement/Views/Settings/NavigationBarSettingsView.swift @@ -26,7 +26,7 @@ struct NavigationBarSettingsView: View { var body: some View { ScrollView { VStack(alignment: .leading) { - Text("Notification Bar Settings") + Text("Navigation Bar") .font(.largeTitle) .bold() Text("Customize the navigation bar for all pages at the top of the website") @@ -37,7 +37,6 @@ struct NavigationBarSettingsView: View { .font(.headline) TextField("", text: $content.settings.navigationBar.iconPath) .textFieldStyle(.roundedBorder) - .frame(maxWidth: 300) Text("Specify the path to the icon file with regard to the final website folder.") .foregroundStyle(.secondary) .padding(.bottom, 30) diff --git a/CHDataManagement/Views/Settings/PageSettingsView.swift b/CHDataManagement/Views/Settings/PageSettingsView.swift index f061f78..03f04b1 100644 --- a/CHDataManagement/Views/Settings/PageSettingsView.swift +++ b/CHDataManagement/Views/Settings/PageSettingsView.swift @@ -21,16 +21,22 @@ struct PageSettingsView: View { .font(.headline) IntegerField("", number: $content.settings.pages.contentWidth) .textFieldStyle(.roundedBorder) - .frame(maxWidth: 400) Text("The maximum width of the content in pages (in pixels)") .foregroundStyle(.secondary) .padding(.bottom) + Text("Image Width") + .font(.headline) + IntegerField("", number: $content.settings.pages.largeImageWidth) + .textFieldStyle(.roundedBorder) + Text("The maximum width of images that are diplayed fullscreen") + .foregroundStyle(.secondary) + .padding(.bottom) + Text("Page URL Prefix") .font(.headline) TextField("", text: $content.settings.pages.pageUrlPrefix) .textFieldStyle(.roundedBorder) - .frame(maxWidth: 400) Text("The URL prefix used for the links to pages") .foregroundStyle(.secondary) .padding(.bottom) diff --git a/CHDataManagement/Views/Settings/SectionedSettingsView.swift b/CHDataManagement/Views/Settings/SectionedSettingsView.swift deleted file mode 100644 index e9f4197..0000000 --- a/CHDataManagement/Views/Settings/SectionedSettingsView.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI - -struct SectionedSettingsView: View { - - @State - private var selectedSection: SettingsSection? = .generation - - var body: some View { - NavigationSplitView { - SettingsSidebar(selectedSection: $selectedSection) - .frame(minWidth: 200, idealWidth: 200, maxWidth: 200) - } detail: { - GenerationDetailView(section: selectedSection) - } - } -} - - -struct GenerationDetailView: View { - - let section: SettingsSection? - - var body: some View { - Group { - switch section { - case .generation: - GenerationSettingsView() - case .folders: - FolderSettingsView() - case .navigationBar: - NavigationBarSettingsView() - case .postFeed: - PostFeedSettingsView() - case .pages: - PageSettingsView() - case .none: - Text("Select a setting from the sidebar") - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding() - .navigationTitle("") - } -} - -#Preview { - SectionedSettingsView() -} diff --git a/CHDataManagement/Views/Settings/SettingsListView.swift b/CHDataManagement/Views/Settings/SettingsListView.swift new file mode 100644 index 0000000..416a123 --- /dev/null +++ b/CHDataManagement/Views/Settings/SettingsListView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct SettingsListView: View { + + @Binding + var selectedSection: SettingsSection + + var body: some View { + List(SettingsSection.allCases, selection: $selectedSection) { item in + Label(item.rawValue, systemSymbol: item.icon).tag(item) + } + } +} diff --git a/CHDataManagement/Views/Settings/SettingsSection.swift b/CHDataManagement/Views/Settings/SettingsSection.swift index b88cccd..f6dc78a 100644 --- a/CHDataManagement/Views/Settings/SettingsSection.swift +++ b/CHDataManagement/Views/Settings/SettingsSection.swift @@ -2,7 +2,7 @@ import SFSafeSymbols enum SettingsSection: String { - case generation = "Generation" + //case generation = "Generation" case folders = "Folders" @@ -18,7 +18,7 @@ extension SettingsSection { var icon: SFSymbol { switch self { - case .generation: return .arrowTriangle2Circlepath + //case .generation: return .arrowTriangle2Circlepath case .folders: return .folder case .navigationBar: return .menubarRectangle case .postFeed: return .rectangleGrid1x2 diff --git a/CHDataManagement/Views/Settings/SettingsSidebar.swift b/CHDataManagement/Views/Settings/SettingsSidebar.swift deleted file mode 100644 index 5296fba..0000000 --- a/CHDataManagement/Views/Settings/SettingsSidebar.swift +++ /dev/null @@ -1,15 +0,0 @@ -import SwiftUI -import SFSafeSymbols - -struct SettingsSidebar: View { - - @Binding var selectedSection: SettingsSection? - - var body: some View { - List(SettingsSection.allCases, selection: $selectedSection) { item in - Label(item.rawValue, systemSymbol: item.icon) - .tag(item) - } - .navigationTitle("Settings") - } -} diff --git a/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift b/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift index 98e8e77..d272f1a 100644 --- a/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift +++ b/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift @@ -19,50 +19,71 @@ struct LocalizedTagDetailView: View { VStack(alignment: .leading) { Toggle("Appears in overviews", isOn: $tagIsVisible) .toggleStyle(.switch) - .font(.callout) - .foregroundStyle(.secondary) + .font(.headline) + .padding(.bottom) Text("Name") - .font(.callout) - .foregroundStyle(.secondary) + .font(.headline) TextField("", text: $tag.name) + .textFieldStyle(.roundedBorder) + .padding(.bottom) Text("URL String") - .font(.callout) - .foregroundStyle(.secondary) + .font(.headline) TextField("", text: $tag.urlComponent) + .textFieldStyle(.roundedBorder) + .padding(.bottom) Text("Original url") - .font(.callout) - .foregroundStyle(.secondary) + .font(.headline) Text(tag.originalUrl ?? "-") .padding(.top, 1) .padding(.bottom) Text("Subtitle") - .font(.callout) - .foregroundStyle(.secondary) + .font(.headline) OptionalTextField("", text: $tag.subtitle) + .textFieldStyle(.roundedBorder) + .padding(.bottom) - Text("Description") - .font(.callout) - .foregroundStyle(.secondary) - OptionalTextField("", text: $tag.description) + Text("Link Preview Description") + .font(.headline) + .padding(.top) + OptionalDescriptionField(text: $tag.description) + .textFieldStyle(.roundedBorder) + .padding(.bottom) - Text("Thumbnail") - .font(.callout) - .foregroundStyle(.secondary) - Button(action: { showImagePicker = true }) { - Text(tag.thumbnail?.id ?? "Select") + HStack { + Text("Link Preview Image") + .font(.headline) + IconButton(symbol: .squareAndPencilCircleFill, + size: 22, + color: .blue) { + showImagePicker = true + } + + IconButton(symbol: .trashCircleFill, + size: 22, + color: .red) { + tag.linkPreviewImage = nil + }.disabled(tag.linkPreviewImage == nil) + Spacer() } + .buttonStyle(.plain) - .foregroundStyle(.blue) + if let image = tag.linkPreviewImage { + image.imageToDisplay + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400, maxHeight: 300) + .cornerRadius(8) + } } .padding() } .sheet(isPresented: $showImagePicker) { ImagePickerView(showImagePicker: $showImagePicker) { image in - tag.thumbnail = image + tag.linkPreviewImage = image } } }