Generate first tag pages

This commit is contained in:
Christoph Hagen 2024-12-09 17:47:03 +01:00
parent 4f08526978
commit 8183bc4903
35 changed files with 719 additions and 1105 deletions

View File

@ -20,19 +20,14 @@
E218502B2CF790B30090B18B /* PostContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502A2CF790AC0090B18B /* PostContentView.swift */; }; E218502B2CF790B30090B18B /* PostContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502A2CF790AC0090B18B /* PostContentView.swift */; };
E218502D2CF791440090B18B /* PostImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502C2CF791440090B18B /* PostImagesView.swift */; }; E218502D2CF791440090B18B /* PostImagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502C2CF791440090B18B /* PostImagesView.swift */; };
E218502F2CFAF69C0090B18B /* WebsiteGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */; }; E218502F2CFAF69C0090B18B /* WebsiteGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */; };
E21850312CFAF8880090B18B /* Content+Import.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850302CFAF8840090B18B /* Content+Import.swift */; };
E21850332CFAFA2F0090B18B /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850322CFAFA200090B18B /* Settings.swift */; }; E21850332CFAFA2F0090B18B /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850322CFAFA200090B18B /* Settings.swift */; };
E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850342CFAFA570090B18B /* SettingsFile.swift */; }; E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850342CFAFA570090B18B /* SettingsFile.swift */; };
E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */; }; E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */; };
E21850392CFCA6C00090B18B /* WebsiteData+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */; }; E21850392CFCA6C00090B18B /* WebsiteData+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */; };
E218503D2CFCFD910090B18B /* LocalizedPostFeedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */; }; E218503D2CFCFD910090B18B /* LocalizedPostFeedSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */; };
E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = E24252002C50E0A40029FF16 /* HighlightedTextEditor */; }; E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = E24252002C50E0A40029FF16 /* HighlightedTextEditor */; };
E24252032C5163CF0029FF16 /* Importer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252022C5163CF0029FF16 /* Importer.swift */; };
E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252052C51684E0029FF16 /* GenericMetadata.swift */; };
E24252082C5168750029FF16 /* GenericMetadata+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */; };
E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; }; E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; };
E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; }; E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; };
E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DF02C7523F400F1F079 /* ImportableTag.swift */; };
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.swift */; }; E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.swift */; };
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */; }; E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */; };
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50C2CFD9B9C00AEF16D /* PostTagAssignmentView.swift */; }; E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50C2CFD9B9C00AEF16D /* PostTagAssignmentView.swift */; };
@ -113,6 +108,7 @@
E29D31612D06D95C0051B7F4 /* ResourceFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31602D06D9570051B7F4 /* ResourceFileType.swift */; }; E29D31612D06D95C0051B7F4 /* ResourceFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31602D06D9570051B7F4 /* ResourceFileType.swift */; };
E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */; }; E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */; };
E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31682D0702670051B7F4 /* TextFileContentView.swift */; }; E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31682D0702670051B7F4 /* TextFileContentView.swift */; };
E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */; };
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; }; E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; };
E2A21C082CB17B870060935B /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C072CB17B810060935B /* TagView.swift */; }; E2A21C082CB17B870060935B /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C072CB17B810060935B /* TagView.swift */; };
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.swift */; }; E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.swift */; };
@ -169,18 +165,13 @@
E218502A2CF790AC0090B18B /* PostContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostContentView.swift; sourceTree = "<group>"; }; E218502A2CF790AC0090B18B /* PostContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostContentView.swift; sourceTree = "<group>"; };
E218502C2CF791440090B18B /* PostImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostImagesView.swift; sourceTree = "<group>"; }; E218502C2CF791440090B18B /* PostImagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostImagesView.swift; sourceTree = "<group>"; };
E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteGenerator.swift; sourceTree = "<group>"; }; E218502E2CFAF6990090B18B /* WebsiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteGenerator.swift; sourceTree = "<group>"; };
E21850302CFAF8840090B18B /* Content+Import.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Import.swift"; sourceTree = "<group>"; };
E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; }; E21850322CFAFA200090B18B /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
E21850342CFAFA570090B18B /* SettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFile.swift; sourceTree = "<group>"; }; E21850342CFAFA570090B18B /* SettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFile.swift; sourceTree = "<group>"; };
E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = "<group>"; }; E21850362CFCA5580090B18B /* LocalizedPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostSettings.swift; sourceTree = "<group>"; };
E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteData+Mock.swift"; sourceTree = "<group>"; }; E21850382CFCA6BA0090B18B /* WebsiteData+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebsiteData+Mock.swift"; sourceTree = "<group>"; };
E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = "<group>"; }; E218503C2CFCFD8C0090B18B /* LocalizedPostFeedSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPostFeedSettingsView.swift; sourceTree = "<group>"; };
E24252022C5163CF0029FF16 /* Importer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Importer.swift; sourceTree = "<group>"; };
E24252052C51684E0029FF16 /* GenericMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMetadata.swift; sourceTree = "<group>"; };
E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GenericMetadata+Localized.swift"; sourceTree = "<group>"; };
E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = "<group>"; }; E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = "<group>"; };
E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; }; E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
E2581DF02C7523F400F1F079 /* ImportableTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportableTag.swift; sourceTree = "<group>"; };
E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = "<group>"; }; E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = "<group>"; };
E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = "<group>"; }; E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = "<group>"; };
E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTagAssignmentView.swift; sourceTree = "<group>"; }; E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTagAssignmentView.swift; sourceTree = "<group>"; };
@ -258,6 +249,7 @@
E29D31602D06D9570051B7F4 /* ResourceFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceFileType.swift; sourceTree = "<group>"; }; E29D31602D06D9570051B7F4 /* ResourceFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceFileType.swift; sourceTree = "<group>"; };
E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationIcon.swift; sourceTree = "<group>"; }; E29D31622D06E95A0051B7F4 /* NavigationIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationIcon.swift; sourceTree = "<group>"; };
E29D31682D0702670051B7F4 /* TextFileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFileContentView.swift; sourceTree = "<group>"; }; E29D31682D0702670051B7F4 /* TextFileContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFileContentView.swift; sourceTree = "<group>"; };
E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListPageGenerator.swift; sourceTree = "<group>"; };
E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = "<group>"; }; E2A21C022CB16C220060935B /* Environment+Language.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+Language.swift"; sourceTree = "<group>"; };
E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; }; E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; };
E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = "<group>"; }; E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = "<group>"; };
@ -317,17 +309,6 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
E24252042C5168430029FF16 /* Import */ = {
isa = PBXGroup;
children = (
E24252022C5163CF0029FF16 /* Importer.swift */,
E2581DF02C7523F400F1F079 /* ImportableTag.swift */,
E24252052C51684E0029FF16 /* GenericMetadata.swift */,
E24252072C5168750029FF16 /* GenericMetadata+Localized.swift */,
);
path = Import;
sourceTree = "<group>";
};
E25DA5112CFF001900AEF16D /* Model */ = { E25DA5112CFF001900AEF16D /* Model */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -371,6 +352,7 @@
E25DA5782D01C56200AEF16D /* Generator */ = { E25DA5782D01C56200AEF16D /* Generator */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E29D316A2D07488B0051B7F4 /* PostListPageGenerator.swift */,
E29D31252D0370A50051B7F4 /* VideoOption.swift */, E29D31252D0370A50051B7F4 /* VideoOption.swift */,
E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */, E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */,
E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */, E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */,
@ -517,7 +499,6 @@
E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */, E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */,
E25DA5162CFF00F200AEF16D /* Content+Save.swift */, E25DA5162CFF00F200AEF16D /* Content+Save.swift */,
E25DA5142CFF00B900AEF16D /* Content+Load.swift */, E25DA5142CFF00B900AEF16D /* Content+Load.swift */,
E21850302CFAF8840090B18B /* Content+Import.swift */,
E24252092C52C9260029FF16 /* ContentLanguage.swift */, E24252092C52C9260029FF16 /* ContentLanguage.swift */,
E25DA59A2D024A2900AEF16D /* DateItem.swift */, E25DA59A2D024A2900AEF16D /* DateItem.swift */,
E2A21C502CBBD53C0060935B /* FileResource.swift */, E2A21C502CBBD53C0060935B /* FileResource.swift */,
@ -637,7 +618,6 @@
E2DD047B2C276F32003BFF1F /* CHDataManagement.entitlements */, E2DD047B2C276F32003BFF1F /* CHDataManagement.entitlements */,
E2B85F552C4BD0AD0047CD0C /* Extensions */, E2B85F552C4BD0AD0047CD0C /* Extensions */,
E2DD047C2C276F32003BFF1F /* Preview Content */, E2DD047C2C276F32003BFF1F /* Preview Content */,
E24252042C5168430029FF16 /* Import */,
); );
path = CHDataManagement; path = CHDataManagement;
sourceTree = "<group>"; sourceTree = "<group>";
@ -753,9 +733,7 @@
E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */, E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */,
E25DA5412D00446C00AEF16D /* PostSettings.swift in Sources */, E25DA5412D00446C00AEF16D /* PostSettings.swift in Sources */,
E2A37D192CEA36A90000979F /* LocalizedTag.swift in Sources */, E2A37D192CEA36A90000979F /* LocalizedTag.swift in Sources */,
E24252062C51684E0029FF16 /* GenericMetadata.swift in Sources */,
E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */, E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */,
E21850312CFAF8880090B18B /* Content+Import.swift in Sources */,
E25DA5172CFF00F500AEF16D /* Content+Save.swift in Sources */, E25DA5172CFF00F500AEF16D /* Content+Save.swift in Sources */,
E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */, E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */,
E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */, E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */,
@ -779,7 +757,6 @@
E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */, E29D31632D06E95D0051B7F4 /* NavigationIcon.swift in Sources */,
E25DA5832D01C7A400AEF16D /* VideoFileType.swift in Sources */, E25DA5832D01C7A400AEF16D /* VideoFileType.swift in Sources */,
E29D31512D06168E0051B7F4 /* PostListView.swift in Sources */, E29D31512D06168E0051B7F4 /* PostListView.swift in Sources */,
E2581DF12C7523F400F1F079 /* ImportableTag.swift in Sources */,
E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */, E2A37D0C2CE4036B0000979F /* LocalizedPage.swift in Sources */,
E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */, E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */,
E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */, E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */,
@ -792,7 +769,6 @@
E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */, E29D31492D0489BB0051B7F4 /* AddFileView.swift in Sources */,
E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */, E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */,
E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */, E29D312E2D03A0D70051B7F4 /* LocalizedPageDetailView.swift in Sources */,
E24252082C5168750029FF16 /* GenericMetadata+Localized.swift in Sources */,
E2581DED2C75202400F1F079 /* Tag.swift in Sources */, E2581DED2C75202400F1F079 /* Tag.swift in Sources */,
E29D315B2D06D63E0051B7F4 /* ModelFileType.swift in Sources */, E29D315B2D06D63E0051B7F4 /* ModelFileType.swift in Sources */,
E29D31302D03A2C50051B7F4 /* DescriptionField.swift in Sources */, E29D31302D03A2C50051B7F4 /* DescriptionField.swift in Sources */,
@ -807,7 +783,6 @@
E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */, E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */,
E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */, E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */,
E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */, E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */,
E24252032C5163CF0029FF16 /* Importer.swift in Sources */,
E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */, E2A37D252CEBD7A10000979F /* PageListView.swift in Sources */,
E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */, E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */,
E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */, E21850232CF10C850090B18B /* TagSelectionView.swift in Sources */,
@ -826,6 +801,7 @@
E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */, E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */,
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */, E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */, E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */,
E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */, E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */,
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */, E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */,
E25DA5432D0094A900AEF16D /* SettingsSidebar.swift in Sources */, E25DA5432D0094A900AEF16D /* SettingsSidebar.swift in Sources */,

