Simplify images, tag overview

This commit is contained in:
Christoph Hagen 2025-01-04 08:44:26 +01:00
parent 4d4275e072
commit 22e7d9a05a
49 changed files with 603 additions and 509 deletions

View File

@ -41,7 +41,7 @@
E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903B2D0F8A74009F8D77 /* OptionalTextFieldPropertyView.swift */; }; E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903B2D0F8A74009F8D77 /* OptionalTextFieldPropertyView.swift */; };
E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903D2D0F8EFD009F8D77 /* StringPropertyView.swift */; }; E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903D2D0F8EFD009F8D77 /* StringPropertyView.swift */; };
E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */; }; E22990402D0F95EC009F8D77 /* FolderOnDiskPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */; };
E22990422D107A95009F8D77 /* ImageJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990412D107A94009F8D77 /* ImageJob.swift */; }; E22990422D107A95009F8D77 /* ImageVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990412D107A94009F8D77 /* ImageVersion.swift */; };
E22990462D10B7A7009F8D77 /* SecurityScopeStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */; }; E22990462D10B7A7009F8D77 /* SecurityScopeStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */; };
E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990472D10B7B7009F8D77 /* StorageAccessError.swift */; }; E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990472D10B7B7009F8D77 /* StorageAccessError.swift */; };
E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */; }; E229904A2D10BB90009F8D77 /* SecurityScopeBookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */; };
@ -80,7 +80,6 @@
E25DA5852D01C92700AEF16D /* ShorthandMarkdownKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */; }; E25DA5852D01C92700AEF16D /* ShorthandMarkdownKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */; };
E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */; }; E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */; };
E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58A2D020C9200AEF16D /* PageImage.swift */; }; E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58A2D020C9200AEF16D /* PageImage.swift */; };
E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */; };
E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58E2D02368A00AEF16D /* PageSettings.swift */; }; E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58E2D02368A00AEF16D /* PageSettings.swift */; };
E25DA5912D023A8400AEF16D /* IntegerField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5902D023A7E00AEF16D /* IntegerField.swift */; }; E25DA5912D023A8400AEF16D /* IntegerField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5902D023A7E00AEF16D /* IntegerField.swift */; };
E25DA5932D023B3C00AEF16D /* PageSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */; }; E25DA5932D023B3C00AEF16D /* PageSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */; };
@ -209,6 +208,9 @@
E2FE0F112D268E7E002963B7 /* PageCodeProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F102D268E78002963B7 /* PageCodeProcessor.swift */; }; E2FE0F112D268E7E002963B7 /* PageCodeProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F102D268E78002963B7 /* PageCodeProcessor.swift */; };
E2FE0F152D26918F002963B7 /* PageHtmlProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F142D269188002963B7 /* PageHtmlProcessor.swift */; }; E2FE0F152D26918F002963B7 /* PageHtmlProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F142D269188002963B7 /* PageHtmlProcessor.swift */; };
E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */; }; E2FE0F172D2698D5002963B7 /* LocalizedPageId.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */; };
E2FE0F192D2723E3002963B7 /* ImageSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F182D2723E3002963B7 /* ImageSet.swift */; };
E2FE0F1B2D274FDF002963B7 /* LinkPreviewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F1A2D274FDA002963B7 /* LinkPreviewItem.swift */; };
E2FE0F1E2D281AE1002963B7 /* TagOverviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0F1D2D281ACE002963B7 /* TagOverviewGenerator.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -246,7 +248,7 @@
E229903B2D0F8A74009F8D77 /* OptionalTextFieldPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextFieldPropertyView.swift; sourceTree = "<group>"; }; E229903B2D0F8A74009F8D77 /* OptionalTextFieldPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalTextFieldPropertyView.swift; sourceTree = "<group>"; };
E229903D2D0F8EFD009F8D77 /* StringPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringPropertyView.swift; sourceTree = "<group>"; }; E229903D2D0F8EFD009F8D77 /* StringPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringPropertyView.swift; sourceTree = "<group>"; };
E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderOnDiskPropertyView.swift; sourceTree = "<group>"; }; E229903F2D0F95DA009F8D77 /* FolderOnDiskPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderOnDiskPropertyView.swift; sourceTree = "<group>"; };
E22990412D107A94009F8D77 /* ImageJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageJob.swift; sourceTree = "<group>"; }; E22990412D107A94009F8D77 /* ImageVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageVersion.swift; sourceTree = "<group>"; };
E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopeStatus.swift; sourceTree = "<group>"; }; E22990452D10B7A6009F8D77 /* SecurityScopeStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopeStatus.swift; sourceTree = "<group>"; };
E22990472D10B7B7009F8D77 /* StorageAccessError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageAccessError.swift; sourceTree = "<group>"; }; E22990472D10B7B7009F8D77 /* StorageAccessError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageAccessError.swift; sourceTree = "<group>"; };
E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopeBookmark.swift; sourceTree = "<group>"; }; E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityScopeBookmark.swift; sourceTree = "<group>"; };
@ -281,7 +283,6 @@
E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShorthandMarkdownKey.swift; sourceTree = "<group>"; }; E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShorthandMarkdownKey.swift; sourceTree = "<group>"; };
E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Generation.swift"; sourceTree = "<group>"; }; E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Generation.swift"; sourceTree = "<group>"; };
E25DA58A2D020C9200AEF16D /* PageImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImage.swift; sourceTree = "<group>"; }; E25DA58A2D020C9200AEF16D /* PageImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImage.swift; sourceTree = "<group>"; };
E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteImage.swift; sourceTree = "<group>"; };
E25DA58E2D02368A00AEF16D /* PageSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettings.swift; sourceTree = "<group>"; }; E25DA58E2D02368A00AEF16D /* PageSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettings.swift; sourceTree = "<group>"; };
E25DA5902D023A7E00AEF16D /* IntegerField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegerField.swift; sourceTree = "<group>"; }; E25DA5902D023A7E00AEF16D /* IntegerField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegerField.swift; sourceTree = "<group>"; };
E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsFile.swift; sourceTree = "<group>"; }; E25DA5922D023B3600AEF16D /* PageSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsFile.swift; sourceTree = "<group>"; };
@ -409,6 +410,9 @@
E2FE0F102D268E78002963B7 /* PageCodeProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageCodeProcessor.swift; sourceTree = "<group>"; }; E2FE0F102D268E78002963B7 /* PageCodeProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageCodeProcessor.swift; sourceTree = "<group>"; };
E2FE0F142D269188002963B7 /* PageHtmlProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHtmlProcessor.swift; sourceTree = "<group>"; }; E2FE0F142D269188002963B7 /* PageHtmlProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHtmlProcessor.swift; sourceTree = "<group>"; };
E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPageId.swift; sourceTree = "<group>"; }; E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPageId.swift; sourceTree = "<group>"; };
E2FE0F182D2723E3002963B7 /* ImageSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSet.swift; sourceTree = "<group>"; };
E2FE0F1A2D274FDA002963B7 /* LinkPreviewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewItem.swift; sourceTree = "<group>"; };
E2FE0F1D2D281ACE002963B7 /* TagOverviewGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOverviewGenerator.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -432,6 +436,7 @@
E229901A2D0E3F09009F8D77 /* Item */ = { E229901A2D0E3F09009F8D77 /* Item */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E2FE0F1A2D274FDA002963B7 /* LinkPreviewItem.swift */,
E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */, E2FE0F162D2698D5002963B7 /* LocalizedPageId.swift */,
E229902B2D0F6FC0009F8D77 /* ItemId.swift */, E229902B2D0F6FC0009F8D77 /* ItemId.swift */,
E229901D2D0E4362009F8D77 /* LocalizedItem.swift */, E229901D2D0E4362009F8D77 /* LocalizedItem.swift */,
@ -488,15 +493,15 @@
children = ( children = (
E2FE0F072D2689DC002963B7 /* Post Lists */, E2FE0F072D2689DC002963B7 /* Post Lists */,
E29D31B62D0DAC030051B7F4 /* Page Content */, E29D31B62D0DAC030051B7F4 /* Page Content */,
E2FE0F1C2D281A7B002963B7 /* Page Generators */,
E22990232D0EDBD0009F8D77 /* HeaderElement.swift */, E22990232D0EDBD0009F8D77 /* HeaderElement.swift */,
E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */, E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */,
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */, E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
E22990412D107A94009F8D77 /* ImageJob.swift */, E22990412D107A94009F8D77 /* ImageVersion.swift */,
E2FE0F182D2723E3002963B7 /* ImageSet.swift */,
E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */, E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */,
E29D316C2D07A4FF0051B7F4 /* PageGenerationResults.swift */, E29D316C2D07A4FF0051B7F4 /* PageGenerationResults.swift */,
E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */, E2FE0EE52D15A0B1002963B7 /* GenerationResults.swift */,
E25DA5982D02401A00AEF16D /* PageGenerator.swift */,
E2B85F3C2C4293F80047CD0C /* FeedPageGenerator.swift */,
E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */, E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */,
E29D31252D0370A50051B7F4 /* VideoOption.swift */, E29D31252D0370A50051B7F4 /* VideoOption.swift */,
E29D318F2D0B34870051B7F4 /* GenerationAnomaly.swift */, E29D318F2D0B34870051B7F4 /* GenerationAnomaly.swift */,
@ -726,7 +731,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E29D311E2D0320D90051B7F4 /* ContentElements */, E29D311E2D0320D90051B7F4 /* ContentElements */,
E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */,
E25DA58A2D020C9200AEF16D /* PageImage.swift */, E25DA58A2D020C9200AEF16D /* PageImage.swift */,
E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */, E25DA51E2CFF15C100AEF16D /* NavigationBar.swift */,
E2FE0EF92D25AFB5002963B7 /* PageHeader.swift */, E2FE0EF92D25AFB5002963B7 /* PageHeader.swift */,
@ -848,6 +852,16 @@
path = "Post Lists"; path = "Post Lists";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
E2FE0F1C2D281A7B002963B7 /* Page Generators */ = {
isa = PBXGroup;
children = (
E2FE0F1D2D281ACE002963B7 /* TagOverviewGenerator.swift */,
E25DA5982D02401A00AEF16D /* PageGenerator.swift */,
E2B85F3C2C4293F80047CD0C /* FeedPageGenerator.swift */,
);
path = "Page Generators";
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -972,7 +986,7 @@
E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */, E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */,
E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */, E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */,
E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */, E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */,
E22990422D107A95009F8D77 /* ImageJob.swift in Sources */, E22990422D107A95009F8D77 /* ImageVersion.swift in Sources */,
E29D317F2D086F4C0051B7F4 /* StatisticsIcons.swift in Sources */, E29D317F2D086F4C0051B7F4 /* StatisticsIcons.swift in Sources */,
E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */, E229904E2D13535C009F8D77 /* SecurityBookmark.swift in Sources */,
E2A21C082CB17B870060935B /* TagView.swift in Sources */, E2A21C082CB17B870060935B /* TagView.swift in Sources */,
@ -987,7 +1001,6 @@
E22990342D0F77E9009F8D77 /* PagePropertyView.swift in Sources */, E22990342D0F77E9009F8D77 /* PagePropertyView.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 */,
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */, E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */,
E25DA5382D00420E00AEF16D /* LocalizedPostSettingsFile.swift in Sources */, E25DA5382D00420E00AEF16D /* LocalizedPostSettingsFile.swift in Sources */,
E2B85F3D2C4293F80047CD0C /* FeedPageGenerator.swift in Sources */, E2B85F3D2C4293F80047CD0C /* FeedPageGenerator.swift in Sources */,
@ -1043,6 +1056,7 @@
E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */, E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */,
E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */, E229903E2D0F8F02009F8D77 /* StringPropertyView.swift in Sources */,
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */, E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
E2FE0F1B2D274FDF002963B7 /* LinkPreviewItem.swift in Sources */,
E2FE0F062D267350002963B7 /* TextFieldPropertyView.swift in Sources */, E2FE0F062D267350002963B7 /* TextFieldPropertyView.swift in Sources */,
E29D31B82D0DAC250051B7F4 /* ButtonCommand.swift in Sources */, E29D31B82D0DAC250051B7F4 /* ButtonCommand.swift in Sources */,
E29D31962D0C186E0051B7F4 /* PathSettings.swift in Sources */, E29D31962D0C186E0051B7F4 /* PathSettings.swift in Sources */,
@ -1122,8 +1136,10 @@
E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */, E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */,
E25DA5932D023B3C00AEF16D /* PageSettingsFile.swift in Sources */, E25DA5932D023B3C00AEF16D /* PageSettingsFile.swift in Sources */,
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */, E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */,
E2FE0F192D2723E3002963B7 /* ImageSet.swift in Sources */,
E2A21C362CB9A3D70060935B /* PathSettingsView.swift in Sources */, E2A21C362CB9A3D70060935B /* PathSettingsView.swift in Sources */,
E29D31362D0435430051B7F4 /* TabSelection.swift in Sources */, E29D31362D0435430051B7F4 /* TabSelection.swift in Sources */,
E2FE0F1E2D281AE1002963B7 /* TagOverviewGenerator.swift in Sources */,
E29D31572D06D38B0051B7F4 /* AddTagView.swift in Sources */, E29D31572D06D38B0051B7F4 /* AddTagView.swift in Sources */,
E25DA5362D0041EB00AEF16D /* PostSettingsFile.swift in Sources */, E25DA5362D0041EB00AEF16D /* PostSettingsFile.swift in Sources */,
E29D31792D083DE50051B7F4 /* PageContentResultsView.swift in Sources */, E29D31792D083DE50051B7F4 /* PageContentResultsView.swift in Sources */,

View File

@ -26,7 +26,7 @@ final class GenerationResults: ObservableObject {
var requiredFiles: Set<FileResource> = [] var requiredFiles: Set<FileResource> = []
@Published @Published
var imagesToGenerate: Set<ImageGenerationJob> = [] var imagesToGenerate: Set<ImageVersion> = []
@Published @Published
var invalidCommands: Set<String> = [] var invalidCommands: Set<String> = []
@ -38,7 +38,7 @@ final class GenerationResults: ObservableObject {
var unsavedOutputFiles: Set<String> = [] var unsavedOutputFiles: Set<String> = []
@Published @Published
var failedImages: Set<ImageGenerationJob> = [] var failedImages: Set<ImageVersion> = []
@Published @Published
var emptyPages: Set<LocalizedPageId> = [] var emptyPages: Set<LocalizedPageId> = []
@ -151,11 +151,11 @@ final class GenerationResults: ObservableObject {
update { self.requiredFiles.formUnion(files) } update { self.requiredFiles.formUnion(files) }
} }
func generate(_ image: ImageGenerationJob) { func generate(_ image: ImageVersion) {
update { self.imagesToGenerate.insert(image) } update { self.imagesToGenerate.insert(image) }
} }
func generate<S>(_ images: S) where S: Sequence, S.Element == ImageGenerationJob { func generate<S>(_ images: S) where S: Sequence, S.Element == ImageVersion {
update { self.imagesToGenerate.formUnion(images) } update { self.imagesToGenerate.formUnion(images) }
} }
@ -167,7 +167,7 @@ final class GenerationResults: ObservableObject {
update { self.warnings.insert(warning) } update { self.warnings.insert(warning) }
} }
func failed(image: ImageGenerationJob) { func failed(image: ImageVersion) {
update { self.failedImages.insert(image) } update { self.failedImages.insert(image) }
} }

View File

@ -15,20 +15,33 @@ final class ImageGenerator {
self.storage = storage self.storage = storage
self.settings = settings self.settings = settings
self.generatedImages = storage.loadListOfGeneratedImages() ?? [:] self.generatedImages = storage.loadListOfGeneratedImages() ?? [:]
print("ImageGenerator: Loaded list of \(totalImageCount) already generated images")
} }
private var outputFolder: String { private var outputFolder: String {
settings.paths.imagesOutputFolderPath settings.paths.imagesOutputFolderPath
} }
private var totalImageCount: Int {
generatedImages.values.reduce(0) { $0 + $1.count }
}
@discardableResult
func save() -> Bool { func save() -> Bool {
guard storage.save(listOfGeneratedImages: generatedImages) else { guard storage.save(listOfGeneratedImages: generatedImages) else {
print("Failed to save list of generated images") print("ImageGenerator: Failed to save list of generated images")
return false return false
} }
print("ImageGenerator: Saved list of \(totalImageCount) images")
return true return true
} }
private var avifCommands: Set<String> = []
func printAvifCommands() {
avifCommands.sorted().forEach { print($0) }
}
/** /**
Remove all versions of an image, so that they will be recreated on the next run. Remove all versions of an image, so that they will be recreated on the next run.
@ -44,26 +57,27 @@ final class ImageGenerator {
print("Image generator: \(generatedImages.count)/\(images.count) images (\(versionCount) versions)") print("Image generator: \(generatedImages.count)/\(images.count) images (\(versionCount) versions)")
} }
private func needsToGenerate(version: String, for image: String) -> Bool { private func hasPreviouslyGenerated(_ version: ImageVersion) -> Bool {
if exists(version) { guard let versions = generatedImages[version.image.id] else {
return false return false
} }
guard let versions = generatedImages[image] else { return versions.contains(version.versionId)
return true
}
guard versions.contains(version) else {
return true
}
return !exists(version)
} }
private func hasNowGenerated(version: String, for image: String) { private func needsToGenerate(_ version: ImageVersion) -> Bool {
guard var versions = generatedImages[image] else { if hasPreviouslyGenerated(version) {
generatedImages[image] = [version] return false
return
} }
versions.insert(version) if exists(version) {
generatedImages[image] = versions // Mark as already generated
hasNowGenerated(version)
return false
}
return true
}
private func hasNowGenerated(_ version: ImageVersion) {
generatedImages[version.image.id, default: []].insert(version.versionId)
} }
private func removeVersions(for image: String) { private func removeVersions(for image: String) {
@ -72,53 +86,59 @@ final class ImageGenerator {
// MARK: Files // MARK: Files
private func exists(_ image: String) -> Bool { private func exists(_ version: ImageVersion) -> Bool {
storage.hasFileInOutputFolder(relativePath(for: image)) storage.hasFileInOutputFolder(version.outputPath)
} }
private func relativePath(for image: String) -> String { private func write(imageData data: Data, of version: ImageVersion) -> Bool {
outputFolder + "/" + image return storage.write(data, to: version.outputPath)
}
private func write(imageData data: Data, version: String) -> Bool {
return storage.write(data, to: relativePath(for: version))
} }
// MARK: Image operations // MARK: Image operations
func generate(job: ImageGenerationJob) -> Bool { func generate(version: ImageVersion) -> Bool {
guard needsToGenerate(version: job.version, for: job.image) else { guard needsToGenerate(version) else {
return true return true
} }
guard let data = storage.fileData(for: job.image) else { guard let data = version.image.dataContent() else {
print("Failed to load image \(job.image)") print("ImageGenerator: Failed to load data for image \(version.image.id)")
return false return false
} }
guard let originalImage = NSImage(data: data) else { guard let originalImage = NSImage(data: data) else {
print("Failed to load image") print("ImageGenerator: Failed to load image \(version.image.id)")
return false return false
} }
let representation = create(image: originalImage, width: CGFloat(job.maximumWidth), height: CGFloat(job.maximumHeight)) let representation = create(image: originalImage, width: CGFloat(version.maximumWidth), height: CGFloat(version.maximumHeight))
guard let data = create(image: representation, type: job.type, quality: job.quality) else { guard let data = create(image: representation, type: version.type, quality: version.quality) else {
print("Failed to get data for type \(job.type)") print("ImageGenerator: Failed to get data for type \(version.type) of image \(version.image.id)")
return false return false
} }
if job.type == .avif { if version.type == .avif {
let input = job.version.fileNameAndExtension.fileName + "." + job.image.fileExtension! // AVIF conversion is very slow, so we save bash commands
print("avifenc -q 70 \(input) \(job.version)") // for the conversion instead
hasNowGenerated(version: job.version, for: job.image) let baseVersion = ImageVersion(
image: version.image,
type: version.image.type,
maximumWidth: version.maximumWidth,
maximumHeight: version.maximumHeight)
let originalImagePath = storage.outputPath(to: baseVersion.outputPath)!.path()
let generatedImagePath = storage.outputPath(to: version.outputPath)!.path()
let quality = Int(version.quality * 100)
avifCommands.insert("avifenc -q \(quality) '\(originalImagePath)' '\(generatedImagePath)'")
// hasNowGenerated(version)
return true return true
} }
guard write(imageData: data, version: job.version) else { guard write(imageData: data, of: version) else {
return false return false
} }
hasNowGenerated(version: job.version, for: job.image) hasNowGenerated(version)
return true return true
} }

View File

@ -1,73 +0,0 @@
import Foundation
struct ImageGenerationJob {
let image: String
let type: FileType
let maximumWidth: Int
let maximumHeight: Int
let quality: CGFloat
init(image: String, type: FileType, maximumWidth: CGFloat, maximumHeight: CGFloat, quality: CGFloat = 0.7) {
self.image = image
self.type = type
self.maximumWidth = Int(maximumWidth)
self.maximumHeight = Int(maximumHeight)
self.quality = quality
}
init(image: String, type: FileType, maximumWidth: Int, maximumHeight: Int, quality: CGFloat = 0.7) {
self.image = image
self.type = type
self.maximumWidth = maximumWidth
self.maximumHeight = maximumHeight
self.quality = quality
}
var version: String {
let fileName = image.fileNameAndExtension.fileName
let prefix = "\(fileName)@\(maximumWidth)x\(maximumHeight)"
return "\(prefix).\(type.fileExtension)"
}
static func imageSet(for image: String, maxWidth: Int, maxHeight: Int, quality: CGFloat = 0.7) -> [ImageGenerationJob] {
let type = FileType(fileExtension: image.fileExtension)
let width2x = maxWidth * 2
let height2x = maxHeight * 2
return [
.init(image: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x, quality: quality),
.init(image: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x, quality: quality),
.init(image: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: type, maximumWidth: width2x, maximumHeight: height2x, quality: quality)
]
}
}
extension ImageGenerationJob: Equatable {
static func == (lhs: ImageGenerationJob, rhs: ImageGenerationJob) -> Bool {
lhs.version == rhs.version
}
}
extension ImageGenerationJob: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(version)
}
}
extension ImageGenerationJob: Comparable {
static func < (lhs: ImageGenerationJob, rhs: ImageGenerationJob) -> Bool {
lhs.version < rhs.version
}
}

View File

@ -0,0 +1,52 @@
import Foundation
struct ImageSet {
let image: FileResource
let maxWidth: Int
let maxHeight: Int
let quality: CGFloat
let description: String
init(image: FileResource, maxWidth: Int, maxHeight: Int, description: String, quality: CGFloat = 0.7) {
self.image = image
self.maxWidth = maxWidth
self.maxHeight = maxHeight
self.description = description
self.quality = quality
}
var jobs: [ImageVersion] {
let type = image.type
let width2x = maxWidth * 2
let height2x = maxHeight * 2
return [
.init(image: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x, quality: quality),
.init(image: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x, quality: quality),
.init(image: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight, quality: quality),
.init(image: image, type: type, maximumWidth: width2x, maximumHeight: height2x, quality: quality)
]
}
var content: String {
let fileExtension = image.type.fileExtension.map { "." + $0 } ?? ""
let prefix1x = "/\(image.outputImageFolder)/\(maxWidth)x\(maxHeight)"
let prefix2x = "/\(image.outputImageFolder)/\(maxWidth*2)x\(maxHeight*2)"
var result = "<picture>"
result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>"
result += "<source type='image/webp' srcset='\(prefix1x).webm 1x, \(prefix1x).webm 2x'/>"
result += "<img srcset='\(prefix2x)\(fileExtension) 2x' src='\(prefix1x)\(fileExtension)' loading='lazy' alt='\(description.htmlEscaped())'/>"
result += "</picture>"
return result
}
}

View File

@ -0,0 +1,78 @@
import Foundation
/**
A version of an image with a specific size and possible different image type.
*/
struct ImageVersion {
/// The name of the image file to convert
let image: FileResource
let type: FileType
let maximumWidth: Int
let maximumHeight: Int
let quality: CGFloat
init(image: FileResource, type: FileType, maximumWidth: CGFloat, maximumHeight: CGFloat, quality: CGFloat = 0.7) {
self.image = image
self.type = type
self.maximumWidth = Int(maximumWidth)
self.maximumHeight = Int(maximumHeight)
self.quality = quality
}
init(image: FileResource, type: FileType, maximumWidth: Int, maximumHeight: Int, quality: CGFloat = 0.7) {
self.image = image
self.type = type
self.maximumWidth = maximumWidth
self.maximumHeight = maximumHeight
self.quality = quality
}
/// A unique id of the version for this image (not unique across images)
var versionId: String {
"\(maximumWidth)-\(maximumHeight)-\(type.fileExtension!)"
}
/// The path of the generated image version in the output folder (without leading slash)
var outputPath: String {
image.outputPath(width: maximumWidth, height: maximumHeight, type: type)
}
}
extension ImageVersion: Identifiable {
var id: String {
image.id + "-" + versionId
}
}
extension ImageVersion: Equatable {
static func == (lhs: ImageVersion, rhs: ImageVersion) -> Bool {
lhs.image.id == rhs.image.id &&
lhs.maximumWidth == rhs.maximumWidth &&
lhs.maximumHeight == rhs.maximumHeight &&
lhs.type == rhs.type
}
}
extension ImageVersion: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(image.id)
hasher.combine(maximumWidth)
hasher.combine(maximumHeight)
hasher.combine(type)
}
}
extension ImageVersion: Comparable {
static func < (lhs: ImageVersion, rhs: ImageVersion) -> Bool {
lhs.id < rhs.id
}
}

View File

@ -39,8 +39,6 @@ extension KnownHeaderElement: Comparable {
static func < (lhs: KnownHeaderElement, rhs: KnownHeaderElement) -> Bool { static func < (lhs: KnownHeaderElement, rhs: KnownHeaderElement) -> Bool {
lhs.rawValue < rhs.rawValue lhs.rawValue < rhs.rawValue
} }
} }
extension KnownHeaderElement: CustomStringConvertible { extension KnownHeaderElement: CustomStringConvertible {

View File

@ -50,12 +50,19 @@ struct AudioPlayerCommandProcessor: CommandProcessor {
results.missing(file: song.cover, containedIn: file) results.missing(file: song.cover, containedIn: file)
continue continue
} }
guard image.type.isImage else {
results.warning("Cover '\(song.cover)' in file \(fileId) is not an image file")
continue
}
guard let audioFile = content.file(song.file) else { guard let audioFile = content.file(song.file) else {
results.missing(file: song.cover, containedIn: file) results.missing(file: song.cover, containedIn: file)
continue continue
} }
#warning("Check if file is audio") guard audioFile.type.isAudio else {
results.warning("Song '\(song.file)' in file \(fileId) is not an audio file")
continue
}
let coverUrl = image.absoluteUrl let coverUrl = image.absoluteUrl
let playlistItem = AudioPlayer.PlaylistItem( let playlistItem = AudioPlayer.PlaylistItem(

View File

@ -106,7 +106,8 @@ struct PageHtmlProcessor: CommandProcessor {
results.warning("Failed to find <source> elements in inline HTML: \(error)") results.warning("Failed to find <source> elements in inline HTML: \(error)")
return return
} }
checkSourceSetAttributes(sources: sources)
checkSourceAttributes(sources: sources)
} }
private func checkSourceSetAttributes(sources: [Element]) { private func checkSourceSetAttributes(sources: [Element]) {

View File

@ -29,7 +29,8 @@ final class FeedPageGenerator {
showTitle: Bool, showTitle: Bool,
pageNumber: Int, pageNumber: Int,
totalPages: Int, totalPages: Int,
languageButtonUrl: String) -> String { languageButtonUrl: String,
linkPrefix: String) -> String {
var headers = content.defaultPageHeaders var headers = content.defaultPageHeaders
var footer = "" var footer = ""
if posts.contains(where: { $0.images.count > 1 }) { if posts.contains(where: { $0.images.count > 1 }) {
@ -63,7 +64,10 @@ final class FeedPageGenerator {
content += FeedEntry(data: post).content content += FeedEntry(data: post).content
} }
if totalPages > 1 { if totalPages > 1 {
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content content += PostFeedPageNavigation(
linkPrefix: linkPrefix,
currentPage: pageNumber,
numberOfPages: totalPages).content
} }
} }
return page.content return page.content

View File

@ -6,12 +6,11 @@ final class PageGenerator {
self.content = content self.content = content
} }
private func makeHeaders(requiredItems: Set<KnownHeaderElement>) -> Set<HeaderElement> { private func makeHeaders(requiredItems: Set<KnownHeaderElement>, results: PageGenerationResults) -> Set<HeaderElement> {
var result = content.defaultPageHeaders var result = content.defaultPageHeaders
for item in requiredItems { for item in requiredItems {
guard let header = item.header(content: content) else { guard let header = item.header(content: content) else {
print("Missing header \(item)") results.warning("Header \(item) not configured in settings")
#warning("Add warning on missing file assignment")
continue continue
} }
result.insert(header) result.insert(header)
@ -20,6 +19,7 @@ final class PageGenerator {
} }
private func makeEmptyPageContent(in language: ContentLanguage) -> String { private func makeEmptyPageContent(in language: ContentLanguage) -> String {
#warning("Configure empty page text in settings")
switch language { switch language {
case .english: case .english:
return ContentBox( return ContentBox(
@ -56,7 +56,7 @@ final class PageGenerator {
url: tag.absoluteUrl(in: language)) url: tag.absoluteUrl(in: language))
} }
let headers = makeHeaders(requiredItems: results.requiredHeaders) let headers = makeHeaders(requiredItems: results.requiredHeaders, results: results)
results.require(files: headers.compactMap { $0.file }) results.require(files: headers.compactMap { $0.file })
let iconUrl = content.settings.navigation.localized(in: language).rootUrl let iconUrl = content.settings.navigation.localized(in: language).rootUrl

View File

@ -0,0 +1,74 @@
final class TagOverviewGenerator {
let content: Content
let language: ContentLanguage
let results: PageGenerationResults
init(content: Content, language: ContentLanguage, results: PageGenerationResults) {
self.content = content
self.language = language
self.results = results
}
func generatePage(tags: [Tag], overview: TagOverviewPage) {
let iconUrl = content.settings.navigation.localized(in: language).rootUrl
let languageUrl = overview.absoluteUrl(in: language.next)
let languageButton = NavigationBar.Link(
text: language.next.rawValue,
url: languageUrl)
let localized = overview.localized(in: language)
let pageHeader = PageHeader(
language: language,
title: localized.linkPreviewTitle ?? localized.title,
description: localized.linkPreviewDescription,
iconUrl: iconUrl,
languageButton: languageButton,
links: content.navigationBar(in: language),
headers: content.defaultPageHeaders,
icons: [])
let page = GenericPage(
header: pageHeader,
additionalFooter: "") { content in
content += "<h1>\(localized.title)</h1>"
for tag in tags {
let localized = tag.localized(in: self.language)
let url = tag.absoluteUrl(in: self.language)
let title = localized.name
let description = localized.description ?? ""
let image = self.makePageImage(item: localized)
content += RelatedPageLink(
title: title,
description: description,
url: url,
image: image)
.content
}
// if totalPages > 1 {
// content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
// }
}
let fileContent = page.content
let url = overview.absoluteUrl(in: language) + ".html"
guard content.storage.write(fileContent, to: url) else {
results.unsavedOutput(url, source: .tagOverview)
return
}
}
private func makePageImage(item: LinkPreviewItem) -> ImageSet? {
item.linkPreviewImage.map { image in
let size = content.settings.pages.pageLinkImageSize
let imageSet = image.imageSet(width: size, height: size, language: language)
results.require(imageSet: imageSet)
return imageSet
}
}
}

View File

@ -160,20 +160,11 @@ final class PageContentParser {
guard !image.type.isSvg else { guard !image.type.isSvg else {
return SvgImage(imagePath: path, altText: altText).content return SvgImage(imagePath: path, altText: altText).content
} }
let thumbnail = image.imageSet(width: thumbnailWidth, height: thumbnailWidth, language: language)
results.require(imageSet: thumbnail)
let thumbnail = FeedEntryData.Image( let largeImage = image.imageSet(width: largeImageWidth, height: largeImageWidth, language: language)
rawImagePath: path, results.require(imageSet: largeImage)
width: thumbnailWidth,
height: thumbnailWidth,
altText: altText)
results.requireImageSet(for: image, size: thumbnailWidth)
let largeImage = FeedEntryData.Image(
rawImagePath: path,
width: largeImageWidth,
height: largeImageWidth,
altText: altText)
results.requireImageSet(for: image, size: largeImageWidth)
return PageImage( return PageImage(
imageId: imageId.replacingOccurrences(of: ".", with: "-"), imageId: imageId.replacingOccurrences(of: ".", with: "-"),
@ -221,18 +212,18 @@ final class PageContentParser {
results.invalid(command: .video, markdown) results.invalid(command: .video, markdown)
return nil return nil
} }
if case let .poster(imageId) = option { switch option {
case .poster(let imageId):
if let image = content.image(imageId) { if let image = content.image(imageId) {
results.used(file: image)
let width = 2*thumbnailWidth let width = 2*thumbnailWidth
let fullLink = WebsiteImage.imagePath(source: image.absoluteUrl, width: width, height: width) let version = image.imageVersion(width: width, height: width, type: .jpg)
return .poster(image: fullLink) results.require(image: version)
return .poster(image: version.outputPath)
} else { } else {
results.missing(file: imageId, source: "Video command poster") results.missing(file: imageId, source: "Video command poster")
return nil // Image file not present, so skip the option return nil // Image file not present, so skip the option
} }
} case .src(let videoId):
if case let .src(videoId) = option {
if let video = content.video(videoId) { if let video = content.video(videoId) {
results.used(file: video) results.used(file: video)
let link = video.absoluteUrl let link = video.absoluteUrl
@ -241,9 +232,10 @@ final class PageContentParser {
results.missing(file: videoId, source: "Video command source") results.missing(file: videoId, source: "Video command source")
return nil // Video file not present, so skip the option return nil // Video file not present, so skip the option
} }
} default:
return option return option
} }
}
/** /**
Format: `![page](<pageId>)` Format: `![page](<pageId>)`
@ -270,17 +262,7 @@ final class PageContentParser {
let url = page.absoluteUrl(in: language) let url = page.absoluteUrl(in: language)
let title = localized.linkPreviewTitle ?? localized.title let title = localized.linkPreviewTitle ?? localized.title
let description = localized.linkPreviewDescription ?? "" let description = localized.linkPreviewDescription ?? ""
let image = makePageImage(item: localized)
let image = localized.linkPreviewImage.map { image in
let size = content.settings.pages.pageLinkImageSize
results.used(file: image)
results.requireImageSet(for: image, size: size)
return RelatedPageLink.Image(
url: image.absoluteUrl,
description: image.localized(in: language),
size: size)
}
return RelatedPageLink( return RelatedPageLink(
title: title, title: title,
@ -309,16 +291,7 @@ final class PageContentParser {
let url = tag.absoluteUrl(in: language) let url = tag.absoluteUrl(in: language)
let title = localized.name let title = localized.name
let description = localized.description ?? "" let description = localized.description ?? ""
let image = makePageImage(item: localized)
let image = localized.linkPreviewImage.map { image in
let size = content.settings.pages.pageLinkImageSize
results.requireImageSet(for: image, size: size)
return RelatedPageLink.Image(
url: image.absoluteUrl,
description: image.localized(in: language),
size: size)
}
return RelatedPageLink( return RelatedPageLink(
title: title, title: title,
@ -328,6 +301,15 @@ final class PageContentParser {
.content .content
} }
private func makePageImage(item: LinkPreviewItem) -> ImageSet? {
item.linkPreviewImage.map { image in
let size = content.settings.pages.pageLinkImageSize
let imageSet = image.imageSet(width: size, height: size, language: language)
results.require(imageSet: imageSet)
return imageSet
}
}
/** /**
Format: `![model](<file>)` Format: `![model](<file>)`
*/ */

View File

@ -64,7 +64,7 @@ final class PageGenerationResults: ObservableObject {
private(set) var requiredFiles: Set<FileResource> private(set) var requiredFiles: Set<FileResource>
/// The image versions required for this page /// The image versions required for this page
private(set) var imagesToGenerate: Set<ImageGenerationJob> private(set) var imagesToGenerate: Set<ImageVersion>
private(set) var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = [] private(set) var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = []
@ -127,10 +127,16 @@ final class PageGenerationResults: ObservableObject {
delegate.missing(file: file) delegate.missing(file: file)
} }
func requireImageSet(for image: FileResource, size: Int) { func require(image: ImageVersion) {
let jobs = ImageGenerationJob.imageSet(for: image.id, maxWidth: size, maxHeight: size) imagesToGenerate.insert(image)
used(file: image.image)
delegate.generate(image)
}
func require(imageSet: ImageSet) {
let jobs = imageSet.jobs
imagesToGenerate.formUnion(jobs) imagesToGenerate.formUnion(jobs)
used(file: image) used(file: imageSet.image)
delegate.generate(jobs) delegate.generate(jobs)
} }

View File

@ -11,6 +11,10 @@ struct FeedGeneratorSource: PostListPageGeneratorSource {
false false
} }
var postsPerPage: Int {
content.settings.posts.postsPerPage
}
var pageTitle: String { var pageTitle: String {
content.settings.localized(in: language).title content.settings.localized(in: language).title
} }

View File

@ -16,12 +16,8 @@ final class PostListPageGenerator {
source.content.settings.posts.contentWidth source.content.settings.posts.contentWidth
} }
private var postsPerPage: Int {
source.content.settings.posts.postsPerPage
}
private func pageUrl(in language: ContentLanguage, pageNumber: Int) -> String { private func pageUrl(in language: ContentLanguage, pageNumber: Int) -> String {
"\(source.pageUrlPrefix(for: language))/\(pageNumber).html" "\(source.pageUrlPrefix(for: language))/\(pageNumber)"
} }
func createPages(for posts: [Post]) { func createPages(for posts: [Post]) {
@ -29,6 +25,7 @@ final class PostListPageGenerator {
guard totalCount > 0 else { guard totalCount > 0 else {
return return
} }
let postsPerPage = source.postsPerPage
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up
for pageIndex in 1...numberOfPages { for pageIndex in 1...numberOfPages {
@ -43,10 +40,11 @@ final class PostListPageGenerator {
let posts: [FeedEntryData] = posts.map { post in let posts: [FeedEntryData] = posts.map { post in
let localized: LocalizedPost = post.localized(in: language) let localized: LocalizedPost = post.localized(in: language)
#warning("Add post link text to settings or to each post")
let linkUrl = post.linkedPage.map { let linkUrl = post.linkedPage.map {
FeedEntryData.Link( FeedEntryData.Link(
url: $0.absoluteUrl(in: language), url: $0.absoluteUrl(in: language),
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings text: language == .english ? "View" : "Anzeigen")
} }
let tags: [FeedEntryData.Tag] = post.tags.filter { $0.isVisible }.map { tag in let tags: [FeedEntryData.Tag] = post.tags.filter { $0.isVisible }.map { tag in
@ -54,7 +52,10 @@ final class PostListPageGenerator {
url: tag.absoluteUrl(in: language)) url: tag.absoluteUrl(in: language))
} }
let images = localized.images.map(createFeedImage) let images = localized.images.map { image in
image.imageSet(width: mainContentMaximumWidth, height: mainContentMaximumWidth, language: language)
}
images.forEach(source.results.require)
return FeedEntryData( return FeedEntryData(
entryId: post.id, entryId: post.id,
@ -68,7 +69,7 @@ final class PostListPageGenerator {
let feedPageGenerator = FeedPageGenerator(content: source.content, results: source.results) let feedPageGenerator = FeedPageGenerator(content: source.content, results: source.results)
let languageButtonUrl = pageUrl(in: language.next, pageNumber: pageIndex) let languageButtonUrl = "/" + pageUrl(in: language.next, pageNumber: pageIndex)
let fileContent = feedPageGenerator.generatePage( let fileContent = feedPageGenerator.generatePage(
language: language, language: language,
@ -78,23 +79,15 @@ final class PostListPageGenerator {
showTitle: source.showTitle, showTitle: source.showTitle,
pageNumber: pageIndex, pageNumber: pageIndex,
totalPages: pageCount, totalPages: pageCount,
languageButtonUrl: languageButtonUrl) languageButtonUrl: languageButtonUrl,
let filePath = pageUrl(in: language, pageNumber: pageIndex) linkPrefix: "/" + source.pageUrlPrefix(for: language) + "/")
let filePath = pageUrl(in: language, pageNumber: pageIndex) + ".html"
guard save(fileContent, to: filePath) else { guard save(fileContent, to: filePath) else {
source.results.unsavedOutput(filePath, source: .feed) source.results.unsavedOutput(filePath, source: .feed)
return return
} }
} }
private func createFeedImage(for image: FileResource) -> FeedEntryData.Image {
source.results.requireImageSet(for: image, size: mainContentMaximumWidth)
return .init(
rawImagePath: image.absoluteUrl,
width: mainContentMaximumWidth,
height: mainContentMaximumWidth,
altText: image.localized(in: language))
}
private func save(_ content: String, to relativePath: String) -> Bool { private func save(_ content: String, to relativePath: String) -> Bool {
source.content.storage.write(content, to: relativePath) source.content.storage.write(content, to: relativePath)
} }

View File

@ -14,4 +14,6 @@ protocol PostListPageGeneratorSource {
var pageDescription: String { get } var pageDescription: String { get }
func pageUrlPrefix(for language: ContentLanguage) -> String func pageUrlPrefix(for language: ContentLanguage) -> String
var postsPerPage: Int { get }
} }

View File

@ -13,6 +13,10 @@ struct TagPageGeneratorSource: PostListPageGeneratorSource {
true true
} }
var postsPerPage: Int {
content.settings.posts.postsPerPage
}
var pageTitle: String { var pageTitle: String {
tag.localized(in: language).name tag.localized(in: language).name
} }

View File

@ -96,7 +96,7 @@ enum VideoOption {
case .height(let height): return "height='\(height)'" case .height(let height): return "height='\(height)'"
case .width(let width): return "width='\(width)'" case .width(let width): return "width='\(width)'"
case .preload(let option): return "preload='\(option)'" case .preload(let option): return "preload='\(option)'"
case .poster(let image): return "poster='\(image)'" case .poster(let image): return "poster='/\(image)'"
case .src(let url): return "src='\(url)'" case .src(let url): return "src='\(url)'"
} }
} }

View File

@ -4,11 +4,11 @@ import SFSafeSymbols
#warning("Fix podcast") #warning("Fix podcast")
#warning("Fix CV") #warning("Fix CV")
#warning("Fix endeavor basics (image compare)") #warning("Fix endeavor basics (image compare)")
#warning("Fix cap mosaic GIF")
#warning("Add custom url string to external files (optional)") #warning("Add custom url string to external files (optional)")
#warning("Show all warnings on page content") #warning("Show all warnings on page content")
#warning("Button to delete file") #warning("Button to delete file, show in finder, replace, mark changed (-> images)")
#warning("Add link to other language")
#warning("Transfer images of posts to other language") #warning("Transfer images of posts to other language")
#warning("Show tag selection view for pages") #warning("Show tag selection view for pages")
#warning("Button to replace files") #warning("Button to replace files")
@ -16,12 +16,13 @@ import SFSafeSymbols
#warning("Calculate file sizes") #warning("Calculate file sizes")
#warning("Specify image aspect ratio to prevent page jumps") #warning("Specify image aspect ratio to prevent page jumps")
#warning("Add version and source url properties to file resources") #warning("Add version and source url properties to file resources")
#warning("Consolidate all errors in Content") #warning("Show errors during loading of content")
#warning("Generate pages for posts") #warning("Generate pages for posts")
#warning("Clean up mock content") #warning("Clean up mock content")
#warning("Show posts linking to a page") #warning("Show posts linking to a page")
#warning("Add author to settings and page headers") #warning("Add author to settings and page headers")
#warning("Mark changed images for generation") #warning("Check for files in output folder not generated by app")
#warning("Fix GIFs: Don't rescale, don't use image set")
@main @main
struct MainView: App { struct MainView: App {

View File

@ -74,12 +74,14 @@ extension Content {
completed += 1 completed += 1
status("Generating required images: \(completed) / \(count)") status("Generating required images: \(completed) / \(count)")
} }
if imageGenerator.generate(job: image) { if imageGenerator.generate(version: image) {
continue continue
} }
results.failed(image: image) results.failed(image: image)
} }
imageGenerator.save()
imageGenerator.printAvifCommands()
//let images = Set(self.images.map { $0.id }) //let images = Set(self.images.map { $0.id })
//imageGenerator.recalculateGeneratedImages(by: images) //imageGenerator.recalculateGeneratedImages(by: images)
} }
@ -244,11 +246,16 @@ extension Content {
- Note: Run on background thread - Note: Run on background thread
*/ */
private func generateTagOverviewPagesInternal() { private func generateTagOverviewPagesInternal() {
guard let tagOverview else {
print("Generator: No tag overview page to generate")
return
}
status("Generating tag overview page") status("Generating tag overview page")
for language in ContentLanguage.allCases { for language in ContentLanguage.allCases {
guard shouldGenerateWebsite else { return } guard shouldGenerateWebsite else { return }
let results = results.makeResults(for: .tagOverview, in: language) let results = results.makeResults(for: .tagOverview, in: language)
#warning("Create layout for tag overview page") let generator = TagOverviewGenerator(content: self, language: language, results: results)
generator.generatePage(tags: tags, overview: tagOverview)
} }
} }

View File

@ -146,7 +146,27 @@ extension Content {
self.files = files.values.sorted { $0.id } self.files = files.values.sorted { $0.id }
self.posts = posts.values.sorted(ascending: false) { $0.startDate } self.posts = posts.values.sorted(ascending: false) { $0.startDate }
self.tagOverview = tagOverview self.tagOverview = tagOverview
self.settings = .init(file: settings, tags: tags, pages: pages, files: files, posts: posts, tagOverview: tagOverview) self.settings = .init(file: settings, files: files) { raw in
#warning("Notify about missing links")
guard let type = ItemType(rawValue: raw, posts: posts, pages: pages, tags: tags) else {
return nil
}
switch type {
case .general:
return nil
case .post(let post):
return post
case .feed:
return nil // TODO: Provide feed object
case .page(let page):
return page
case .tagPage(let tag):
return tag
case .tagOverview:
return tagOverview
}
}
print("Content loaded") print("Content loaded")
} }

View File

@ -34,6 +34,7 @@ final class Content: ObservableObject {
@Published @Published
private(set) var isGeneratingWebsite = false private(set) var isGeneratingWebsite = false
@Published
private(set) var shouldGenerateWebsite = false private(set) var shouldGenerateWebsite = false
init(settings: Settings, init(settings: Settings,

View File

@ -76,6 +76,33 @@ final class FileResource: Item {
Image(systemSymbol: .exclamationmarkTriangle) Image(systemSymbol: .exclamationmarkTriangle)
} }
/// The path to the output folder where image versions are stored (no leading slash)
var outputImageFolder: String {
"\(content.settings.paths.imagesOutputFolderPath)/\(id.fileNameWithoutExtension)"
}
func outputPath(width: Int, height: Int, type: FileType?) -> String {
let prefix = "/\(outputImageFolder)/\(width)x\(height)"
guard let ext = type?.fileExtension else {
return prefix
}
return prefix + "." + ext
}
func imageSet(width: Int, height: Int, language: ContentLanguage, quality: CGFloat = 0.7) -> ImageSet {
let description = self.localized(in: language)
return .init(
image: self,
maxWidth: width,
maxHeight: height,
description: description,
quality: quality)
}
func imageVersion(width: Int, height: Int, type: FileType) -> ImageVersion {
.init(image: self, type: type, maximumWidth: width, maximumHeight: height)
}
// MARK: Paths // MARK: Paths
/** /**

View File

@ -103,6 +103,10 @@ enum FileType: String {
case aac case aac
case m4b
case m4a
// MARK: Other // MARK: Other
case noExtension case noExtension
@ -143,7 +147,7 @@ enum FileType: String {
return .image return .image
case .mp4, .m4v, .webm: case .mp4, .m4v, .webm:
return .video return .video
case .mp3, .aac: case .mp3, .aac, .m4b, .m4a:
return .audio return .audio
case .js, .css, .ttf: case .js, .css, .ttf:
return .asset return .asset
@ -160,65 +164,38 @@ enum FileType: String {
} }
} }
var fileExtension: String { var fileExtension: String? {
switch self { switch self {
case .noExtension, .unknown: return "" case .noExtension, .unknown: return nil
default: default:
return rawValue return rawValue
} }
} }
var isImage: Bool { var isImage: Bool {
switch self { category == .image
case .jpg, .png, .avif, .webp, .gif, .svg, .tiff:
return true
default:
return false
}
} }
var isVideo: Bool { var isVideo: Bool {
switch self { category == .video
case .mp4, .m4v, .webm:
return true
default:
return false
}
} }
var isAudio: Bool { var isAudio: Bool {
switch self { category == .audio
case .mp3, .aac:
return true
default:
return false
}
} }
var isAsset: Bool { var isAsset: Bool {
switch self { category == .asset
case .js, .css, .ttf:
return true
default:
return false
}
} }
var isTextFile: Bool { var isTextFile: Bool {
switch self { switch category {
case .html, .cpp, .swift, .css, .js, .json, .conf, .yaml: case .text, .code: return true
return true default: break
default:
return false
} }
}
var isOtherFile: Bool {
switch self { switch self {
case .noExtension, .zip, .cddx, .pdf, .key, .psd: case .css, .js: return true
return true default: return false
default:
return false
} }
} }

View File

@ -0,0 +1,9 @@
protocol LinkPreviewItem {
var linkPreviewImage: FileResource? { get }
var linkPreviewTitle: String? { get }
var linkPreviewDescription: String? { get }
}

View File

@ -71,3 +71,7 @@ final class LocalizedPage: ObservableObject {
!content.containsPage(withUrlComponent: urlComponent) !content.containsPage(withUrlComponent: urlComponent)
} }
} }
extension LocalizedPage: LinkPreviewItem {
}

View File

@ -44,3 +44,7 @@ final class LocalizedPost: ObservableObject {
self.linkPreviewDescription = linkPreviewDescription self.linkPreviewDescription = linkPreviewDescription
} }
} }
extension LocalizedPost: LinkPreviewItem {
}

View File

@ -45,3 +45,14 @@ final class LocalizedTag: ObservableObject {
!content.containsTag(withUrlComponent: urlComponent) !content.containsTag(withUrlComponent: urlComponent)
} }
} }
extension LocalizedTag: LinkPreviewItem {
var linkPreviewTitle: String? {
self.name
}
var linkPreviewDescription: String? {
description
}
}

View File

@ -12,39 +12,16 @@ final class NavigationSettings: ObservableObject {
@Published @Published
var english: LocalizedNavigationSettings var english: LocalizedNavigationSettings
init(navigationItems: [Item], german: LocalizedNavigationSettings, english: LocalizedNavigationSettings) { init(navigationItems: [Item],
german: LocalizedNavigationSettings,
english: LocalizedNavigationSettings) {
self.navigationItems = navigationItems self.navigationItems = navigationItems
self.german = german self.german = german
self.english = english self.english = english
} }
init(file: NavigationSettingsFile, init(file: NavigationSettingsFile, map: (String) -> Item?) {
tags: [String : Tag], self.navigationItems = file.navigationItems.compactMap(map)
pages: [String : Page],
files: [String : FileResource],
posts: [String : Post],
tagOverview: TagOverviewPage?) {
#warning("Notify about missing links")
self.navigationItems = file.navigationItems.compactMap { raw in
guard let type = ItemType(rawValue: raw, posts: posts, pages: pages, tags: tags) else {
return nil
}
switch type {
case .general:
return nil
case .post(let post):
return post
case .feed:
return nil // TODO: Provide feed object
case .page(let page):
return page
case .tagPage(let tag):
return tag
case .tagOverview:
return tagOverview
}
}
self.german = LocalizedNavigationSettings(file: file.german) self.german = LocalizedNavigationSettings(file: file.german)
self.english = LocalizedNavigationSettings(file: file.english) self.english = LocalizedNavigationSettings(file: file.english)
} }

View File

@ -37,20 +37,8 @@ final class Settings: ObservableObject {
} }
} }
init(file: SettingsFile, init(file: SettingsFile, files: [String : FileResource], map: (String) -> Item?) {
tags: [String : Tag], self.navigation = NavigationSettings(file: file.navigation, map: map)
pages: [String : Page],
files: [String : FileResource],
posts: [String : Post],
tagOverview: TagOverviewPage?) {
self.navigation = NavigationSettings(
file: file.navigation,
tags: tags,
pages: pages,
files: files,
posts: posts,
tagOverview: tagOverview)
self.posts = PostSettings(file: file.posts, files: files) self.posts = PostSettings(file: file.posts, files: files)
self.pages = PageSettings(file: file.pages, files: files) self.pages = PageSettings(file: file.pages, files: files)

View File

@ -1,39 +1,32 @@
struct RelatedPageLink { /**
An element showing a box with info about a related page.
struct Image { Contains an optional thumbnail image, a title and a description.
*/
let url: String struct RelatedPageLink: HtmlProducer {
let description: String
let size: Int
}
/// The title of the linked page
let title: String let title: String
/// A short description of the linked page
let description: String let description: String
/// The url to the linked page
let url: String let url: String
let image: Image? /// The optional thumbnail image to display
let image: ImageSet?
var content: String { func populate(_ result: inout String) {
var result = ""
result += "<a href='\(url)' class='related-box-wrapper'>" result += "<a href='\(url)' class='related-box-wrapper'>"
result += "<div class='related-box'>" result += "<div class='related-box'>"
if let image { if let image {
result += WebsiteImage( result += image.content
rawImagePath: image.url,
width: image.size,
height: image.size,
altText: image.description)
.content
} }
result += "<div class='related-content'>" result += "<div class='related-content'>"
result += "<h3>\(title)</h3>" result += "<h3>\(title)</h3>"
result += "<p>\(description)</p>" result += "<p>\(description)</p>"
result += "</div></div></a>" // Close related-box-wrapper, related-box result += "</div></div></a>" // Close related-box-wrapper, related-box
return result
} }
} }

View File

@ -14,8 +14,7 @@ struct FeedEntry {
var content: String { var content: String {
var result = "<article><div class='card\(cardLinkClassText)'>" var result = "<article><div class='card\(cardLinkClassText)'>"
ImageGallery(id: data.entryId, images: data.images) ImageGallery(id: data.entryId, images: data.images).populate(&result)
.addContent(to: &result)
if let url = data.link?.url { if let url = data.link?.url {
result += "<div class='card-content' onclick=\"window.location.href='\(url)'\">" result += "<div class='card-content' onclick=\"window.location.href='\(url)'\">"

View File

@ -13,9 +13,9 @@ struct FeedEntryData {
let text: [String] let text: [String]
let images: [Image] let images: [ImageSet]
init(entryId: String, title: String?, textAboveTitle: String, link: Link?, tags: [Tag], text: [String], images: [Image]) { init(entryId: String, title: String?, textAboveTitle: String, link: Link?, tags: [Tag], text: [String], images: [ImageSet]) {
self.entryId = entryId self.entryId = entryId
self.title = title self.title = title
self.textAboveTitle = textAboveTitle self.textAboveTitle = textAboveTitle
@ -40,16 +40,4 @@ struct FeedEntryData {
let url: String let url: String
} }
struct Image {
let rawImagePath: String
let width: Int
let height: Int
let altText: String
}
} }

View File

@ -1,49 +1,50 @@
import Foundation import Foundation
struct ImageGallery { /**
An element showing a selection of images one by one using navigation buttons.
*/
struct ImageGallery: HtmlProducer {
/// The unique id to distinguish different galleries in HTML and JavaScript
let id: String let id: String
let images: [FeedEntryData.Image] /// The images to display
let images: [ImageSet]
/// A version of the id that is safe to use in HTML and JavaScript
private var htmlSafeId: String { private var htmlSafeId: String {
ImageGallery.htmlSafe(id) ImageGallery.htmlSafe(id)
} }
init(id: String, images: [FeedEntryData.Image]) { init(id: String, images: [ImageSet]) {
self.id = id self.id = id
self.images = images self.images = images
} }
func addContent(to result: inout String) { func populate(_ result: inout String) {
guard !images.isEmpty else { guard !images.isEmpty else {
return return
} }
result += "<div id='\(htmlSafeId)' class='swiper'><div class='swiper-wrapper'>" result += "<div id='\(htmlSafeId)' class='swiper'><div class='swiper-wrapper'>"
guard images.count > 1 else { let needsPagination = images.count > 1
result += "<div class='swiper-slide'>"
result += WebsiteImage(image: images[0]).content
result += "</div></div></div>" // Close swiper-slide, swiper, swiper-wrapper
return
}
for image in images { for image in images {
// TODO: Use different images based on device
result += "<div class='swiper-slide'>" result += "<div class='swiper-slide'>"
result += image.content
result += WebsiteImage(image: image).content if needsPagination {
result += "<div class='swiper-lazy-preloader swiper-lazy-preloader-white'></div>" result += "<div class='swiper-lazy-preloader swiper-lazy-preloader-white'></div>"
}
result += "</div>" // Close swiper-slide result += "</div>" // Close swiper-slide
} }
result += "</div>" // Close swiper-wrapper result += "</div>" // Close swiper-wrapper
if needsPagination {
result += "<div class='swiper-button-next'></div>" result += "<div class='swiper-button-next'></div>"
result += "<div class='swiper-button-prev'></div>" result += "<div class='swiper-button-prev'></div>"
result += "<div class='swiper-pagination'></div>" result += "<div class='swiper-pagination'></div>"
}
result += "</div>" // Close swiper result += "</div>" // Close swiper
} }

View File

@ -1,27 +1,34 @@
import Foundation import Foundation
struct PageImage { /**
An image that is part of the page content.
A tap/click on the image shows a fullscreen version of the image, including an optional caption.
*/
struct PageImage: HtmlProducer {
/// The HTML id attribute used to enable fullscreen images
let imageId: String let imageId: String
let thumbnail: FeedEntryData.Image /// The small version of the image visible on the page
let thumbnail: ImageSet
let largeImage: FeedEntryData.Image /// The large version of the image for fullscreen view
let largeImage: ImageSet
/// The optional caption text below the fullscreen image
let caption: String? let caption: String?
var content: String { func populate(_ result: inout String) {
var result = ""
result += "<div class='content-image' onclick=\"document.getElementById('\(imageId)').classList.add('active')\">" result += "<div class='content-image' onclick=\"document.getElementById('\(imageId)').classList.add('active')\">"
result += WebsiteImage(image: thumbnail).content result += thumbnail.content
result += "</div>" result += "</div>"
result += "<div id='\(imageId)' class='fullscreen-image' onclick=\"document.getElementById('\(imageId)').classList.remove('active')\">" result += "<div id='\(imageId)' class='fullscreen-image' onclick=\"document.getElementById('\(imageId)').classList.remove('active')\">"
result += WebsiteImage(image: largeImage).content result += largeImage.content
if let caption { if let caption {
result += "<div class='caption'>\(caption)</div>" result += "<div class='caption'>\(caption)</div>"
} }
result += "<div class='close'></div>" result += "<div class='close'></div>"
result += "</div>" result += "</div>"
return result
} }
} }

View File

@ -2,30 +2,20 @@ import Foundation
struct PostFeedPageNavigation { struct PostFeedPageNavigation {
let language: ContentLanguage let linkPrefix: String
let currentPage: Int let currentPage: Int
let numberOfPages: Int let numberOfPages: Int
init(currentPage: Int, numberOfPages: Int, language: ContentLanguage) { init(linkPrefix: String, currentPage: Int, numberOfPages: Int) {
self.linkPrefix = linkPrefix
self.currentPage = currentPage self.currentPage = currentPage
self.numberOfPages = numberOfPages self.numberOfPages = numberOfPages
self.language = language
} }
private func pageLink(_ page: Int) -> String { private func pageLink(_ page: Int) -> String {
guard page > 1 else { return "href='/feed'" } "href='\(linkPrefix)\(page)'"
return "href='/feed-\(page)'"
}
private func previousText() -> String {
switch language {
case .english:
return "Previous"
case .german:
return "Zurück"
}
} }
private func addPreviousButton(to result: inout String) { private func addPreviousButton(to result: inout String) {

View File

@ -1,48 +0,0 @@
struct WebsiteImage {
static func imagePath(prefix: String, width: Int, height: Int) -> String {
"\(prefix)@\(width)x\(height)"
}
static func imagePath(prefix: String, extension fileExtension: String, width: Int, height: Int) -> String {
"\(prefix)@\(width)x\(height).\(fileExtension)"
}
static func imagePath(source: String, width: Int, height: Int) -> String {
let (prefix, ext) = source.fileNameAndExtension
return imagePath(prefix: prefix, extension: ext ?? ".jpg", width: width, height: height)
}
private let prefix1x: String
private let prefix2x: String
private let altText: String
private let ext: String
init(image: FeedEntryData.Image) {
self.init(rawImagePath: image.rawImagePath,
width: image.width,
height: image.height,
altText: image.altText)
}
init(rawImagePath: String, width: Int, height: Int, altText: String) {
let (prefix, ext) = rawImagePath.fileNameAndExtension
self.prefix1x = WebsiteImage.imagePath(prefix: prefix, width: width, height: height)
self.prefix2x = WebsiteImage.imagePath(prefix: prefix, width: 2*width, height: 2*height)
self.altText = altText.htmlEscaped()
self.ext = ext ?? "jpg"
}
var content: String {
var result = "<picture>"
result += "<source type='image/avif' srcset='\(prefix1x).avif 1x, \(prefix2x).avif 2x'/>"
result += "<source type='image/webp' srcset='\(prefix1x).webm 1x, \(prefix1x).webm 2x'/>"
result += "<img srcset='\(prefix2x).\(ext) 2x' src='\(prefix1x).\(ext)' loading='lazy' alt='\(altText)'/>"
result += "</picture>"
return result
}
}

View File

@ -28,6 +28,10 @@ struct SecurityBookmark {
// MARK: Write // MARK: Write
func fullPath(to relativePath: String) -> URL {
url.appending(path: relativePath, directoryHint: .notDirectory)
}
/** /**
Write the data of an encodable value to a relative path in the content folder, Write the data of an encodable value to a relative path in the content folder,
or delete the file if nil is passed. or delete the file if nil is passed.
@ -68,7 +72,7 @@ struct SecurityBookmark {
createParentFolder: Bool = true, createParentFolder: Bool = true,
ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool { ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool {
perform { url in perform { url in
let file = url.appending(path: relativePath, directoryHint: .notDirectory) let file = fullPath(to: relativePath)
if exists(file) { if exists(file) {
switch overwrite { switch overwrite {

View File

@ -56,7 +56,6 @@ final class Storage: ObservableObject {
// MARK: Pages // MARK: Pages
private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String { private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String {
"\(id)-\(language.rawValue).md" "\(id)-\(language.rawValue).md"
} }
@ -236,6 +235,23 @@ final class Storage: ObservableObject {
// MARK: Files // MARK: Files
/**
The full path to a resource file in the content folder
- Parameter file: The filename of the file
- Note: Only for resource files, since path is relative to files folder
*/
func path(toFile file: String) -> URL? {
contentScope?.fullPath(to: filePath(file: file))
}
/**
The full file path to a file in the output folder
- Parameter relativePath: The path of the file relative to the output folder
*/
func outputPath(to relativePath: String) -> URL? {
outputScope?.fullPath(to: relativePath)
}
private func filePath(file fileId: String) -> String { private func filePath(file fileId: String) -> String {
filesFolderName + "/" + fileId filesFolderName + "/" + fileId
} }
@ -337,6 +353,7 @@ final class Storage: ObservableObject {
} }
print("Found \(allImages.count) generated images") print("Found \(allImages.count) generated images")
let images = Set(allImages) let images = Set(allImages)
#warning("TODO: Fix counting generated images")
return imageSet.reduce(into: [:]) { result, imageName in return imageSet.reduce(into: [:]) { result, imageName in
let prefix = imageName.fileNameWithoutExtension + "@" let prefix = imageName.fileNameWithoutExtension + "@"
let versions = images.filter { $0.hasPrefix(prefix) } let versions = images.filter { $0.hasPrefix(prefix) }

View File

@ -1,34 +1,5 @@
import SwiftUI import SwiftUI
enum FileFilterType: String, Hashable, CaseIterable, Identifiable {
case images
case text
case videos
case other
var text: String {
switch self {
case .images: return "Image"
case .text: return "Text"
case .videos: return "Video"
case .other: return "Other"
}
}
var id: String {
rawValue
}
func matches(_ type: FileType) -> Bool {
switch self {
case .images: return type.isImage
case .text: return type.isTextFile
case .videos: return type.isVideo
case .other: return type.isOtherFile
}
}
}
struct FileListView: View { struct FileListView: View {
@EnvironmentObject @EnvironmentObject
@ -38,21 +9,23 @@ struct FileListView: View {
var selectedFile: FileResource? var selectedFile: FileResource?
@State @State
private var selectedFileType: FileFilterType private var selectedFileType: FileTypeCategory? = nil
@State @State
private var searchString = "" private var searchString = ""
let allowedType: FileFilterType? let allowedType: FileTypeCategory?
init(selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) { init(selectedFile: Binding<FileResource?>, allowedType: FileTypeCategory? = nil) {
self._selectedFile = selectedFile self._selectedFile = selectedFile
self.allowedType = allowedType self.allowedType = allowedType
self.selectedFileType = allowedType ?? .images
} }
var filesBySelectedType: [FileResource] { var filesBySelectedType: [FileResource] {
content.files.filter { selectedFileType.matches($0.type) } guard let selectedFileType else {
return content.files
}
return content.files.filter { selectedFileType == $0.type.category }
} }
var filteredFiles: [FileResource] { var filteredFiles: [FileResource] {
@ -64,14 +37,17 @@ struct FileListView: View {
var body: some View { var body: some View {
VStack(alignment: .center) { VStack(alignment: .center) {
if allowedType == nil {
Picker("", selection: $selectedFileType) { Picker("", selection: $selectedFileType) {
ForEach(FileFilterType.allCases) { type in let all: FileTypeCategory? = nil
Text("All").tag(all)
ForEach(FileTypeCategory.allCases) { type in
Text(type.text).tag(type) Text(type.text).tag(type)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.menu)
.padding(.trailing, 7) .padding(.trailing, 7)
.disabled(allowedType != nil) }
TextField("", text: $searchString, prompt: Text("Search")) TextField("", text: $searchString, prompt: Text("Search"))
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.padding(.horizontal, 8) .padding(.horizontal, 8)
@ -93,7 +69,7 @@ struct FileListView: View {
return return
} }
if newValue.matches(selectedFile.type) { if newValue == selectedFile.type.category {
return return
} }
DispatchQueue.main.async { DispatchQueue.main.async {
@ -102,10 +78,11 @@ struct FileListView: View {
} }
} }
.onAppear { .onAppear {
if selectedFile == nil { if let allowedType {
DispatchQueue.main.async { selectedFileType = allowedType
selectedFile = content.files.first
} }
if selectedFile == nil {
selectedFile = content.files.first
} }
} }
} }

View File

@ -8,9 +8,9 @@ struct FileSelectionView: View {
@Binding @Binding
private var selectedFile: FileResource? private var selectedFile: FileResource?
let allowedType: FileFilterType? let allowedType: FileTypeCategory?
init(selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) { init(selectedFile: Binding<FileResource?>, allowedType: FileTypeCategory? = nil) {
self._selectedFile = selectedFile self._selectedFile = selectedFile
self.newSelection = selectedFile.wrappedValue self.newSelection = selectedFile.wrappedValue
self.allowedType = allowedType self.allowedType = allowedType

View File

@ -11,12 +11,12 @@ struct MultiFileSelectionView: View {
@Binding @Binding
private var selectedFiles: [FileResource] private var selectedFiles: [FileResource]
let allowedType: FileFilterType? let allowedType: FileTypeCategory?
let insertSorted: Bool let insertSorted: Bool
@State @State
private var selectedFileType: FileFilterType private var selectedFileType: FileTypeCategory?
@State @State
private var searchString = "" private var searchString = ""
@ -24,16 +24,19 @@ struct MultiFileSelectionView: View {
@State @State
private var newSelection: [FileResource] private var newSelection: [FileResource]
init(selectedFiles: Binding<[FileResource]>, allowedType: FileFilterType? = nil, insertSorted: Bool = false) { init(selectedFiles: Binding<[FileResource]>, allowedType: FileTypeCategory? = nil, insertSorted: Bool = false) {
self._selectedFiles = selectedFiles self._selectedFiles = selectedFiles
self.newSelection = selectedFiles.wrappedValue self.newSelection = selectedFiles.wrappedValue
self.allowedType = allowedType self.allowedType = allowedType
self.selectedFileType = allowedType ?? .images self.selectedFileType = allowedType ?? .image
self.insertSorted = insertSorted self.insertSorted = insertSorted
} }
private var filesBySelectedType: [FileResource] { private var filesBySelectedType: [FileResource] {
content.files.filter { selectedFileType.matches($0.type) } guard let selectedFileType else {
return content.files
}
return content.files.filter { selectedFileType == $0.type.category }
} }
private var filteredFiles: [FileResource] { private var filteredFiles: [FileResource] {
@ -75,7 +78,9 @@ struct MultiFileSelectionView: View {
} }
VStack { VStack {
Picker("", selection: $selectedFileType) { Picker("", selection: $selectedFileType) {
ForEach(FileFilterType.allCases) { type in let all: FileTypeCategory? = nil
Text("All").tag(all)
ForEach(FileTypeCategory.allCases) { type in
Text(type.text).tag(type) Text(type.text).tag(type)
} }
} }

View File

@ -9,9 +9,9 @@ struct FilePropertyView: View {
@Binding @Binding
var selectedFile: FileResource? var selectedFile: FileResource?
let allowedType: FileFilterType? let allowedType: FileTypeCategory?
init(title: LocalizedStringKey, footer: LocalizedStringKey, selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) { init(title: LocalizedStringKey, footer: LocalizedStringKey, selectedFile: Binding<FileResource?>, allowedType: FileTypeCategory? = nil) {
self.title = title self.title = title
self.footer = footer self.footer = footer
self._selectedFile = selectedFile self._selectedFile = selectedFile

View File

@ -30,7 +30,7 @@ struct OptionalImagePropertyView: View {
} }
} }
.sheet(isPresented: $showSelectionSheet) { .sheet(isPresented: $showSelectionSheet) {
FileSelectionView(selectedFile: $selectedImage, allowedType: .images) FileSelectionView(selectedFile: $selectedImage, allowedType: .image)
} }
} }
} }

View File

@ -51,7 +51,7 @@ struct PostImagesView: View {
} }
} }
.sheet(isPresented: $showImagePicker) { .sheet(isPresented: $showImagePicker) {
MultiFileSelectionView(selectedFiles: $post.images, allowedType: .images) MultiFileSelectionView(selectedFiles: $post.images, allowedType: .image)
} }
} }

View File

@ -39,11 +39,12 @@ struct GenerationContentView: View {
if content.isGeneratingWebsite { if content.isGeneratingWebsite {
content.endCurrentGeneration() content.endCurrentGeneration()
} else { } else {
generateFullWebsite() content.generateWebsiteInAllLanguages()
} }
} label: { } label: {
Text(content.isGeneratingWebsite ? "Cancel" : "Generate") Text(content.isGeneratingWebsite ? "Cancel" : "Generate")
} }
.disabled(content.isGeneratingWebsite != content.shouldGenerateWebsite)
if content.isGeneratingWebsite { if content.isGeneratingWebsite {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
@ -108,39 +109,6 @@ struct GenerationContentView: View {
} }
}.padding() }.padding()
} }
private func generateFullWebsite() {
DispatchQueue.main.async {
content.generateWebsiteInAllLanguages()
}
#warning("Update feed generation")
/*
guard let url = content.storage.outputPath else {
print("Invalid output path")
return
}
guard FileManager.default.fileExists(atPath: url.path) else {
print("Missing output folder")
return
}
isGeneratingWebsite = true
DispatchQueue.global(qos: .userInitiated).async {
let generator = LocalizedWebsiteGenerator(
content: content,
language: language)
_ = generator.generateWebsite { text in
DispatchQueue.main.async {
self.generatorText = text
}
}
DispatchQueue.main.async {
isGeneratingWebsite = false
self.generatorText = "Generation complete"
}
}
*/
}
} }
#Preview { #Preview {

View File

@ -2,8 +2,6 @@ import SFSafeSymbols
enum SettingsSection: String { enum SettingsSection: String {
//case generation = "Generation"
case folders = "Folders" case folders = "Folders"
case navigationBar = "Navigation Bar" case navigationBar = "Navigation Bar"
@ -21,7 +19,7 @@ extension SettingsSection {
var icon: SFSymbol { var icon: SFSymbol {
switch self { switch self {
case .folders: return .folder case .folders: return .folder
case .navigationBar: return .menubarRectangle case .navigationBar: return .menubarArrowUpRectangle
case .postFeed: return .rectangleGrid1x2 case .postFeed: return .rectangleGrid1x2
case .pages: return .docRichtext case .pages: return .docRichtext
case .tagOverview: return .tag case .tagOverview: return .tag

View File

@ -17,10 +17,12 @@ struct TagOverviewDetailView: View {
if let page = content.tagOverview?.localized(in: language) { if let page = content.tagOverview?.localized(in: language) {
TagOverviewDetails(page: page) TagOverviewDetails(page: page)
.id(language)
} else { } else {
Button("Create", action: createTagOverviewPage) Button("Create", action: createTagOverviewPage)
} }
} }
.padding()
} }
} }
@ -70,6 +72,5 @@ private struct TagOverviewDetails: View {
text: $page.linkPreviewDescription, text: $page.linkPreviewDescription,
footer: "The description to show in previews of the page") footer: "The description to show in previews of the page")
} }
.padding()
} }
} }