From 5fb689ac7cfeeb437df38b7774bf51ef64d3e599 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Fri, 6 Dec 2024 21:59:36 +0100 Subject: [PATCH] Generate pages, image descriptions --- CHDataManagement.xcodeproj/project.pbxproj | 138 +++++- .../xcshareddata/swiftpm/Package.resolved | 20 +- .../Extensions/String+Extensions.swift | 86 ++++ .../Generator/GenerationResultsHandler.swift | 18 + .../ImageGenerator.swift | 23 + .../Generator/PageContentGenerator.swift | 445 ++++++++++++++++++ .../Generator/PageGenerator.swift | 43 ++ .../Generator/ShorthandMarkdownKey.swift | 51 ++ CHDataManagement/Generator/VideoOption.swift | 11 + .../WebsiteGenerator.swift | 93 ++-- .../Model/Content+Generation.swift | 44 ++ CHDataManagement/Model/Content+Load.swift | 29 +- CHDataManagement/Model/Content+Save.swift | 23 +- CHDataManagement/Model/Content.swift | 6 - CHDataManagement/Model/DateItem.swift | 85 ++++ CHDataManagement/Model/FileResource.swift | 9 + CHDataManagement/Model/ImageResource.swift | 32 +- CHDataManagement/Model/LocalizedPage.swift | 16 - CHDataManagement/Model/LocalizedPost.swift | 24 - CHDataManagement/Model/Page.swift | 4 + CHDataManagement/Model/Post.swift | 73 +-- .../Model => Model/Types}/FileType.swift | 43 +- .../Model/{ => Types}/ImageType.swift | 0 CHDataManagement/Model/Types/VideoType.swift | 36 ++ .../ContentElements/ContentPageVideo.swift | 19 + .../ContentElements/DownloadButtons.swift | 34 ++ .../ContentElements/HikingStatistics.swift | 42 ++ .../ContentElements/TagList.swift | 29 ++ .../Page Elements/FeedEntry.swift | 19 +- .../Page Elements/FeedEntryData.swift | 18 +- .../Page Elements/ImageGallery.swift | 14 +- .../Page Elements/PageImage.swift | 27 ++ .../Page Elements/WebsiteImage.swift | 35 ++ CHDataManagement/Pages/ContentPage.swift | 73 +++ .../Preview Content/Content+Mock.swift | 1 - .../Storage/Model/ImageDescriptions.swift | 13 + CHDataManagement/Storage/Storage.swift | 82 +++- CHDataManagement/Views/Files/FilesView.swift | 3 +- .../Views/Images/ImageDetailsView.swift | 7 +- .../Views/Pages/PageContentView.swift | 103 ++++ .../Views/Pages/PageDetailView.swift | 51 -- .../Views/Pages/PageListView.swift | 4 +- 42 files changed, 1653 insertions(+), 273 deletions(-) create mode 100644 CHDataManagement/Generator/GenerationResultsHandler.swift rename CHDataManagement/{Storage => Generator}/ImageGenerator.swift (87%) create mode 100644 CHDataManagement/Generator/PageContentGenerator.swift create mode 100644 CHDataManagement/Generator/PageGenerator.swift create mode 100644 CHDataManagement/Generator/ShorthandMarkdownKey.swift create mode 100644 CHDataManagement/Generator/VideoOption.swift rename CHDataManagement/{Storage => Generator}/WebsiteGenerator.swift (67%) create mode 100644 CHDataManagement/Model/Content+Generation.swift create mode 100644 CHDataManagement/Model/DateItem.swift rename CHDataManagement/{Storage/Model => Model/Types}/FileType.swift (50%) rename CHDataManagement/Model/{ => Types}/ImageType.swift (100%) create mode 100644 CHDataManagement/Model/Types/VideoType.swift create mode 100644 CHDataManagement/Page Elements/ContentElements/ContentPageVideo.swift create mode 100644 CHDataManagement/Page Elements/ContentElements/DownloadButtons.swift create mode 100644 CHDataManagement/Page Elements/ContentElements/HikingStatistics.swift create mode 100644 CHDataManagement/Page Elements/ContentElements/TagList.swift create mode 100644 CHDataManagement/Page Elements/PageImage.swift create mode 100644 CHDataManagement/Page Elements/WebsiteImage.swift create mode 100644 CHDataManagement/Pages/ContentPage.swift create mode 100644 CHDataManagement/Storage/Model/ImageDescriptions.swift create mode 100644 CHDataManagement/Views/Pages/PageContentView.swift delete mode 100644 CHDataManagement/Views/Pages/PageDetailView.swift diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index e3cc461..117dc1a 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -66,10 +66,28 @@ 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 */; }; + E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */; }; + E25DA57D2D01C67900AEF16D /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA57C2D01C67900AEF16D /* Ink */; }; + E25DA5802D01C6AC00AEF16D /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA57F2D01C6AC00AEF16D /* Splash */; }; + E25DA5832D01C7A400AEF16D /* VideoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5822D01C7A100AEF16D /* VideoType.swift */; }; + E25DA5852D01C92700AEF16D /* ShorthandMarkdownKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */; }; + E25DA5872D01CA9300AEF16D /* GenerationResultsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */; }; + E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */; }; + E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58A2D020C9200AEF16D /* PageImage.swift */; }; + E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */; }; E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58E2D02368A00AEF16D /* PageSettings.swift */; }; E25DA5912D023A8400AEF16D /* IntegerField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5902D023A7E00AEF16D /* IntegerField.swift */; }; E25DA5932D023B3C00AEF16D /* PageSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */; }; E25DA5952D023BD100AEF16D /* PageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5942D023BCC00AEF16D /* PageSettingsView.swift */; }; + E25DA5972D023F9F00AEF16D /* ContentPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5962D023F9900AEF16D /* ContentPage.swift */; }; + 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 */; }; + 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 */; }; + E29D312A2D039B090051B7F4 /* ImageDescriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31292D039B050051B7F4 /* ImageDescriptions.swift */; }; E2A21C012CB16A820060935B /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C002CB16A820060935B /* PostView.swift */; }; E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; }; E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C042CB176670060935B /* LocalizedText.swift */; }; @@ -83,7 +101,7 @@ E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C292CB2AA4C0060935B /* Post+Mock.swift */; }; E2A21C2C2CB2BB250060935B /* PostList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C2B2CB2BB210060935B /* PostList.swift */; }; E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C2F2CB490F90060935B /* HorizontalCenter.swift */; }; - E2A21C332CB5BCAC0060935B /* PageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C312CB5BCAC0060935B /* PageDetailView.swift */; }; + E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C312CB5BCAC0060935B /* PageContentView.swift */; }; E2A21C362CB9A3D70060935B /* FolderSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C352CB9A3D70060935B /* FolderSettingsView.swift */; }; E2A21C3B2CB9D9A60060935B /* ImageResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C3A2CB9D9A50060935B /* ImageResource.swift */; }; E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C452CBAE2E50060935B /* FeedEntryContent.swift */; }; @@ -180,10 +198,26 @@ 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 = ""; }; + E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContentGenerator.swift; sourceTree = ""; }; + E25DA5822D01C7A100AEF16D /* VideoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoType.swift; sourceTree = ""; }; + E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShorthandMarkdownKey.swift; sourceTree = ""; }; + E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationResultsHandler.swift; sourceTree = ""; }; + E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Generation.swift"; sourceTree = ""; }; + E25DA58A2D020C9200AEF16D /* PageImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImage.swift; sourceTree = ""; }; + E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteImage.swift; sourceTree = ""; }; E25DA58E2D02368A00AEF16D /* PageSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettings.swift; sourceTree = ""; }; E25DA5902D023A7E00AEF16D /* IntegerField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegerField.swift; sourceTree = ""; }; E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsFile.swift; sourceTree = ""; }; E25DA5942D023BCC00AEF16D /* PageSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsView.swift; sourceTree = ""; }; + E25DA5962D023F9900AEF16D /* ContentPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPage.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + E29D31292D039B050051B7F4 /* ImageDescriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDescriptions.swift; sourceTree = ""; }; E2A21C002CB16A820060935B /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = ""; }; E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = ""; }; E2A21C042CB176670060935B /* LocalizedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedText.swift; sourceTree = ""; }; @@ -197,7 +231,7 @@ E2A21C292CB2AA4C0060935B /* Post+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+Mock.swift"; sourceTree = ""; }; E2A21C2B2CB2BB210060935B /* PostList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostList.swift; sourceTree = ""; }; E2A21C2F2CB490F90060935B /* HorizontalCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalCenter.swift; sourceTree = ""; }; - E2A21C312CB5BCAC0060935B /* PageDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageDetailView.swift; sourceTree = ""; }; + E2A21C312CB5BCAC0060935B /* PageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContentView.swift; sourceTree = ""; }; E2A21C352CB9A3D70060935B /* FolderSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSettingsView.swift; sourceTree = ""; }; E2A21C3A2CB9D9A50060935B /* ImageResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResource.swift; sourceTree = ""; }; E2A21C452CBAE2E50060935B /* FeedEntryContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEntryContent.swift; sourceTree = ""; }; @@ -243,8 +277,10 @@ files = ( E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */, E25DA52C2CFFC3EC00AEF16D /* SDWebImageAVIFCoder in Frameworks */, + E25DA57D2D01C67900AEF16D /* Ink in Frameworks */, E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */, E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */, + E25DA5802D01C6AC00AEF16D /* Splash in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -265,9 +301,9 @@ E25DA5112CFF001900AEF16D /* Model */ = { isa = PBXGroup; children = ( + E29D31292D039B050051B7F4 /* ImageDescriptions.swift */, E25DA5322D0041C400AEF16D /* Settings */, E21850142CEE55D40090B18B /* FileOnDisk.swift */, - E21850162CEE55FB0090B18B /* FileType.swift */, E2A37D102CE537670000979F /* PageFile.swift */, E21850182CEE561B0090B18B /* PageOnDisk.swift */, E2A37D142CE68BEA0000979F /* PostFile.swift */, @@ -302,10 +338,45 @@ path = Settings; sourceTree = ""; }; + E25DA5782D01C56200AEF16D /* Generator */ = { + isa = PBXGroup; + children = ( + E29D31252D0370A50051B7F4 /* VideoOption.swift */, + E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */, + E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */, + E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */, + E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */, + E25DA5982D02401A00AEF16D /* PageGenerator.swift */, + E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, + ); + path = Generator; + sourceTree = ""; + }; + E25DA5812D01C79800AEF16D /* Types */ = { + isa = PBXGroup; + children = ( + E21850162CEE55FB0090B18B /* FileType.swift */, + E25DA5282CFFBFB800AEF16D /* ImageType.swift */, + E25DA5822D01C7A100AEF16D /* VideoType.swift */, + ); + path = Types; + sourceTree = ""; + }; + E29D311E2D0320D90051B7F4 /* ContentElements */ = { + isa = PBXGroup; + children = ( + E29D31272D0371870051B7F4 /* ContentPageVideo.swift */, + E29D31232D0366820051B7F4 /* TagList.swift */, + E29D31212D0363FA0051B7F4 /* DownloadButtons.swift */, + E29D311F2D0320E20051B7F4 /* HikingStatistics.swift */, + ); + path = ContentElements; + sourceTree = ""; + }; E2A21C322CB5BCAC0060935B /* Pages */ = { isa = PBXGroup; children = ( - E2A21C312CB5BCAC0060935B /* PageDetailView.swift */, + E2A21C312CB5BCAC0060935B /* PageContentView.swift */, E2A37D242CEBD7A10000979F /* PageListView.swift */, ); path = Pages; @@ -364,9 +435,7 @@ isa = PBXGroup; children = ( E25DA5112CFF001900AEF16D /* Model */, - E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, E2A37D0D2CE527040000979F /* Storage.swift */, - E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */, ); path = Storage; sourceTree = ""; @@ -386,9 +455,11 @@ E2B85F392C428F020047CD0C /* Model */ = { isa = PBXGroup; children = ( + E25DA59A2D024A2900AEF16D /* DateItem.swift */, + E25DA5812D01C79800AEF16D /* Types */, E25DA53B2D0042EA00AEF16D /* Settings */, - E25DA5282CFFBFB800AEF16D /* ImageType.swift */, E2E06DFA2CA4A6570019C2AF /* Content.swift */, + E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */, E25DA5162CFF00F200AEF16D /* Content+Save.swift */, E25DA5142CFF00B900AEF16D /* Content+Load.swift */, E21850302CFAF8840090B18B /* Content+Import.swift */, @@ -409,6 +480,7 @@ E2B85F3E2C4293FF0047CD0C /* Pages */ = { isa = PBXGroup; children = ( + E25DA5962D023F9900AEF16D /* ContentPage.swift */, E25DA51C2CFF135B00AEF16D /* GenericPage.swift */, E2B85F3C2C4293F80047CD0C /* PageInFeed.swift */, ); @@ -418,6 +490,9 @@ E2B85F3F2C42946E0047CD0C /* Page Elements */ = { isa = PBXGroup; children = ( + E29D311E2D0320D90051B7F4 /* ContentElements */, + E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */, + E25DA58A2D020C9200AEF16D /* PageImage.swift */, E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */, E25DA51A2CFF08AF00AEF16D /* PostFeedPageNavigation.swift */, E2B85F422C4294F60047CD0C /* FeedEntry.swift */, @@ -499,6 +574,7 @@ isa = PBXGroup; children = ( E2DD04732C276F31003BFF1F /* CHDataManagementApp.swift */, + E25DA5782D01C56200AEF16D /* Generator */, E2A37D0F2CE5375E0000979F /* Storage */, E2B85F392C428F020047CD0C /* Model */, E2B85F462C42C7CA0047CD0C /* Views */, @@ -549,6 +625,8 @@ E24252002C50E0A40029FF16 /* HighlightedTextEditor */, E25DA52B2CFFC3EC00AEF16D /* SDWebImageAVIFCoder */, E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */, + E25DA57C2D01C67900AEF16D /* Ink */, + E25DA57F2D01C6AC00AEF16D /* Splash */, ); productName = CHDataManagement; productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */; @@ -583,6 +661,8 @@ E24251FF2C50E0A40029FF16 /* XCRemoteSwiftPackageReference "HighlightedTextEditor" */, E25DA52A2CFFC3EC00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageAVIFCoder" */, E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, + E25DA57B2D01C67900AEF16D /* XCRemoteSwiftPackageReference "ink" */, + E25DA57E2D01C6AC00AEF16D /* XCRemoteSwiftPackageReference "Splash" */, ); productRefGroup = E2DD04712C276F31003BFF1F /* Products */; projectDirPath = ""; @@ -610,7 +690,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E29D31242D0366860051B7F4 /* TagList.swift in Sources */, E25DA5772D018B9900AEF16D /* File+Mock.swift in Sources */, + E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */, E218502B2CF790B30090B18B /* PostContentView.swift in Sources */, E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */, E2A21C162CB1A3C90060935B /* PostImageGalleryView.swift in Sources */, @@ -619,6 +701,7 @@ E25DA5412D00446C00AEF16D /* PostSettings.swift in Sources */, E2A37D192CEA36A90000979F /* LocalizedTag.swift in Sources */, E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */, + E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */, E21850312CFAF8880090B18B /* Content+Import.swift in Sources */, E25DA5172CFF00F500AEF16D /* Content+Save.swift in Sources */, E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */, @@ -638,10 +721,14 @@ E2A21C282CB29B2A0060935B /* FeedEntryData.swift in Sources */, E25DA53F2D00441F00AEF16D /* NavigationBarSettings.swift in Sources */, E218502F2CFAF69C0090B18B /* WebsiteGenerator.swift in Sources */, + E25DA5832D01C7A400AEF16D /* VideoType.swift in Sources */, E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */, E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */, E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */, + E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */, + E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */, E25DA5382D00420E00AEF16D /* LocalizedPostSettingsFile.swift in Sources */, + E25DA5972D023F9F00AEF16D /* ContentPage.swift in Sources */, E2B85F3D2C4293F80047CD0C /* PageInFeed.swift in Sources */, E25DA5952D023BD100AEF16D /* PageSettingsView.swift in Sources */, E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */, @@ -653,16 +740,20 @@ E2A37D152CE68BEC0000979F /* PostFile.swift in Sources */, E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */, E21850392CFCA6C00090B18B /* WebsiteData+Mock.swift in Sources */, + E29D31262D0370A80051B7F4 /* VideoOption.swift in Sources */, E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */, E2A37D172CE73F1A0000979F /* TagFile.swift in Sources */, E2A37D292CED2C6A0000979F /* TagsListView.swift in Sources */, + E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */, E24252032C5163CF0029FF16 /* Importer.swift in Sources */, E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */, + E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */, E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */, E21850152CEE55D40090B18B /* FileOnDisk.swift in Sources */, - E2A21C332CB5BCAC0060935B /* PageDetailView.swift in Sources */, + E2A21C332CB5BCAC0060935B /* PageContentView.swift in Sources */, E2A21C122CB18D560060935B /* DatePickerView.swift in Sources */, E21850332CFAFA2F0090B18B /* Settings.swift in Sources */, + E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */, E21850192CEE561C0090B18B /* PageOnDisk.swift in Sources */, E2A21C4D2CBB16B50060935B /* ImagesView.swift in Sources */, E2A21C202CB28ED20060935B /* MockImage.swift in Sources */, @@ -678,6 +769,7 @@ E25DA5432D0094A900AEF16D /* SettingsSidebar.swift in Sources */, E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */, E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */, + E29D31202D0320E70051B7F4 /* HikingStatistics.swift in Sources */, E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */, E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */, E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */, @@ -700,18 +792,22 @@ E2A37D0E2CE527070000979F /* Storage.swift in Sources */, E2E06E002CA4A8F00019C2AF /* Page+Mock.swift in Sources */, E218502D2CF791440090B18B /* PostImagesView.swift in Sources */, + E29D312A2D039B090051B7F4 /* ImageDescriptions.swift in Sources */, E2B85F572C4BD0BB0047CD0C /* Binding+Extension.swift in Sources */, E2B85F432C4294F60047CD0C /* FeedEntry.swift in Sources */, E25DA56D2D00EBCF00AEF16D /* NavigationBarSettingsView.swift in Sources */, E25DA5272CFF745700AEF16D /* URL+Extensions.swift in Sources */, E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */, E25DA5712D01015400AEF16D /* GenerationSettingsView.swift in Sources */, + E25DA5872D01CA9300AEF16D /* GenerationResultsHandler.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 */, E2A21C362CB9A3D70060935B /* FolderSettingsView.swift in Sources */, E2A21C012CB16A820060935B /* PostView.swift in Sources */, E2A21C052CB1766C0060935B /* LocalizedText.swift in Sources */, @@ -965,6 +1061,22 @@ minimumVersion = 0.14.6; }; }; + E25DA57B2D01C67900AEF16D /* XCRemoteSwiftPackageReference "ink" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/johnsundell/ink.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.6.0; + }; + }; + E25DA57E2D01C6AC00AEF16D /* XCRemoteSwiftPackageReference "Splash" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/JohnSundell/Splash"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.16.0; + }; + }; E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; @@ -991,6 +1103,16 @@ package = E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; productName = SDWebImageWebPCoder; }; + E25DA57C2D01C67900AEF16D /* Ink */ = { + isa = XCSwiftPackageProductDependency; + package = E25DA57B2D01C67900AEF16D /* XCRemoteSwiftPackageReference "ink" */; + productName = Ink; + }; + E25DA57F2D01C6AC00AEF16D /* Splash */ = { + isa = XCSwiftPackageProductDependency; + package = E25DA57E2D01C6AC00AEF16D /* XCRemoteSwiftPackageReference "Splash" */; + productName = Splash; + }; E2B85F352C426BEE0047CD0C /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; diff --git a/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index badfef9..7e2873d 100644 --- a/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CHDataManagement.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fbe90465f57759d9e85fb24c88e821179f0610fa0fa1239083ea8ffab228185f", + "originHash" : "09586c34852addc5d95a3a7234451be33efeecd2b5dbd5ef8607a959add71d3f", "pins" : [ { "identity" : "highlightedtexteditor", @@ -10,6 +10,15 @@ "version" : "2.1.2" } }, + { + "identity" : "ink", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnsundell/ink.git", + "state" : { + "revision" : "bcc9f219900a62c4210e6db726035d7f03ae757b", + "version" : "0.6.0" + } + }, { "identity" : "libaom-xcode", "kind" : "remoteSourceControl", @@ -81,6 +90,15 @@ "revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a", "version" : "5.3.0" } + }, + { + "identity" : "splash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JohnSundell/Splash", + "state" : { + "revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8", + "version" : "0.16.0" + } } ], "version" : 3 diff --git a/CHDataManagement/Extensions/String+Extensions.swift b/CHDataManagement/Extensions/String+Extensions.swift index 7491179..921404f 100644 --- a/CHDataManagement/Extensions/String+Extensions.swift +++ b/CHDataManagement/Extensions/String+Extensions.swift @@ -12,6 +12,69 @@ extension String { var nonEmpty: String? { isEmpty ? nil : self } + + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + func indented(by indentation: String) -> String { + components(separatedBy: "\n").joined(separator: "\n" + indentation) + } + + var withoutEmptyLines: String { + components(separatedBy: "\n") + .filter { !$0.trimmed.isEmpty } + .joined(separator: "\n") + } + + /** + 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. + */ + func dropAfterLast(_ separator: String) -> String { + guard contains(separator) else { + return self + } + return components(separatedBy: separator).dropLast().joined(separator: separator) + } + + func dropBeforeFirst(_ separator: String) -> String { + guard contains(separator) else { + return self + } + return components(separatedBy: separator).dropFirst().joined(separator: separator) + } + + func lastComponentAfter(_ separator: String) -> String { + components(separatedBy: separator).last! + } + + /** + Insert the new content before the last occurence of the specified separator. + + If the separator does not appear in the string, then the new content is simply appended. + */ + func insert(_ content: String, beforeLast separator: String) -> String { + let parts = components(separatedBy: separator) + guard parts.count > 1 else { + return self + content + } + return parts.dropLast().joined(separator: separator) + content + separator + parts.last! + } + + /** + Remove everything behind the first separator. + + Also removes the separator itself. If the separator is not contained in the string, then the full string is returned. + */ + func dropAfterFirst(_ separator: T) -> String where T: StringProtocol { + components(separatedBy: separator).first! + } + + func between(_ start: String, and end: String) -> String { + dropBeforeFirst(start).dropAfterFirst(end) + } } extension String { @@ -30,3 +93,26 @@ extension String { return (fileName: parts.dropLast().joined(separator: "."), fileExtension: parts.last) } } + +extension Substring { + + func dropBeforeFirst(_ separator: String) -> String { + guard contains(separator) else { + return String(self) + } + return components(separatedBy: separator).dropFirst().joined(separator: separator) + } + + func between(_ start: String, and end: String) -> String { + components(separatedBy: start).last! + .components(separatedBy: end).first! + } + + func between(first: String, andLast last: String) -> String { + dropBeforeFirst(first).dropAfterLast(last) + } + + func last(after: String) -> String { + components(separatedBy: after).last! + } +} diff --git a/CHDataManagement/Generator/GenerationResultsHandler.swift b/CHDataManagement/Generator/GenerationResultsHandler.swift new file mode 100644 index 0000000..b31ea33 --- /dev/null +++ b/CHDataManagement/Generator/GenerationResultsHandler.swift @@ -0,0 +1,18 @@ +import Foundation + +final class GenerationResultsHandler { + + var requiredVideoFiles: Set = [] + + /// Generic warnings for pages + private var pageWarnings: [(message: String, source: String)] = [] + + func warning(_ message: String, page: Page) { + pageWarnings.append((message, page.id)) + print("Page: \(page.id): \(message)") + } + + func addRequiredVideoFile(fileId: String) { + requiredVideoFiles.insert(fileId) + } +} diff --git a/CHDataManagement/Storage/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift similarity index 87% rename from CHDataManagement/Storage/ImageGenerator.swift rename to CHDataManagement/Generator/ImageGenerator.swift index 02a1b01..d35b456 100644 --- a/CHDataManagement/Storage/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -50,6 +50,7 @@ final class ImageGenerator { } func runJobs(callback: (String) -> Void) -> Bool { + print("Generating \(jobs.count) images...") for job in jobs { callback("Generating image \(job.version)") guard generate(job: job) else { @@ -69,6 +70,28 @@ final class ImageGenerator { return "\(prefix).\(type.fileExtension)" } + func generateImageSet(for image: String, maxWidth: CGFloat, maxHeight: CGFloat, altText: String) -> FeedEntryData.Image { + let type = ImageType(fileExtension: image.fileExtension!)! + + let width2x = maxWidth * 2 + let height2x = maxHeight * 2 + + _ = generateVersion(for: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight) + _ = generateVersion(for: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x) + + _ = generateVersion(for: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight) + _ = generateVersion(for: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x) + + _ = generateVersion(for: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight) + _ = generateVersion(for: image, type: type, maximumWidth: width2x, maximumHeight: height2x) + + let path = "/" + relativeImageOutputPath + "/" + image + return .init(rawImagePath: path, + width: Int(maxWidth), + height: Int(maxHeight), + altText: altText) + } + func generateVersion(for image: String, type: ImageType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String { let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight) let fullPath = "/" + relativeImageOutputPath + "/" + version diff --git a/CHDataManagement/Generator/PageContentGenerator.swift b/CHDataManagement/Generator/PageContentGenerator.swift new file mode 100644 index 0000000..9f1fdc0 --- /dev/null +++ b/CHDataManagement/Generator/PageContentGenerator.swift @@ -0,0 +1,445 @@ +import Foundation +import Ink +import Splash + +typealias VideoSource = (url: String, type: VideoType) + +final class PageContentParser { + + private let pageLinkMarker = "page:" + + private let largeImageIndicator = "*large*" + + private let swift = SyntaxHighlighter(format: HTMLOutputFormat()) + + private let results: GenerationResultsHandler + + 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 + self.content = content + self.language = language + self.results = results + self.imageGenerator = imageGenerator + } + + 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]) + return parser.html(from: content) + } + + 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 { + // 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 + return html.replacingOccurrences(of: textToChange, with: pagePath) + } + + // TODO: Check that linked file exists +// if let filePath = page.nonAbsolutePathRelativeToRootForContainedInputFile(file) { +// // The target of the page link must be present after generation is complete +// results.expect(file: filePath, source: page.path) +// } + return html + } + + private func handleHTML(html: String, markdown: Substring) -> String { + // TODO: Check HTML code in markdown for required resources + //print("[HTML] Found in page \(page.path):") + //print(markdown) + // Things to check: + // String { + let id = markdown + .last(after: "#") + .trimmed + .filter { $0.isNumber || $0.isLetter || $0 == " " } + .lowercased() + .components(separatedBy: " ") + .filter { $0 != "" } + .joined(separator: "-") + let parts = html.components(separatedBy: ">") + return parts[0] + " id=\"\(id)\">" + parts.dropFirst().joined(separator: ">") + } + + private func processMarkdownImage(markdown: Substring, html: String) -> String { + // First, check the content type, then parse the remaining arguments + // Notation: + // -> Optional argument + // -> Repeated argument (0 or more) + // ![url](;) + // ![image](;] + // ![video](;;] + // ![svg](;<;;;?>) + // ![download](<,,;...) + // ![box](;<body>) + // ![model](<file>;<description>) + // ![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 arguments = argumentList.components(separatedBy: ";") + + let rawCommand = markdown.between("![", and: "]").trimmed + guard rawCommand != "" else { + return handleImage(arguments) + } + + guard let convertedCommand = rawCommand.removingPercentEncoding, + let command = ShorthandMarkdownKey(rawValue: convertedCommand) else { + // Treat unknown commands as normal links + print("Unknown markdown command: \(rawCommand)") + return html + } + + switch command { + case .image: + return handleImage(arguments) + case .hikingStatistics: + return handleHikingStatistics(arguments) + case .downloadButtons: + return handleDownloadButtons(arguments) + case .video: + return handleVideo(arguments) + default: + print("Unhandled markdown command: \(command)") + return "" + /* + case .externalLink: + return handleExternalButtons(content: content) + case .includedHtml: + return handleExternalHTML(file: content) + case .box: + return handleSimpleBox(content: content) + case .pageLink: + return handlePageLink(pageId: content) + case .model: + return handle3dModel(content: content) + */ + } + } + + private func handleImage(_ arguments: [String]) -> String { + // [image](<imageId>;<caption?>] + guard (1...2).contains(arguments.count) else { + results.warning("Invalid image arguments: \(arguments)", page: page) + return "" + } + let imageId = arguments[0] + + guard let image = content.image(imageId) else { + results.warning("Missing image \(imageId)", page: page) + return "" + } + 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, + altText: altText) + + let largeImageWidth = CGFloat(1200) // TODO: Move to settings + + let largeImage = imageGenerator.generateImageSet( + for: imageId, + maxWidth: largeImageWidth, maxHeight: largeImageWidth, + altText: altText) + + return PageImage( + imageId: imageId.replacingOccurrences(of: ".", with: "-"), + thumbnail: thumbnail, + largeImage: largeImage, + caption: caption).content + } + + private func handleHikingStatistics(_ arguments: [String]) -> String { + guard (1...5).contains(arguments.count) else { + results.warning("Invalid hiking statistic arguments: \(arguments)", page: page) + return "" + } + + let time = arguments[0].trimmed + let elevationUp = arguments.count > 1 ? arguments[1].trimmed : nil + let elevationDown = arguments.count > 2 ? arguments[2].trimmed : nil + let distance = arguments.count > 3 ? arguments[3].trimmed : nil + let calories = arguments.count > 4 ? arguments[4].trimmed : nil + + return HikingStatistics( + time: time, + elevationUp: elevationUp, + elevationDown: elevationDown, + distance: distance, + calories: calories) + .content + } + + private func handleDownloadButtons(_ arguments: [String]) -> String { + let buttons: [DownloadButtons.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) + return nil + } + let file = parts[0].trimmed + let title = parts[1].trimmed + let downloadName = parts.count > 2 ? parts[2].trimmed : nil + + // Ensure that file is available + guard let filePath = content.pathToFile(file) else { + results.warning("Missing download file \(file)", page: page) + return nil + } + + return DownloadButtons.Item(filePath: filePath, text: title, downloadFileName: downloadName) + } + return DownloadButtons(items: buttons).content + } + + private func handleVideo(_ arguments: [String]) -> String { + guard arguments.count >= 1 else { + 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 + } + + guard let filePath = content.pathToFile(fileId), + let file = content.file(id: fileId) else { + results.warning("Missing video file \(fileId)", page: page) + return "" + } + guard let videoType = file.type.videoType?.htmlType else { + results.warning("Unknown video file type for \(fileId)", page: page) + return "" + } + + results.addRequiredVideoFile(fileId: fileId) + + return ContentPageVideo( + filePath: filePath, + videoType: videoType, + options: options) + .content + } + + /* + + private func handleGif(file: String, altText: String) -> String { + let imagePath = page.pathRelativeToRootForContainedInputFile(file) + results.require(file: imagePath, source: page.path) + + guard let size = results.getImageSize(atPath: imagePath, source: page.path) else { + return "" + } + let width = Int(size.width) + let height = Int(size.height) + return factory.html.image(file: file, width: width, height: height, altText: altText) + } + + private func handleSvg(file: String, area: String?) -> String { + let imagePath = page.pathRelativeToRootForContainedInputFile(file) + results.require(file: imagePath, source: page.path) + + guard let size = results.getImageSize(atPath: imagePath, source: page.path) else { + return "" // Missing image warning already produced + } + let width = Int(size.width) + let height = Int(size.height) + + var altText = "image " + file.lastComponentAfter("/") + guard let area = area else { + return factory.html.image(file: file, width: width, height: height, altText: altText) + } + let parts = area.components(separatedBy: ",").map { $0.trimmed } + switch parts.count { + case 1: + return factory.html.image(file: file, width: width, height: height, altText: parts[0]) + case 4: + break + case 5: + altText = parts[4] + default: + results.warning("Invalid area string for svg image", source: page.path) + return factory.html.image(file: file, width: width, height: height, altText: altText) + } + guard let x = Int(parts[0]), + let y = Int(parts[1]), + let partWidth = Int(parts[2]), + let partHeight = Int(parts[3]) else { + results.warning("Invalid area string for svg image", source: page.path) + return factory.html.image(file: file, width: width, height: height, altText: altText) + } + let part = SVGSelection(x, y, partWidth, partHeight) + return factory.html.svgImage(file: file, part: part, altText: altText) + } + + private func handleFile(file: String, fileExtension: String) -> String { + 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) + } + return factory.html.externalButtons(buttons) + } + + private func handleExternalHTML(file: String) -> String { + let path = page.pathRelativeToRootForContainedInputFile(file) + return results.getContentOfRequiredFile(at: path, source: page.path) ?? "" + } + + private func handleSimpleBox(content: String) -> String { + let parts = content.components(separatedBy: ";") + guard parts.count > 1 else { + results.warning("Invalid box specification", page: page) + return "" + } + let title = parts[0] + let text = parts.dropFirst().joined(separator: ";") + return factory.makePlaceholder(title: title, text: text) + } + + private func handlePageLink(pageId: String) -> String { + guard let linkedPage = siteRoot.find(pageId) else { + // Checking the page path will add it to the missing pages + _ = results.getPagePath(for: pageId, source: page.path, language: language) + // Remove link since the page can't be found + return "" + } + guard linkedPage.state == .standard else { + // Prevent linking to unpublished content + return "" + } + var content = [PageLinkTemplate.Key: String]() + + content[.title] = linkedPage.title(for: language) + content[.altText] = "" + + let fullThumbnailPath = linkedPage.thumbnailFilePath(for: language).destination + // Note: Here we assume that the thumbnail was already used elsewhere, so already generated + let relativeImageUrl = page.relativePathToOtherSiteElement(file: fullThumbnailPath) + let metadata = linkedPage.localized(for: language) + + if linkedPage.state.hasThumbnailLink { + let fullPageUrl = linkedPage.fullPageUrl(for: language) + let relativePageUrl = page.relativePathToOtherSiteElement(file: fullPageUrl) + content[.url] = "href=\"\(relativePageUrl)\"" + } + + content[.image] = relativeImageUrl.dropAfterLast(".") + if let suffix = metadata.thumbnailSuffix { + content[.title] = factory.html.make(title: metadata.title, suffix: suffix) + } else { + content[.title] = metadata.title + } + + let path = linkedPage.makePath(language: language, from: siteRoot) + content[.path] = factory.pageLink.makePath(components: path) + + content[.description] = metadata.relatedContentText + if let parent = linkedPage.findParent(from: siteRoot), parent.thumbnailStyle == .large { + content[.className] = " related-page-link-large" + } + + // We assume that the thumbnail images are already required by overview pages. + return factory.pageLink.generate(content) + } + + private func handle3dModel(content: String) -> String { + let parts = content.components(separatedBy: ";") + guard parts.count > 1 else { + results.warning("Invalid 3d model specification", page: page) + return "" + } + let file = parts[0] + guard file.hasSuffix(".glb") else { + results.warning("Invalid 3d model file \(file) (must be .glb)", page: page) + return "" + } + + // Ensure that file is available + let filePath = page.pathRelativeToRootForContainedInputFile(file) + results.require(file: filePath, source: page.path) + + // Add required file to head + headers.insert(.modelViewer) + + let description = parts.dropFirst().joined(separator: ";") + return """ + <model-viewer alt="\(description)" src="\(file)" ar shadow-intensity="1" camera-controls touch-action="pan-y"></model-viewer> + """ + } + */ +} diff --git a/CHDataManagement/Generator/PageGenerator.swift b/CHDataManagement/Generator/PageGenerator.swift new file mode 100644 index 0000000..a46937b --- /dev/null +++ b/CHDataManagement/Generator/PageGenerator.swift @@ -0,0 +1,43 @@ +final class PageGenerator { + + private let content: Content + + private let imageGenerator: ImageGenerator + + 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) -> String { + let contentGenerator = PageContentParser( + page: page, + content: content, + language: language, + results: results, + imageGenerator: imageGenerator) + + let rawPageContent = content.storage.pageContent(for: page.id, language: language) + + let pageContent = contentGenerator.generatePage(from: rawPageContent) + + let localized = page.localized(in: language) + + return ContentPage( + language: language, + dateString: page.dateText(in: language), + title: localized.title, + tags: page.tags.map { $0.data(in: language) }, + linkTitle: localized.linkPreviewTitle ?? localized.title, + description: localized.linkPreviewDescription ?? "", + navigationBarData: navigationBarData, + pageContent: pageContent) + .content + } + +} diff --git a/CHDataManagement/Generator/ShorthandMarkdownKey.swift b/CHDataManagement/Generator/ShorthandMarkdownKey.swift new file mode 100644 index 0000000..7ad3a54 --- /dev/null +++ b/CHDataManagement/Generator/ShorthandMarkdownKey.swift @@ -0,0 +1,51 @@ +import Foundation + +/** + A string key used in markdown to indicate special elements + */ +enum ShorthandMarkdownKey: String { + + /// A standard url + /// Format: `![url](<url>;<text>)` + case url + + /// An image + /// Format: `![image](<imageId>;<caption?>]` + case image + + /// Statistics about hiking + /// Format: `![hiking-stats](<` + case hikingStatistics = "hiking-stats" + + /// A video + /// Format: `![video](<fileId>;<alt>;<option1...>]` + case video + + /// An SVG image + /// Format: `![svg](<fileId>;<<x>;<y>;<width>;<height>?>)` + + /// A variable number of download buttons for file downloads + /// Format: `[download](<<fileId>,<text>,<download-filename?>;...)` + case downloadButtons = "download" + + /// A box with a title and content + /// Format: `![box](<title>;<body>)` + case box + + /// A 3D model to display + /// Format: `![model](<file>;<description>)` + case model + + /// A pretty link to another page on the site. + /// Format: `![page](<pageId>)` + case pageLink = "page" + + /// A large button to an external page. + /// Format: `![external](<<url>;<text>...>` + case externalLink = "external" + + /// Additional HTML code include verbatim into the page. + /// Format: `![html](<fileId>)` + case includedHtml = "html" + +} diff --git a/CHDataManagement/Generator/VideoOption.swift b/CHDataManagement/Generator/VideoOption.swift new file mode 100644 index 0000000..296c588 --- /dev/null +++ b/CHDataManagement/Generator/VideoOption.swift @@ -0,0 +1,11 @@ + +/// HTML video options +enum VideoOption: String { + case controls + case autoplay + case muted + case loop + case playsinline + case poster + case preload +} diff --git a/CHDataManagement/Storage/WebsiteGenerator.swift b/CHDataManagement/Generator/WebsiteGenerator.swift similarity index 67% rename from CHDataManagement/Storage/WebsiteGenerator.swift rename to CHDataManagement/Generator/WebsiteGenerator.swift index f2cfe35..1d68e93 100644 --- a/CHDataManagement/Storage/WebsiteGenerator.swift +++ b/CHDataManagement/Generator/WebsiteGenerator.swift @@ -31,13 +31,19 @@ final class WebsiteGenerator { } private var mainContentMaximumWidth: CGFloat { - content.settings.posts.contentWidth + CGFloat(content.settings.posts.contentWidth) } private let content: Content private let imageGenerator: ImageGenerator + private var navigationBarData: NavigationBarData { + createNavigationBarData( + settings: content.settings.navigationBar, + iconDescription: localizedSettings.navigationBarIconDescription) + } + init(content: Content, language: ContentLanguage) { self.language = language self.content = content @@ -67,16 +73,12 @@ final class WebsiteGenerator { return true } - let navBarData = createNavigationBarData( - settings: content.settings.navigationBar, - iconDescription: localizedSettings.navigationBarIconDescription) - let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up for pageIndex in 1...numberOfPages { let startIndex = (pageIndex - 1) * postsPerPage let endIndex = min(pageIndex * postsPerPage, totalCount) let postsOnPage = content.posts[startIndex..<endIndex] - guard createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navBarData) else { + guard createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navigationBarData) else { return false } } @@ -95,26 +97,11 @@ final class WebsiteGenerator { } private func createImageSet(for image: ImageResource) -> FeedEntryData.Image { - let size1x = mainContentMaximumWidth - let size2x = mainContentMaximumWidth * 2 - - let avif1x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size1x, maximumHeight: size1x) - let avif2x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size2x, maximumHeight: size2x) - - let webp1x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size1x, maximumHeight: size1x) - let webp2x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size2x, maximumHeight: size2x) - - let jpg1x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size1x, maximumHeight: size1x) - let jpg2x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size2x, maximumHeight: size2x) - - return FeedEntryData.Image( - altText: image.altText.getText(for: language), - avif1x: avif1x, - avif2x: avif2x, - webp1x: webp1x, - webp2x: webp2x, - jpg1x: jpg1x, - jpg2x: jpg2x) + imageGenerator.generateImageSet( + for: image.id, + maxWidth: mainContentMaximumWidth, + maxHeight: mainContentMaximumWidth, + altText: image.getDescription(for: language)) } private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice<Post>, bar: NavigationBarData) -> Bool { @@ -122,7 +109,9 @@ final class WebsiteGenerator { let localized: LocalizedPost = post.localized(in: language) let linkUrl = post.linkedPage.map { - FeedEntryData.Link(url: $0.localized(in: language).relativeUrl, text: "View") + FeedEntryData.Link( + url: content.pageLink($0, language: language), + text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings } return FeedEntryData( @@ -151,6 +140,56 @@ final class WebsiteGenerator { } } + private func generatePagesFolderIfNeeded() -> Bool { + let relativePath = content.settings.pages.pageUrlPrefix + + return content.storage.write(in: .outputPath) { folder in + let outputFile = folder.appendingPathComponent(relativePath, isDirectory: true) + do { + try outputFile.ensureFolderExistence() + return true + } catch { + return false + } + } + } + + func generate(page: Page) -> Bool { + guard generatePagesFolderIfNeeded() else { + print("Failed to generate output folder") + return false + } + let pageGenerator = PageGenerator(content: content, imageGenerator: imageGenerator, navigationBarData: navigationBarData) + let content = pageGenerator.generate(page: page, language: language) + + let path = self.content.pageLink(page, language: language) + ".html" + guard save(content, to: path) else { + print("Failed to save page") + return false + } + guard imageGenerator.runJobs(callback: { _ in }) else { + return false + } + guard copy(requiredVideoFiles: pageGenerator.results.requiredVideoFiles) else { + return false + } + return true + } + + private func copy(requiredVideoFiles: Set<String>) -> Bool { + print("Copying \(requiredVideoFiles.count) videos...") + for fileId in requiredVideoFiles { + guard let outputPath = content.pathToFile(fileId) else { + return false + } + guard content.storage.copy(file: fileId, to: outputPath) else { + print("Failed to copy video file to output folder") + return false + } + } + return true + } + private func save(_ content: String, to relativePath: String) -> Bool { guard let data = content.data(using: .utf8) else { print("Failed to create data for \(relativePath)") diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift new file mode 100644 index 0000000..bd5d796 --- /dev/null +++ b/CHDataManagement/Model/Content+Generation.swift @@ -0,0 +1,44 @@ +extension Content { + + func pageLink(_ page: Page, language: ContentLanguage) -> String { + // TODO: Record link to trace connections between pages + var prefix = settings.pages.pageUrlPrefix + if !prefix.hasPrefix("/") { + prefix = "/" + prefix + } + if !prefix.hasSuffix("/") { + prefix.append("/") + } + + return prefix + page.localized(in: language).urlString + } + + func pageLink(pageId: String, language: ContentLanguage) -> String? { + guard let page = pages.first(where: { $0.id == pageId }) else { + // TODO: Note missing link + print("Missing page \(pageId) linked") + return nil + } + return pageLink(page, language: language) + } + + func pathToFile(_ fileId: String) -> String? { + guard let file = file(id: fileId) else { + return nil + } + #warning("Add files path to settings") + return "/files/\(file.uniqueId)" + } + + func image(_ imageId: String) -> ImageResource? { + images.first { $0.id == imageId } + } + + func imageLink(imageId: String) { + + } + + 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 f7b8319..7a76390 100644 --- a/CHDataManagement/Model/Content+Load.swift +++ b/CHDataManagement/Model/Content+Load.swift @@ -49,6 +49,9 @@ extension Content { let storage = Storage(baseFolder: URL(filePath: contentPath)) let settings = try storage.loadSettings() + let imageDescriptions = storage.loadImageDescriptions().reduce(into: [:]) { descriptions, description in + descriptions[description.imageId] = description + } let tagData = try storage.loadAllTags() let pagesData = try storage.loadAllPages() @@ -57,20 +60,20 @@ extension Content { var images: [String : ImageResource] = [:] var files: [FileResource] = [] - var videos: [String] = [] for (file, url) in filesData { let ext = file.components(separatedBy: ".").last!.lowercased() let type = FileType(fileExtension: ext) - switch type { - case .image: - images[file] = ImageResource(uniqueId: file, altText: .init(en: "", de: ""), fileUrl: url) - case .file: - files.append(FileResource(uniqueId: file, description: "")) - case .video: - videos.append(file) - case .resource: - break + if case .image(let type) = type { + let descriptions = imageDescriptions[file] + images[file] = ImageResource( + type: type, + uniqueId: file, + en: descriptions?.english ?? "", + de: descriptions?.german ?? "", + fileUrl: url) + } else { + files.append(FileResource(type: type, uniqueId: file, description: "")) } } @@ -104,7 +107,6 @@ extension Content { self.pages = pages.values.sorted(ascending: false) { $0.startDate } self.files = files.sorted { $0.uniqueId } self.images = images.values.sorted { $0.id } - self.videos = videos self.posts = posts.sorted(ascending: false) { $0.startDate } self.settings = makeSettings(settings, tags: tags) } @@ -119,10 +121,15 @@ extension Content { postsPerPage: settings.posts.postsPerPage, contentWidth: settings.posts.contentWidth) + let pages = PageSettings( + pageUrlPrefix: settings.pages.pageUrlPrefix, + contentWidth: settings.pages.contentWidth) + return Settings( outputDirectoryPath: settings.outputDirectoryPath, navigationBar: navigationBar, posts: posts, + pages: pages, german: convert(settings.german), english: convert(settings.english)) } diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index 1bc5ac1..54e0f99 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -17,11 +17,23 @@ extension Content { } storage.save(settings: settings.file) + let imageDescriptions: [ImageDescriptions] = images.sorted().compactMap { image in + guard !image.englishDescription.isEmpty || !image.germanDescription.isEmpty else { + return nil + } + return ImageDescriptions( + imageId: image.id, + german: image.germanDescription.nonEmpty, + english: image.englishDescription.nonEmpty) + } + + storage.save(imageDescriptions: imageDescriptions) + do { try storage.deletePostFiles(notIn: posts.map { $0.id }) try storage.deletePageFiles(notIn: pages.map { $0.id }) try storage.deleteTagFiles(notIn: tags.map { $0.id }) - let allFiles = files.map { $0.uniqueId } + images.map { $0.id } + videos + let allFiles = files.map { $0.uniqueId } + images.map { $0.id } try storage.deleteFiles(notIn: allFiles) } catch { print("Failed to remove unused files: \(error)") @@ -128,6 +140,7 @@ extension Settings { outputDirectoryPath: outputDirectoryPath, navigationBar: navigationBar.file, posts: posts.file, + pages: pages.file, german: german.file, english: english.file) } @@ -141,6 +154,14 @@ private extension PostSettings { } } +private extension PageSettings { + + var file: PageSettingsFile { + .init(pageUrlPrefix: pageUrlPrefix, + contentWidth: contentWidth) + } +} + private extension LocalizedSettings { var file: LocalizedSettingsFile { diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index 5ec7a9a..f5d7025 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -19,9 +19,6 @@ final class Content: ObservableObject { @Published var images: [ImageResource] - @Published - var videos: [String] - @Published var files: [FileResource] @@ -45,7 +42,6 @@ final class Content: ObservableObject { tags: [Tag], images: [ImageResource], files: [FileResource], - videos: [String], storedContentPath: String) { self.settings = settings self.posts = posts @@ -53,7 +49,6 @@ final class Content: ObservableObject { self.tags = tags self.images = images self.files = files - self.videos = videos self.storedContentPath = storedContentPath self.contentPath = storedContentPath self.storage = Storage(baseFolder: URL(filePath: storedContentPath)) @@ -75,7 +70,6 @@ final class Content: ObservableObject { self.tags = [] self.images = [] self.files = [] - self.videos = [] contentPath = storedContentPath do { diff --git a/CHDataManagement/Model/DateItem.swift b/CHDataManagement/Model/DateItem.swift new file mode 100644 index 0000000..e04c0f6 --- /dev/null +++ b/CHDataManagement/Model/DateItem.swift @@ -0,0 +1,85 @@ +import Foundation + +protocol DateItem { + + var startDate: Date { get } + + var hasEndDate: Bool { get } + + var endDate: Date { get } +} + +extension DateItem { + + private func datePrefixString(in language: ContentLanguage) -> String { + guard Calendar.current.isDate(startDate, equalTo: endDate, toGranularity: .year) else { + // Different year, return full string + return startDate.formatted(date: .long, time: .omitted) + } + guard Calendar.current.isDate(startDate, equalTo: endDate, toGranularity: .month) else { + // Different month + return DateItemStorage.dayAndMonth(of: startDate, in: language) + } + + return DateItemStorage.day.string(from: startDate) + } + + func dateText(in language: ContentLanguage) -> String { + guard hasEndDate else { + return DateItemStorage.dateString(for: startDate, in: language) + } + let endText = DateItemStorage.dateString(for: endDate, in: language) + return "\(datePrefixString(in: language)) - \(endText)" + } +} + +private enum DateItemStorage { + + static let englishDate: DateFormatter = { + let df = DateFormatter() + df.locale = .init(identifier: "en") + df.dateFormat = "d. MMMM yyyy" + return df + }() + + static let germanDate: DateFormatter = { + let df = DateFormatter() + df.locale = .init(identifier: "de") + df.dateFormat = "d. MMMM yyyy" + return df + }() + + static let englishDayAndMonth: DateFormatter = { + let df = DateFormatter() + df.locale = .init(identifier: "en") + df.dateFormat = "d. MMMM" + return df + }() + + static let germanDayAndMonth: DateFormatter = { + let df = DateFormatter() + df.locale = .init(identifier: "de") + df.dateFormat = "d. MMMM" + return df + }() + + static let day: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "d." + return df + }() + + static func dayAndMonth(of date: Date, in language: ContentLanguage) -> String { + switch language { + case .english: return englishDayAndMonth.string(from: date) + case .german: return germanDayAndMonth.string(from: date) + } + } + + static func dateString(for date: Date, in language: ContentLanguage) -> String { + switch language { + case .english: return englishDate.string(from: date) + case .german: return germanDate.string(from: date) + } + } +} diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index 9ce1b50..374ba6d 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -2,6 +2,8 @@ import Foundation final class FileResource: ObservableObject { + let type: FileType + /// Globally unique id @Published var uniqueId: String @@ -10,6 +12,13 @@ final class FileResource: ObservableObject { var description: String init(uniqueId: String, description: String) { + self.type = FileType(fileExtension: uniqueId.fileExtension) + self.uniqueId = uniqueId + self.description = description + } + + init(type: FileType, uniqueId: String, description: String) { + self.type = type self.uniqueId = uniqueId self.description = description } diff --git a/CHDataManagement/Model/ImageResource.swift b/CHDataManagement/Model/ImageResource.swift index 06fe220..66ffbc3 100644 --- a/CHDataManagement/Model/ImageResource.swift +++ b/CHDataManagement/Model/ImageResource.swift @@ -2,12 +2,18 @@ import SwiftUI final class ImageResource: ObservableObject { + @Published + var type: ImageType + /// Globally unique id @Published var id: String @Published - var altText: LocalizedText + var germanDescription: String + + @Published + var englishDescription: String @Published var size: CGSize = .zero @@ -21,28 +27,46 @@ final class ImageResource: ObservableObject { private let source: ImageSource - init(uniqueId: String, altText: LocalizedText, fileUrl: URL) { + init(type: ImageType, uniqueId: String, en: String, de: String, fileUrl: URL) { + self.type = type self.id = uniqueId self.source = .file(fileUrl) - self.altText = altText + self.englishDescription = en + self.germanDescription = de } init(resourceName: String) { + self.type = ImageType(fileExtension: resourceName.fileExtension!)! self.id = resourceName self.source = .resource(resourceName) - self.altText = .init(en: "A test image included in the bundle", de: "Ein Test-Image aus dem Bundle") + self.englishDescription = "A test image included in the bundle" + self.germanDescription = "Ein Test-Image aus dem Bundle" } private enum ImageSource { case file(URL) case resource(String) } + + func getDescription(for language: ContentLanguage) -> String { + switch language { + case .english: return englishDescription + case .german: return germanDescription + } + } } extension ImageResource: Identifiable { } +extension ImageResource: Comparable { + + static func < (lhs: ImageResource, rhs: ImageResource) -> Bool { + lhs.id < rhs.id + } +} + extension ImageResource: Equatable { static func == (lhs: ImageResource, rhs: ImageResource) -> Bool { diff --git a/CHDataManagement/Model/LocalizedPage.swift b/CHDataManagement/Model/LocalizedPage.swift index ff90162..a72914d 100644 --- a/CHDataManagement/Model/LocalizedPage.swift +++ b/CHDataManagement/Model/LocalizedPage.swift @@ -85,20 +85,4 @@ final class LocalizedPage: ObservableObject { self.linkPreviewTitle = linkPreviewTitle self.linkPreviewDescription = linkPreviewDescription } - - @MainActor - func editableTitle() -> Binding<String> { - Binding( - get: { - self.title - }, - set: { newValue in - self.title = newValue - } - ) - } - - var relativeUrl: String { - "/page/\(urlString)" - } } diff --git a/CHDataManagement/Model/LocalizedPost.swift b/CHDataManagement/Model/LocalizedPost.swift index 6992b96..5e5bdf4 100644 --- a/CHDataManagement/Model/LocalizedPost.swift +++ b/CHDataManagement/Model/LocalizedPost.swift @@ -39,28 +39,4 @@ final class LocalizedPost: ObservableObject { self.linkPreviewTitle = linkPreviewTitle self.linkPreviewDescription = linkPreviewDescription } - - @MainActor - func editableTitle() -> Binding<String> { - Binding( - get: { - self.title - }, - set: { newValue in - self.title = newValue - } - ) - } - - @MainActor - func editableContent() -> Binding<String> { - Binding( - get: { - self.content - }, - set: { newValue in - self.content = newValue - } - ) - } } diff --git a/CHDataManagement/Model/Page.swift b/CHDataManagement/Model/Page.swift index 78c8199..ca8964a 100644 --- a/CHDataManagement/Model/Page.swift +++ b/CHDataManagement/Model/Page.swift @@ -84,3 +84,7 @@ extension Page: Hashable { hasher.combine(id) } } + +extension Page: DateItem { + +} diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift index cdf6d9f..5216a35 100644 --- a/CHDataManagement/Model/Post.swift +++ b/CHDataManagement/Model/Post.swift @@ -80,77 +80,6 @@ extension Post: Hashable { } } +extension Post: DateItem { -// MARK: Feed entry - -extension Post { - - private static let englishDate: DateFormatter = { - let df = DateFormatter() - df.locale = .init(identifier: "en") - df.dateFormat = "d. MMMM yyyy" - return df - }() - - private static let germanDate: DateFormatter = { - let df = DateFormatter() - df.locale = .init(identifier: "de") - df.dateFormat = "d. MMMM yyyy" - return df - }() - - private static let englishDayAndMonth: DateFormatter = { - let df = DateFormatter() - df.locale = .init(identifier: "en") - df.dateFormat = "d. MMMM" - return df - }() - - private static let germanDayAndMonth: DateFormatter = { - let df = DateFormatter() - df.locale = .init(identifier: "de") - df.dateFormat = "d. MMMM" - return df - }() - - private static let day: DateFormatter = { - let df = DateFormatter() - df.dateFormat = "d." - return df - }() - - private static func dayAndMonth(of date: Date, in language: ContentLanguage) -> String { - switch language { - case .english: return englishDayAndMonth.string(from: date) - case .german: return germanDayAndMonth.string(from: date) - } - } - - private static func dateString(for date: Date, in language: ContentLanguage) -> String { - switch language { - case .english: return englishDate.string(from: date) - case .german: return germanDate.string(from: date) - } - } - - private func datePrefixString(in language: ContentLanguage) -> String { - guard Calendar.current.isDate(startDate, equalTo: endDate, toGranularity: .year) else { - // Different year, return full string - return startDate.formatted(date: .long, time: .omitted) - } - guard Calendar.current.isDate(startDate, equalTo: endDate, toGranularity: .month) else { - // Different month - return Post.dayAndMonth(of: startDate, in: language) - } - - return Post.day.string(from: startDate) - } - - func dateText(in language: ContentLanguage) -> String { - guard hasEndDate else { - return Post.dateString(for: startDate, in: language) - } - let endText = Post.dateString(for: endDate, in: language) - return "\(datePrefixString(in: language)) - \(endText)" - } } diff --git a/CHDataManagement/Storage/Model/FileType.swift b/CHDataManagement/Model/Types/FileType.swift similarity index 50% rename from CHDataManagement/Storage/Model/FileType.swift rename to CHDataManagement/Model/Types/FileType.swift index ba0200a..f73499b 100644 --- a/CHDataManagement/Storage/Model/FileType.swift +++ b/CHDataManagement/Model/Types/FileType.swift @@ -3,13 +3,17 @@ import Foundation enum FileType { case image(ImageType) - case file - case video - case resource + case file(String) + case video(VideoType) + case resource(String) - init(fileExtension: String) { - switch fileExtension.lowercased() { + init(fileExtension: String?) { + guard let ext = fileExtension?.lowercased() else { + self = .file("") + return + } + switch ext { case "jpg", "jpeg": self = .image(.jpg) case "png": @@ -21,20 +25,25 @@ enum FileType { case "gif": self = .image(.gif) case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift": - self = .file + self = .file(ext) case "mp4": - self = .video + self = .video(.mp4) + case "m4v": + self = .video(.m4v) + case "webm": + self = .video(.webm) case "key", "psd": - self = .resource + self = .resource(ext) default: - print("Unhandled file type: \(fileExtension)") - self = .resource + print("Unhandled file type: \(ext)") + self = .resource(ext) } } var fileExtension: String { switch self { case .image(let imageType): return imageType.fileExtension + case .video(let videoType): return videoType.fileExtension default: return "" // TODO: Fix } @@ -46,4 +55,18 @@ enum FileType { } return false } + + var isVideo: Bool { + if case .video = self { + return true + } + return false + } + + var videoType: VideoType? { + if case .video(let videoType) = self { + return videoType + } + return nil + } } diff --git a/CHDataManagement/Model/ImageType.swift b/CHDataManagement/Model/Types/ImageType.swift similarity index 100% rename from CHDataManagement/Model/ImageType.swift rename to CHDataManagement/Model/Types/ImageType.swift diff --git a/CHDataManagement/Model/Types/VideoType.swift b/CHDataManagement/Model/Types/VideoType.swift new file mode 100644 index 0000000..3230f36 --- /dev/null +++ b/CHDataManagement/Model/Types/VideoType.swift @@ -0,0 +1,36 @@ + +enum VideoType: String { + + case mp4 + + case m4v + + case webm +} + +extension VideoType { + + var fileExtension: String { + switch self { + case .mp4: + return "mp4" + case .m4v: + return "m4v" + case .webm: + return "webm" + } + } + + var htmlType: String { + switch self { + case .mp4, .m4v: + return "video/mp4" + case .webm: + return "video/webm" + } + } +} + +extension VideoType: CaseIterable { + +} diff --git a/CHDataManagement/Page Elements/ContentElements/ContentPageVideo.swift b/CHDataManagement/Page Elements/ContentElements/ContentPageVideo.swift new file mode 100644 index 0000000..3276f6f --- /dev/null +++ b/CHDataManagement/Page Elements/ContentElements/ContentPageVideo.swift @@ -0,0 +1,19 @@ + +struct ContentPageVideo: HtmlProducer { + + let filePath: String + + let videoType: String + + let options: [VideoOption] + + func populate(_ result: inout String) { + result += "<video\(optionString)>Video not supported." + result += "<source src='\(filePath)' type='\(videoType)'>" + result += "</video>" + } + + private var optionString: String { + options.map { " " + $0.rawValue }.joined() + } +} diff --git a/CHDataManagement/Page Elements/ContentElements/DownloadButtons.swift b/CHDataManagement/Page Elements/ContentElements/DownloadButtons.swift new file mode 100644 index 0000000..86ef8f1 --- /dev/null +++ b/CHDataManagement/Page Elements/ContentElements/DownloadButtons.swift @@ -0,0 +1,34 @@ + +struct DownloadButtons { + + struct Item { + + let filePath: String + + let text: String + + let downloadFileName: String? + } + + let items: [Item] + + init(items: [Item]) { + self.items = items + } + + var content: String { + var result = "<p style='display: flex'>" + for item in items { + addButton(of: item, to: &result) + } + result += "</p>" + return result + } + + 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>" + } +} diff --git a/CHDataManagement/Page Elements/ContentElements/HikingStatistics.swift b/CHDataManagement/Page Elements/ContentElements/HikingStatistics.swift new file mode 100644 index 0000000..30f6908 --- /dev/null +++ b/CHDataManagement/Page Elements/ContentElements/HikingStatistics.swift @@ -0,0 +1,42 @@ + +struct HikingStatistics { + + private let time: String? + + private let elevationUp: String? + + private let elevationDown: String? + + private let distance: String? + + private let calories: String? + + init(time: String?, elevationUp: String?, elevationDown: String?, distance: String?, calories: String?) { + self.time = time + self.elevationUp = elevationUp + self.elevationDown = elevationDown + self.distance = distance + self.calories = calories + } + + var content: String { + var result = "<div class='stats-container'>" + if let time { + result += "<div><svg><use href='#icon-clock'></use></svg>\(time)</div>" + } + if let elevationUp { + result += "<div><svg><use href='#icon-arrow-up'></use></svg>\(elevationUp)</div>" + } + if let elevationDown { + result += "<div><svg><use href='#icon-arrow-down'></use></svg>\(elevationDown)</div>" + } + if let distance { + result += "<div><svg><use href='#icon-sign'></use></svg>\(distance)</div>" + } + if let calories { + result += "<div><svg><use href='#icon-flame'></use></svg>\(calories)</div>" + } + result += "</div>" + return result + } +} diff --git a/CHDataManagement/Page Elements/ContentElements/TagList.swift b/CHDataManagement/Page Elements/ContentElements/TagList.swift new file mode 100644 index 0000000..048b784 --- /dev/null +++ b/CHDataManagement/Page Elements/ContentElements/TagList.swift @@ -0,0 +1,29 @@ +protocol HtmlProducer { + + func populate(_ result: inout String) +} + +extension HtmlProducer { + + var content: String { + var result = "" + populate(&result) + return result + } +} + +struct TagList: HtmlProducer { + + let tags: [FeedEntryData.Tag] + + func populate(_ result: inout String) { + guard !tags.isEmpty else { + return + } + result += "<div class='tags'>" + for tag in tags { + result += "<span class='tag' onclick=\"location.href='\(tag.url)'; event.stopPropagation();\">\(tag.name)</span>" + } + result += "</div>" + } +} diff --git a/CHDataManagement/Page Elements/FeedEntry.swift b/CHDataManagement/Page Elements/FeedEntry.swift index 730c18e..b48315d 100644 --- a/CHDataManagement/Page Elements/FeedEntry.swift +++ b/CHDataManagement/Page Elements/FeedEntry.swift @@ -2,15 +2,18 @@ import Foundation struct FeedEntry { - let data: FeedEntryData + private let data: FeedEntryData init(data: FeedEntryData) { self.data = data } + private var cardLinkClassText: String { + data.link != nil ? " linked-card" : "" + } + var content: String { - #warning("TODO: Select CSS classes based on existence of link (hover effects, mouse pointer") - var result = "<div class='card'>" + var result = "<div class='card\(cardLinkClassText)'>" ImageGallery(id: data.entryId, images: data.images) .addContent(to: &result) @@ -23,14 +26,8 @@ struct FeedEntry { if let title = data.title { result += "<h2>\(title.htmlEscaped())</h2>" } - if !data.tags.isEmpty { - result += "<div class='tags'>" - for tag in data.tags { - result += "<span class='tag' onclick=\"location.href='\(tag.url)'; event.stopPropagation();\">\(tag.name)</span>" - //result += "<a class='tag' href='\(tag.url)'>\(tag.name)</a>" - } - result += "</div>" - } + result += TagList(tags: data.tags).content + for paragraph in data.text { result += "<p>\(paragraph)</p>" } diff --git a/CHDataManagement/Page Elements/FeedEntryData.swift b/CHDataManagement/Page Elements/FeedEntryData.swift index e10fc53..56796b0 100644 --- a/CHDataManagement/Page Elements/FeedEntryData.swift +++ b/CHDataManagement/Page Elements/FeedEntryData.swift @@ -43,19 +43,13 @@ struct FeedEntryData { struct Image { + let rawImagePath: String + + let width: Int + + let height: Int + let altText: String - let avif1x: String - - let avif2x: String - - let webp1x: String - - let webp2x: String - - let jpg1x: String - - let jpg2x: String - } } diff --git a/CHDataManagement/Page Elements/ImageGallery.swift b/CHDataManagement/Page Elements/ImageGallery.swift index 7393a49..7f9b2e1 100644 --- a/CHDataManagement/Page Elements/ImageGallery.swift +++ b/CHDataManagement/Page Elements/ImageGallery.swift @@ -15,16 +15,6 @@ struct ImageGallery { self.images = images } - private func imageCode(_ image: FeedEntryData.Image) -> String { - //return "<img src='\(image.mainImageUrl)' loading='lazy' alt='\(image.altText.htmlEscaped())'>" - var result = "<picture>" - result += "<source type='image/avif' srcset='\(image.avif1x) 1x, \(image.avif2x) 2x'/>" - result += "<source type='image/webp' srcset='\(image.webp1x) 1x, \(image.webp2x) 2x'/>" - result += "<img srcset='\(image.jpg2x) 2x' src='\(image.jpg1x)' loading='lazy' alt='\(image.altText.htmlEscaped())'/>" - result += "</picture>" - return result - } - func addContent(to result: inout String) { guard !images.isEmpty else { return @@ -33,7 +23,7 @@ struct ImageGallery { result += "<div id='\(htmlSafeId)' class='swiper'><div class='swiper-wrapper'>" guard images.count > 1 else { - result += imageCode(images[0]) + result += WebsiteImage(image: images[0]).content result += "</div></div>" // Close swiper, swiper-wrapper return } @@ -42,7 +32,7 @@ struct ImageGallery { // TODO: Use different images based on device result += "<div class='swiper-slide'>" - result += imageCode(image) + result += WebsiteImage(image: image).content result += "<div class='swiper-lazy-preloader swiper-lazy-preloader-white'></div>" diff --git a/CHDataManagement/Page Elements/PageImage.swift b/CHDataManagement/Page Elements/PageImage.swift new file mode 100644 index 0000000..eace4af --- /dev/null +++ b/CHDataManagement/Page Elements/PageImage.swift @@ -0,0 +1,27 @@ +import Foundation + +struct PageImage { + + let imageId: String + + let thumbnail: FeedEntryData.Image + + let largeImage: FeedEntryData.Image + + let caption: String? + + var content: String { + var result = "" + result += "<div class='content-image' onclick=\"document.getElementById('\(imageId)').classList.add('active')\">" + result += WebsiteImage(image: thumbnail).content + result += "</div>" + result += "<div id='\(imageId)' class='fullscreen-image' onclick=\"document.getElementById('\(imageId)').classList.remove('active')\">" + result += WebsiteImage(image: largeImage).content + if let caption { + result += "<div class='caption'>\(caption)</div>" + } + result += "<div class='close'></div>" + result += "</div>" + return result + } +} diff --git a/CHDataManagement/Page Elements/WebsiteImage.swift b/CHDataManagement/Page Elements/WebsiteImage.swift new file mode 100644 index 0000000..ee7673e --- /dev/null +++ b/CHDataManagement/Page Elements/WebsiteImage.swift @@ -0,0 +1,35 @@ + +struct WebsiteImage { + + private let prefix1x: String + + private let prefix2x: String + + private let altText: String + + private let ext: String + + init(image: FeedEntryData.Image) { + self.init(rawImagePath: image.rawImagePath, + width: image.width, + height: image.height, + altText: image.altText) + } + + 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.altText = altText.htmlEscaped() + self.ext = ext ?? "jpg" + } + + var content: String { + var result = "<picture>" + result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>" + result += "<source type='image/webp' srcset='\(prefix1x).webm 1x, \(prefix1x).webm 2x'/>" + result += "<img srcset='\(prefix2x).\(ext) 2x' src='\(prefix1x).\(ext)' loading='lazy' alt='\(altText)'/>" + result += "</picture>" + return result + } +} diff --git a/CHDataManagement/Pages/ContentPage.swift b/CHDataManagement/Pages/ContentPage.swift new file mode 100644 index 0000000..3603725 --- /dev/null +++ b/CHDataManagement/Pages/ContentPage.swift @@ -0,0 +1,73 @@ +import Foundation + +struct ContentPage: HtmlProducer { + + private let linkTitle: String + + private let description: String + + private let language: ContentLanguage + + private let dateString: String + + private let title: String + + private let tags: [FeedEntryData.Tag] + + private let navigationBarData: NavigationBarData + + private let pageContent: String + + init(language: ContentLanguage, dateString: String, title: String, tags: [FeedEntryData.Tag], linkTitle: String, description: String, navigationBarData: NavigationBarData, pageContent: String) { + self.language = language + self.dateString = dateString + self.title = title + self.tags = tags + self.linkTitle = linkTitle + self.description = description + self.navigationBarData = navigationBarData + self.pageContent = pageContent + } + + func populate(_ result: inout String) { + // TODO: Add headers and footers from page content + result += "<!DOCTYPE html><html lang=\"\(language.rawValue)\">" + result += PageHead(title: title, description: description, additionalHeaders: "").content + result += "<body>" + result += NavigationBar(data: navigationBarData).content + + result += "<main><article>" + result += "<div style=\"height: 70px;\"></div>" + result += "<h3>\(dateString)</h3>" + result += "<h1>\(title)</h1>" + result += TagList(tags: tags).content + result += symbols + result += pageContent + result += "</article></main>" + + result += "" // TODO: Footer + 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> + """ +} diff --git a/CHDataManagement/Preview Content/Content+Mock.swift b/CHDataManagement/Preview Content/Content+Mock.swift index 78c0934..809d7b5 100644 --- a/CHDataManagement/Preview Content/Content+Mock.swift +++ b/CHDataManagement/Preview Content/Content+Mock.swift @@ -21,6 +21,5 @@ extension Content { tags: [.hiking, .mountains, .nature, .sports], images: [], files: [], - videos: [], storedContentPath: dbPath) } diff --git a/CHDataManagement/Storage/Model/ImageDescriptions.swift b/CHDataManagement/Storage/Model/ImageDescriptions.swift new file mode 100644 index 0000000..f2b3035 --- /dev/null +++ b/CHDataManagement/Storage/Model/ImageDescriptions.swift @@ -0,0 +1,13 @@ + +struct ImageDescriptions { + + let imageId: String + + let german: String? + + let english: String? +} + +extension ImageDescriptions: Codable { + +} diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 63813db..f83127c 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -154,7 +154,7 @@ final class Storage { let contentUrl = pageContentUrl(pageId: pageId, language: language) guard fm.fileExists(atPath: contentUrl.path()) else { print("No file at \(contentUrl.path())") - return "New file" + return "" } do { return try String(contentsOf: contentUrl, encoding: .utf8) @@ -235,6 +235,34 @@ final class Storage { // MARK: Files + private var imageDescriptionFilename: String { + "image-descriptions.json" + } + + private var imageDescriptionUrl: URL { + baseFolder.appending(path: "image-descriptions.json") + } + + func loadImageDescriptions() -> [ImageDescriptions] { + do { + return try read(relativePath: imageDescriptionFilename) + } catch { + print("Failed to read image descriptions: \(error)") + return [] + } + } + + @discardableResult + func save(imageDescriptions: [ImageDescriptions]) -> Bool { + do { + try writeIfChanged(imageDescriptions, to: imageDescriptionFilename) + return true + } catch { + print("Failed to write image descriptions: \(error)") + return false + } + } + /// The folder path where other files are stored (by their unique name) var filesFolder: URL { subFolder("files") } @@ -251,6 +279,26 @@ final class Storage { return copy(file: url, to: contentUrl, type: "file", id: fileId) } + func copy(file fileId: String, to relativeOutputPath: String) -> Bool { + do { + try operate(in: .contentPath) { contentPath in + try operate(in: .outputPath) { outputPath in + let output = outputPath.appending(path: relativeOutputPath, directoryHint: .notDirectory) + if output.exists { + return + } + let input = contentPath.appending(path: "files/\(fileId)", directoryHint: .notDirectory) + try output.ensureParentFolderExistence() + try FileManager.default.copyItem(at: input, to: output) + } + } + return true + } catch { + print("Failed to copy file \(fileId) to output folder: \(error)") + return false + } + } + func loadAllFiles() throws -> [String : URL] { try files(in: filesFolder).reduce(into: [:]) { files, url in files[url.lastPathComponent] = url @@ -365,6 +413,30 @@ final class Storage { } } + private func writeIfChanged<T>(_ value: T, to relativePath: String) throws where T: Encodable { + try operate(in: .contentPath) { contentPath in + let url = contentPath.appending(path: relativePath, directoryHint: .notDirectory) + let data = try encoder.encode(value) + if fm.fileExists(atPath: url.path()) { + // Check if content is the same, to prevent unnecessary writes + do { + let oldData = try Data(contentsOf: url) + if data == oldData { + // File is the same, don't write + return + } + } catch { + print("Failed to read file \(url.path()) for equality check: \(error)") + // No check possible, write file + } + } else { + print("Writing new file \(url.path())") + } + try data.write(to: url) + print("Saved file \(url.path())") + } + } + /** Encode a value and write it to a file, if the content changed */ @@ -426,6 +498,14 @@ final class Storage { return write(data: data, type: type, id: id, to: file) } + private func read<T>(relativePath: String) throws -> T where T: Decodable { + try operate(in: .contentPath) { baseFolder in + let url = baseFolder.appending(path: relativePath, directoryHint: .notDirectory) + let data = try Data(contentsOf: url) + return try decoder.decode(T.self, from: data) + } + } + private func read<T>(at url: URL) throws -> T where T: Decodable { let data = try Data(contentsOf: url) return try decoder.decode(T.self, from: data) diff --git a/CHDataManagement/Views/Files/FilesView.swift b/CHDataManagement/Views/Files/FilesView.swift index 352c3ce..cf9d6b7 100644 --- a/CHDataManagement/Views/Files/FilesView.swift +++ b/CHDataManagement/Views/Files/FilesView.swift @@ -60,7 +60,8 @@ struct FilesView: View { print("A file '\(fileId)' already exists") continue } - let file = FileResource(uniqueId: fileId, description: "") + let type = FileType(fileExtension: fileId.fileExtension) + let file = FileResource(type: type, uniqueId: fileId, description: "") guard content.storage.copyFile(at: url, fileId: fileId) else { print("Failed to import file '\(fileId)'") continue diff --git a/CHDataManagement/Views/Images/ImageDetailsView.swift b/CHDataManagement/Views/Images/ImageDetailsView.swift index 9d136f0..dd5ad23 100644 --- a/CHDataManagement/Views/Images/ImageDetailsView.swift +++ b/CHDataManagement/Views/Images/ImageDetailsView.swift @@ -26,9 +26,12 @@ struct ImageDetailsView: View { Text("Update") } } - Text("Description") + Text("German Description") .font(.headline) - TextField("", text: image.altText.text(for: language)) + TextField("", text: $image.germanDescription) + Text("English Description") + .font(.headline) + TextField("", text: $image.englishDescription) Text("Info") .font(.headline) HStack(alignment: .top) { diff --git a/CHDataManagement/Views/Pages/PageContentView.swift b/CHDataManagement/Views/Pages/PageContentView.swift new file mode 100644 index 0000000..6ae16e8 --- /dev/null +++ b/CHDataManagement/Views/Pages/PageContentView.swift @@ -0,0 +1,103 @@ +import SwiftUI +import HighlightedTextEditor + +struct PageContentView: View { + + @ObservedObject + var page: Page + + @ObservedObject + private var localized: LocalizedPage + + let language: ContentLanguage + + @EnvironmentObject + private var content: Content + + @State + private var isGeneratingWebsite = false + + @State + private var pageContent: String = "" + + init(page: Page, language: ContentLanguage) { + self.page = page + self.localized = page.localized(in: language) + self.language = language + } + + var body: some View { + VStack(alignment: .leading) { + TextField("", text: $localized.title) + .font(.title) + .textFieldStyle(.plain) + + HStack(alignment: .firstTextBaseline) { + Button(action: loadContent) { + Text("Load") + } + Button(action: saveContent) { + Text("Save") + } + Button(action: generate) { + Text("Generate") + } + .disabled(isGeneratingWebsite) + Spacer() + } + HighlightedTextEditor( + text: $pageContent, + highlightRules: .markdown) + } + .padding() + .onAppear(perform: loadContent) + .onDisappear(perform: saveContent) + } + + private func loadContent() { + let content = content.storage.pageContent(for: page.id, language: language) + guard content != "" else { + pageContent = "New file" + return + } + pageContent = content + } + + private func saveContent() { + guard pageContent != "", pageContent != "New file" else { + return + } + content.storage.save(pageContent: pageContent, for: page.id, language: language) + } + + private func generate() { + guard content.settings.outputDirectoryPath != "" else { + print("Invalid output path") + return + } + let url = URL(fileURLWithPath: content.settings.outputDirectoryPath) + + guard FileManager.default.fileExists(atPath: url.path) else { + print("Missing output folder") + return + } + 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") + } + DispatchQueue.main.async { + isGeneratingWebsite = false + print("Done") + } + } + } +} + +#Preview { + PageContentView(page: .empty, language: .english) +} diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift deleted file mode 100644 index 16f4706..0000000 --- a/CHDataManagement/Views/Pages/PageDetailView.swift +++ /dev/null @@ -1,51 +0,0 @@ -import SwiftUI -import HighlightedTextEditor - -struct PageDetailView: View { - - @ObservedObject - var page: Page - - @Environment(\.language) - private var language - - @EnvironmentObject - private var content: Content - - @State - private var pageContent: String = "" - - var body: some View { - VStack(alignment: .leading) { - TextField("", text: page.localized(in: language).editableTitle()) - .font(.title) - .textFieldStyle(.plain) - - HStack(alignment: .firstTextBaseline) { - Button(action: loadContent) { - Text("Load") - } - Button(action: saveContent) { - Text("Save") - } - Spacer() - } - HighlightedTextEditor( - text: $pageContent, - highlightRules: .markdown) - } - .padding() - } - - private func loadContent() { - pageContent = content.storage.pageContent(for: page.id, language: language) - } - - private func saveContent() { - content.storage.save(pageContent: pageContent, for: page.id, language: language) - } -} - -#Preview { - PageDetailView(page: .empty) -} diff --git a/CHDataManagement/Views/Pages/PageListView.swift b/CHDataManagement/Views/Pages/PageListView.swift index 007f515..c0cd290 100644 --- a/CHDataManagement/Views/Pages/PageListView.swift +++ b/CHDataManagement/Views/Pages/PageListView.swift @@ -42,8 +42,8 @@ struct PageListView: View { .navigationSplitViewColumnWidth(min: 300, ideal: 300, max: 300) } content: { if let selected { - PageDetailView(page: selected) - .id(selected.id) + PageContentView(page: selected, language: language) + .id(selected.id + language.rawValue) .layoutPriority(1) } else { // Fallback if no item is selected