View File

@ -33,7 +33,12 @@ final class ImageGenerator {
init(storage: Storage, relativeImageOutputPath: String) { init(storage: Storage, relativeImageOutputPath: String) {
self.storage = storage self.storage = storage
self.relativeImageOutputPath = relativeImageOutputPath self.relativeImageOutputPath = relativeImageOutputPath
self.generatedImages = storage.loadListOfGeneratedImages() do {
self.generatedImages = try storage.loadListOfGeneratedImages()
} catch {
print("Failed to load list of previously generated images: \(error)")
self.generatedImages = [:]
}
} }
func prepareForGeneration() -> Bool { func prepareForGeneration() -> Bool {
@ -60,7 +65,13 @@ final class ImageGenerator {
} }
func save() -> Bool { func save() -> Bool {
storage.save(listOfGeneratedImages: generatedImages) do {
try storage.save(listOfGeneratedImages: generatedImages)
return true
} catch {
print("Failed to save list of generated images: \(error)")
return false
}
} }
private func versionFileName(image: String, type: ImageFileType, width: CGFloat, height: CGFloat) -> String { private func versionFileName(image: String, type: ImageFileType, width: CGFloat, height: CGFloat) -> String {

View File

@ -14,7 +14,7 @@ final class PageGenerator {
self.navigationBarData = navigationBarData self.navigationBarData = navigationBarData
} }
func generate(page: Page, language: ContentLanguage) -> String { func generate(page: Page, language: ContentLanguage) throws -> String {
let contentGenerator = PageContentParser( let contentGenerator = PageContentParser(
page: page, page: page,
content: content, content: content,
@ -22,22 +22,26 @@ final class PageGenerator {
results: results, results: results,
imageGenerator: imageGenerator) imageGenerator: imageGenerator)
let rawPageContent = content.storage.pageContent(for: page.id, language: language) let rawPageContent = try content.storage.pageContent(for: page.id, language: language)
let pageContent = contentGenerator.generatePage(from: rawPageContent) let pageContent = contentGenerator.generatePage(from: rawPageContent)
let localized = page.localized(in: language) let localized = page.localized(in: language)
let tags: [FeedEntryData.Tag] = page.tags.map { tag in
.init(name: tag.localized(in: language).name,
url: content.tagLink(tag, language: language))
}
return ContentPage( return ContentPage(
language: language, language: language,
dateString: page.dateText(in: language), dateString: page.dateText(in: language),
title: localized.title, title: localized.title,
tags: page.tags.map { $0.data(in: language) }, tags: tags,
linkTitle: localized.linkPreviewTitle ?? localized.title, linkTitle: localized.linkPreviewTitle ?? localized.title,
description: localized.linkPreviewDescription ?? "", description: localized.linkPreviewDescription ?? "",
navigationBarData: navigationBarData, navigationBarData: navigationBarData,
pageContent: pageContent) pageContent: pageContent)
.content .content
} }
} }

View File

@ -0,0 +1,118 @@
import Foundation
final class PostListPageGenerator {
private let language: ContentLanguage
private let content: Content
private let imageGenerator: ImageGenerator
private let navigationBarData: NavigationBarData
private let showTitle: Bool
private let pageTitle: String
private let pageDescription: String
/// The url of the page, excluding the extension
private let pageUrlPrefix: String
init(language: ContentLanguage, content: Content, imageGenerator: ImageGenerator, navigationBarData: NavigationBarData, showTitle: Bool, pageTitle: String, pageDescription: String, pageUrlPrefix: String) {
self.language = language
self.content = content
self.imageGenerator = imageGenerator
self.navigationBarData = navigationBarData
self.showTitle = showTitle
self.pageTitle = pageTitle
self.pageDescription = pageDescription
self.pageUrlPrefix = pageUrlPrefix
}
private var mainContentMaximumWidth: CGFloat {
CGFloat(content.settings.posts.contentWidth)
}
private var postsPerPage: Int {
content.settings.posts.postsPerPage
}
func createPages(for posts: [Post]) -> Bool {
let totalCount = posts.count
guard totalCount > 0 else {
return true
}
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 = posts[startIndex..<endIndex]
guard createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navigationBarData) else {
return false
}
}
return true
}
private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice<Post>, bar: NavigationBarData) -> Bool {
let posts: [FeedEntryData] = posts.map { post in
let localized: LocalizedPost = post.localized(in: language)
let linkUrl = post.linkedPage.map {
FeedEntryData.Link(
url: content.pageLink($0, language: language),
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings
}
let tags: [FeedEntryData.Tag] = post.tags.map { tag in
.init(name: tag.localized(in: language).name,
url: content.tagLink(tag, language: language))
}
return FeedEntryData(
entryId: "\(post.id)",
title: localized.title,
textAboveTitle: post.dateText(in: language),
link: linkUrl,
tags: tags,
text: [localized.content], // TODO: Convert from markdown to html
images: localized.images.map(createImageSet))
}
let feed = PageInFeed(
language: language,
title: pageTitle,
showTitle: showTitle,
description: pageDescription,
navigationBarData: bar,
pageNumber: pageIndex,
totalPages: pageCount,
posts: posts)
let fileContent = feed.content
if pageIndex == 1 {
return save(fileContent, to: "\(pageUrlPrefix).html")
} else {
return save(fileContent, to: "\(pageUrlPrefix)-\(pageIndex).html")
}
}
private func createImageSet(for image: FileResource) -> FeedEntryData.Image {
imageGenerator.generateImageSet(
for: image.id,
maxWidth: mainContentMaximumWidth,
maxHeight: mainContentMaximumWidth,
altText: image.getDescription(for: language))
}
private func save(_ content: String, to relativePath: String) -> Bool {
do {
try self.content.storage.write(content: content, to: relativePath)
return true
} catch {
print("Failed to write page \(relativePath)")
return false
}
}
}

View File

@ -14,18 +14,6 @@ final class WebsiteGenerator {
content.settings.posts.postsPerPage content.settings.posts.postsPerPage
} }
private var postFeedTitle: String {
localizedSettings.posts.title
}
private var postFeedDescription: String {
localizedSettings.posts.description
}
private var postFeedUrlPrefix: String {
localizedSettings.posts.feedUrlPrefix
}
private var navigationIconPath: String { private var navigationIconPath: String {
content.settings.navigationBar.iconPath content.settings.navigationBar.iconPath
} }
@ -57,7 +45,10 @@ final class WebsiteGenerator {
guard imageGenerator.prepareForGeneration() else { guard imageGenerator.prepareForGeneration() else {
return false return false
} }
guard createPostFeedPages() else { guard createMainPostFeedPages() else {
return false
}
guard generateTagPages() else {
return false return false
} }
guard imageGenerator.runJobs(callback: callback) else { guard imageGenerator.runJobs(callback: callback) else {
@ -66,18 +57,37 @@ final class WebsiteGenerator {
return imageGenerator.save() return imageGenerator.save()
} }
private func createPostFeedPages() -> Bool { private func createMainPostFeedPages() -> Bool {
let totalCount = content.posts.count let generator = PostListPageGenerator(
guard totalCount > 0 else { language: language,
return true content: content,
imageGenerator: imageGenerator,
navigationBarData: navigationBarData,
showTitle: false,
pageTitle: localizedSettings.posts.title,
pageDescription: localizedSettings.posts.description,
pageUrlPrefix: localizedSettings.posts.feedUrlPrefix)
return generator.createPages(for: content.posts)
} }
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up private func generateTagPages() -> Bool {
for pageIndex in 1...numberOfPages { for tag in content.tags {
let startIndex = (pageIndex - 1) * postsPerPage let posts = content.posts.filter { $0.tags.contains(tag) }
let endIndex = min(pageIndex * postsPerPage, totalCount) guard posts.count > 0 else { continue }
let postsOnPage = content.posts[startIndex..<endIndex]
guard createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navigationBarData) else { let localized = tag.localized(in: language)
#warning("Get tag url prefix from settings")
let generator = PostListPageGenerator(
language: language,
content: content,
imageGenerator: imageGenerator,
navigationBarData: navigationBarData,
showTitle: true,
pageTitle: localized.name,
pageDescription: localized.description ?? "",
pageUrlPrefix: "tags/\(localized.urlComponent)")
guard generator.createPages(for: posts) else {
return false return false
} }
} }
@ -95,50 +105,6 @@ final class WebsiteGenerator {
navigationItems: navigationItems) navigationItems: navigationItems)
} }
private func createImageSet(for image: FileResource) -> FeedEntryData.Image {
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 {
let posts: [FeedEntryData] = posts.map { post in
let localized: LocalizedPost = post.localized(in: language)
let linkUrl = post.linkedPage.map {
FeedEntryData.Link(
url: content.pageLink($0, language: language),
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings
}
return FeedEntryData(
entryId: "\(post.id)",
title: localized.title,
textAboveTitle: post.dateText(in: language),
link: linkUrl,
tags: post.tags.map { $0.data(in: language) },
text: [localized.content], // TODO: Convert from markdown to html
images: localized.images.map(createImageSet))
}
let feed = PageInFeed(
language: language,
title: postFeedTitle,
description: postFeedDescription,
navigationBarData: bar,
pageNumber: pageIndex,
totalPages: pageCount,
posts: posts)
let fileContent = feed.content
if pageIndex == 1 {
return save(fileContent, to: "\(postFeedUrlPrefix).html")
} else {
return save(fileContent, to: "\(postFeedUrlPrefix)-\(pageIndex).html")
}
}
private func generatePagesFolderIfNeeded() -> Bool { private func generatePagesFolderIfNeeded() -> Bool {
let relativePath = content.settings.pages.pageUrlPrefix let relativePath = content.settings.pages.pageUrlPrefix
@ -159,7 +125,14 @@ final class WebsiteGenerator {
return false return false
} }
let pageGenerator = PageGenerator(content: content, imageGenerator: imageGenerator, navigationBarData: navigationBarData) let pageGenerator = PageGenerator(content: content, imageGenerator: imageGenerator, navigationBarData: navigationBarData)
let content = pageGenerator.generate(page: page, language: language)
let content: String
do {
content = try pageGenerator.generate(page: page, language: language)
} catch {
print("Failed to generate page \(page.id) in language \(language): \(error)")
return false
}
let path = self.content.pageLink(page, language: language) + ".html" let path = self.content.pageLink(page, language: language) + ".html"
guard save(content, to: path) else { guard save(content, to: path) else {
@ -181,8 +154,10 @@ final class WebsiteGenerator {
guard let outputPath = content.pathToFile(fileId) else { guard let outputPath = content.pathToFile(fileId) else {
return false return false
} }
guard content.storage.copy(file: fileId, to: outputPath) else { do {
print("Failed to copy video file to output folder") try content.storage.copy(file: fileId, to: outputPath)
} catch {
print("Failed to copy video file: \(error)")
return false return false
} }
} }
@ -190,23 +165,12 @@ final class WebsiteGenerator {
} }
private func save(_ content: String, to relativePath: String) -> Bool { private func save(_ content: String, to relativePath: String) -> Bool {
guard let data = content.data(using: .utf8) else {
print("Failed to create data for \(relativePath)")
return false
}
return save(data, to: relativePath)
}
private func save(_ data: Data, to relativePath: String) -> Bool {
self.content.storage.write(in: .outputPath) { folder in
let outputFile = folder.appendingPathComponent(relativePath, isDirectory: false)
do { do {
try data.write(to: outputFile) try self.content.storage.write(content: content, to: relativePath)
return true return true
} catch { } catch {
print("Failed to save \(outputFile.path()): \(error)") print("Failed to write page \(relativePath)")
return false return false
} }
} }
} }
}

View File

@ -1,153 +0,0 @@
import Foundation
extension GenericMetadata {
/**
Metadata localized for a specific language.
*/
struct LocalizedMetadata {
/**
The language for which the content is specified.
- Note: This field is mandatory
*/
let language: String?
/**
The title used in the page header.
- Note: This field is mandatory
*/
let title: String?
/**
The subtitle used in the page header.
*/
let subtitle: String?
/**
The description text used in the page header
*/
let description: String?
/**
The title to use for the link preview.
If `nil` is specified, then the localized element `title` is used.
*/
let linkPreviewTitle: String?
/**
The file name of the link preview image.
- Note: The image must be located in the element folder.
- Note: If `nil` is specified, then the (localized) thumbnail is used.
*/
let linkPreviewImage: String?
/**
The description text for the link preview.
- Note: If `nil` is specified, then first the (localized) element `subtitle` is used.
If this is `nil` too, then the localized `description` of the element is used.
*/
let linkPreviewDescription: String?
/**
The text on the link to show the section page when previewing multiple sections on an overview page.
- Note: If this value is inherited from the parent, if it is not defined. There must be at least one
element in the path that defines this property.
*/
let moreLinkText: String?
/**
The text on the back navigation link of **contained** elements.
This text does not appear on the section page, but on the pages contained within the section.
- Note: If this property is not specified, then the root `backLinkText` is used.
- Note: The root element must specify this property.
*/
let backLinkText: String?
/**
The text to show as a title for placeholder boxes
Placeholders are included in missing pages.
- Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property.
*/
let placeholderTitle: String?
/**
The text to show as a description for placeholder boxes
Placeholders are included in missing pages.
- Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property.
*/
let placeholderText: String?
/**
An optional suffix to add to the title on a page.
This can be useful to express a different author, project grouping, etc.
*/
let titleSuffix: String?
/**
An optional suffix to add to the thumbnail title of a page.
This can be useful to express a different author, project grouping, etc.
*/
let thumbnailSuffix: String?
/**
A text to place in the top right corner of a large thumbnail.
The text should be a very short string to fit into the corner, like `soon`, or `draft`
- Note: This property is ignored if `thumbnailStyle` is not `large`.
*/
let cornerText: String?
/**
The external url to use instead of automatically generating the page.
This property can be used for links to other parts of the site, like additional services.
It can also be set to manually write a page.
*/
let externalUrl: String?
/**
The text to display for content related to the current page.
This property is mandatory at root level, and is propagated to child elements.
*/
let relatedContentText: String?
/**
The text to display on a navigation element pointing to this element as the previous page.
This property is mandatory at root level, and is propagated to child elements.
*/
let navigationTextAsPreviousPage: String?
/**
The text to display on the navigation element pointing to this element as the next page.
This property is mandatory at root level, and is propagated to child elements.
*/
let navigationTextAsNextPage: String?
/**
The text to display above a slideshow for most recent items.
Only used for elements that define `showMostRecentSection = true`
*/
let mostRecentTitle: String?
/**
The text to display above a slideshow for featured items.
Only used for elements that define `showFeaturedSection = true`
*/
let featuredTitle: String?
}
}
extension GenericMetadata.LocalizedMetadata: Codable {
}

View File

@ -1,137 +0,0 @@
import Foundation
/**
The metadata for all site elements.
*/
struct GenericMetadata {
/**
A custom id to uniquely identify the element on the site.
The id is used for short-hand links to pages, in the form of `![page](page_id)`
for thumbnail previews or `[text](page:page_id)` for simple links.
If no custom id is set, then the name of the element folder is used.
*/
let customId: String?
/**
The author of the content.
If no author is set, then the author from the parent element is used.
*/
let author: String?
/**
The (start) date of the element.
The date is printed on content pages and may also used for sorting elements,
depending on the `useManualSorting` property of the parent.
*/
let date: String?
/**
The end date of the element.
This property can be used to specify a date range for a content page.
*/
let endDate: String?
/**
The deployment state of the page.
- Note: This property defaults to ``PageState.standard`
*/
let state: String?
/**
The sort index of the page for manual sorting.
- Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set.
*/
let sortIndex: Int?
/**
All files which may occur in content but is stored externally.
Missing files which would otherwise produce a warning are ignored when included here.
- Note: This property defaults to an empty set.
*/
let externalFiles: Set<String>?
/**
Specifies additional files which should be copied to the destination when generating the content.
- Note: This property defaults to an empty set.
*/
let requiredFiles: Set<String>?
/**
Additional images required by the element.
These images are specified as: `source_name destination_name width (height)`.
*/
let images: Set<String>?
/**
The path to the thumbnail file.
This property is optional, and defaults to ``Element.defaultThumbnailName``.
Note: The generator first looks for localized versions of the thumbnail by appending `-[lang]` to the file name,
e.g. `customThumb-en.jpg`. If no file is found, then the specified file is tried.
*/
let thumbnailPath: String?
/**
The style of thumbnail to use when generating overviews.
- Note: This property is only relevant for sections.
- Note: This property is inherited from the parent if not specified.
*/
let thumbnailStyle: String?
/**
Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`.
- Note: This property is only relevant for sections.
- Note: This property defaults to `false`
*/
let useManualSorting: Bool?
/**
The number of items to show when generating overviews of this element.
- Note: This property is only relevant for sections.
- Note: This property is inherited from the parent if not specified.
*/
let overviewItemCount: Int?
/**
Indicate the header type to be generated automatically.
If this option is set to `none`, then custom header code should be present in the page source files
- Note: If not specified, this property defaults to `left`.
- Note: Overview pages are always using `center`.
*/
let headerType: String?
/**
Indicate that the overview section should contain a `Newest Content` section before the other sections.
- Note: If not specified, this property defaults to `false`
*/
let showMostRecentSection: Bool?
/**
Indicate that the overview section should contain a `Featured Content` section before the other sections.
The elements are the page ids of the elements contained in the feature.
- Note: If not specified, this property defaults to `false`
*/
let featuredPages: [String]?
/**
The localized metadata for each language.
*/
let languages: [LocalizedMetadata]?
}
extension GenericMetadata: Codable {
}

View File

@ -1,33 +0,0 @@
import Foundation
struct ImportableTag {
let languages: [TagLanguage]
func info(for language: ContentLanguage) -> TagLanguage? {
languages.first { $0.language == language.rawValue }
}
}
extension ImportableTag: Codable {
}
struct TagLanguage {
let language: String
let title: String
let subtitle: String?
let description: String?
let moreLinkText: String?
let backLinkText: String?
}
extension TagLanguage: Codable {
}

View File

@ -1,290 +0,0 @@
import Foundation
final class Importer {
var posts: [String : PostFile] = [:]
var pages: [String : PageOnDisk] = [:]
var tags: [String : TagFile] = [:]
var files: [String : FileOnDisk] = [:]
var ignoredFiles: [URL] = []
var foldersToSearch: [(path: String, tag: String)] = [
("/Users/ch/Downloads/Website/projects/electronics", "electronics"),
("/Users/ch/Downloads/Website/projects/endeavor", "endeavor"),
("/Users/ch/Downloads/Website/projects/furniture", "furniture"),
("/Users/ch/Downloads/Website/projects/lighting", "lighting"),
("/Users/ch/Downloads/Website/projects/other", "other"),
("/Users/ch/Downloads/Website/projects/sewing", "sewing"),
("/Users/ch/Downloads/Website/projects/software", "software"),
("/Users/ch/Downloads/Website/articles", "articles"),
("/Users/ch/Downloads/Website/photography", "photography"),
("/Users/ch/Downloads/Website/travel", "travel")
]
func importContent() throws {
for (path, name) in foldersToSearch {
let folder = URL(filePath: path)
let pageFolders = try findPageFolders(in: folder)
let tag = try importTag(name: name, folder: folder)
for pageFolder in pageFolders {
try importEntry(at: pageFolder, tag: tag)
}
}
}
private func importTag(name: String, folder: URL) throws -> String {
let metadataUrl = folder.appending(path: "metadata.json", directoryHint: .notDirectory)
let data = try Data(contentsOf: metadataUrl)
let meta = try JSONDecoder().decode(ImportableTag.self, from: data)
let thumbnailUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory)
var thumbnail: FileOnDisk? = nil
if FileManager.default.fileExists(atPath: thumbnailUrl.path()) {
thumbnail = FileOnDisk(type: .image(.jpg), url: thumbnailUrl, name: "\(name)-thumbnail.jpg")
add(resource: thumbnail!)
}
func makeTag(metadata: TagLanguage) throws -> LocalizedTagFile {
let language = ContentLanguage(rawValue: metadata.language)!
let originalUrl = folder
.appendingPathComponent("\(language.rawValue).html", isDirectory: false)
.path()
.replacingOccurrences(of: "/Users/ch/Downloads/Website", with: "")
return LocalizedTagFile(
urlComponent: metadata.title.lowercased().replacingOccurrences(of: " ", with: "-"),
name: metadata.title,
subtitle: metadata.subtitle,
description: metadata.description,
thumbnail: thumbnail?.name,
originalURL: originalUrl)
}
let en = meta.info(for: .english)!
let de = meta.info(for: .german)!
let tagId = en.title.lowercased().replacingOccurrences(of: " ", with: "-")
let enTag = try makeTag(metadata: en)
let deTag = try makeTag(metadata: de)
let tag = TagFile(
id: enTag.urlComponent,
isVisible: true,
german: deTag,
english: enTag)
tags[tagId] = tag
return tagId
}
private func findPageFolders(in folder: URL) throws -> [URL] {
try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.hasDirectoryPath }
}
private func findResources(in folder: URL, pageId: String) throws -> [FileOnDisk] {
try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { !$0.hasDirectoryPath }
.compactMap { url in
let fileName = url.lastPathComponent
let fileExtension = url.pathExtension
guard fileName != "metadata.json",
fileName != "de.md",
fileName != "en.md" else {
return nil
}
let type = FileType(fileExtension: fileExtension)
guard case .other = type else {
self.ignoredFiles.append(url)
return nil
}
let name = pageId + "-" + fileName
return FileOnDisk(type: type, url: url, name: name)
}
}
private func importEntry(at url: URL, tag: String) throws {
let metadataUrl = url.appending(path: "metadata.json", directoryHint: .notDirectory)
guard FileManager.default.fileExists(atPath: metadataUrl.path()) else {
print("No entry at \(url.path())")
return
}
let data = try Data(contentsOf: metadataUrl)
let meta = try JSONDecoder().decode(GenericMetadata.self, from: data)
let pageId = meta.customId ?? url.lastPathComponent
let resources = try findResources(in: url, pageId: pageId)
guard let languages = meta.languages else {
print("No languages for \(url.path())")
return
}
let externalFiles = meta.externalFiles ?? []
let requiredFiles = meta.requiredFiles ?? []
let date = meta.date!.toDate()
let endDate = meta.endDate?.toDate()
let de = languages.first { $0.language == "de" }!
let en = languages.first { $0.language == "en" }!
@discardableResult
func makePage(_ content: GenericMetadata.LocalizedMetadata) throws -> (LocalizedPageFile, URL, LocalizedPostFile) {
let language = ContentLanguage(rawValue: content.language!)!
let id: String
if language == .english {
id = pageId
} else {
id = pageId + "-" + language.rawValue
}
let originalUrl = url
.appendingPathComponent("\(language.rawValue).html", isDirectory: false)
.path()
.replacingOccurrences(of: "/Users/ch/Downloads/Website", with: "")
var pageFiles = Set(resources.map { $0.name })
let thumbnail = try determineThumbnail(in: resources, folder: url, customPath: meta.thumbnailPath, pageId: id, language: language.rawValue)
if let thumbnail {
pageFiles.insert(thumbnail.name)
}
let page = LocalizedPageFile(
url: id,
files: pageFiles.sorted(),
externalFiles: externalFiles.sorted(),
requiredFiles: requiredFiles.sorted(),
title: content.title!,
linkPreviewImage: thumbnail?.name,
linkPreviewTitle: content.linkPreviewTitle,
linkPreviewDescription: content.linkPreviewDescription,
lastModifiedDate: nil,
originalURL: originalUrl)
let contentUrl = url.appendingPathComponent("\(content.language!).md", isDirectory: false)
let postContent = content.linkPreviewDescription ?? content.description ?? ""
let post = createPost(page: page, content: postContent)
return (page, contentUrl, post)
}
let (dePage, deUrl, dePost) = try makePage(de)
let (enPage, enUrl, enPost) = try makePage(en)
let page = PageFile(
isDraft: meta.state == "draft",
tags: [tag],
createdDate: date,
startDate: date,
endDate: endDate,
german: dePage,
english: enPage)
if pages[pageId] != nil {
print("Conflicting page id \(pageId)")
}
pages[pageId] = .init(page: page, deContentUrl: deUrl, enContentUrl: enUrl)
for resource in resources {
add(resource: resource)
}
let post = PostFile(
isDraft: page.isDraft || meta.state == "hidden",
createdDate: page.createdDate,
startDate: page.startDate,
endDate: page.endDate,
tags: page.tags,
german: dePost,
english: enPost,
linkedPageId: pageId)
posts[pageId] = post
}
private func add(resource: FileOnDisk) {
guard let existingFile = files[resource.name] else {
files[resource.name] = resource
return
}
guard existingFile.url != resource.url else {
return
}
print("Conflicting name for file \(resource.name)")
}
private func determineThumbnail(in resources: [FileOnDisk], folder: URL, customPath: String?, pageId: String, language: String) throws -> FileOnDisk? {
guard let thumbnailUrl = findThumbnailUrl(in: folder, customPath: customPath, language: language) else {
return nil
}
return resources.first { $0.url == thumbnailUrl }
}
private func determineThumbnail(in folder: URL, customPath: String?, pageId: String, language: String) throws -> FileOnDisk? {
guard let thumbnailUrl = findThumbnailUrl(in: folder, customPath: customPath, language: language) else {
return nil
}
let id = pageId + "-" + thumbnailUrl.lastPathComponent
return FileOnDisk(image: id, url: thumbnailUrl)
}
private func findThumbnailUrl(in folder: URL, customPath: String?, language: String) -> URL? {
if let customPath {
return folder.appending(path: customPath, directoryHint: .notDirectory)
}
let thumbnailImageUrl = folder.appending(path: "thumbnail.jpg", directoryHint: .notDirectory)
if FileManager.default.fileExists(atPath: thumbnailImageUrl.path()) {
return thumbnailImageUrl
}
let localizedThumbnail = folder.appending(path: "thumbnail-\(language).jpg", directoryHint: .notDirectory)
if FileManager.default.fileExists(atPath: localizedThumbnail.path()) {
return localizedThumbnail
}
print("No thumbnail found in \(folder.path())")
return nil
}
private func createPost(page: LocalizedPageFile, content: String) -> LocalizedPostFile {
let images = page.linkPreviewImage.map { [$0] } ?? []
return LocalizedPostFile(
images: images.sorted(),
title: page.linkPreviewTitle ?? page.title,
content: content,
lastModifiedDate: nil,
linkPreviewImage: nil,
linkPreviewTitle: nil,
linkPreviewDescription: nil)
}
}
private extension String {
private static let metadataDate: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yy"
return df
}()
func toDate() -> Date {
String.metadataDate.date(from: self)!
}
}

View File

@ -1,6 +1,14 @@
import SwiftUI import SwiftUI
import SFSafeSymbols import SFSafeSymbols
/*
Page: One page -> One post with overview
Post: One post -> No page
Page update: One page -> Multiple posts
*/
#warning("Consolidate images and files") #warning("Consolidate images and files")
#warning("Allow selection of pages as navigation bar items") #warning("Allow selection of pages as navigation bar items")
@ -171,7 +179,11 @@ struct MainView: App {
private func save() { private func save() {
// Save all changed files // Save all changed files
content.saveToDisk() do {
try content.saveToDisk()
} catch {
print("Failed to save content: \(error.localizedDescription)")
}
} }
private func loadContent() { private func loadContent() {

View File

@ -1,5 +1,10 @@
extension Content { extension Content {
#warning("Get tag url prefix from settings")
func tagLink(_ tag: Tag, language: ContentLanguage) -> String {
"/tags/\(tag.localized(in: language).urlComponent).html"
}
func pageLink(_ page: Page, language: ContentLanguage) -> String { func pageLink(_ page: Page, language: ContentLanguage) -> String {
// TODO: Record link to trace connections between pages // TODO: Record link to trace connections between pages
var prefix = settings.pages.pageUrlPrefix var prefix = settings.pages.pageUrlPrefix

View File

@ -1,63 +0,0 @@
import Foundation
extension Content {
func importOldContent() {
let importer = Importer()
do {
try importer.importContent()
} catch {
print(error)
return
}
for (_, file) in importer.files.sorted(by: { $0.key < $1.key }) {
storage.copyFile(at: file.url, fileId: file.name)
// TODO: Store alt text for image and videos
}
var missingPages: [String] = []
for (pageId, page) in importer.pages.sorted(by: { $0.key < $1.key }) {
storage.save(pageMetadata: page.page, for: pageId)
if FileManager.default.fileExists(atPath: page.deContentUrl.path()) {
storage.copyPageContent(from: page.deContentUrl, for: pageId, language: .german)
} else {
missingPages.append(pageId + " (DE)")
}
if FileManager.default.fileExists(atPath: page.enContentUrl.path()) {
storage.copyPageContent(from: page.enContentUrl, for: pageId, language: .english)
} else {
missingPages.append(pageId + " (EN)")
}
}
for (tagId, tag) in importer.tags {
storage.save(tagMetadata: tag, for: tagId)
}
for (postId, post) in importer.posts {
storage.save(post: post, for: postId)
}
let ignoredFiles = importer.ignoredFiles
.map { $0.path() }
.sorted()
print("Ignored files:")
for file in ignoredFiles {
print(file)
}
print("Missing pages:")
for page in missingPages {
print(page)
}
do {
try loadFromDisk()
} catch {
print("Failed to load from disk: \(error)")
}
}
}

View File

@ -49,7 +49,7 @@ extension Content {
let storage = Storage(baseFolder: URL(filePath: contentPath)) let storage = Storage(baseFolder: URL(filePath: contentPath))
let settings = try storage.loadSettings() let settings = try storage.loadSettings()
let imageDescriptions = storage.loadFileDescriptions().reduce(into: [:]) { descriptions, description in let imageDescriptions = try storage.loadFileDescriptions().reduce(into: [:]) { descriptions, description in
descriptions[description.fileId] = description descriptions[description.fileId] = description
} }
@ -84,6 +84,7 @@ extension Content {
let english = convert(post.english, images: images) let english = convert(post.english, images: images)
return Post( return Post(
content: self,
id: postId, id: postId,
isDraft: post.isDraft, isDraft: post.isDraft,
createdDate: post.createdDate, createdDate: post.createdDate,
@ -129,6 +130,7 @@ extension Content {
pagesData.reduce(into: [:]) { pages, data in pagesData.reduce(into: [:]) { pages, data in
let (pageId, page) = data let (pageId, page) = data
pages[pageId] = Page( pages[pageId] = Page(
content: self,
id: pageId, id: pageId,
isDraft: page.isDraft, isDraft: page.isDraft,
createdDate: page.createdDate, createdDate: page.createdDate,

View File

@ -2,20 +2,20 @@ import Foundation
extension Content { extension Content {
func saveToDisk() { func saveToDisk() throws {
//print("Starting save") //print("Starting save")
for page in pages { for page in pages {
storage.save(pageMetadata: page.pageFile, for: page.id) try storage.save(pageMetadata: page.pageFile, for: page.id)
} }
for post in posts { for post in posts {
storage.save(post: post.postFile, for: post.id) try storage.save(post: post.postFile, for: post.id)
} }
for tag in tags { for tag in tags {
storage.save(tagMetadata: tag.tagFile, for: tag.id) try storage.save(tagMetadata: tag.tagFile, for: tag.id)
} }
storage.save(settings: settings.file) try storage.save(settings: settings.file)
let fileDescriptions: [FileDescriptions] = files.sorted().compactMap { file in let fileDescriptions: [FileDescriptions] = files.sorted().compactMap { file in
guard !file.englishDescription.isEmpty || !file.germanDescription.isEmpty else { guard !file.englishDescription.isEmpty || !file.germanDescription.isEmpty else {
@ -27,22 +27,19 @@ extension Content {
english: file.englishDescription.nonEmpty) english: file.englishDescription.nonEmpty)
} }
storage.save(fileDescriptions: fileDescriptions) try storage.save(fileDescriptions: fileDescriptions)
do { do {
try storage.deletePostFiles(notIn: posts.map { $0.id }) try storage.deletePostFiles(notIn: posts.map { $0.id })
try storage.deletePageFiles(notIn: pages.map { $0.id }) try storage.deletePageFiles(notIn: pages.map { $0.id })
try storage.deleteTagFiles(notIn: tags.map { $0.id }) try storage.deleteTagFiles(notIn: tags.map { $0.id })
try storage.deleteFiles(notIn: files.map { $0.id }) try storage.deleteFileResources(notIn: files.map { $0.id })
} catch { } catch {
print("Failed to remove unused files: \(error)") print("Failed to remove unused files: \(error)")
} }
// TODO: Remove all files that are no longer in use (they belong to deleted items)
//print("Finished save")
} }
} }
private extension Page { private extension Page {
var pageFile: PageFile { var pageFile: PageFile {

View File

@ -36,13 +36,3 @@ final class LocalizedTag: ObservableObject {
self.originalUrl = originalUrl self.originalUrl = originalUrl
} }
} }
extension LocalizedTag {
func data() -> FeedEntryData.Tag {
.init(
name: name,
url: "tags/\(urlComponent).html"
)
}
}

View File

@ -2,6 +2,8 @@ import Foundation
final class Page: ObservableObject { final class Page: ObservableObject {
unowned let content: Content
/** /**
The unique id of the entry The unique id of the entry
*/ */
@ -40,7 +42,8 @@ final class Page: ObservableObject {
@Published @Published
var images: Set<String> = [] var images: Set<String> = []
init(id: String, init(content: Content,
id: String,
isDraft: Bool, isDraft: Bool,
createdDate: Date, createdDate: Date,
startDate: Date, startDate: Date,
@ -48,6 +51,7 @@ final class Page: ObservableObject {
german: LocalizedPage, german: LocalizedPage,
english: LocalizedPage, english: LocalizedPage,
tags: [Tag]) { tags: [Tag]) {
self.content = content
self.id = id self.id = id
self.isDraft = isDraft self.isDraft = isDraft
self.createdDate = createdDate self.createdDate = createdDate
@ -65,6 +69,15 @@ final class Page: ObservableObject {
case .english: return english case .english: return english
} }
} }
func update(id newId: String) -> Bool {
guard content.storage.move(page: id, to: newId) else {
print("Failed to move file of page \(id)")
return false
}
id = newId
return true
}
} }
extension Page: Identifiable { extension Page: Identifiable {

View File

@ -2,6 +2,8 @@ import Foundation
final class Post: ObservableObject { final class Post: ObservableObject {
unowned let content: Content
@Published @Published
var id: String var id: String
@ -33,7 +35,8 @@ final class Post: ObservableObject {
@Published @Published
var linkedPage: Page? var linkedPage: Page?
init(id: String, init(content: Content,
id: String,
isDraft: Bool, isDraft: Bool,
createdDate: Date, createdDate: Date,
startDate: Date, startDate: Date,
@ -42,6 +45,7 @@ final class Post: ObservableObject {
german: LocalizedPost, german: LocalizedPost,
english: LocalizedPost, english: LocalizedPost,
linkedPage: Page? = nil) { linkedPage: Page? = nil) {
self.content = content
self.id = id self.id = id
self.isDraft = isDraft self.isDraft = isDraft
self.createdDate = createdDate self.createdDate = createdDate
@ -60,6 +64,17 @@ final class Post: ObservableObject {
case .german: return german case .german: return german
} }
} }
func update(id newId: String) -> Bool {
do {
try content.storage.move(post: id, to: newId)
} catch {
print("Failed to move file of post \(id)")
return false
}
id = newId
return true
}
} }
extension Post: Identifiable { extension Post: Identifiable {

View File

@ -37,18 +37,6 @@ final class Tag: ObservableObject {
} }
} }
extension Tag {
func data(in language: ContentLanguage) -> FeedEntryData.Tag {
switch language {
case .english:
return english.data()
case .german:
return german.data()
}
}
}
extension Tag: Identifiable { extension Tag: Identifiable {
} }

View File

@ -10,6 +10,8 @@ struct PageInFeed {
let title: String let title: String
let showTitle: Bool
let description: String let description: String
let navigationBarData: NavigationBarData let navigationBarData: NavigationBarData
@ -42,10 +44,15 @@ struct PageInFeed {
data: navigationBarData, data: navigationBarData,
additionalHeaders: headers, additionalHeaders: headers,
additionalFooter: footer) { content in additionalFooter: footer) { content in
if showTitle {
content += "<h1>\(title)</h1>"
}
for post in posts { for post in posts {
content += FeedEntry(data: post).content content += FeedEntry(data: post).content
} }
if totalPages > 1 {
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
}
}.content }.content
} }

View File

@ -4,6 +4,7 @@ extension Page {
static var empty: Page { static var empty: Page {
.init( .init(
content: .mock,
id: "my-id", id: "my-id",
isDraft: true, isDraft: true,
createdDate: Date(), createdDate: Date(),

View File

@ -2,7 +2,8 @@
extension Post { extension Post {
static var empty: Post { static var empty: Post {
.init(id: "empty", .init(content: Content.mock,
id: "empty",
isDraft: true, isDraft: true,
createdDate: .now, createdDate: .now,
startDate: .now, startDate: .now,
@ -15,6 +16,7 @@ extension Post {
static var mock: Post { static var mock: Post {
Post( Post(
content: Content.mock,
id: "mock", id: "mock",
isDraft: false, isDraft: false,
createdDate: .now, createdDate: .now,
@ -28,6 +30,7 @@ extension Post {
static var fullMock: Post { static var fullMock: Post {
.init( .init(
content: Content.mock,
id: "full", id: "full",
isDraft: true, isDraft: true,
createdDate: .now, createdDate: .now,

View File

@ -12,3 +12,12 @@ struct LocalizedPostSettingsFile {
} }
extension LocalizedPostSettingsFile: Codable { } extension LocalizedPostSettingsFile: Codable { }
extension LocalizedPostSettingsFile {
static var `default`: LocalizedPostSettingsFile {
.init(feedTitle: "A title",
feedDescription: "A description",
feedUrlPrefix: "blog")
}
}

View File

@ -10,3 +10,11 @@ struct LocalizedSettingsFile {
extension LocalizedSettingsFile: Codable { extension LocalizedSettingsFile: Codable {
} }
extension LocalizedSettingsFile {
static var `default`: LocalizedSettingsFile {
.init(navigationBarIconDescription: "An icon",
posts: .default)
}
}

View File

@ -10,3 +10,10 @@ struct NavigationBarSettingsFile {
extension NavigationBarSettingsFile: Codable { } extension NavigationBarSettingsFile: Codable { }
extension NavigationBarSettingsFile {
static var `default`: NavigationBarSettingsFile {
.init(navigationIconPath: "/assets/icons/icon.svg",
navigationTags: [])
}
}

View File

@ -9,3 +9,11 @@ struct PageSettingsFile {
extension PageSettingsFile: Codable { extension PageSettingsFile: Codable {
} }
extension PageSettingsFile {
static var `default`: PageSettingsFile {
.init(pageUrlPrefix: "page",
contentWidth: 600)
}
}

View File

@ -10,3 +10,11 @@ struct PostSettingsFile {
} }
extension PostSettingsFile: Codable { } extension PostSettingsFile: Codable { }
extension PostSettingsFile {
static var `default`: PostSettingsFile {
.init(postsPerPage: 25,
contentWidth: 600)
}
}

View File

@ -17,3 +17,17 @@ struct SettingsFile {
} }
extension SettingsFile: Codable { } extension SettingsFile: Codable { }
extension SettingsFile {
static var `default`: SettingsFile {
.init(
outputDirectoryPath: "",
navigationBar: .default,
posts: .default,
pages: .default,
german: .default,
english: .default
)
}
}

View File

@ -15,6 +15,10 @@ enum StorageAccessError: Error {
case folderAccessFailed(URL) case folderAccessFailed(URL)
case stringConversionFailed
case fileNotFound(String)
} }
extension StorageAccessError: CustomStringConvertible { extension StorageAccessError: CustomStringConvertible {
@ -27,6 +31,10 @@ extension StorageAccessError: CustomStringConvertible {
return "Failed to resolve bookmark: \(error)" return "Failed to resolve bookmark: \(error)"
case .folderAccessFailed(let url): case .folderAccessFailed(let url):
return "Failed to access folder: \(url.path())" return "Failed to access folder: \(url.path())"
case .stringConversionFailed:
return "Failed to convert string to data"
case .fileNotFound(let path):
return "File not found: \(path)"
} }
} }
} }
@ -102,10 +110,10 @@ final class Storage {
func createFolderStructure() throws { func createFolderStructure() throws {
try operate(in: .contentPath) { contentPath in try operate(in: .contentPath) { contentPath in
try create(folder: pagesFolder) try create(folder: pagesFolder(in: contentPath))
try create(folder: filesFolder(in: contentPath)) try create(folder: filesFolder(in: contentPath))
try create(folder: postsFolder) try create(folder: postsFolder(in: contentPath))
try create(folder: tagsFolder) try create(folder: tagsFolder(in: contentPath))
} }
} }
@ -114,60 +122,59 @@ final class Storage {
private let pagesFolderName = "pages" private let pagesFolderName = "pages"
/// The folder path where the markdown and metadata files of the pages are stored (by their id/url component) /// The folder path where the markdown and metadata files of the pages are stored (by their id/url component)
private var pagesFolder: URL { subFolder(pagesFolderName) } private func pagesFolder(in folder: URL) -> URL {
folder.appending(path: pagesFolderName, directoryHint: .isDirectory)
}
private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String { private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String {
"\(id)-\(language.rawValue).md" "\(id)-\(language.rawValue).md"
} }
private func pageContentPath(page pageId: String, language: ContentLanguage) -> String {
pagesFolderName + "/" + pageContentFileName(pageId, language)
}
private func pageMetadataPath(page pageId: String) -> String {
pagesFolderName + "/" + pageId + ".json"
}
private func pageFileName(_ id: String) -> String { private func pageFileName(_ id: String) -> String {
id + ".json" id + ".json"
} }
private func pageContentUrl(pageId: String, language: ContentLanguage) -> URL { private func pageContentUrl(page pageId: String, language: ContentLanguage, in folder: URL) -> URL {
pagesFolder.appending(path: pageContentFileName(pageId, language), directoryHint: .notDirectory) let fileName = pageContentFileName(pageId, language)
return pagesFolder(in: folder).appending(path: fileName, directoryHint: .notDirectory)
} }
private func pageMetadataUrl(pageId: String) -> URL { private func pageMetadataUrl(page pageId: String, in folder: URL) -> URL {
pagesFolder.appending(path: pageFileName(pageId), directoryHint: .notDirectory) let fileName = pageFileName(pageId)
return pagesFolder(in: folder).appending(path: fileName, directoryHint: .notDirectory)
} }
@discardableResult func save(pageContent: String, for pageId: String, language: ContentLanguage) throws {
func save(pageContent: String, for pageId: String, language: ContentLanguage) -> Bool { let path = pageContentPath(page: pageId, language: language)
let contentUrl = pageContentUrl(pageId: pageId, language: language) try writeIfChanged(content: pageContent, to: path)
return write(content: pageContent, to: contentUrl, type: "page", id: pageId)
} }
@discardableResult func save(pageMetadata: PageFile, for pageId: String) throws {
func save(pageMetadata: PageFile, for pageId: String) -> Bool { let path = pageMetadataPath(page: pageId)
let contentUrl = pageMetadataUrl(pageId: pageId) try writeIfChanged(pageMetadata, to: path)
return write(pageMetadata, type: "page", id: pageId, to: contentUrl)
}
@discardableResult
func copyPageContent(from url: URL, for pageId: String, language: ContentLanguage) -> Bool {
let contentUrl = pageContentUrl(pageId: pageId, language: language)
return copy(file: url, to: contentUrl, type: "page content", id: pageId)
} }
func loadAllPages() throws -> [String : PageFile] { func loadAllPages() throws -> [String : PageFile] {
try loadAll(in: pagesFolder) try decodeAllFromJson(in: pagesFolderName)
} }
func pageContent(for pageId: String, language: ContentLanguage) -> String { func pageContent(for pageId: String, language: ContentLanguage) throws -> String {
let contentUrl = pageContentUrl(pageId: pageId, language: language) let path = pageContentPath(page: pageId, language: language)
guard fm.fileExists(atPath: contentUrl.path()) else { return try readString(at: path, defaultValue: "")
print("No file at \(contentUrl.path())")
return ""
}
do {
return try String(contentsOf: contentUrl, encoding: .utf8)
} catch {
print("Failed to load page content for \(pageId) (\(language)): \(error)")
return error.localizedDescription
}
} }
/**
Delete all files associated with pages that are not in the given set
- Note: This function requires a security scope for the content path
*/
func deletePageFiles(notIn pages: [String]) throws { func deletePageFiles(notIn pages: [String]) throws {
var files = Set(pages.map(pageFileName)) var files = Set(pages.map(pageFileName))
for language in ContentLanguage.allCases { for language in ContentLanguage.allCases {
@ -176,66 +183,112 @@ final class Storage {
try deleteFiles(in: pagesFolderName, notIn: files) try deleteFiles(in: pagesFolderName, notIn: files)
} }
func move(page pageId: String, to newFile: String) -> Bool {
do {
try operate(in: .contentPath) { contentPath in
// Move the metadata file
let source = pageMetadataUrl(page: pageId, in: contentPath)
let destination = pageMetadataUrl(page: newFile, in: contentPath)
try fm.moveItem(at: source, to: destination)
// Move the existing content files
for language in ContentLanguage.allCases {
let source = pageContentUrl(page: pageId, language: language, in: contentPath)
guard source.exists else { continue }
let destination = pageContentUrl(page: newFile, language: language, in: contentPath)
try fm.moveItem(at: source, to: destination)
}
}
return true
} catch {
print("Failed to move page file \(pageId) to \(newFile): \(error)")
return false
}
}
// MARK: Posts // MARK: Posts
private let postsFolderName = "posts" private let postsFolderName = "posts"
/// The folder path where the markdown files of the posts are stored (by their unique id/url component) private func postFileName(_ postId: String) -> String {
private var postsFolder: URL { subFolder(postsFolderName) } postId + ".json"
private func postFileUrl(postId: String) -> URL {
postsFolder.appending(path: postId, directoryHint: .notDirectory).appendingPathExtension("json")
} }
@discardableResult /// The folder path where the markdown files of the posts are stored (by their unique id/url component)
func save(post: PostFile, for postId: String) -> Bool { private func postsFolder(in folder: URL) -> URL {
let contentUrl = postFileUrl(postId: postId) folder.appending(path: postsFolderName, directoryHint: .isDirectory)
return write(post, type: "post", id: postId, to: contentUrl) }
private func postFileUrl(post postId: String, in folder: URL) -> URL {
let path = postFilePath(post: postId)
return folder.appending(path: path, directoryHint: .notDirectory)
}
private func postFilePath(post postId: String) -> String {
postsFolderName + "/" + postFileName(postId)
}
func save(post: PostFile, for postId: String) throws {
let path = postFilePath(post: postId)
try writeIfChanged(post, to: path)
} }
func loadAllPosts() throws -> [String : PostFile] { func loadAllPosts() throws -> [String : PostFile] {
try loadAll(in: postsFolder) try decodeAllFromJson(in: postsFolderName)
}
private func post(at url: URL) throws -> PostFile {
try read(at: url)
} }
private func postContent(for postId: String) throws -> PostFile { private func postContent(for postId: String) throws -> PostFile {
let url = postFileUrl(postId: postId) let path = postFilePath(post: postId)
return try post(at: url) return try read(at: path)
} }
/**
Delete all files associated with posts that are not in the given set
- Note: This function requires a security scope for the content path
*/
func deletePostFiles(notIn posts: [String]) throws { func deletePostFiles(notIn posts: [String]) throws {
let files = Set(posts.map { $0 + ".json" }) let files = Set(posts.map(postFileName))
try deleteFiles(in: postsFolderName, notIn: files) try deleteFiles(in: postsFolderName, notIn: files)
} }
func move(post postId: String, to newFile: String) throws {
try operate(in: .contentPath) { contentPath in
let source = postFileUrl(post: postId, in: contentPath)
let destination = postFileUrl(post: newFile, in: contentPath)
try fm.moveItem(at: source, to: destination)
}
}
// MARK: Tags // MARK: Tags
private let tagsFolderName = "tags" private let tagsFolderName = "tags"
private func tagFileName(tagId: String) -> String {
tagId + ".json"
}
/// The folder path where the source images are stored (by their unique name) /// The folder path where the source images are stored (by their unique name)
private var tagsFolder: URL { subFolder(tagsFolderName) } private func tagsFolder(in folder: URL) -> URL {
folder.appending(path: tagsFolderName)
private func tagFileUrl(tagId: String) -> URL {
tagsFolder.appending(path: tagId, directoryHint: .notDirectory)
} }
private func tagMetadataUrl(tagId: String) -> URL { private func relativeTagFilePath(tagId: String) -> String {
tagFileUrl(tagId: tagId).appendingPathExtension("json") tagsFolderName + "/" + tagFileName(tagId: tagId)
} }
@discardableResult func save(tagMetadata: TagFile, for tagId: String) throws {
func save(tagMetadata: TagFile, for tagId: String) -> Bool { let path = relativeTagFilePath(tagId: tagId)
let contentUrl = tagMetadataUrl(tagId: tagId) try writeIfChanged(tagMetadata, to: path)
return write(tagMetadata, type: "tag", id: tagId, to: contentUrl)
} }
func loadAllTags() throws -> [String : TagFile] { func loadAllTags() throws -> [String : TagFile] {
try loadAll(in: tagsFolder) try decodeAllFromJson(in: tagsFolderName)
} }
/**
Delete all files associated with tags that are not in the given set
- Note: This function requires a security scope for the content path
*/
func deleteTagFiles(notIn tags: [String]) throws { func deleteTagFiles(notIn tags: [String]) throws {
let files = Set(tags.map { $0 + ".json" }) let files = Set(tags.map { $0 + ".json" })
try deleteFiles(in: tagsFolderName, notIn: files) try deleteFiles(in: tagsFolderName, notIn: files)
@ -245,32 +298,24 @@ final class Storage {
private let fileDescriptionFilename = "file-descriptions.json" private let fileDescriptionFilename = "file-descriptions.json"
func loadFileDescriptions() -> [FileDescriptions] { func loadFileDescriptions() throws -> [FileDescriptions] {
do { try read(at: fileDescriptionFilename, defaultValue: [])
return try read(relativePath: fileDescriptionFilename)
} catch {
print("Failed to read file descriptions: \(error)")
return []
}
} }
@discardableResult func save(fileDescriptions: [FileDescriptions]) throws {
func save(fileDescriptions: [FileDescriptions]) -> Bool {
do {
try writeIfChanged(fileDescriptions, to: fileDescriptionFilename) try writeIfChanged(fileDescriptions, to: fileDescriptionFilename)
return true
} catch {
print("Failed to write file descriptions: \(error)")
return false
}
} }
// MARK: Files // MARK: Files
private let filesFolderName = "files" private let filesFolderName = "files"
private func filePath(file fileId: String) -> String {
filesFolderName + "/" + fileId
}
/// The folder path where other files are stored (by their unique name) /// The folder path where other files are stored (by their unique name)
func filesFolder(in folder: URL) -> URL { private func filesFolder(in folder: URL) -> URL {
folder.appending(path: filesFolderName, directoryHint: .isDirectory) folder.appending(path: filesFolderName, directoryHint: .isDirectory)
} }
@ -281,37 +326,24 @@ final class Storage {
/** /**
Copy an external file to the content folder Copy an external file to the content folder
*/ */
@discardableResult func copyFile(at url: URL, fileId: String) throws {
func copyFile(at url: URL, fileId: String) -> Bool {
do {
try operate(in: .contentPath) { contentPath in try operate(in: .contentPath) { contentPath in
let destination = fileUrl(file: fileId, in: contentPath) let destination = fileUrl(file: fileId, in: contentPath)
try fm.copyItem(at: url, to: destination) try fm.copyItem(at: url, to: destination)
} }
return true
} catch {
print("Failed to copy external file \(url.path()) to \(fileId): \(error)")
return false
}
} }
func move(file fileId: String, to newFile: String) -> Bool { func move(file fileId: String, to newFile: String) throws {
do {
try operate(in: .contentPath) { contentPath in try operate(in: .contentPath) { contentPath in
let source = fileUrl(file: fileId, in: contentPath) let source = fileUrl(file: fileId, in: contentPath)
let destination = fileUrl(file: newFile, in: contentPath) let destination = fileUrl(file: newFile, in: contentPath)
try fm.moveItem(at: source, to: destination) try fm.moveItem(at: source, to: destination)
} }
return true
} catch {
print("Failed to move file \(fileId) to \(newFile): \(error)")
return false
}
} }
func copy(file fileId: String, to relativeOutputPath: String) -> Bool { func copy(file fileId: String, to relativeOutputPath: String) throws {
do { let path = filePath(file: fileId)
try operate(in: .contentPath) { contentPath in try withScopedContent(file: path) { input in
try operate(in: .outputPath) { outputPath in try operate(in: .outputPath) { outputPath in
let output = outputPath.appending(path: relativeOutputPath, directoryHint: .notDirectory) let output = outputPath.appending(path: relativeOutputPath, directoryHint: .notDirectory)
if output.exists { if output.exists {
@ -319,82 +351,62 @@ final class Storage {
} }
try output.ensureParentFolderExistence() try output.ensureParentFolderExistence()
let input = fileUrl(file: fileId, in: contentPath)
try FileManager.default.copyItem(at: input, to: output) 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] { func loadAllFiles() throws -> [String] {
try operate(in: .contentPath) { contentPath in try self.existingFiles(in: filesFolderName)
let folder = filesFolder(in: contentPath) .map { $0.lastPathComponent }
return try files(in: folder).map { $0.lastPathComponent }
}
} }
func deleteFiles(notIn fileSet: [String]) throws { /**
Delete all file resources that are not in the given set
- Note: This function requires a security scope for the content path
*/
func deleteFileResources(notIn fileSet: [String]) throws {
try deleteFiles(in: filesFolderName, notIn: Set(fileSet)) try deleteFiles(in: filesFolderName, notIn: Set(fileSet))
} }
func fileContent(for file: String) throws -> String { func fileContent(for fileId: String) throws -> String {
try operate(in: .contentPath) { folder in let path = filePath(file: fileId)
let fileUrl = folder return try readString(at: path)
.appending(path: "files", directoryHint: .isDirectory)
.appending(path: file, directoryHint: .notDirectory)
return try String(contentsOf: fileUrl, encoding: .utf8)
}
} }
func fileData(for file: String) throws -> Data { func fileData(for fileId: String) throws -> Data {
try operate(in: .contentPath) { folder in let path = filePath(file: fileId)
let fileUrl = folder return try readExistingFile(at: path)
.appending(path: "files", directoryHint: .isDirectory)
.appending(path: file, directoryHint: .notDirectory)
return try Data(contentsOf: fileUrl)
}
} }
// MARK: Website data // MARK: Website data
private var settingsDataUrl: URL { private let settingsDataFileName: String = "settings.json"
baseFolder.appending(path: "settings.json", directoryHint: .notDirectory)
}
func loadSettings() throws -> SettingsFile { func loadSettings() throws -> SettingsFile {
try read(at: settingsDataUrl) try read(at: settingsDataFileName, defaultValue: .default)
} }
@discardableResult func save(settings: SettingsFile) throws {
func save(settings: SettingsFile) -> Bool { try writeIfChanged(settings, to: settingsDataFileName)
write(settings, type: "Settings", id: "-", to: settingsDataUrl)
} }
// MARK: Image generation data // MARK: Image generation data
private var generatedImagesListUrl: URL { private let generatedImagesListName = "generated-images.json"
baseFolder.appending(component: "generated-images.json", directoryHint: .notDirectory)
func loadListOfGeneratedImages() throws -> [String : [String]] {
try read(at: generatedImagesListName, defaultValue: [:])
} }
func loadListOfGeneratedImages() -> [String : [String]] { func save(listOfGeneratedImages: [String : [String]]) throws {
let url = generatedImagesListUrl try writeIfChanged(listOfGeneratedImages, to: generatedImagesListName)
guard url.exists else {
return [:]
}
do {
return try read(at: url)
} catch {
print("Failed to read list of generated images: \(error)")
return [:]
}
} }
func save(listOfGeneratedImages: [String : [String]]) -> Bool { // MARK: Output files
write(listOfGeneratedImages, type: "generated images list", id: "-", to: generatedImagesListUrl)
func write(content: String, to relativeOutputPath: String) throws {
try writeIfChanged(content: content, to: relativeOutputPath, in: .outputPath)
} }
// MARK: Folder access // MARK: Folder access
@ -417,6 +429,21 @@ final class Storage {
} }
} }
private func withScopedContent<T>(file relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T {
try withScopedContent(relativePath, in: scope, directoryHint: .notDirectory, operation)
}
private func withScopedContent<T>(folder relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T {
try withScopedContent(relativePath, in: scope, directoryHint: .isDirectory, operation)
}
private func withScopedContent<T>(_ relativePath: String, in scope: SecurityScopeBookmark, directoryHint: URL.DirectoryHint, _ operation: (URL) throws -> T) throws -> T {
try operate(in: scope) {
let url = $0.appending(path: relativePath, directoryHint: directoryHint)
return try operation(url)
}
}
func operate<T>(in scope: SecurityScopeBookmark, operation: (URL) throws -> T) throws -> T { func operate<T>(in scope: SecurityScopeBookmark, operation: (URL) throws -> T) throws -> T {
guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else { guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else {
throw StorageAccessError.noBookmarkData throw StorageAccessError.noBookmarkData
@ -444,10 +471,13 @@ final class Storage {
// MARK: Writing files // MARK: Writing files
/**
Delete files in a subPath of the content folder which are not in the given set of files
- Note: This function requires a security scope for the content path
*/
private func deleteFiles(in folder: String, notIn fileSet: Set<String>) throws { private func deleteFiles(in folder: String, notIn fileSet: Set<String>) throws {
try operate(in: .contentPath) { contentPath in try withScopedContent(folder: folder) { folderUrl in
let subFolder = contentPath.appending(path: folder, directoryHint: .isDirectory) let filesToDelete = try files(in: folderUrl)
let filesToDelete = try files(in: subFolder)
.filter { !fileSet.contains($0.lastPathComponent) } .filter { !fileSet.contains($0.lastPathComponent) }
for file in filesToDelete { for file in filesToDelete {
@ -457,10 +487,33 @@ final class Storage {
} }
} }
/**
Write the data of an encodable value to a relative path in the content folder
- Note: This function requires a security scope for the content path
*/
private func writeIfChanged<T>(_ value: T, to relativePath: String) throws where T: Encodable { 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) let data = try encoder.encode(value)
try writeIfChanged(data: data, to: relativePath)
}
/**
Write the data of a string to a relative path in the content folder
- Note: This function requires a security scope for the content path
*/
private func writeIfChanged(content: String, to relativePath: String, in scope: SecurityScopeBookmark = .contentPath) throws {
guard let data = content.data(using: .utf8) else {
print("Failed to convert string to data for file at \(relativePath)")
throw StorageAccessError.stringConversionFailed
}
try writeIfChanged(data: data, to: relativePath, in: scope)
}
/**
Write the data to a relative path in the content folder
- Note: This function requires a security scope for the content path
*/
private func writeIfChanged(data: Data, to relativePath: String, in scope: SecurityScopeBookmark = .contentPath) throws {
try withScopedContent(file: relativePath, in: scope) { url in
if fm.fileExists(atPath: url.path()) { if fm.fileExists(atPath: url.path()) {
// Check if content is the same, to prevent unnecessary writes // Check if content is the same, to prevent unnecessary writes
do { do {
@ -475,6 +528,7 @@ final class Storage {
} }
} else { } else {
print("Writing new file \(url.path())") print("Writing new file \(url.path())")
try url.ensureParentFolderExistence()
} }
try data.write(to: url) try data.write(to: url)
print("Saved file \(url.path())") print("Saved file \(url.path())")
@ -482,84 +536,88 @@ final class Storage {
} }
/** /**
Encode a value and write it to a file, if the content changed
- Note: This function requires a security scope for the content path
*/ */
private func write<T>(_ value: T, type: String, id: String, to file: URL) -> Bool where T: Encodable { private func read<T>(at relativePath: String, defaultValue: T? = nil) throws -> T where T: Decodable {
let content: Data guard let data = try readData(at: relativePath) else {
do { guard let defaultValue else {
content = try encoder.encode(value) throw StorageAccessError.fileNotFound(relativePath)
} catch {
print("Failed to encode content of \(type) '\(id)': \(error)")
return false
} }
return write(data: content, type: type, id: id, to: file) return defaultValue
}
return try decoder.decode(T.self, from: data)
} }
/** /**
Write data to a file if the content changed
- Note: This function requires a security scope for the content path
*/ */
private func write(data: Data, type: String, id: String, to file: URL) -> Bool { private func readString(at relativePath: String, defaultValue: String? = nil) throws -> String {
if fm.fileExists(atPath: file.path()) { try withScopedContent(file: relativePath) { url in
// Check if content is the same, to prevent unnecessary writes guard url.exists else {
do { guard let defaultValue else {
let oldData = try Data(contentsOf: file) throw StorageAccessError.fileNotFound(relativePath)
if data == oldData {
// File is the same, don't write
return true
} }
} catch { return defaultValue
print("Failed to read file \(file.path()) for equality check: \(error)")
// No check possible, write file
} }
} else { return try String(contentsOf: url, encoding: .utf8)
print("Writing new file \(file.path())")
}
do {
try data.write(to: file, options: .atomic)
print("Saved file \(file.path())")
return true
} catch {
print("Failed to save content for \(type) '\(id)': \(error)")
return false
} }
} }
private func copy(file: URL, to destination: URL, type: String, id: String) -> Bool { private func readExistingFile(at relativePath: String) throws -> Data {
do { guard let data = try readData(at: relativePath) else {
try fm.copyItem(at: file, to: destination) throw StorageAccessError.fileNotFound(relativePath)
return true }
} catch { return data
print("Failed to copy content file for \(type) '\(id)': \(error)") }
return false
/**
- Note: This function requires a security scope for the content path
*/
private func readData(at relativePath: String) throws -> Data? {
try withScopedContent(file: relativePath) { url in
guard url.exists else {
return nil
}
return try Data(contentsOf: url)
} }
} }
private func write(content: String, to file: URL, type: String, id: String) -> Bool { private func getFiles(in folder: URL) throws -> [URL] {
guard let data = content.data(using: .utf8) else { try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
print("Failed to convert string to data for \(type) '\(id)'") .filter { !$0.hasDirectoryPath }
return false
}
return write(data: data, type: type, id: id, to: file)
} }
private func read<T>(relativePath: String) throws -> T where T: Decodable { private func existingFiles(in folder: String) throws -> [URL] {
try operate(in: .contentPath) { baseFolder in try withScopedContent(folder: folder, getFiles)
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)
}
private func loadAll<T>(in folder: URL) throws -> [String : T] where T: Decodable { - Note: This function requires a security scope for the content path
try files(in: folder, type: "json").reduce(into: [:]) { items, url in */
private func decodeAllFromJson<T>(in folder: String) throws -> [String : T] where T: Decodable {
try withScopedContent(folder: folder) { folderUrl in
try getFiles(in: folderUrl)
.filter { $0.pathExtension.lowercased() == "json" }
.reduce(into: [:]) { items, url in
let id = url.deletingPathExtension().lastPathComponent let id = url.deletingPathExtension().lastPathComponent
let item: T = try read(at: url) let data = try Data(contentsOf: url)
items[id] = item items[id] = try decoder.decode(T.self, from: data)
}
}
}
/**
- Note: This function requires a security scope for the content path
*/
private func copy(file: URL, to relativePath: String) throws {
try withScopedContent(file: relativePath) { destination in
try destination.ensureParentFolderExistence()
try fm.copyItem(at: file, to: destination)
} }
} }
} }

View File

@ -85,9 +85,10 @@ struct AddFileView: View {
print("Skipping existing file \(file.uniqueId)") print("Skipping existing file \(file.uniqueId)")
continue continue
} }
do {
guard content.storage.copyFile(at: file.url, fileId: file.uniqueId) else { try content.storage.copyFile(at: file.url, fileId: file.uniqueId)
print("Failed to import file '\(file.uniqueId)' at \(file.url.path())") } catch {
print("Failed to import file '\(file.uniqueId)' at \(file.url.path()): \(error)")
return return
} }

View File

@ -55,7 +55,9 @@ struct FileDetailView: View {
} }
private func setNewId() { private func setNewId() {
guard file.content.storage.move(file: file.id, to: newId) else { do {
try file.content.storage.move(file: file.id, to: newId)
} catch {
print("Failed to move file \(file.id)") print("Failed to move file \(file.id)")
newId = file.id newId = file.id
return return

View File

@ -67,6 +67,7 @@ struct AddPageView: View {
private func addNewPage() { private func addNewPage() {
let page = Page( let page = Page(
content: content,
id: newPageId, id: newPageId,
isDraft: true, isDraft: true,
createdDate: .now, createdDate: .now,

View File

@ -20,6 +20,9 @@ struct LocalizedPageContentView: View {
@State @State
private var pageContent: String = "" private var pageContent: String = ""
@State
private var didLoadContent = false
init(pageId: String, page: LocalizedPage) { init(pageId: String, page: LocalizedPage) {
self.pageId = pageId self.pageId = pageId
self.page = page self.page = page
@ -50,18 +53,32 @@ struct LocalizedPageContentView: View {
} }
private func loadContent() { private func loadContent() {
let content = content.storage.pageContent(for: pageId, language: language) do {
let content = try content.storage.pageContent(for: pageId, language: language)
guard content != "" else { guard content != "" else {
pageContent = "New file" pageContent = "New file"
didLoadContent = false
return return
} }
pageContent = content pageContent = content
didLoadContent = true
} catch {
print("Failed to load page content: \(error)")
pageContent = "Failed to load"
}
} }
private func saveContent() { private func saveContent() {
guard pageContent != "", pageContent != "New file" else { guard didLoadContent else {
return return
} }
content.storage.save(pageContent: pageContent, for: pageId, language: language) do {
try content.storage.save(pageContent: pageContent, for: pageId, language: language)
} catch {
print("Failed to save content: \(error)")
}
} }
} }

View File

@ -14,8 +14,22 @@ struct PageDetailView: View {
@State @State
private var isGeneratingWebsite = false private var isGeneratingWebsite = false
@State
private var newId: String
init(page: Page) { init(page: Page) {
self.page = page self.page = page
self.newId = page.id
}
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private var idExists: Bool {
page.content.pages.contains { $0.id == newId }
}
private var containsInvalidCharacters: Bool {
newId.rangeOfCharacter(from: allowedCharactersInPostId) != nil
} }
var body: some View { var body: some View {
@ -25,10 +39,12 @@ struct PageDetailView: View {
Text("Generate") Text("Generate")
} }
.disabled(isGeneratingWebsite) .disabled(isGeneratingWebsite)
Text("ID") HStack {
.font(.headline) TextField("", text: $newId)
TextField("", text: $page.id)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(newId.isEmpty || containsInvalidCharacters || idExists)
}
.padding(.bottom) .padding(.bottom)
HStack { HStack {
@ -102,12 +118,20 @@ struct PageDetailView: View {
} }
} }
} }
private func setNewId() {
guard page.update(id: newId) else {
newId = page.id
return
}
page.id = newId
}
} }
extension PageDetailView: MainContentView { extension PageDetailView: MainContentView {
init(item: Page) { init(item: Page) {
self.page = item self.init(page: item)
} }
static let itemDescription = "a page" static let itemDescription = "a page"

View File

@ -67,6 +67,7 @@ struct AddPostView: View {
private func addNewPost() { private func addNewPost() {
let post = Post( let post = Post(
content: content,
id: newPostId, id: newPostId,
isDraft: true, isDraft: true,
createdDate: .now, createdDate: .now,

View File

@ -34,10 +34,24 @@ struct PostDetailView: View {
private var language private var language
@ObservedObject @ObservedObject
private var item: Post private var post: Post
@State
private var newId: String
init(post: Post) { init(post: Post) {
self.item = post self.post = post
self.newId = post.id
}
private let allowedCharactersInPostId = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private var idExists: Bool {
post.content.posts.contains { $0.id == newId }
}
private var containsInvalidCharacters: Bool {
newId.rangeOfCharacter(from: allowedCharactersInPostId) != nil
} }
var body: some View { var body: some View {
@ -45,15 +59,19 @@ struct PostDetailView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("ID") Text("ID")
.font(.headline) .font(.headline)
TextField("", text: $item.id) HStack {
TextField("", text: $newId)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(newId.isEmpty || containsInvalidCharacters || idExists)
}
.padding(.bottom) .padding(.bottom)
HStack { HStack {
Text("Draft") Text("Draft")
.font(.headline) .font(.headline)
Spacer() Spacer()
Toggle("", isOn: $item.isDraft) Toggle("", isOn: $post.isDraft)
.toggleStyle(.switch) .toggleStyle(.switch)
} }
.padding(.bottom) .padding(.bottom)
@ -62,7 +80,7 @@ struct PostDetailView: View {
Text("Start") Text("Start")
.font(.headline) .font(.headline)
Spacer() Spacer()
DatePicker("", selection: $item.startDate, displayedComponents: .date) DatePicker("", selection: $post.startDate, displayedComponents: .date)
.datePickerStyle(.compact) .datePickerStyle(.compact)
.padding(.bottom) .padding(.bottom)
} }
@ -71,34 +89,42 @@ struct PostDetailView: View {
Text("Has end date") Text("Has end date")
.font(.headline) .font(.headline)
Spacer() Spacer()
Toggle("", isOn: $item.hasEndDate) Toggle("", isOn: $post.hasEndDate)
.toggleStyle(.switch) .toggleStyle(.switch)
.padding(.bottom) .padding(.bottom)
} }
if item.hasEndDate { if post.hasEndDate {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
Text("End date") Text("End date")
.font(.headline) .font(.headline)
Spacer() Spacer()
DatePicker("", selection: $item.endDate, displayedComponents: .date) DatePicker("", selection: $post.endDate, displayedComponents: .date)
.datePickerStyle(.compact) .datePickerStyle(.compact)
.padding(.bottom) .padding(.bottom)
} }
} }
LocalizedPostDetailView(post: item.localized(in: language)) LocalizedPostDetailView(post: post.localized(in: language))
} }
.padding() .padding()
} }
} }
private func setNewId() {
guard post.update(id: newId) else {
newId = post.id
return
}
post.id = newId
}
} }
extension PostDetailView: MainContentView { extension PostDetailView: MainContentView {
init(item: Post) { init(item: Post) {
self.item = item self.init(post: item)
} }
static let itemDescription = "a post" static let itemDescription = "a post"