Rework content commands, add audio player

This commit is contained in:
Christoph Hagen 2024-12-14 16:31:40 +01:00
parent b3b8c9a610
commit be2aab2ea8
52 changed files with 1758 additions and 767 deletions

View File

@ -58,7 +58,6 @@
E25DA5802D01C6AC00AEF16D /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = E25DA57F2D01C6AC00AEF16D /* Splash */; };
E25DA5832D01C7A400AEF16D /* VideoFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5822D01C7A100AEF16D /* VideoFileType.swift */; };
E25DA5852D01C92700AEF16D /* ShorthandMarkdownKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */; };
E25DA5872D01CA9300AEF16D /* GenerationResultsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */; };
E25DA5892D01CBD300AEF16D /* Content+Generation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */; };
E25DA58B2D020C9500AEF16D /* PageImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58A2D020C9200AEF16D /* PageImage.swift */; };
E25DA58D2D021BA400AEF16D /* WebsiteImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */; };
@ -69,7 +68,7 @@
E25DA5972D023F9F00AEF16D /* ContentPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5962D023F9900AEF16D /* ContentPage.swift */; };
E25DA5992D02401E00AEF16D /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5982D02401A00AEF16D /* PageGenerator.swift */; };
E25DA59B2D024A2B00AEF16D /* DateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA59A2D024A2900AEF16D /* DateItem.swift */; };
E29D31202D0320E70051B7F4 /* HikingStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D311F2D0320E20051B7F4 /* HikingStatistics.swift */; };
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D311F2D0320E20051B7F4 /* ContentLabels.swift */; };
E29D31222D0363FD0051B7F4 /* ContentButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31212D0363FA0051B7F4 /* ContentButtons.swift */; };
E29D31242D0366860051B7F4 /* TagList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31232D0366820051B7F4 /* TagList.swift */; };
E29D31262D0370A80051B7F4 /* VideoOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31252D0370A50051B7F4 /* VideoOption.swift */; };
@ -107,7 +106,7 @@
E29D31712D08234D0051B7F4 /* GenerationDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31702D08234D0051B7F4 /* GenerationDetailView.swift */; };
E29D31792D083DE50051B7F4 /* PageContentResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31782D083DDA0051B7F4 /* PageContentResultsView.swift */; };
E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D317C2D086AAE0051B7F4 /* Int+Random.swift */; };
E29D317F2D086F4C0051B7F4 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D317E2D086F490051B7F4 /* Icons.swift */; };
E29D317F2D086F4C0051B7F4 /* StatisticsIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D317E2D086F490051B7F4 /* StatisticsIcons.swift */; };
E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31822D0A43D60051B7F4 /* RelatedPageLink.swift */; };
E29D31852D0AE8EE0051B7F4 /* RequiredHeaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31842D0AE8EE0051B7F4 /* RequiredHeaders.swift */; };
E29D31872D0AE9DE0051B7F4 /* AdditionalPageHeaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31862D0AE9D40051B7F4 /* AdditionalPageHeaders.swift */; };
@ -119,6 +118,24 @@
E29D31942D0B7D280051B7F4 /* SvgImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31932D0B7D250051B7F4 /* SvgImage.swift */; };
E29D31962D0C186E0051B7F4 /* PathSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31952D0C18690051B7F4 /* PathSettings.swift */; };
E29D31982D0C19340051B7F4 /* PathSettingsFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31972D0C19300051B7F4 /* PathSettingsFile.swift */; };
E29D319B2D0C452B0051B7F4 /* PageIssue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D319A2D0C452B0051B7F4 /* PageIssue.swift */; };
E29D319D2D0C45B90051B7F4 /* PageIssueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D319C2D0C45B60051B7F4 /* PageIssueView.swift */; };
E29D319F2D0C46310051B7F4 /* PageIssueChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D319E2D0C46290051B7F4 /* PageIssueChecker.swift */; };
E29D31A12D0C75CA0051B7F4 /* Content+Validation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31A02D0C75C50051B7F4 /* Content+Validation.swift */; };
E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31A22D0CC98B0051B7F4 /* Item.swift */; };
E29D31A52D0CD03F0051B7F4 /* FileSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31A42D0CD03A0051B7F4 /* FileSelectionView.swift */; };
E29D31A82D0CDC5D0051B7F4 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */; };
E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31A92D0CEE3C0051B7F4 /* AudioPlayer.swift */; };
E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31AC2D0DA5310051B7F4 /* AudioPlayerIcons.swift */; };
E29D31B12D0DA5510051B7F4 /* ContentIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31B02D0DA5510051B7F4 /* ContentIcon.swift */; };
E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31B22D0DA6E50051B7F4 /* ButtonIcons.swift */; };
E29D31B52D0DA8490051B7F4 /* PageIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31B42D0DA8490051B7F4 /* PageIcon.swift */; };
E29D31B82D0DAC250051B7F4 /* ButtonCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31B72D0DAC1D0051B7F4 /* ButtonCommand.swift */; };
E29D31BA2D0DB5080051B7F4 /* LabelsCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31B92D0DB4EF0051B7F4 /* LabelsCommand.swift */; };
E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31BB2D0DB5110051B7F4 /* CommandProcessor.swift */; };
E29D31BE2D0DB85A0051B7F4 /* AudioPlayerCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31BD2D0DB8560051B7F4 /* AudioPlayerCommand.swift */; };
E29D31C02D0DB9F20051B7F4 /* AudioPlayerContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31BF2D0DB9ED0051B7F4 /* AudioPlayerContent.swift */; };
E29D31C32D0DBEF20051B7F4 /* Song.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31C22D0DBEF00051B7F4 /* Song.swift */; };
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C022CB16C220060935B /* Environment+Language.swift */; };
E2A21C082CB17B870060935B /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C072CB17B810060935B /* TagView.swift */; };
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.swift */; };
@ -209,7 +226,6 @@
E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContentGenerator.swift; sourceTree = "<group>"; };
E25DA5822D01C7A100AEF16D /* VideoFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFileType.swift; sourceTree = "<group>"; };
E25DA5842D01C92600AEF16D /* ShorthandMarkdownKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShorthandMarkdownKey.swift; sourceTree = "<group>"; };
E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationResultsHandler.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>"; };
E25DA58C2D021BA000AEF16D /* WebsiteImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteImage.swift; sourceTree = "<group>"; };
@ -220,7 +236,7 @@
E25DA5962D023F9900AEF16D /* ContentPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPage.swift; sourceTree = "<group>"; };
E25DA5982D02401A00AEF16D /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = "<group>"; };
E25DA59A2D024A2900AEF16D /* DateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateItem.swift; sourceTree = "<group>"; };
E29D311F2D0320E20051B7F4 /* HikingStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HikingStatistics.swift; sourceTree = "<group>"; };
E29D311F2D0320E20051B7F4 /* ContentLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLabels.swift; sourceTree = "<group>"; };
E29D31212D0363FA0051B7F4 /* ContentButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentButtons.swift; sourceTree = "<group>"; };
E29D31232D0366820051B7F4 /* TagList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagList.swift; sourceTree = "<group>"; };
E29D31252D0370A50051B7F4 /* VideoOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoOption.swift; sourceTree = "<group>"; };
@ -258,7 +274,7 @@
E29D31702D08234D0051B7F4 /* GenerationDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationDetailView.swift; sourceTree = "<group>"; };
E29D31782D083DDA0051B7F4 /* PageContentResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageContentResultsView.swift; sourceTree = "<group>"; };
E29D317C2D086AAE0051B7F4 /* Int+Random.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Random.swift"; sourceTree = "<group>"; };
E29D317E2D086F490051B7F4 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = "<group>"; };
E29D317E2D086F490051B7F4 /* StatisticsIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsIcons.swift; sourceTree = "<group>"; };
E29D31822D0A43D60051B7F4 /* RelatedPageLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedPageLink.swift; sourceTree = "<group>"; };
E29D31842D0AE8EE0051B7F4 /* RequiredHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequiredHeaders.swift; sourceTree = "<group>"; };
E29D31862D0AE9D40051B7F4 /* AdditionalPageHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalPageHeaders.swift; sourceTree = "<group>"; };
@ -270,6 +286,23 @@
E29D31932D0B7D250051B7F4 /* SvgImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SvgImage.swift; sourceTree = "<group>"; };
E29D31952D0C18690051B7F4 /* PathSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathSettings.swift; sourceTree = "<group>"; };
E29D31972D0C19300051B7F4 /* PathSettingsFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathSettingsFile.swift; sourceTree = "<group>"; };
E29D319A2D0C452B0051B7F4 /* PageIssue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageIssue.swift; sourceTree = "<group>"; };
E29D319C2D0C45B60051B7F4 /* PageIssueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageIssueView.swift; sourceTree = "<group>"; };
E29D319E2D0C46290051B7F4 /* PageIssueChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageIssueChecker.swift; sourceTree = "<group>"; };
E29D31A02D0C75C50051B7F4 /* Content+Validation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Validation.swift"; sourceTree = "<group>"; };
E29D31A22D0CC98B0051B7F4 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
E29D31A42D0CD03A0051B7F4 /* FileSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSelectionView.swift; sourceTree = "<group>"; };
E29D31A92D0CEE3C0051B7F4 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = "<group>"; };
E29D31AC2D0DA5310051B7F4 /* AudioPlayerIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerIcons.swift; sourceTree = "<group>"; };
E29D31B02D0DA5510051B7F4 /* ContentIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentIcon.swift; sourceTree = "<group>"; };
E29D31B22D0DA6E50051B7F4 /* ButtonIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIcons.swift; sourceTree = "<group>"; };
E29D31B42D0DA8490051B7F4 /* PageIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageIcon.swift; sourceTree = "<group>"; };
E29D31B72D0DAC1D0051B7F4 /* ButtonCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonCommand.swift; sourceTree = "<group>"; };
E29D31B92D0DB4EF0051B7F4 /* LabelsCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelsCommand.swift; sourceTree = "<group>"; };
E29D31BB2D0DB5110051B7F4 /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
E29D31BD2D0DB8560051B7F4 /* AudioPlayerCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerCommand.swift; sourceTree = "<group>"; };
E29D31BF2D0DB9ED0051B7F4 /* AudioPlayerContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerContent.swift; sourceTree = "<group>"; };
E29D31C22D0DBEF00051B7F4 /* Song.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Song.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>"; };
E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = "<group>"; };
@ -323,6 +356,7 @@
E25DA52F2CFFC91B00AEF16D /* SDWebImageWebPCoder in Frameworks */,
E24252012C50E0A40029FF16 /* HighlightedTextEditor in Frameworks */,
E25DA5802D01C6AC00AEF16D /* Splash in Frameworks */,
E29D31A82D0CDC5D0051B7F4 /* SwiftSoup in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -370,10 +404,10 @@
E25DA5782D01C56200AEF16D /* Generator */ = {
isa = PBXGroup;
children = (
E29D31B62D0DAC030051B7F4 /* Page Content */,
E29D31912D0B3EF30051B7F4 /* PageCommandExtractor.swift */,
E29D318F2D0B34870051B7F4 /* PageContentAnomaly.swift */,
E29D31842D0AE8EE0051B7F4 /* RequiredHeaders.swift */,
E25DA5862D01CA8D00AEF16D /* GenerationResultsHandler.swift */,
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
E218502E2CFAF6990090B18B /* LocalizedWebsiteGenerator.swift */,
E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */,
@ -403,16 +437,17 @@
E29D311E2D0320D90051B7F4 /* ContentElements */ = {
isa = PBXGroup;
children = (
E29D31C12D0DBED70051B7F4 /* AudioPlayer */,
E29D31AB2D0DA52C0051B7F4 /* Icons */,
E29D31932D0B7D250051B7F4 /* SvgImage.swift */,
E29D318A2D0B07E60051B7F4 /* ContentBox.swift */,
E29D31882D0AED1B0051B7F4 /* ModelViewer.swift */,
E29D31862D0AE9D40051B7F4 /* AdditionalPageHeaders.swift */,
E29D31822D0A43D60051B7F4 /* RelatedPageLink.swift */,
E29D317E2D086F490051B7F4 /* Icons.swift */,
E29D31272D0371870051B7F4 /* ContentPageVideo.swift */,
E29D31232D0366820051B7F4 /* TagList.swift */,
E29D31212D0363FA0051B7F4 /* ContentButtons.swift */,
E29D311F2D0320E20051B7F4 /* HikingStatistics.swift */,
E29D311F2D0320E20051B7F4 /* ContentLabels.swift */,
);
path = ContentElements;
sourceTree = "<group>";
@ -432,11 +467,54 @@
E29D318C2D0B2E5E0051B7F4 /* Content */ = {
isa = PBXGroup;
children = (
E29D31992D0C451B0051B7F4 /* Pages */,
E29D318D2D0B2E640051B7F4 /* PageSettingsContentView.swift */,
);
path = Content;
sourceTree = "<group>";
};
E29D31992D0C451B0051B7F4 /* Pages */ = {
isa = PBXGroup;
children = (
E29D319E2D0C46290051B7F4 /* PageIssueChecker.swift */,
E29D319C2D0C45B60051B7F4 /* PageIssueView.swift */,
E29D319A2D0C452B0051B7F4 /* PageIssue.swift */,
);
path = Pages;
sourceTree = "<group>";
};
E29D31AB2D0DA52C0051B7F4 /* Icons */ = {
isa = PBXGroup;
children = (
E29D31B42D0DA8490051B7F4 /* PageIcon.swift */,
E29D31B22D0DA6E50051B7F4 /* ButtonIcons.swift */,
E29D31B02D0DA5510051B7F4 /* ContentIcon.swift */,
E29D31AC2D0DA5310051B7F4 /* AudioPlayerIcons.swift */,
E29D317E2D086F490051B7F4 /* StatisticsIcons.swift */,
);
path = Icons;
sourceTree = "<group>";
};
E29D31B62D0DAC030051B7F4 /* Page Content */ = {
isa = PBXGroup;
children = (
E29D31BD2D0DB8560051B7F4 /* AudioPlayerCommand.swift */,
E29D31BB2D0DB5110051B7F4 /* CommandProcessor.swift */,
E29D31B92D0DB4EF0051B7F4 /* LabelsCommand.swift */,
E29D31B72D0DAC1D0051B7F4 /* ButtonCommand.swift */,
);
path = "Page Content";
sourceTree = "<group>";
};
E29D31C12D0DBED70051B7F4 /* AudioPlayer */ = {
isa = PBXGroup;
children = (
E29D31A92D0CEE3C0051B7F4 /* AudioPlayer.swift */,
E29D31BF2D0DB9ED0051B7F4 /* AudioPlayerContent.swift */,
);
path = AudioPlayer;
sourceTree = "<group>";
};
E2A21C322CB5BCAC0060935B /* Pages */ = {
isa = PBXGroup;
children = (
@ -493,6 +571,7 @@
E29D31682D0702670051B7F4 /* TextFileContentView.swift */,
E29D314A2D04FC940051B7F4 /* FileToAdd.swift */,
E29D314C2D04FCBF0051B7F4 /* FileToAddView.swift */,
E29D31A42D0CD03A0051B7F4 /* FileSelectionView.swift */,
);
path = Files;
sourceTree = "<group>";
@ -523,9 +602,11 @@
E2B85F392C428F020047CD0C /* Model */ = {
isa = PBXGroup;
children = (
E29D31A22D0CC98B0051B7F4 /* Item.swift */,
E25DA5812D01C79800AEF16D /* Types */,
E25DA53B2D0042EA00AEF16D /* Settings */,
E2E06DFA2CA4A6570019C2AF /* Content.swift */,
E29D31A02D0C75C50051B7F4 /* Content+Validation.swift */,
E25DA5882D01CBCE00AEF16D /* Content+Generation.swift */,
E25DA5162CFF00F200AEF16D /* Content+Save.swift */,
E25DA5142CFF00B900AEF16D /* Content+Load.swift */,
@ -538,6 +619,7 @@
E2A37D182CEA36A40000979F /* LocalizedTag.swift */,
E2A9CB7D2C7BCF2A005C89CC /* Page.swift */,
E25A0B882CE4021400F33674 /* LocalizedPage.swift */,
E29D31C22D0DBEF00051B7F4 /* Song.swift */,
);
path = Model;
sourceTree = "<group>";
@ -690,6 +772,7 @@
E25DA52E2CFFC91B00AEF16D /* SDWebImageWebPCoder */,
E25DA57C2D01C67900AEF16D /* Ink */,
E25DA57F2D01C6AC00AEF16D /* Splash */,
E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */,
);
productName = CHDataManagement;
productReference = E2DD04702C276F31003BFF1F /* CHDataManagement.app */;
@ -726,6 +809,7 @@
E25DA52D2CFFC91B00AEF16D /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */,
E25DA57B2D01C67900AEF16D /* XCRemoteSwiftPackageReference "ink" */,
E25DA57E2D01C6AC00AEF16D /* XCRemoteSwiftPackageReference "Splash" */,
E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
);
productRefGroup = E2DD04712C276F31003BFF1F /* Products */;
projectDirPath = "";
@ -761,6 +845,7 @@
E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */,
E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */,
E2A37D212CEA94EC0000979F /* Sequence+Sorted.swift in Sources */,
E29D31BA2D0DB5080051B7F4 /* LabelsCommand.swift in Sources */,
E29D31692D0702700051B7F4 /* TextFileContentView.swift in Sources */,
E25DA5412D00446C00AEF16D /* PostSettings.swift in Sources */,
E2A37D192CEA36A90000979F /* LocalizedTag.swift in Sources */,
@ -768,11 +853,13 @@
E29D31902D0B34870051B7F4 /* PageContentAnomaly.swift in Sources */,
E29D31872D0AE9DE0051B7F4 /* AdditionalPageHeaders.swift in Sources */,
E25DA5172CFF00F500AEF16D /* Content+Save.swift in Sources */,
E29D31AD2D0DA5360051B7F4 /* AudioPlayerIcons.swift in Sources */,
E25DA5452D00952E00AEF16D /* SettingsSection.swift in Sources */,
E2A21C462CBAE2E60060935B /* FeedEntryContent.swift in Sources */,
E21850252CF38BCE0090B18B /* TextEntrySheet.swift in Sources */,
E2A37D1D2CEA922D0000979F /* LocalizedPost.swift in Sources */,
E21850172CEE55FC0090B18B /* FileType.swift in Sources */,
E29D31B12D0DA5510051B7F4 /* ContentIcon.swift in Sources */,
E29D31612D06D95C0051B7F4 /* ResourceFileType.swift in Sources */,
E2A37D112CE537800000979F /* PageFile.swift in Sources */,
E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */,
@ -780,7 +867,7 @@
E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */,
E29D31852D0AE8EE0051B7F4 /* RequiredHeaders.swift in Sources */,
E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */,
E29D317F2D086F4C0051B7F4 /* Icons.swift in Sources */,
E29D317F2D086F4C0051B7F4 /* StatisticsIcons.swift in Sources */,
E2A21C082CB17B870060935B /* TagView.swift in Sources */,
E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */,
E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */,
@ -807,6 +894,7 @@
E29D31922D0B3EFC0051B7F4 /* PageCommandExtractor.swift in Sources */,
E29D315B2D06D63E0051B7F4 /* ModelFileType.swift in Sources */,
E29D31302D03A2C50051B7F4 /* DescriptionField.swift in Sources */,
E29D31BE2D0DB85A0051B7F4 /* AudioPlayerCommand.swift in Sources */,
E29D31552D06D2CE0051B7F4 /* TagListView.swift in Sources */,
E29D31982D0C19340051B7F4 /* PathSettingsFile.swift in Sources */,
E2A37D152CE68BEC0000979F /* PostFile.swift in Sources */,
@ -816,6 +904,7 @@
E21850272CF3B42D0090B18B /* PostDetailView.swift in Sources */,
E2A37D172CE73F1A0000979F /* TagFile.swift in Sources */,
E29D318E2D0B2E680051B7F4 /* PageSettingsContentView.swift in Sources */,
E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */,
E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */,
E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */,
E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */,
@ -829,6 +918,7 @@
E21850332CFAFA2F0090B18B /* Settings.swift in Sources */,
E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */,
E29D31412D04887F0051B7F4 /* SelectedDetailView.swift in Sources */,
E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */,
E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */,
E21850192CEE561C0090B18B /* PageOnDisk.swift in Sources */,
E2A21C202CB28ED20060935B /* MockImage.swift in Sources */,
@ -836,22 +926,29 @@
E25DA5752D018B6100AEF16D /* FileDetailView.swift in Sources */,
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */,
E21850352CFAFA5A0090B18B /* SettingsFile.swift in Sources */,
E29D31A12D0C75CA0051B7F4 /* Content+Validation.swift in Sources */,
E29D316D2D07A5050051B7F4 /* PageGenerationResults.swift in Sources */,
E25DA5192CFF035900AEF16D /* Array+Split.swift in Sources */,
E29D31B82D0DAC250051B7F4 /* ButtonCommand.swift in Sources */,
E29D31962D0C186E0051B7F4 /* PathSettings.swift in Sources */,
E2B85F412C4294790047CD0C /* PageHead.swift in Sources */,
E29D316B2D07488B0051B7F4 /* PostListPageGenerator.swift in Sources */,
E218501D2CEE6CB60090B18B /* VerticalCenter.swift in Sources */,
E29D31B32D0DA6E80051B7F4 /* ButtonIcons.swift in Sources */,
E29D319D2D0C45B90051B7F4 /* PageIssueView.swift in Sources */,
E25DA5732D018AA100AEF16D /* FileContentView.swift in Sources */,
E25DA5232CFF6C3700AEF16D /* ImageGenerator.swift in Sources */,
E2A9CB7E2C7BCF2A005C89CC /* Page.swift in Sources */,
E29D31202D0320E70051B7F4 /* HikingStatistics.swift in Sources */,
E29D31202D0320E70051B7F4 /* ContentLabels.swift in Sources */,
E29D314B2D04FC950051B7F4 /* FileToAdd.swift in Sources */,
E2E06DFB2CA4A65E0019C2AF /* Content.swift in Sources */,
E29D31B52D0DA8490051B7F4 /* PageIcon.swift in Sources */,
E25DA51D2CFF135E00AEF16D /* GenericPage.swift in Sources */,
E29D319B2D0C452B0051B7F4 /* PageIssue.swift in Sources */,
E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */,
E29D31472D04892E0051B7F4 /* FileListView.swift in Sources */,
E218503D2CFCFD910090B18B /* LocalizedPostFeedSettingsView.swift in Sources */,
E29D31A52D0CD03F0051B7F4 /* FileSelectionView.swift in Sources */,
E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */,
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */,
E21850372CFCA55F0090B18B /* LocalizedPostSettings.swift in Sources */,
@ -859,11 +956,14 @@
E218501F2CEE6DAC0090B18B /* ImagePickerView.swift in Sources */,
E29D31452D0488CB0051B7F4 /* SelectedContentView.swift in Sources */,
E2A37D1B2CEA45560000979F /* Tag+Mock.swift in Sources */,
E29D319F2D0C46310051B7F4 /* PageIssueChecker.swift in Sources */,
E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */,
E29D31322D03B5680051B7F4 /* LocalizedPostDetailView.swift in Sources */,
E29D31712D08234D0051B7F4 /* GenerationDetailView.swift in Sources */,
E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */,
E29D31C32D0DBEF20051B7F4 /* Song.swift in Sources */,
E29D31532D0618740051B7F4 /* AddPageView.swift in Sources */,
E29D31C02D0DB9F20051B7F4 /* AudioPlayerContent.swift in Sources */,
E25DA5292CFFBFBB00AEF16D /* ImageFileType.swift in Sources */,
E25DA51F2CFF15C400AEF16D /* NavigationBar.swift in Sources */,
E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */,
@ -881,10 +981,10 @@
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */,
E29D31342D03B5D50051B7F4 /* IconButton.swift in Sources */,
E25DA5712D01015400AEF16D /* GenerationContentView.swift in Sources */,
E25DA5872D01CA9300AEF16D /* GenerationResultsHandler.swift in Sources */,
E29D316F2D0822770051B7F4 /* SettingsListView.swift in Sources */,
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */,
E25DA5852D01C92700AEF16D /* ShorthandMarkdownKey.swift in Sources */,
E29D31AA2D0CEE3F0051B7F4 /* AudioPlayer.swift in Sources */,
E25DA5152CFF00C100AEF16D /* Content+Load.swift in Sources */,
E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */,
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */,
@ -1161,6 +1261,14 @@
minimumVersion = 0.16.0;
};
};
E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.7.6;
};
};
E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
@ -1197,6 +1305,11 @@
package = E25DA57E2D01C6AC00AEF16D /* XCRemoteSwiftPackageReference "Splash" */;
productName = Splash;
};
E29D31A72D0CDC5D0051B7F4 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = E29D31A62D0CDC5D0051B7F4 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
E2B85F352C426BEE0047CD0C /* SFSafeSymbols */ = {
isa = XCSwiftPackageProductDependency;
package = E2B85F342C426BED0047CD0C /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;

View File

@ -1,5 +1,5 @@
{
"originHash" : "09586c34852addc5d95a3a7234451be33efeecd2b5dbd5ef8607a959add71d3f",
"originHash" : "610a80083aa646fbd77d72ddb7dcc16342884551283091b7b1ebf40042816810",
"pins" : [
{
"identity" : "highlightedtexteditor",
@ -99,6 +99,15 @@
"revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
"version" : "0.16.0"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "0837db354faf9c9deb710dc597046edaadf5360f",
"version" : "2.7.6"
}
}
],
"version" : 3

View File

@ -1,24 +0,0 @@
import Foundation
final class GenerationResultsHandler {
var requiredVideoFiles: Set<String> = []
/// Generic warnings for pages
private var pageWarnings: [(message: String, source: String)] = []
private var missingPages: [String : [String]] = [:]
func warning(_ message: String, page: Page) {
pageWarnings.append((message, page.id))
print("Page: \(page.id): \(message)")
}
func addRequiredVideoFile(fileId: String) {
requiredVideoFiles.insert(fileId)
}
func missing(page: String, linkedBy source: String) {
missingPages[page, default: []].append(source)
}
}

View File

@ -130,7 +130,7 @@ final class LocalizedWebsiteGenerator {
return true
}
let path = self.content.absoluteUrlToPage(page, language: language) + ".html"
let path = page.absoluteUrl(for: language) + ".html"
guard save(content, to: path) else {
print("Failed to save page")
return false
@ -151,9 +151,8 @@ final class LocalizedWebsiteGenerator {
continue
}
let outputPath = content.absoluteUrlToFile(file)
do {
try content.storage.copy(file: file.id, to: outputPath)
try content.storage.copy(file: file.id, to: file.absoluteUrl)
} catch {
print("Failed to copy file \(file.id): \(error)")
return false

View File

@ -0,0 +1,94 @@
import Foundation
struct AudioPlayerCommandProcessor: CommandProcessor {
let commandType: ShorthandMarkdownKey = .audioPlayer
let content: Content
let results: PageGenerationResults
init(content: Content, results: PageGenerationResults) {
self.content = content
self.results = results
}
func process(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count == 2 else {
results.invalid(command: .audioPlayer, "Invalid audio player arguments")
return ""
}
let fileId = arguments[0]
let titleText = arguments[1]
guard content.isValidIdForFile(fileId) else {
results.invalid(command: .audioPlayer, "Invalid file id \(fileId) for audio player")
return ""
}
guard let file = content.file(fileId) else {
results.missingFiles.insert(fileId)
return ""
}
let songs: [Song]
do {
let data = try file.dataContent()
songs = try JSONDecoder().decode([Song].self, from: data)
} catch {
results.issues.insert(.failedToLoadContent(error))
return ""
}
var playlist: [AudioPlayer.PlaylistItem] = []
var amplitude: [AmplitudeSong] = []
for song in songs {
guard let image = content.image(song.cover) else {
results.missing(file: song.cover, markdown: "Missing cover image \(song.cover) in \(file.id)")
continue
}
guard let audioFile = content.file(song.file) else {
results.missing(file: song.file, markdown: "Missing audio file \(song.file) in \(file.id)")
continue
}
#warning("Check if file is audio")
let coverUrl = image.absoluteUrl
let playlistItem = AudioPlayer.PlaylistItem(
index: playlist.count,
image: coverUrl,
name: song.name,
album: song.album,
track: song.track,
artist: song.artist)
let amplitudeSong = AmplitudeSong(
name: song.name,
artist: song.artist,
album: song.album,
track: "\(song.track)",
url: audioFile.absoluteUrl,
cover_art_url: coverUrl)
playlist.append(playlistItem)
amplitude.append(amplitudeSong)
}
let footerScript = AudioPlayerScript(items: amplitude).content
results.requiredFooters.insert(footerScript)
results.requiredHeaders.insert(.audioPlayerCss)
results.requiredHeaders.insert(.amplitude)
results.requiredIcons.formUnion([
.audioPlayerClose,
.audioPlayerPlaylist,
.audioPlayerNext,
.audioPlayerPrevious,
.audioPlayerPlay,
.audioPlayerPause
])
return AudioPlayer(playingText: titleText, items: playlist).content
}
}

View File

@ -0,0 +1,103 @@
struct ButtonCommandProcessor: CommandProcessor {
let commandType: ShorthandMarkdownKey = .buttons
let content: Content
let results: PageGenerationResults
init(content: Content, results: PageGenerationResults) {
self.content = content
self.results = results
}
/**
Format: `![buttons](<<fileId>,<text>,<download-filename?>;...)`
Format: `![buttons](type=<specification>;...)`
Types:
- Download: `download=<fileId>,<text>,<download-filename?>`
- External link: `external=<url>,<text>`
- Git: `git=<url>,<text>`
- Play: `play-circle=<text>,<click-action>`
*/
func process(_ arguments: [String], markdown: Substring) -> String {
let buttons = arguments.compactMap { convert(button: $0, markdown: markdown) }
return ContentButtons(items: buttons).content
}
private func convert(button: String, markdown: Substring) -> ContentButtons.Item? {
guard let type = PageIcon(rawValue: button.dropAfterFirst("=").trimmed) else {
results.invalid(command: commandType, markdown)
return nil
}
let parts = button.dropBeforeFirst("=").components(separatedBy: ",").map { $0.trimmed }
switch type {
case .buttonDownload:
return download(arguments: parts, markdown: markdown)
case .buttonGitLink:
return link(icon: .buttonGitLink, arguments: parts, markdown: markdown)
case .buttonExternalLink:
return link(icon: .buttonExternalLink, arguments: parts, markdown: markdown)
case .buttonPlay:
return play(arguments: parts, markdown: markdown)
default:
results.invalid(command: commandType, markdown)
return nil
}
}
private func download(arguments: [String], markdown: Substring) -> ContentButtons.Item? {
guard (2...3).contains(arguments.count) else {
results.invalid(command: commandType, markdown)
return nil
}
let fileId = arguments[0].trimmed
let title = arguments[1].trimmed
let downloadName = arguments.count > 2 ? arguments[2].trimmed : nil
guard let file = content.file(fileId) else {
results.missing(file: fileId, markdown: markdown)
return nil
}
results.files.insert(file)
results.requiredIcons.insert(.buttonDownload)
return ContentButtons.Item(
icon: .buttonDownload,
filePath: file.absoluteUrl,
text: title,
downloadFileName: downloadName)
}
private func link(icon: PageIcon, arguments: [String], markdown: Substring) -> ContentButtons.Item? {
guard arguments.count == 2 else {
results.invalid(command: .buttons, markdown)
return nil
}
let rawUrl = arguments[0].trimmed
guard let url = rawUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
results.invalid(command: .buttons, markdown)
return nil
}
results.externalLinks.insert(rawUrl)
results.requiredIcons.insert(icon)
let title = arguments[1].trimmed
return .init(icon: icon, filePath: url, text: title)
}
private func play(arguments: [String], markdown: Substring) -> ContentButtons.Item? {
guard arguments.count == 2 else {
results.invalid(command: .buttons, markdown)
return nil
}
let text = arguments[0].trimmed
let event = arguments[1].trimmed
results.requiredIcons.insert(.buttonPlay)
return .init(icon: .buttonPlay, filePath: nil, text: text, onClickText: event)
}
}

View File

@ -0,0 +1,9 @@
protocol CommandProcessor {
var commandType: ShorthandMarkdownKey { get }
init(content: Content, results: PageGenerationResults)
func process(_ arguments: [String], markdown: Substring) -> String
}

View File

@ -0,0 +1,30 @@
struct LabelsCommandProcessor: CommandProcessor {
let commandType: ShorthandMarkdownKey = .labels
let content: Content
let results: PageGenerationResults
init(content: Content, results: PageGenerationResults) {
self.content = content
self.results = results
}
func process(_ arguments: [String], markdown: Substring) -> String {
let labels: [ContentLabel] = arguments.compactMap { arg in
let parts = arg.components(separatedBy: "=")
guard parts.count == 2 else {
results.invalid(command: .labels, markdown)
return nil
}
guard let icon = PageIcon(rawValue: parts[0].trimmed) else {
results.invalid(command: .labels, markdown)
return nil
}
return .init(icon: icon, value: parts[1])
}
return ContentLabels(labels: labels).content
}
}

View File

@ -1,20 +1,21 @@
import Ink
#warning("Remove if unused")
final class PageCommandExtractor {
private var occurences: [(full: String, command: String, arguments: [String])] = []
private var occurrences: [(full: String, command: String, arguments: [String])] = []
func findOccurences(of command: ShorthandMarkdownKey, in content: String) -> [(full: String, arguments: [String])] {
findOccurences(of: command.rawValue, in: content)
func findOccurrences(of command: ShorthandMarkdownKey, in content: String) -> [(full: String, arguments: [String])] {
findOccurrences(of: command.rawValue, in: content)
}
func findOccurences(of command: String, in content: String) -> [(full: String, arguments: [String])] {
func findOccurrences(of command: String, in content: String) -> [(full: String, arguments: [String])] {
let parser = MarkdownParser(modifiers: [
Modifier(target: .images, closure: processMarkdownImage),
])
_ = parser.html(from: content)
return occurences
return occurrences
.filter { $0.command == command }
.map { ($0.full, $0.arguments) }
}
@ -25,7 +26,7 @@ final class PageCommandExtractor {
let command = markdown.between("![", and: "]").trimmed
occurences.append((full: String(markdown), command: command, arguments: arguments))
occurrences.append((full: String(markdown), command: command, arguments: arguments))
return ""
}
}

View File

@ -1,12 +1,10 @@
enum PageContentAnomaly {
case failedToLoadContent(Error)
case missingFile(String)
case missingPage(String)
case missingTag(String)
case unknownCommand(String)
case invalidCommandArguments(command: ShorthandMarkdownKey, arguments: [String])
case missingFile(file: String, markdown: String)
case missingPage(page: String, markdown: String)
case missingTag(tag: String, markdown: String)
case invalidCommand(command: ShorthandMarkdownKey?, markdown: String)
}
extension PageContentAnomaly: Identifiable {
@ -15,20 +13,32 @@ extension PageContentAnomaly: Identifiable {
switch self {
case .failedToLoadContent:
return "load-failed"
case .missingFile(let string):
case .missingFile(let string, _):
return "missing-file-\(string)"
case .missingPage(let string):
case .missingPage(let string, _):
return "missing-page-\(string)"
case .missingTag(let string):
case .missingTag(let string, _):
return "missing-tag-\(string)"
case .unknownCommand(let string):
return "unknown-command-\(string)"
case .invalidCommandArguments(let command, let arguments):
return "invalid-arguments-\(command)-\(arguments.joined(separator: "-"))"
case .invalidCommand(_, let markdown):
return "invalid-command-\(markdown)"
}
}
}
extension PageContentAnomaly: Equatable {
static func == (lhs: PageContentAnomaly, rhs: PageContentAnomaly) -> Bool {
lhs.id == rhs.id
}
}
extension PageContentAnomaly: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
extension PageContentAnomaly {
enum Severity: String, CaseIterable {
@ -40,7 +50,7 @@ extension PageContentAnomaly {
switch self {
case .failedToLoadContent:
return .error
case .missingFile, .missingPage, .missingTag, .unknownCommand, .invalidCommandArguments:
case .missingFile, .missingPage, .missingTag, .invalidCommand:
return .warning
}
}
@ -52,16 +62,14 @@ extension PageContentAnomaly: CustomStringConvertible {
switch self {
case .failedToLoadContent(let error):
return "Failed to load content: \(error)"
case .missingFile(let string):
return "Missing file \(string)"
case .missingPage(let string):
return "Missing page \(string)"
case .missingTag(let string):
return "Missing tag \(string)"
case .unknownCommand(let string):
return "Unknown command \(string)"
case .invalidCommandArguments(let command, let arguments):
return "Invalid command arguments for \(command): \(arguments)"
case .missingFile(let string, _):
return "Missing file: \(string)"
case .missingPage(let string, _):
return "Missing page: \(string)"
case .missingTag(let string, _):
return "Missing tag: \(string)"
case .invalidCommand(_, let markdown):
return "Invalid command: \(markdown)"
}
}
}

View File

@ -1,6 +1,7 @@
import Foundation
import Ink
import Splash
import SwiftSoup
typealias VideoSource = (url: String, type: VideoFileType)
@ -18,6 +19,12 @@ final class PageContentParser {
private let content: Content
private let buttonHandler: ButtonCommandProcessor
private let labelHandler: LabelsCommandProcessor
private let audioPlayer: AudioPlayerCommandProcessor
let language: ContentLanguage
var largeImageWidth: Int {
@ -31,6 +38,9 @@ final class PageContentParser {
init(content: Content, language: ContentLanguage) {
self.content = content
self.language = language
self.buttonHandler = .init(content: content, results: results)
self.labelHandler = .init(content: content, results: results)
self.audioPlayer = .init(content: content, results: results)
}
func requestImages(_ generator: ImageGenerator) {
@ -77,7 +87,7 @@ final class PageContentParser {
if file.hasPrefix(tagLinkMarker) {
return handleTagLink(file: file, html: html, markdown: markdown)
}
#warning("Check existence of linked file")
results.externalLinks.insert(file)
return html
}
@ -86,12 +96,12 @@ final class PageContentParser {
let textToChange = file.dropAfterFirst("#")
let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "")
guard let page = content.page(pageId) else {
results.missingPages.insert(pageId)
results.missing(page: pageId, markdown: markdown)
// Remove link since the page can't be found
return markdown.between("[", and: "]")
}
results.linkedPages.insert(page)
let pagePath = content.absoluteUrlToPage(page, language: language)
let pagePath = page.absoluteUrl(for: language)
return html.replacingOccurrences(of: textToChange, with: pagePath)
}
@ -100,7 +110,7 @@ final class PageContentParser {
let textToChange = file.dropAfterFirst("#")
let tagId = textToChange.replacingOccurrences(of: tagLinkMarker, with: "")
guard let tag = content.tag(tagId) else {
results.missingTags.insert(tagId)
results.missing(tag: tagId, markdown: markdown)
// Remove link since the tag can't be found
return markdown.between("[", and: "]")
}
@ -109,10 +119,71 @@ final class PageContentParser {
return html.replacingOccurrences(of: textToChange, with: tagPath)
}
private func handleHTML(html: String, markdown: Substring) -> String {
private func handleHTML(_: String, markdown: Substring) -> String {
let result = String(markdown)
#warning("Check HTML code in markdown for required resources")
findImages(in: result)
findLinks(in: result)
findSourceSets(in: result)
// Things to check: <img src= <a href= <source>
return html
return result
}
private func findImages(in markdown: String) {
do {
// Parse the HTML string
let document = try SwiftSoup.parse(markdown)
// Select all 'img' elements
let imgElements = try document.select("img")
// Extract the 'src' attributes from each 'img' element
let srcAttributes = try imgElements.array().compactMap { try $0.attr("src") }
for src in srcAttributes {
print("Found image in html: \(src)")
}
} catch {
print("Error parsing HTML: \(error)")
}
}
private func findLinks(in markdown: String) {
do {
// Parse the HTML string
let document = try SwiftSoup.parse(markdown)
// Select all 'img' elements
let linkElements = try document.select("a")
// Extract the 'src' attributes from each 'img' element
let srcAttributes = try linkElements.array().compactMap { try $0.attr("href") }
for src in srcAttributes {
print("Found link in html: \(src)")
}
} catch {
print("Error parsing HTML: \(error)")
}
}
private func findSourceSets(in markdown: String) {
do {
// Parse the HTML string
let document = try SwiftSoup.parse(markdown)
// Select all 'img' elements
let linkElements = try document.select("source")
// Extract the 'src' attributes from each 'img' element
let srcAttributes = try linkElements.array().compactMap { try $0.attr("srcset") }
for src in srcAttributes {
print("Found source set in html: \(src)")
}
} catch {
print("Error parsing HTML: \(error)")
}
}
/**
@ -151,40 +222,38 @@ final class PageContentParser {
let rawCommand = percentDecoded(markdown.between("![", and: "]").trimmed)
guard rawCommand != "" else {
return handleImage(arguments)
return handleImage(arguments, markdown: markdown)
}
guard let command = ShorthandMarkdownKey(rawValue: rawCommand) else {
// Treat unknown commands as normal links
results.unknownCommands.append(rawCommand)
results.invalid(command: nil, markdown)
return html
}
switch command {
case .image:
return handleImage(arguments)
case .hikingStatistics:
return handleHikingStatistics(arguments)
case .downloadButtons:
return handleDownloadButtons(arguments)
return handleImage(arguments, markdown: markdown)
case .labels:
return labelHandler.process(arguments, markdown: markdown)
case .buttons:
return buttonHandler.process(arguments, markdown: markdown)
case .video:
return handleVideo(arguments)
case .externalLink:
return handleExternalButtons(arguments)
case .gitLink:
return handleGitButtons(arguments)
return handleVideo(arguments, markdown: markdown)
case .pageLink:
return handlePageLink(arguments)
return handlePageLink(arguments, markdown: markdown)
case .includedHtml:
return handleExternalHtml(arguments)
return handleExternalHtml(arguments, markdown: markdown)
case .box:
return handleSimpleBox(arguments)
return handleSimpleBox(arguments, markdown: markdown)
case .model:
return handleModel(arguments)
return handleModel(arguments, markdown: markdown)
case .svg:
return handleSvg(arguments)
return handleSvg(arguments, markdown: markdown)
case .audioPlayer:
return audioPlayer.process(arguments, markdown: markdown)
default:
results.unknownCommands.append(command.rawValue)
results.invalid(command: nil, markdown)
return ""
}
}
@ -192,15 +261,15 @@ final class PageContentParser {
/**
Format: `[image](<imageId>;<caption?>]`
*/
private func handleImage(_ arguments: [String]) -> String {
private func handleImage(_ arguments: [String], markdown: Substring) -> String {
guard (1...2).contains(arguments.count) else {
results.invalidCommandArguments.append((.image , arguments))
results.invalid(command: .image, markdown)
return ""
}
let imageId = arguments[0]
guard let image = content.image(imageId) else {
results.missingFiles.insert(imageId)
results.missing(file: imageId, markdown: markdown)
return ""
}
results.files.insert(image)
@ -208,7 +277,7 @@ final class PageContentParser {
let caption = arguments.count == 2 ? arguments[1] : nil
let altText = image.getDescription(for: language)
let path = content.absoluteUrlToFile(image)
let path = image.absoluteUrl
guard !image.type.isSvg else {
return SvgImage(imagePath: path, altText: altText).content
@ -235,170 +304,80 @@ final class PageContentParser {
caption: caption).content
}
/**
Format: `![hiking-stats](<time>;<elevation-up>;<elevation-down>;<distance>;<calories>)`
*/
private func handleHikingStatistics(_ arguments: [String]) -> String {
#warning("Make statistics more generic using key-value pairs")
guard (1...5).contains(arguments.count) else {
results.invalidCommandArguments.append((.hikingStatistics, arguments))
return ""
}
let time = arguments[0].trimmed
let elevationUp = arguments.count > 1 ? arguments[1].trimmed : nil
let elevationDown = arguments.count > 2 ? arguments[2].trimmed : nil
let distance = arguments.count > 3 ? arguments[3].trimmed : nil
let calories = arguments.count > 4 ? arguments[4].trimmed : nil
return HikingStatistics(
time: time,
elevationUp: elevationUp,
elevationDown: elevationDown,
distance: distance,
calories: calories)
.content
}
/**
Format: `![download](<<fileId>,<text>,<download-filename?>;...)`
*/
private func handleDownloadButtons(_ arguments: [String]) -> String {
let buttons = arguments.compactMap(convertButton)
return ContentButtons(items: buttons).content
}
private func convertButton(definition button: String) -> ContentButtons.Item? {
let parts = button.components(separatedBy: ",")
guard (2...3).contains(parts.count) else {
results.invalidCommandArguments.append((.downloadButtons, parts))
return nil
}
let fileId = parts[0].trimmed
let title = parts[1].trimmed
let downloadName = parts.count > 2 ? parts[2].trimmed : nil
guard let file = content.file(id: fileId) else {
results.missingFiles.insert(fileId)
return nil
}
results.files.insert(file)
let filePath = content.absoluteUrlToFile(file)
return ContentButtons.Item(icon: .download, filePath: filePath, text: title, downloadFileName: downloadName)
}
/**
Format: `![video](<fileId>;<option1...>]`
*/
private func handleVideo(_ arguments: [String]) -> String {
private func handleVideo(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count >= 1 else {
results.invalidCommandArguments.append((.video, arguments))
results.invalid(command: .video, markdown)
return ""
}
let fileId = arguments[0].trimmed
let options = arguments.dropFirst().compactMap(convertVideoOption)
let options = arguments.dropFirst().compactMap { convertVideoOption($0, markdown: markdown) }
guard let file = content.file(id: fileId) else {
results.missingFiles.insert(fileId)
guard let file = content.file(fileId) else {
results.missing(file: fileId, markdown: markdown)
return ""
}
results.files.insert(file)
guard let videoType = file.type.videoType?.htmlType else {
results.invalidCommandArguments.append((.video, arguments))
results.invalid(command: .video, markdown)
return ""
}
let filePath = content.absoluteUrlToFile(file)
return ContentPageVideo(
filePath: filePath,
filePath: file.absoluteUrl,
videoType: videoType,
options: options)
.content
}
private func convertVideoOption(_ videoOption: String) -> VideoOption? {
private func convertVideoOption(_ videoOption: String, markdown: Substring) -> VideoOption? {
guard let optionText = videoOption.trimmed.nonEmpty else {
return nil
}
guard let option = VideoOption(rawValue: optionText) else {
results.invalidCommandArguments.append((.video, [optionText]))
results.invalid(command: .video, markdown)
return nil
}
if case let .poster(imageId) = option {
if let image = content.image(imageId) {
results.files.insert(image)
let link = content.absoluteUrlToFile(image)
let width = 2*thumbnailWidth
let fullLink = WebsiteImage.imagePath(source: link, width: width, height: width)
let fullLink = WebsiteImage.imagePath(source: image.absoluteUrl, width: width, height: width)
return .poster(image: fullLink)
} else {
results.missingFiles.insert(imageId)
results.missing(file: imageId, markdown: markdown)
return nil // Image file not present, so skip the option
}
}
if case let .src(videoId) = option {
if let video = content.video(videoId) {
results.files.insert(video)
let link = content.absoluteUrlToFile(video)
let link = video.absoluteUrl
// TODO: Set correct video path?
return .src(link)
} else {
results.missingFiles.insert(videoId)
results.missing(file: videoId, markdown: markdown)
return nil // Video file not present, so skip the option
}
}
return option
}
private func handleExternalButtons(_ arguments: [String]) -> String {
// ![external](<<url>;<text>...>
handleButtons(icon: .externalLink, arguments: arguments)
}
private func handleGitButtons(_ arguments: [String]) -> String {
// ![git](<<url>;<text>...>
handleButtons(icon: .gitLink, arguments: arguments)
}
private func handleButtons(icon: PageIcon, arguments: [String]) -> String {
guard arguments.count >= 1 else {
results.invalidCommandArguments.append((.externalLink, arguments))
return ""
}
let buttons: [ContentButtons.Item] = arguments.compactMap { button in
let parts = button.components(separatedBy: ",")
guard parts.count == 2 else {
results.invalidCommandArguments.append((.externalLink, parts))
return nil
}
let rawUrl = parts[0].trimmed
guard let url = rawUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
results.invalidCommandArguments.append((.externalLink, parts))
return nil
}
let title = parts[1].trimmed
return .init(
icon: icon,
filePath: url,
text: title)
}
return ContentButtons(items: buttons).content
}
/**
Format: `![html](<fileId>)`
*/
private func handleExternalHtml(_ arguments: [String]) -> String {
private func handleExternalHtml(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count == 1 else {
results.invalidCommandArguments.append((.includedHtml, arguments))
results.invalid(command: .includedHtml, markdown)
return ""
}
let fileId = arguments[0]
guard let file = content.file(id: fileId) else {
results.missingFiles.insert(fileId)
guard let file = content.file(fileId) else {
results.missing(file: fileId, markdown: markdown)
return ""
}
return file.textContent()
@ -407,9 +386,9 @@ final class PageContentParser {
/**
Format: `![box](<title>;<body>)`
*/
private func handleSimpleBox(_ arguments: [String]) -> String {
private func handleSimpleBox(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count > 1 else {
results.invalidCommandArguments.append((.box, arguments))
results.invalid(command: .box, markdown)
return ""
}
let title = arguments[0]
@ -420,15 +399,15 @@ final class PageContentParser {
/**
Format: `![page](<pageId>)`
*/
private func handlePageLink(_ arguments: [String]) -> String {
private func handlePageLink(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count == 1 else {
results.invalidCommandArguments.append((.pageLink, arguments))
results.invalid(command: .pageLink, markdown)
return ""
}
let pageId = arguments[0]
guard let page = content.page(pageId) else {
results.missingPages.insert(pageId)
results.missing(page: pageId, markdown: markdown)
return ""
}
guard !page.isDraft else {
@ -437,7 +416,7 @@ final class PageContentParser {
}
let localized = page.localized(in: language)
let url = content.absoluteUrlToPage(page, language: language)
let url = page.absoluteUrl(for: language)
let title = localized.linkPreviewTitle ?? localized.title
let description = localized.linkPreviewDescription ?? ""
@ -447,7 +426,7 @@ final class PageContentParser {
results.imagesToGenerate.insert(.init(size: size, image: image))
return RelatedPageLink.Image(
url: content.absoluteUrlToFile(image),
url: image.absoluteUrl,
description: image.getDescription(for: language),
size: size)
}
@ -463,32 +442,31 @@ final class PageContentParser {
/**
Format: `![model](<file>)`
*/
private func handleModel(_ arguments: [String]) -> String {
private func handleModel(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count == 1 else {
results.invalidCommandArguments.append((.model, arguments))
results.invalid(command: .model, markdown)
return ""
}
let fileId = arguments[0]
guard fileId.hasSuffix(".glb") else {
results.invalidCommandArguments.append((.model, ["\(fileId) is not a .glb file"]))
results.invalid(command: .model, markdown)
return ""
}
guard let file = content.file(id: fileId) else {
results.missingFiles.insert(fileId)
guard let file = content.file(fileId) else {
results.missing(file: fileId, markdown: markdown)
return ""
}
results.files.insert(file)
results.requiredHeaders.insert(.modelViewer)
let path = content.absoluteUrlToFile(file)
let description = file.getDescription(for: language)
return ModelViewer(file: path, description: description).content
return ModelViewer(file: file.absoluteUrl, description: description).content
}
private func handleSvg(_ arguments: [String]) -> String {
private func handleSvg(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count == 5 else {
results.invalidCommandArguments.append((.svg, arguments))
results.invalid(command: .svg, markdown)
return ""
}
@ -496,26 +474,24 @@ final class PageContentParser {
let y = Int(arguments[2]),
let partWidth = Int(arguments[3]),
let partHeight = Int(arguments[4]) else {
results.invalidCommandArguments.append((.svg, arguments))
results.invalid(command: .svg, markdown)
return ""
}
let imageId = arguments[0]
guard let image = content.image(imageId) else {
results.missingFiles.insert(imageId)
results.missing(file: imageId, markdown: markdown)
return ""
}
guard case .image(let imageType) = image.type,
imageType == .svg else {
results.invalidCommandArguments.append((.svg, arguments))
results.invalid(command: .svg, markdown)
return ""
}
let path = content.absoluteUrlToFile(image)
return PartialSvgImage(
imagePath: path,
imagePath: image.absoluteUrl,
altText: image.getDescription(for: language),
x: x,
y: y,
@ -523,7 +499,6 @@ final class PageContentParser {
height: partHeight)
.content
}
}
/*

View File

@ -23,6 +23,9 @@ final class PageGenerationResults: ObservableObject {
@Published
var linkedTags: Set<Tag> = []
@Published
var externalLinks: Set<String> = []
@Published
var files: Set<FileResource> = []
@ -39,10 +42,7 @@ final class PageGenerationResults: ObservableObject {
var missingTags: Set<String> = []
@Published
var unknownCommands: [String] = []
@Published
var invalidCommandArguments: [(command: ShorthandMarkdownKey, arguments: [String])] = []
var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = []
@Published
var requiredHeaders: RequiredHeaders = []
@ -50,26 +50,45 @@ final class PageGenerationResults: ObservableObject {
@Published
var requiredFooters: Set<String> = []
@Published
var requiredIcons: Set<PageIcon> = []
@Published
var issues: Set<PageContentAnomaly> = []
func reset() {
linkedPages = []
linkedTags = []
externalLinks = []
files = []
imagesToGenerate = []
missingPages = []
missingFiles = []
missingTags = []
unknownCommands = []
invalidCommandArguments = []
invalidCommands = []
requiredHeaders = []
requiredFooters = []
requiredIcons = []
issues = []
}
var convertedWarnings: [PageContentAnomaly] {
var result = [PageContentAnomaly]()
result += missingPages.map { .missingPage($0) }
result += missingFiles.map { .missingFile($0) }
result += unknownCommands.map { .unknownCommand($0) }
result += invalidCommandArguments.map { .invalidCommandArguments(command: $0.command, arguments: $0.arguments) }
return result
func invalid(command: ShorthandMarkdownKey?, _ markdown: Substring) {
invalidCommands.append((command, String(markdown)))
issues.insert(.invalidCommand(command: command, markdown: String(markdown)))
}
func missing(page: String, markdown: Substring) {
missingPages.insert(page)
issues.insert(.missingPage(page: page, markdown: String(markdown)))
}
func missing(tag: String, markdown: Substring) {
missingTags.insert(tag)
issues.insert(.missingTag(tag: tag, markdown: String(markdown)))
}
func missing(file: String, markdown: Substring) {
missingFiles.insert(file)
issues.insert(.missingFile(file: file, markdown: String(markdown)))
}
}

View File

@ -43,7 +43,8 @@ final class PageGenerator {
navigationBarLinks: navigationBarLinks,
pageContent: pageContent,
headers: headers.content,
footers: contentGenerator.results.requiredFooters.sorted())
footers: contentGenerator.results.requiredFooters.sorted(),
icons: contentGenerator.results.requiredIcons)
.content
return (fullPage, contentGenerator.results)

View File

@ -62,7 +62,7 @@ final class PostListPageGenerator {
let linkUrl = post.linkedPage.map {
FeedEntryData.Link(
url: content.absoluteUrlToPage($0, language: language),
url: $0.absoluteUrl(for: language),
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings
}
@ -104,7 +104,7 @@ final class PostListPageGenerator {
maxWidth: mainContentMaximumWidth,
maxHeight: mainContentMaximumWidth)
return .init(
rawImagePath: content.absoluteUrlToFile(image),
rawImagePath: image.absoluteUrl,
width: Int(mainContentMaximumWidth),
height: Int(mainContentMaximumWidth),
altText: image.getDescription(for: language))

View File

@ -5,10 +5,16 @@ enum HeaderFile: String {
case modelViewer = "model-viewer.min.js"
case audioPlayerCss = "audio-player.css"
case amplitude = "amplitude.min.js"
var asModule: Bool {
switch self {
case .codeHightlighting: return false
case .modelViewer: return true
case .amplitude: return false
case .audioPlayerCss: return false
}
}
}

View File

@ -13,20 +13,17 @@ enum ShorthandMarkdownKey: String {
/// Format: `![image](<imageId>;<caption?>]`
case image
/// Statistics about hiking
/// Format: `![hiking-stats](<`
case hikingStatistics = "hiking-stats"
/// Labels with an icon and a value
/// Format: `![labels](<icon=value>...)`
case labels
/// A video
/// Format: `![video](<fileId>;<option1...>]`
case video
/// An SVG image
/// Format: `![svg](<fileId>;<<x>;<y>;<width>;<height>?>)`
/// A variable number of download buttons for file downloads
/// Format: `[download](<<fileId>,<text>,<download-filename?>;...)`
case downloadButtons = "download"
/// Format: `[buttons](type=<<fileId>,<text>,<download-filename?>;...)`
case buttons
/// A box with a title and content
/// Format: `![box](<title>;<body>)`
@ -40,20 +37,16 @@ enum ShorthandMarkdownKey: String {
/// Format: `![page](<pageId>)`
case pageLink = "page"
/// A large button to an external page.
/// Format: `![external](<<url>;<text>...>`
case externalLink = "external"
/// A large button to a git/github page
/// Format: `![git](<<url>;<text>...>`
case gitLink = "git"
/// Additional HTML code include verbatim into the page.
/// Format: `![html](<fileId>)`
case includedHtml = "html"
/// SVG Image showing only a part of the image
/// Format `![svg](<fileId>;`
/// Format `![svg](<fileId>;<<x>;<y>;<width>;<height>>?)`
case svg
/// A player to play audio files
/// Format: `![audio-player](<fileId>;<text>)`
case audioPlayer = "audio-player"
}

View File

@ -4,14 +4,6 @@ extension Content {
("/" + path).replacingOccurrences(of: "//", with: "/")
}
private func pathPrefix(for file: FileResource) -> String {
switch file.type {
case .image: return settings.paths.imagesOutputFolderPath
case .video: return settings.paths.videosOutputFolderPath
default: return settings.paths.filesOutputFolderPath
}
}
// MARK: Paths to items
func absoluteUrlPrefixForTag(_ tag: Tag, language: ContentLanguage) -> String {
@ -22,20 +14,6 @@ extension Content {
absoluteUrlPrefixForTag(tag, language: language) + ".html"
}
func absoluteUrlToPage(_ page: Page, language: ContentLanguage) -> String {
// TODO: Record link to trace connections between pages
makeCleanAbsolutePath(settings.pages.pageUrlPrefix + "/" + page.localized(in: language).urlString)
}
/**
Get the url path to a file in the output folder.
The result is an absolute path from the output folder for use in HTML.
*/
func absoluteUrlToFile(_ file: FileResource) -> String {
let path = pathPrefix(for: file) + "/" + file.id
return makeCleanAbsolutePath(path)
}
// MARK: Find items by id
func page(_ pageId: String) -> Page? {
@ -50,8 +28,8 @@ extension Content {
files.first { $0.id == videoId && $0.type.isVideo }
}
func file(id: String) -> FileResource? {
files.first { $0.id == id }
func file(_ fileId: String) -> FileResource? {
files.first { $0.id == fileId }
}
func tag(_ tagId: String) -> Tag? {

View File

@ -135,6 +135,7 @@ extension Content {
pages[pageId] = Page(
content: self,
id: pageId,
externalLink: page.externalLink,
isDraft: page.isDraft,
createdDate: page.createdDate,
startDate: page.startDate,

View File

@ -47,6 +47,7 @@ private extension Page {
var pageFile: PageFile {
.init(isDraft: isDraft,
externalLink: externalLink,
tags: tags.map { $0.id },
createdDate: createdDate,
startDate: startDate,

View File

@ -0,0 +1,28 @@
import Foundation
extension Content {
private static let disallowedCharactersInIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted
private static let disallowedCharactersInFileIds = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-.")).inverted
func isNewIdForTag(_ id: String) -> Bool {
!tags.contains { $0.id == id }
}
func isNewIdForPage(_ id: String) -> Bool {
!pages.contains { $0.id == id }
}
func isNewIdForPost(_ id: String) -> Bool {
!posts.contains { $0.id == id }
}
func isValidIdForTagOrTagOrPost(_ id: String) -> Bool {
id.rangeOfCharacter(from: Content.disallowedCharactersInIds) == nil
}
func isValidIdForFile(_ id: String) -> Bool {
id.rangeOfCharacter(from: Content.disallowedCharactersInFileIds) == nil
}
}

View File

@ -1,9 +1,7 @@
import Foundation
import SwiftUI
final class FileResource: ObservableObject {
unowned let content: Content
final class FileResource: Item {
let type: FileType
@ -24,24 +22,24 @@ final class FileResource: ObservableObject {
var size: CGSize = .zero
init(content: Content, id: String, isExternallyStored: Bool, en: String, de: String) {
self.content = content
self.id = id
self.type = FileType(fileExtension: id.fileExtension)
self.englishDescription = en
self.germanDescription = de
self.isExternallyStored = isExternallyStored
super.init(content: content)
}
/**
Only for bundle images
*/
init(resourceImage: String, type: ImageFileType) {
self.content = .mock // TODO: Add images to mock
self.type = .image(type)
self.id = resourceImage
self.englishDescription = "A test image included in the bundle"
self.germanDescription = "Ein Testbild aus dem Bundle"
self.isExternallyStored = true
super.init(content: .mock) // TODO: Add images to mock
}
func getDescription(for language: ContentLanguage) -> String {
@ -62,6 +60,10 @@ final class FileResource: ObservableObject {
}
}
func dataContent() throws -> Data {
try content.storage.fileData(for: id)
}
// MARK: Images
var aspectRatio: CGFloat {
@ -94,6 +96,26 @@ final class FileResource: ObservableObject {
private var failureImage: Image {
Image(systemSymbol: .exclamationmarkTriangle)
}
// MARK: Paths
/**
Get the url path to a file in the output folder.
The result is an absolute path from the output folder for use in HTML.
*/
var absoluteUrl: String {
let path = pathPrefix + "/" + id
return makeCleanAbsolutePath(path)
}
private var pathPrefix: String {
switch type {
case .image: return content.settings.paths.imagesOutputFolderPath
case .video: return content.settings.paths.videosOutputFolderPath
default: return content.settings.paths.filesOutputFolderPath
}
}
}
extension FileResource: Identifiable {

View File

@ -0,0 +1,18 @@
import Foundation
class Item: ObservableObject {
unowned let content: Content
init(content: Content) {
self.content = content
}
func makeCleanAbsolutePath(_ path: String) -> String {
"/" + makeCleanRelativePath(path)
}
func makeCleanRelativePath(_ path: String) -> String {
path.components(separatedBy: "/").filter { !$0.isEmpty }.joined(separator: "/")
}
}

View File

@ -1,8 +1,6 @@
import Foundation
final class Page: ObservableObject {
unowned let content: Content
final class Page: Item {
/**
The unique id of the entry
@ -10,6 +8,15 @@ final class Page: ObservableObject {
@Published
var id: String
/**
The external link this page points to.
If this value is not `nil`, then the page has no content
and many other features are disabled.
*/
@Published
var externalLink: String?
@Published
var isDraft: Bool
@ -44,6 +51,7 @@ final class Page: ObservableObject {
init(content: Content,
id: String,
externalLink: String?,
isDraft: Bool,
createdDate: Date,
startDate: Date,
@ -51,8 +59,8 @@ final class Page: ObservableObject {
german: LocalizedPage,
english: LocalizedPage,
tags: [Tag]) {
self.content = content
self.id = id
self.externalLink = externalLink
self.isDraft = isDraft
self.createdDate = createdDate
self.startDate = startDate
@ -61,6 +69,8 @@ final class Page: ObservableObject {
self.german = german
self.english = english
self.tags = tags
super.init(content: content)
}
func localized(in language: ContentLanguage) -> LocalizedPage {
@ -78,6 +88,28 @@ final class Page: ObservableObject {
id = newId
return true
}
var isExternalUrl: Bool {
externalLink != nil
}
// MARK: Paths
func absoluteUrl(for language: ContentLanguage) -> String {
if let url = externalLink {
return url
}
// TODO: Record link to trace connections between pages
return makeCleanAbsolutePath(internalPath(for: language))
}
func filePathRelativeToOutputFolder(for language: ContentLanguage) -> String {
makeCleanRelativePath(internalPath(for: language))
}
private func internalPath(for language: ContentLanguage) -> String {
content.settings.pages.pageUrlPrefix + "/" + localized(in: language).urlString
}
}
extension Page: Identifiable {

View File

@ -0,0 +1,22 @@
struct Song {
let name: String
let artist: String
let album: String
let track: Int
/// The file id of the audio file
let file: String
/// The file id of the cover image
let cover: String
}
extension Song: Codable {
}

View File

@ -15,6 +15,13 @@ final class Tag: ObservableObject {
@Published
var english: LocalizedTag
init(id: String) {
self.isVisible = true
self.english = .init(urlComponent: id, name: id)
let deId = id + "-" + ContentLanguage.german.rawValue
self.german = .init(urlComponent: deId, name: deId)
}
init(isVisible: Bool = true, german: LocalizedTag, english: LocalizedTag) {
self.isVisible = isVisible
self.german = german

View File

@ -5,10 +5,17 @@ struct AdditionalPageHeaders {
let assetPath: String
#warning("Provide paths in settings, import files")
var content: String {
headers.map { header in
let module = header.asModule ? " type='module'" : ""
return "<script\(module) src='\(assetPath)/\(header.rawValue)'></script>"
}.sorted().joined()
headers.map(header).sorted().joined()
}
private func header(for asset: HeaderFile) -> String {
let file = asset.rawValue
guard file.hasSuffix(".js") else {
return "<link rel='stylesheet' type='text/css' href='\(assetPath)/css/\(file)'>"
}
let module = asset.asModule ? " type='module'" : ""
return "<script\(module) src='\(assetPath)/js/\(file)'></script>"
}
}

View File

@ -0,0 +1,128 @@
struct AudioPlayer: HtmlProducer {
let playingText: String
let items: [PlaylistItem]
private var top: String {
"""
<div class='top'>
<div>&nbsp;</div>
<div class='top-center'>\(playingText)</div>
<div class='show-playlist'><svg><use href='#\(AudioPlayerPlaylistIcon.name)'></use></svg></div>
</div>
"""
}
private var center: String {
"""
<div class='center'>
<img data-amplitude-song-info='cover_art_url' class='main-album-art'/>
<div class='song-meta-data'>
<span data-amplitude-song-info='name' class='song-name'></span>
<span data-amplitude-song-info='artist' class='song-artist'></span>
</div>
<div class='time-progress'>
<div id='progress-container'>
<input type='range' class='amplitude-song-slider'/>
<progress id='song-played-progress' class='amplitude-song-played-progress'></progress>
<progress id='song-buffered-progress' class='amplitude-buffered-progress' value='0'></progress>
</div>
<div class='time-container'>
<span class='current-time'>
<span class='amplitude-current-hours'></span>:<span class='amplitude-current-minutes'></span>:<span class='amplitude-current-seconds'></span>
</span>
<span class='duration'>
<span class='amplitude-duration-hours'></span>:<span class='amplitude-duration-minutes'></span>:<span class='amplitude-duration-seconds'></span>
</span>
</div>
</div>
</div>
"""
}
private var controls: String {
"""
<div id="audio-player-controls">
<svg id="previous" class="amplitude-prev"><use href='#\(AudioPlayerPreviousIcon.name)'></use></svg>
<div class="amplitude-play-pause" id="play-pause">
<svg class="play-icon"><use href='#\(AudioPlayerPlayIcon.name)'></use></svg>
<svg class="pause-icon"><use href='#\(AudioPlayerPauseIcon.name)'></use></svg>
</div>
<svg id="next" class="amplitude-next"><use href='#\(AudioPlayerNextIcon.name)'></use></svg>
</div>
"""
}
private var playlistStart: String {
"""
<div id="playlist-container">
<div class="top">
<div class="queue">Playlist</div>
<div class="close-playlist"><svg><use href='#\(AudioPlayerCloseIcon.name)'></use></svg></div>
</div>
<div class="playlist">
"""
}
private var playlistEnd: String {
"""
</div>
<div class="white-player-playlist-controls">
<img data-amplitude-song-info="cover_art_url" class="playlist-album-art"/>
<div class="playlist-controls">
<div class="playlist-meta-data">
<span data-amplitude-song-info="name" class="song-name"></span>
<span data-amplitude-song-info="artist" class="song-artist"></span>
</div>
<div class="playlist-control-wrapper">
<svg class="amplitude-prev" id="playlist-previous"><use href='#\(AudioPlayerPreviousIcon.name)'></use></svg>
<div class="amplitude-play-pause" id="playlist-play-pause">
<svg class="play-icon"><use href='#\(AudioPlayerPlayIcon.name)'></use></svg>
<svg class="pause-icon"><use href='#\(AudioPlayerPauseIcon.name)'></use></svg>
</div>
<svg class="amplitude-next" id="playlist-next"><use href='#\(AudioPlayerNextIcon.name)'></use></svg>
</div>
</div>
</div>
</div>
"""
}
func populate(_ result: inout String) {
result += "<div class='audio-player'>"
result += top
result += center
result += controls
result += playlistStart
for item in items {
result += item.content
}
result += playlistEnd
result += "</div>"
}
struct PlaylistItem {
let index: Int
let image: String
let name: String
let album: String
let track: Int
let artist: String
var content: String {
"""
<div class="playlist-song amplitude-song-container amplitude-play-pause amplitude-paused" data-amplitude-song-index="\(index)"><img src="\(image)"><div class="playlist-song-meta"><span class="playlist-song-name">\(name)</span><span class="playlist-song-artist">\(album) &bull; \(artist)</span></div></div>
"""
}
}
}

View File

@ -0,0 +1,46 @@
import Foundation
struct AmplitudeSong: Codable {
let name: String
let artist: String
let album: String
let track: String
let url: String
let cover_art_url: String
}
struct AudioPlayerScript: HtmlProducer {
let items: [AmplitudeSong]
init(items: [AmplitudeSong]) {
self.items = items
}
func populate(_ result: inout String) {
result += "<script>\n"
result += "Amplitude.init({ songs: "
let songData = try! JSONEncoder().encode(items)
result += String(data: songData, encoding: .utf8)!
result += "});"
result += "function playEntry(index) { Amplitude.playSongAtIndex(index) };"
result += animatePlaylist
result += "</script>"
}
private var animatePlaylist: String {
"""
const el = document.getElementById('playlist-container')
document.getElementsByClassName('show-playlist')[0].addEventListener('click', function(){
el.classList.remove('slide-out-top');
el.classList.add('slide-in-top');
el.style.display = "block";
});
document.getElementsByClassName('close-playlist')[0].addEventListener('click', function(){
el.classList.remove('slide-in-top');
el.classList.add('slide-out-top');
el.style.display = "none";
});
"""
}
}

View File

@ -5,17 +5,20 @@ struct ContentButtons {
let icon: PageIcon
let filePath: String
let filePath: String?
let text: String
let downloadFileName: String?
init(icon: PageIcon, filePath: String, text: String, downloadFileName: String? = nil) {
let onClickText: String?
init(icon: PageIcon, filePath: String?, text: String, downloadFileName: String? = nil, onClickText: String? = nil) {
self.icon = icon
self.filePath = filePath
self.text = text
self.downloadFileName = downloadFileName
self.onClickText = onClickText
}
}
@ -36,8 +39,10 @@ struct ContentButtons {
private func addButton(of item: Item, to result: inout String) {
let downloadText = item.downloadFileName.map { " download='\($0)'" } ?? ""
result += "<a class='tag' href='\(item.filePath)'\(downloadText)>"
result += "<svg><use href='#\(item.icon.name)'></use></svg>\(item.text)"
let linkText = item.filePath.map { " href='\($0)'" } ?? ""
let onClickText = item.onClickText.map { " onClick='\($0)'" } ?? ""
result += "<a class='tag'\(linkText)\(downloadText)\(onClickText)>"
result += "<svg><use href='#\(item.icon.icon.name)'></use></svg>\(item.text)"
result += "</a>"
}
}

View File

@ -0,0 +1,28 @@
struct ContentLabel {
let icon: PageIcon
let value: String
}
struct ContentLabels {
private let labels: [ContentLabel]
init(labels: [ContentLabel]) {
self.labels = labels
}
var content: String {
guard !labels.isEmpty else {
return ""
}
var result = "<div class='labels-container'>"
for label in labels {
result += "<div><svg><use href='#\(label.icon.icon.name)'></use></svg>\(label.value)</div>"
}
result += "</div>"
return result
}
}

View File

@ -1,42 +0,0 @@
struct HikingStatistics {
private let time: String?
private let elevationUp: String?
private let elevationDown: String?
private let distance: String?
private let calories: String?
init(time: String?, elevationUp: String?, elevationDown: String?, distance: String?, calories: String?) {
self.time = time
self.elevationUp = elevationUp
self.elevationDown = elevationDown
self.distance = distance
self.calories = calories
}
var content: String {
var result = "<div class='stats-container'>"
if let time {
result += "<div><svg><use href='#icon-clock'></use></svg>\(time)</div>"
}
if let elevationUp {
result += "<div><svg><use href='#icon-arrow-up'></use></svg>\(elevationUp)</div>"
}
if let elevationDown {
result += "<div><svg><use href='#icon-arrow-down'></use></svg>\(elevationDown)</div>"
}
if let distance {
result += "<div><svg><use href='#icon-sign'></use></svg>\(distance)</div>"
}
if let calories {
result += "<div><svg><use href='#icon-flame'></use></svg>\(calories)</div>"
}
result += "</div>"
return result
}
}

View File

@ -1,107 +0,0 @@
enum PageIcon: CaseIterable {
case time
case elevationUp
case elevationDown
case distance
case calories
case download
case externalLink
case gitLink
var icon: String {
switch self {
case .time: return PageIcon.timeIcon
case .elevationUp: return PageIcon.elevationUpIcon
case .elevationDown: return PageIcon.elevationDownIcon
case .distance: return PageIcon.distanceIcon
case .calories: return PageIcon.caloriesIcon
case .download: return PageIcon.downloadIcon
case .externalLink: return PageIcon.externalLinkIcon
case .gitLink: return PageIcon.gitLinkIcon
}
}
var name: String {
switch self {
case .time: return "icon-clock"
case .elevationUp: return "icon-arrow-up"
case .elevationDown: return "icon-arrow-down"
case .distance: return "icon-sign"
case .calories: return "icon-flame"
case .download: return "icon-download"
case .externalLink: return "icon-external"
case .gitLink: return "icon-git"
}
}
}
extension PageIcon {
private static let timeIcon =
"""
<svg id="icon-clock" width="16" height="16" viewBox="0 0 16 16">
<path fill="currentColor" d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .3.4l3.5 2a.5.5 0 0 0 .4-.8L8 8.7V3.5z"/>
<path fill="currentColor" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
</svg>
"""
private static let elevationUpIcon =
"""
<svg id="icon-arrow-up" width="16" height="16">
<path fill="currentColor" d="m14 2.5a.5.5 0 0 0 -.5-.5h-6a.5.5 0 0 0 0 1h4.8l-10.16 10.15a.5.5 0 0 0 .7.7l10.16-10.14v4.79a.5.5 0 0 0 1 0z"/>
</svg>
"""
private static let elevationDownIcon =
"""
<svg id="icon-arrow-down" width="16" height="16">
<path fill="currentColor" fill-rule="evenodd" d="M14 13.5a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1 0-1h4.8L2.14 2.85a.5.5 0 1 1 .7-.7L13 12.29V7.5a.5.5 0 0 1 1 0v6z"/>
</svg>
"""
private static let distanceIcon =
"""
<svg id="icon-sign" width="16" height="16">
<path fill="currentColor" d="M7 1.4V4H2a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h5v6h2v-6h3.5a1 1 0 0 0 .8-.4l2-2.3a.5.5 0 0 0 0-.6l-2-2.3a1 1 0 0 0-.8-.4H9V1.4a1 1 0 0 0-2 0zM12.5 5l1.7 2-1.7 2H2V5h10.5z"/>
</svg>
"""
private static let caloriesIcon =
"""
<svg id="icon-flame" width="16" height="16">
<path fill="currentColor" d="M8 16c3.3 0 6-2 6-5.5 0-1.5-.5-4-2.5-6 .3 1.5-1.3 2-1.3 2C11 4 9 .5 6 0c.4 2 .5 4-2 6-1.3 1-2 2.7-2 4.5C2 14 4.7 16 8 16Zm0-1c-1.7 0-3-1-3-2.8 0-.7.3-2 1.3-3-.2.8.7 1.3.7 1.3-.4-1.3.5-3.3 2-3.5-.2 1-.3 2 1 3a3 3 0 0 1 1 2.3C11 14 9.7 15 8 15Z"/>
</svg>
"""
private static let downloadIcon: String =
"""
<svg id="icon-download" viewBox="0 0 40 40">
<path fill="currentColor" fill-rule="evenodd" stroke="none" d="M20 40a20 20 0 1 1 20-20 20 20 0 0 1-20 20zm0-36.8A16.8 16.8 0 1 0 36.8 20 16.8 16.8 0 0 0 20 3.2zm.8 27a1 1 0 0 1-1.6 0L12.1 21c-.4-.4 0-1 .7-1H17v-8.7a.8.8 0 0 1 .8-.8h4.4a.8.8 0 0 1 .8.8V20h4.2c.6 0 1.1.5.7 1l-7.1 9.2z"/>
</svg>
"""
private static let externalLinkIcon: String =
"""
<svg id="icon-external" viewBox="0 0 16 16">
<path fill="currentColor" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/>
</svg>
"""
private static let gitLinkIcon: String =
"""
<svg id="icon-git" viewBox="0 0 16 16">
<path fill="currentColor" d="M15.698 7.287 8.712.302a1.03 1.03 0 0 0-1.457 0l-1.45 1.45 1.84 1.84a1.223 1.223 0 0 1 1.55 1.56l1.773 1.774a1.224 1.224 0 0 1 1.267 2.025 1.226 1.226 0 0 1-2.002-1.334L8.58 5.963v4.353a1.226 1.226 0 1 1-1.008-.036V5.887a1.226 1.226 0 0 1-.666-1.608L5.093 2.465l-4.79 4.79a1.03 1.03 0 0 0 0 1.457l6.986 6.986a1.03 1.03 0 0 0 1.457 0l6.953-6.953a1.03 1.03 0 0 0 0-1.457"/>
</svg>
"""
}

View File

@ -0,0 +1,55 @@
struct AudioPlayerPlaylistIcon: ContentIcon {
static let name = "icon-playlist"
static let content = """
<svg id='icon-playlist' viewBox="0 0 28 20"><g fill="none"><rect width="15" height="4" x="13" fill="currentColor" rx="2"/><g fill="currentColor"><path d="M0 1.2C0 .7.4.4.8.7l9.3 5.8c.5.3.5.7 0 1L.8 13.3c-.4.2-.8 0-.8-.5z"/><rect width="14" height="4" x="14" y="8" rx="2"/><rect width="28" height="4" y="16" rx="2"/></g></g></svg>
"""
}
struct AudioPlayerCloseIcon: ContentIcon {
static let name = "icon-close"
static let content = """
<svg id='icon-close' viewBox="0 0 18 18"><path fill="currentColor" d="M9 6.194 3.392.586A1.986 1.986 0 0 0 .582.582c-.78.78-.773 2.033.004 2.81L6.194 9 .586 14.608a1.986 1.986 0 0 0-.004 2.81c.78.78 2.033.773 2.81-.004L9 11.806l5.608 5.608a1.986 1.986 0 0 0 2.81.004c.78-.78.773-2.033-.004-2.81L11.806 9l5.608-5.608a1.986 1.986 0 0 0 .004-2.81 1.982 1.982 0 0 0-2.81.004z"/></svg>
"""
}
struct AudioPlayerPauseIcon: ContentIcon {
static let name = "icon-pause"
static let content = """
<svg id='icon-pause' viewBox="0 0 85 85"><g fill="none"><circle cx="42.5" cy="42.5" fill="currentColor" r="42.5"/><path d="m34 55h6v-24h-6zm12 0h6v-24h-6z" fill="#fff" stroke="#fff"/></g></svg>
"""
}
struct AudioPlayerPlayIcon: ContentIcon {
static let name = "icon-play"
static let content = """
<svg id='icon-play' viewBox="0 0 85 85"><g fill="none"><circle cx="42.5" cy="42.5" r="42.5" fill="currentColor"/><path fill="#fff" d="M33.3 31.3c0-2.3 1.5-3.1 3.4-2l18.8 11.5c2 1.1 2 3 0 4.1L36.7 56.3c-1.9 1.2-3.4.3-3.4-1.9z"/></g>
</svg>
"""
}
struct AudioPlayerPreviousIcon: ContentIcon {
static let name = "icon-previous"
static let content = """
<svg id='icon-previous' viewBox="0 0 53 53"><g fill="none" transform="matrix(-1 0 0 1 53 0)"><circle cx="26.5" cy="26.5" r="26.5" fill="currentColor"/><g fill="#fff" transform="translate(16 17)"><path d="M.4 1.8C.4.6 1.2.2 2.2.8l12.3 7.5c1 .5 1 1.5 0 2L2.2 17.8c-1 .6-1.8.1-1.8-1z"/><rect width="3" height="17" x="18" y="1" rx="1.5"/></g></g></svg>
"""
}
struct AudioPlayerNextIcon: ContentIcon {
static let name = "icon-next"
static let content = """
<svg id='icon-next' viewBox="0 0 53 53"><g fill="none"><circle cx="26.5" cy="26.5" r="26.5" fill="currentColor"/><g fill="#fff" transform="translate(16 17)"><path d="M.4 1.8C.4.6 1.2.2 2.2.8l12.3 7.5c1 .5 1 1.5 0 2L2.2 17.8c-1 .6-1.8.1-1.8-1z"/><rect width="3" height="17" x="18" y="1" rx="1.5"/></g></g></svg>
"""
}

View File

@ -0,0 +1,36 @@
struct ButtonDownloadIcon: ContentIcon {
static let name = "icon-download"
static let content = """
<svg id="icon-download" viewBox="0 0 40 40"><path fill="currentColor" fill-rule="evenodd" stroke="none" d="M20 40a20 20 0 1 1 20-20 20 20 0 0 1-20 20zm0-36.8A16.8 16.8 0 1 0 36.8 20 16.8 16.8 0 0 0 20 3.2zm.8 27a1 1 0 0 1-1.6 0L12.1 21c-.4-.4 0-1 .7-1H17v-8.7a.8.8 0 0 1 .8-.8h4.4a.8.8 0 0 1 .8.8V20h4.2c.6 0 1.1.5.7 1l-7.1 9.2z"/></svg>
"""
}
struct ButtonExternalIcon: ContentIcon {
static let name = "icon-external"
static let content = """
<svg id="icon-external" viewBox="0 0 16 16"><path fill="currentColor" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m7.5-6.923c-.67.204-1.335.82-1.887 1.855A8 8 0 0 0 5.145 4H7.5zM4.09 4a9.3 9.3 0 0 1 .64-1.539 7 7 0 0 1 .597-.933A7.03 7.03 0 0 0 2.255 4zm-.582 3.5c.03-.877.138-1.718.312-2.5H1.674a7 7 0 0 0-.656 2.5zM4.847 5a12.5 12.5 0 0 0-.338 2.5H7.5V5zM8.5 5v2.5h2.99a12.5 12.5 0 0 0-.337-2.5zM4.51 8.5a12.5 12.5 0 0 0 .337 2.5H7.5V8.5zm3.99 0V11h2.653c.187-.765.306-1.608.338-2.5zM5.145 12q.208.58.468 1.068c.552 1.035 1.218 1.65 1.887 1.855V12zm.182 2.472a7 7 0 0 1-.597-.933A9.3 9.3 0 0 1 4.09 12H2.255a7 7 0 0 0 3.072 2.472M3.82 11a13.7 13.7 0 0 1-.312-2.5h-2.49c.062.89.291 1.733.656 2.5zm6.853 3.472A7 7 0 0 0 13.745 12H11.91a9.3 9.3 0 0 1-.64 1.539 7 7 0 0 1-.597.933M8.5 12v2.923c.67-.204 1.335-.82 1.887-1.855q.26-.487.468-1.068zm3.68-1h2.146c.365-.767.594-1.61.656-2.5h-2.49a13.7 13.7 0 0 1-.312 2.5m2.802-3.5a7 7 0 0 0-.656-2.5H12.18c.174.782.282 1.623.312 2.5zM11.27 2.461c.247.464.462.98.64 1.539h1.835a7 7 0 0 0-3.072-2.472c.218.284.418.598.597.933M10.855 4a8 8 0 0 0-.468-1.068C9.835 1.897 9.17 1.282 8.5 1.077V4z"/></svg>
"""
}
struct ButtonGitIcon: ContentIcon {
static let name = "icon-git"
static let content = """
<svg id="icon-git" viewBox="0 0 16 16"><path fill="currentColor" d="M15.698 7.287 8.712.302a1.03 1.03 0 0 0-1.457 0l-1.45 1.45 1.84 1.84a1.223 1.223 0 0 1 1.55 1.56l1.773 1.774a1.224 1.224 0 0 1 1.267 2.025 1.226 1.226 0 0 1-2.002-1.334L8.58 5.963v4.353a1.226 1.226 0 1 1-1.008-.036V5.887a1.226 1.226 0 0 1-.666-1.608L5.093 2.465l-4.79 4.79a1.03 1.03 0 0 0 0 1.457l6.986 6.986a1.03 1.03 0 0 0 1.457 0l6.953-6.953a1.03 1.03 0 0 0 0-1.457"/></svg>
"""
}
struct ButtonPlayIcon: ContentIcon {
static let name = "icon-play-circle"
static let content = """
<svg id='icon-play-circle' viewBox="0 0 1000 1000"><g fill="currentColor"><path d="M452.6 11.2A495 495 0 0 0 90.1 229.8 525.5 525.5 0 0 0 19.8 398c-8.3 40.7-9.8 56.6-9.8 101.6s1.5 60.5 9.8 101.5A529.7 529.7 0 0 0 90 769.5 493.9 493.9 0 0 0 499.6 990c185 0 355.6-106 438.6-272.3a486.8 486.8 0 0 0-46.8-512.3A494.2 494.2 0 0 0 568.6 13.9c-24-3.7-91.5-5.4-116-2.7zm85.5 76.4c31 3.1 59.6 9.2 90.6 19.4a413.4 413.4 0 0 1 263.9 264.2 412 412 0 0 1-100.8 420.6A413.7 413.7 0 0 1 460 911.4 415.2 415.2 0 0 1 87.6 538a416.4 416.4 0 0 1 143.7-353.3 417.2 417.2 0 0 1 306.8-97.2z"/><path d="M375.4 291.7c-4.2 2-7.5 5.4-10 11-3.6 8-3.8 14.1-3.8 196.9 0 183 .2 189 3.8 197 5 10.9 14.4 15.3 26 12.2 11.4-3 320-183.1 329.5-192.3 6.9-6.8 7.6-8.3 7.6-17s-.7-10-7.6-16.8c-7.9-7.6-314-187.2-326.5-191.6-8.3-2.9-11.1-2.9-19 .6z"/></g></svg>
"""
}

View File

@ -0,0 +1,18 @@
protocol ContentIcon {
static var name: String { get }
static var content: String { get }
}
extension ContentIcon {
var name: String {
Self.name
}
var content: String {
Self.content
}
}

View File

@ -0,0 +1,63 @@
enum PageIcon: String, CaseIterable {
// MARK: Statistics
case statisticsTime = "time"
case statisticsElevationUp = "elevation-up"
case statisticsElevationDown = "elevation-down"
case statisticsDistance = "distance"
case statisticsEnergy = "energy"
// MARK: Buttons
case buttonDownload = "download"
case buttonExternalLink = "external"
case buttonGitLink = "git"
case buttonPlay = "play-circle"
// MARK: Audio player
case audioPlayerPlaylist = "playlist"
case audioPlayerClose = "close"
case audioPlayerPlay = "play"
case audioPlayerPause = "pause"
case audioPlayerPrevious = "previous"
case audioPlayerNext = "next"
var icon: ContentIcon.Type {
switch self {
case .statisticsTime: return StatisticsTimeIcon.self
case .statisticsElevationUp: return StatisticsElevationUpIcon.self
case .statisticsElevationDown: return StatisticsElevationDownIcon.self
case .statisticsDistance: return StatisticsDistanceIcon.self
case .statisticsEnergy: return StatisticsEnergyIcon.self
case .buttonDownload: return ButtonDownloadIcon.self
case .buttonExternalLink: return ButtonExternalIcon.self
case .buttonGitLink: return ButtonGitIcon.self
case .buttonPlay: return ButtonPlayIcon.self
case .audioPlayerPlaylist: return AudioPlayerPlaylistIcon.self
case .audioPlayerClose: return AudioPlayerCloseIcon.self
case .audioPlayerPlay: return AudioPlayerPlayIcon.self
case .audioPlayerPause: return AudioPlayerPauseIcon.self
case .audioPlayerPrevious: return AudioPlayerPreviousIcon.self
case .audioPlayerNext: return AudioPlayerNextIcon.self
}
}
}
extension PageIcon: Hashable {
}

View File

@ -0,0 +1,45 @@
struct StatisticsTimeIcon: ContentIcon {
static let name = "icon-time"
static let content = """
<svg id="icon-time" width="16" height="16" viewBox="0 0 16 16"><path fill="currentColor" d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .3.4l3.5 2a.5.5 0 0 0 .4-.8L8 8.7V3.5z"/><path fill="currentColor" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/></svg>
"""
}
struct StatisticsElevationUpIcon: ContentIcon {
static let name = "icon-elevation-up"
static let content = """
<svg id="icon-elevation-up" width="16" height="16"><path fill="currentColor" d="m14 2.5a.5.5 0 0 0 -.5-.5h-6a.5.5 0 0 0 0 1h4.8l-10.16 10.15a.5.5 0 0 0 .7.7l10.16-10.14v4.79a.5.5 0 0 0 1 0z"/></svg>
"""
}
struct StatisticsElevationDownIcon: ContentIcon {
static let name = "icon-elevation-down"
static let content = """
<svg id="icon-elevation-down" width="16" height="16"><path fill="currentColor" fill-rule="evenodd" d="M14 13.5a.5.5 0 0 1-.5.5h-6a.5.5 0 0 1 0-1h4.8L2.14 2.85a.5.5 0 1 1 .7-.7L13 12.29V7.5a.5.5 0 0 1 1 0v6z"/></svg>
"""
}
struct StatisticsDistanceIcon: ContentIcon {
static let name = "icon-distance"
static let content = """
<svg id="icon-distance" width="16" height="16"><path fill="currentColor" d="M7 1.4V4H2a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h5v6h2v-6h3.5a1 1 0 0 0 .8-.4l2-2.3a.5.5 0 0 0 0-.6l-2-2.3a1 1 0 0 0-.8-.4H9V1.4a1 1 0 0 0-2 0zM12.5 5l1.7 2-1.7 2H2V5h10.5z"/></svg>
"""
}
struct StatisticsEnergyIcon: ContentIcon {
static let name = "icon-energy"
static let content = """
<svg id="icon-energy" width="16" height="16"><path fill="currentColor" d="M8 16c3.3 0 6-2 6-5.5 0-1.5-.5-4-2.5-6 .3 1.5-1.3 2-1.3 2C11 4 9 .5 6 0c.4 2 .5 4-2 6-1.3 1-2 2.7-2 4.5C2 14 4.7 16 8 16Zm0-1c-1.7 0-3-1-3-2.8 0-.7.3-2 1.3-3-.2.8.7 1.3.7 1.3-.4-1.3.5-3.3 2-3.5-.2 1-.3 2 1 3a3 3 0 0 1 1 2.3C11 14 9.7 15 8 15Z"/></svg>
"""
}

View File

@ -22,7 +22,9 @@ struct ContentPage: HtmlProducer {
private let footers: String
init(language: ContentLanguage, dateString: String, title: String, tags: [FeedEntryData.Tag], linkTitle: String, description: String, navigationBarLinks: [NavigationBar.Link], pageContent: String, headers: String, footers: [String]) {
private let icons: Set<PageIcon>
init(language: ContentLanguage, dateString: String, title: String, tags: [FeedEntryData.Tag], linkTitle: String, description: String, navigationBarLinks: [NavigationBar.Link], pageContent: String, headers: String, footers: [String], icons: Set<PageIcon>) {
self.language = language
self.dateString = dateString
self.title = title
@ -33,6 +35,7 @@ struct ContentPage: HtmlProducer {
self.pageContent = pageContent
self.headers = headers
self.footers = footers.joined()
self.icons = icons
}
func populate(_ result: inout String) {
@ -55,13 +58,12 @@ struct ContentPage: HtmlProducer {
result += "</body></html>" // Close content
}
#warning("Select only required symbols")
private let symbols: String = {
private var symbols: String {
var result = "<div style='display:none'>"
for icon in PageIcon.allCases {
result += icon.icon
for icon in icons {
result += icon.icon.content
}
result += "</div>"
return result
}()
}
}

View File

@ -6,6 +6,7 @@ extension Page {
.init(
content: .mock,
id: "my-id",
externalLink: nil,
isDraft: true,
createdDate: Date(),
startDate: Date().addingTimeInterval(-86400),

View File

@ -4,6 +4,8 @@ struct PageFile {
let isDraft: Bool
let externalLink: String?
let tags: [String]
let createdDate: Date

View File

@ -0,0 +1,28 @@
import SwiftUI
struct FileSelectionView: View {
@Binding
private var selectedFile: FileResource?
@Environment(\.dismiss)
private var dismiss
init(selectedFile: Binding<FileResource?>) {
self._selectedFile = selectedFile
}
var body: some View {
VStack {
FileListView(selectedFile: $selectedFile)
.frame(minHeight: 500, idealHeight: 600)
HStack {
Button("Cancel") {
selectedFile = nil
dismiss() }
Button("Select") { dismiss() }
}
}
.padding()
}
}

View File

@ -69,6 +69,7 @@ struct AddPageView: View {
let page = Page(
content: content,
id: newPageId,
externalLink: nil,
isDraft: true,
createdDate: .now,
startDate: .now,

View File

@ -6,18 +6,14 @@ struct LocalizedPageContentView: View {
let pageId: String
let language: ContentLanguage
@ObservedObject
var page: LocalizedPage
@Environment(\.language)
private var language
@State
private var isGeneratingWebsite = false
@State
private var loadedPageContentLanguage: ContentLanguage?
@State
private var pageContent: String = ""
@ -27,10 +23,13 @@ struct LocalizedPageContentView: View {
@State
private var generationResults = PageGenerationResults()
@State
private var didChangeContent = false
init(pageId: String, page: LocalizedPage) {
init(pageId: String, page: LocalizedPage, language: ContentLanguage) {
self.pageId = pageId
self.page = page
self.language = language
}
var body: some View {
@ -55,6 +54,9 @@ struct LocalizedPageContentView: View {
HighlightedTextEditor(
text: $pageContent,
highlightRules: .markdown)
.onChange(of: pageContent) {
didChangeContent = true
}
}
.padding()
.onAppear(perform: loadContent)
@ -68,25 +70,33 @@ struct LocalizedPageContentView: View {
guard content != "" else {
pageContent = "New file"
loadedPageContentLanguage = nil
DispatchQueue.main.async {
didChangeContent = false
}
return
}
pageContent = content
loadedPageContentLanguage = language
checkContent()
} catch {
print("Failed to load page content: \(error)")
pageContent = "Failed to load"
loadedPageContentLanguage = nil
}
DispatchQueue.main.async {
didChangeContent = false
}
}
private func saveContent() {
guard let loadedPageContentLanguage else {
guard pageContent != "New file", pageContent != "" else {
// TODO: Delete file?
return
}
guard didChangeContent else {
return
}
do {
try page.content.storage.save(pageContent: pageContent, for: pageId, language: loadedPageContentLanguage)
try page.content.storage.save(pageContent: pageContent, for: pageId, language: language)
didChangeContent = false
} catch {
print("Failed to save content: \(error)")
}

View File

@ -34,10 +34,12 @@ struct LocalizedPageDetailView: View {
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Page URL String")
.font(.headline)
TextField("", text: $newUrlString)
.textFieldStyle(.roundedBorder)
Button("Update", action: setNewId)
.disabled(newUrlString.isEmpty || containsInvalidCharacters || idExists)
.disabled(newUrlString.isEmpty || containsInvalidCharacters || idExists)
}
.padding(.bottom)

View File

@ -74,11 +74,19 @@ struct PageContentResultsView: View {
text: "\(results.files.count + results.missingFiles.count) images and files",
items: results.files.sorted().map { $0.id })
.foregroundStyle(.secondary)
TextWithPopup(
symbol: .docBadgePlus,
text: "\(results.linkedPages.count + results.missingPages.count) page links",
items: results.linkedPages.sorted().map { $0.localized(in: language).title })
.foregroundStyle(.secondary)
TextWithPopup(
symbol: .globe,
text: "\(results.externalLinks.count) external links",
items: results.externalLinks.sorted())
.foregroundStyle(.secondary)
if !results.missingPages.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
@ -93,20 +101,11 @@ struct PageContentResultsView: View {
items: results.missingFiles.sorted())
.foregroundStyle(.red)
}
if !results.unknownCommands.isEmpty {
if !results.invalidCommands.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
text: "\(results.unknownCommands.count) unknown commands",
items: results.unknownCommands.sorted())
.foregroundStyle(.red)
}
if !results.invalidCommandArguments.isEmpty {
TextWithPopup(
symbol: .exclamationmarkTriangleFill,
text: "\(results.invalidCommandArguments.count) errors",
items: results.invalidCommandArguments.map {
"\($0.command.rawValue): \($0.arguments.joined(separator: ";"))"
})
text: "\(results.invalidCommands.count) invalid commands",
items: results.invalidCommands.map { $0.markdown }.sorted())
.foregroundStyle(.red)
}
}

View File

@ -24,16 +24,25 @@ struct PageContentView: View {
@EnvironmentObject
private var content: Content
@State
private var isGeneratingWebsite = false
init(page: Page) {
self.page = page
}
var body: some View {
LocalizedPageContentView(pageId: page.id, page: page.localized(in: language))
.id(page.id + language.rawValue)
if page.isExternalUrl {
VStack {
PageTitleView(page: page.localized(in: language))
.id(page.id + language.rawValue)
Spacer()
Text("No content available for external page")
.font(.title)
.foregroundStyle(.secondary)
Spacer()
}.padding()
} else {
LocalizedPageContentView(pageId: page.id, page: page.localized(in: language), language: language)
.id(page.id + language.rawValue)
}
}
}

View File

@ -62,6 +62,13 @@ struct PageDetailView: View {
}
.padding(.bottom)
Text("External url")
.font(.headline)
OptionalTextField("", text: $page.externalLink,
prompt: "External url")
.textFieldStyle(.roundedBorder)
.padding(.bottom)
HStack {
Text("Draft")
.font(.headline)
@ -120,19 +127,20 @@ struct PageDetailView: View {
return
}
isGeneratingWebsite = true
print("Generating page")
DispatchQueue.global(qos: .userInitiated).async {
var success = true
for language in ContentLanguage.allCases {
let generator = LocalizedWebsiteGenerator(
content: content,
language: language)
if !generator.generate(page: page) {
print("Generation failed")
success = false
}
}
DispatchQueue.main.async {
isGeneratingWebsite = false
print("Done")
didGenerateWebsite = success
}
}
}

View File

@ -39,6 +39,9 @@ struct PostDetailView: View {
@State
private var newId: String
@State
private var showLinkedPagePicker = false
init(post: Post) {
self.post = post
self.newId = post.id
@ -105,11 +108,29 @@ struct PostDetailView: View {
}
}
HStack {
Text("Linked page")
.font(.headline)
IconButton(symbol: .squareAndPencilCircleFill,
size: 22,
color: .blue) {
showLinkedPagePicker = true
}
Spacer()
}
Text(post.linkedPage?.localized(in: language).title ?? "No page linked")
LocalizedPostDetailView(post: post.localized(in: language))
}
.padding()
}
.sheet(isPresented: $showLinkedPagePicker) {
PagePickerView(
showPagePicker: $showLinkedPagePicker,
selectedPage: $post.linkedPage)
}
}
private func setNewId() {

View File

@ -1,33 +1,5 @@
import SwiftUI
private struct PageIssue {
let id: Int
let page: Page
let language: ContentLanguage
let message: PageContentAnomaly
init(page: Page, language: ContentLanguage, message: PageContentAnomaly) {
self.id = .random()
self.page = page
self.language = language
self.message = message
}
var title: String {
page.localized(in: language).title
}
}
extension PageIssue: Identifiable {
}
private struct FixSheet: View {
@Binding
@ -72,275 +44,35 @@ private struct FixSheet: View {
}
}
private struct ErrorSheet: View {
@Binding
var isPresented: Bool
@Binding
var message: String
var body: some View {
VStack {
Text("Error")
.font(.headline)
Text(message)
Button("Dismiss", action: { isPresented = false })
}
}
}
struct PageSettingsContentView: View {
@EnvironmentObject
private var content: Content
@State
private var isCheckingPages: Bool = false
@State
private var issues: [PageIssue] = []
@State
private var message: String = "No fix available"
@State
private var infoItems: [String] = ["No items set"]
@State
private var fixAction: () -> () = {
print("No fix action defined")
}
@State
private var showFixActionSheet: Bool = false
@State
private var errorMessage: String = ""
@State
private var showErrorAlert: Bool = false
@StateObject
var checker: PageIssueChecker = .init()
var body: some View {
VStack(alignment: .leading) {
HStack {
Button("Check pages", action: checkAllPagesForErrors)
.disabled(isCheckingPages)
Button("Fix all", action: applyAllEasyFixes)
if isCheckingPages {
Button("Check pages", action: { checker.check(pages: content.pages) })
.disabled(checker.isCheckingPages)
if checker.isCheckingPages {
ProgressView()
.progressViewStyle(.circular)
.frame(height: 20)
}
}
Text("\(issues.count) Issues")
Text("\(checker.issues.count) Issues")
.font(.headline)
List(issues) { issue in
List(checker.issues.sorted()) { issue in
HStack {
Button("Attempt Fix", action: { attemptFix(issue: issue) })
VStack(alignment: .leading) {
Text(issue.message.description)
Text("\(issue.title) (\(issue.language.rawValue.uppercased()))")
.font(.caption)
}
PageIssueView(issue: issue)
.id(issue.id)
}
.environmentObject(checker)
}
}
.padding()
.sheet(isPresented: $showFixActionSheet) {
FixSheet(isPresented: $showFixActionSheet,
message: $message,
infoItems: $infoItems) {
fixAction()
resetFixSheet()
}
}
.sheet(isPresented: $showErrorAlert) {
ErrorSheet(isPresented: $showErrorAlert, message: $errorMessage)
}
}
private func checkAllPagesForErrors() {
guard !isCheckingPages else {
return
}
isCheckingPages = true
issues = []
DispatchQueue.global(qos: .userInitiated).async {
for language in ContentLanguage.allCases {
let parser = PageContentParser(
content: content,
language: language)
for page in content.pages {
analyze(page: page, parser: parser)
}
}
DispatchQueue.main.async {
self.isCheckingPages = false
}
}
}
private func analyze(page: Page, parser: PageContentParser) {
parser.reset()
do {
let rawPageContent = try content.storage.pageContent(for: page.id, language: parser.language)
_ = parser.generatePage(from: rawPageContent)
let results = parser.results.convertedWarnings.map {
PageIssue(page: page, language: parser.language, message: $0)
}
DispatchQueue.main.async {
issues = results + issues
}
} catch {
let message = PageContentAnomaly.failedToLoadContent(error)
let error = PageIssue(page: page, language: parser.language, message: message)
DispatchQueue.main.async {
issues.insert(error, at: 0)
}
}
}
private func applyAllEasyFixes() {
issues.forEach { issue in
switch issue.message {
case .missingFile(let file):
fix(missingFile: file, in: issue.page, language: issue.language, ask: false)
case .unknownCommand(let string):
fixUnknownCommand(string, in: issue.page, language: issue.language)
default:
return
}
}
}
private func attemptFix(issue: PageIssue) {
switch issue.message {
case .failedToLoadContent:
show(error: "No fix available for read errors")
case .missingFile(let string):
fix(missingFile: string, in: issue.page, language: issue.language)
case .missingPage(let string):
show(error: "No fix available for missing page \(string)")
case .unknownCommand(let string):
fixUnknownCommand(string, in: issue.page, language: issue.language)
case .invalidCommandArguments(let command, let arguments):
show(error: "No fix available for invalid arguments to command \(command) (\(arguments))")
case .missingTag(let string):
show(error: "No fix available for missing tag \(string)")
}
}
private func fix(missingFile: String, in page: Page, language: ContentLanguage, ask: Bool = true) {
print("Fixing missing file \(missingFile)")
let fileId = page.id + "-" + missingFile
if let file = content.file(id: fileId) {
replace(missingFile, with: file.id, in: page, language: language)
// Remove all errors of the page, and generate them new
recalculate(page: page, language: language)
return
}
guard ask else {
return
}
let partialMatches = content.files.filter { $0.id.contains(missingFile) }
guard partialMatches.count == 1 else {
show(error: "Found \(partialMatches.count) matches for file \(missingFile): \(partialMatches.map { $0.id })")
return
}
let file = partialMatches[0]
// Ask to fix partially matching file
let occurences = findOccurences(of: missingFile, in: page, language: language)
message = "Found file '\(file.id)' to match \(missingFile) on page '\(page.localized(in: language).title)'. Do you want to replace it?"
infoItems = occurences
fixAction = {
replace(missingFile, with: file.id, in: page, language: language)
// Remove all errors of the page, and generate them new
recalculate(page: page, language: language)
}
DispatchQueue.main.async {
showFixActionSheet = true
}
}
private func recalculate(page: Page, language: ContentLanguage) {
let remaining = issues.filter {
$0.language != language || $0.page.id != page.id
}
DispatchQueue.main.async {
self.issues = remaining
self.isCheckingPages = true
DispatchQueue.global(qos: .userInitiated).async {
let parser = PageContentParser(content: content, language: language)
self.analyze(page: page, parser: parser)
self.isCheckingPages = false
}
}
}
private func resetFixSheet() {
DispatchQueue.main.async {
self.message = "No fix available"
self.fixAction = { print("No fix action defined") }
self.infoItems = ["No items set"]
}
}
private func show(error: String) {
DispatchQueue.main.async {
errorMessage = error
showErrorAlert = true
}
}
private func findMatchingFile(with missingFile: String, in page: Page) -> FileResource? {
let fileId = page.id + "-" + missingFile
if let file = content.file(id: fileId) {
return file
}
let partialMatches = content.files.filter { $0.id.contains(missingFile) }
if partialMatches.count == 1 {
return partialMatches[0]
}
show(error: "Found \(partialMatches.count) matches for file \(missingFile): \(partialMatches.map { $0.id })")
return nil
}
private func findOccurences(of searchString: String, in page: Page, language: ContentLanguage) -> [String] {
let parts: [String]
do {
parts = try content.storage.pageContent(for: page.id, language: language)
.components(separatedBy: searchString)
} catch {
show(error: "Failed to get page content to find occurences: \(error.localizedDescription)")
return []
}
var occurrences: [String] = []
for index in parts.indices.dropLast() {
let start = parts[index].suffix(10)
let end = parts[index+1].prefix(10)
let full = "...\(start)\(searchString)\(end)...".replacingOccurrences(of: "\n", with: "\\n")
occurrences.append(full)
}
return occurrences
}
private func replace(_ oldString: String, with newString: String, in page: Page, language: ContentLanguage) {
do {
let pageContent = try content.storage.pageContent(for: page.id, language: language)
.replacingOccurrences(of: oldString, with: newString)
try content.storage.save(pageContent: pageContent, for: page.id, language: language)
print("Replaced \(oldString) with \(newString) in page \(page.id) (\(language))")
} catch {
print("Failed to replace in page \(page.id) (\(language)): \(error)")
}
}
private func fixUnknownCommand(_ string: String, in page: Page, language: ContentLanguage) {
show(error: "No fix available for command '\(string)'")
}
}

View File

@ -0,0 +1,49 @@
struct PageIssue {
let page: Page
let language: ContentLanguage
let message: PageContentAnomaly
init(page: Page, language: ContentLanguage, message: PageContentAnomaly) {
self.page = page
self.language = language
self.message = message
print("\(title) (\(language)): \(message)")
}
var title: String {
page.localized(in: language).title
}
}
extension PageIssue: Identifiable {
var id: String {
page.id + "-" + language.rawValue + "-" + message.id
}
}
extension PageIssue: Equatable {
static func == (lhs: PageIssue, rhs: PageIssue) -> Bool {
lhs.id == rhs.id
}
}
extension PageIssue: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
extension PageIssue: Comparable {
static func < (lhs: PageIssue, rhs: PageIssue) -> Bool {
lhs.id < rhs.id
}
}

View File

@ -0,0 +1,84 @@
import Foundation
final class PageIssueChecker: ObservableObject {
@Published
var isCheckingPages: Bool = false
@Published
var issues: Set<PageIssue> = []
init() {
}
func check(pages: [Page], clearListBeforeStart: Bool = true) {
guard !isCheckingPages else {
return
}
isCheckingPages = true
if clearListBeforeStart {
issues = []
}
DispatchQueue.global(qos: .userInitiated).async {
for language in ContentLanguage.allCases {
self.check(pages: pages, in: language)
}
DispatchQueue.main.async {
self.isCheckingPages = false
}
}
}
private func check(pages: [Page], in language: ContentLanguage) {
for page in pages {
analyze(page: page, in: language)
}
}
func check(page: Page, in language: ContentLanguage) {
guard !isCheckingPages else {
return
}
isCheckingPages = true
DispatchQueue.global(qos: .userInitiated).async {
self.analyze(page: page, in: language)
DispatchQueue.main.async {
self.isCheckingPages = false
}
}
}
private func analyze(page: Page, in language: ContentLanguage) {
let parser = PageContentParser(content: page.content, language: language)
let hasPreviousIssues = issues.contains { $0.page == page && $0.language == language }
let pageIssues: [PageIssue]
do {
let rawPageContent = try page.content.storage.pageContent(for: page.id, language: language)
_ = parser.generatePage(from: rawPageContent)
pageIssues = parser.results.issues.map {
PageIssue(page: page, language: language, message: $0)
}
} catch {
let message = PageContentAnomaly.failedToLoadContent(error)
let error = PageIssue(page: page, language: language, message: message)
pageIssues = [error]
}
guard hasPreviousIssues || !pageIssues.isEmpty else {
return
}
update(issues: pageIssues, for: page, in: parser.language)
}
private func update(issues: [PageIssue], for page: Page, in language: ContentLanguage) {
let newIssues = self.issues
.filter { $0.page != page || $0.language != language }
.union(issues)
DispatchQueue.main.async {
self.issues = newIssues
}
}
}

View File

@ -0,0 +1,316 @@
import SwiftUI
private struct ButtonAction {
let name: String
let action: () -> Void
}
private struct PopupSheet: View {
@Binding
var isPresented: Bool
@Binding
var title: String
@Binding
var message: String
var body: some View {
VStack {
Text(title)
.font(.headline)
Text(message)
Button("Dismiss") {
message = ""
isPresented = false
}
}.padding()
}
}
private struct PageIssueGenericView: View {
let issue: PageIssue
let buttons: [ButtonAction]
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(issue.message.description)
Text("\(issue.title) (\(issue.language.rawValue.uppercased()))")
.font(.caption)
}
Spacer()
ForEach(buttons, id: \.name) { button in
Button(button.name, action: button.action)
}
}
}
}
struct PageIssueView: View {
let issue: PageIssue
@EnvironmentObject
private var checker: PageIssueChecker
@EnvironmentObject
private var content: Content
@State
private var showPopupMessage = false
@State
private var popupTitle = "Error"
@State
private var popupMessage = ""
@State
private var showPagePicker = false
@State
private var selectedPage: Page?
@State
private var showFilePicker = false
@State
private var selectedFile: FileResource?
private var buttons: [ButtonAction] {
switch issue.message {
case .failedToLoadContent:
return [.init(name: "Retry", action: retryPageCheck)]
case .missingFile(let missing, _):
return [
.init(name: "Select file", action: { selectFile(missingFile: missing) }),
.init(name: "Create external file", action: { createExternalFile(fileId: missing) })
]
case .missingPage(let missing, _):
return [
.init(name: "Select page", action: selectPage),
.init(name: "Create page", action: { createPage(pageId: missing) })
]
case .missingTag(let missing, _):
return [
.init(name: "Select tag", action: { selectTag(missingPage: missing) }),
.init(name: "Create tag", action: { createTag(tagId: missing) })
]
case .invalidCommand(_, let markdown):
return [.init(name: "Replace text", action: { replaceCommand(originalText: markdown) })]
}
}
var body: some View {
PageIssueGenericView(issue: issue, buttons: buttons)
.sheet(isPresented: $showPopupMessage) {
PopupSheet(isPresented: $showPopupMessage, title: $popupTitle, message: $popupMessage)
}
.sheet(isPresented: $showPagePicker) {
if let page = selectedPage {
didSelect(page: page)
}
} content: {
PagePickerView(
showPagePicker: $showPagePicker,
selectedPage: $selectedPage)
}
.sheet(isPresented: $showFilePicker) {
if let file = selectedFile {
didSelect(file: file)
}
} content: {
FileSelectionView(selectedFile: $selectedFile)
}
}
private func show(error: String) {
DispatchQueue.main.async {
self.popupTitle = "Error"
self.popupMessage = error
self.showPopupMessage = true
}
}
private func show(info: String) {
DispatchQueue.main.async {
self.popupTitle = "Info"
self.popupMessage = info
self.showPopupMessage = true
}
}
private func retryPageCheck() {
DispatchQueue.main.async {
checker.check(pages: content.pages, clearListBeforeStart: false)
}
}
private func selectFile(missingFile: String) {
selectedFile = nil
showFilePicker = true
}
private func didSelect(file newFile: FileResource) {
guard case .missingFile(let missingFile, let markdown) = issue.message else {
show(error: "Inconsistency: Selected file, but issue is not a missing file")
return
}
replace(missing: missingFile, with: newFile.id, in: markdown)
retryPageCheck()
DispatchQueue.main.async {
selectedFile = nil
}
}
private func createExternalFile(fileId: String) {
guard content.isValidIdForFile(fileId) else {
show(error: "Invalid file id, can't create external file")
return
}
let file = FileResource(
content: content,
id: fileId,
isExternallyStored: true,
en: "",
de: "")
content.files.append(file)
retryPageCheck()
}
private func selectPage() {
selectedPage = nil
showPagePicker = true
}
private func didSelect(page newPage: Page) {
guard case .missingPage(let missingPage, let markdown) = issue.message else {
show(error: "Inconsistency: Selected page, but issue is not a missing page")
return
}
replace(missing: missingPage, with: newPage.id, in: markdown)
retryPageCheck()
DispatchQueue.main.async {
selectedPage = nil
}
}
private func createPage(pageId: String) {
guard content.isValidIdForTagOrTagOrPost(pageId) else {
show(error: "Invalid page id, can't create page")
return
}
let deString = pageId + "-" + ContentLanguage.german.rawValue
let page = Page(
content: content,
id: pageId,
externalLink: nil,
isDraft: true,
createdDate: .now,
startDate: .now,
endDate: nil,
german: .init(content: content,
urlString: deString,
title: pageId),
english: .init(content: content,
urlString: pageId,
title: pageId),
tags: [])
content.pages.insert(page, at: 0)
retryPageCheck()
}
private func selectTag(missingPage: String) {
// TODO: Show sheet to select a tag
// TODO: Replace tag id in page content with new tag id
retryPageCheck()
}
private func createTag(tagId: String) {
guard content.isValidIdForTagOrTagOrPost(tagId) else {
show(error: "Invalid tag id, can't create tag")
return
}
let tag = Tag(id: tagId)
content.tags.append(tag)
retryPageCheck()
}
private func replaceCommand(originalText: String) {
// TODO: Show sheet with text input
// TODO: Replace original text in page content with new text
retryPageCheck()
}
// MARK: Page Content manipulation
private func replace(missing: String, with newText: String, in markdown: String) {
let newString = markdown.replacingOccurrences(of: missing, with: newText)
guard newString != markdown else {
show(error: "No change in content detected trying to perform replacement")
return
}
let occurrences = findOccurrences(of: markdown, in: issue.page, language: issue.language)
guard !occurrences.isEmpty else {
show(error: "No occurrences of '\(markdown)' found in the page")
return
}
replace(markdown, with: newString, in: issue.page, language: issue.language)
show(info: "Replaced \(occurrences.count) occurrences of '\(missing)' with '\(newText)'")
retryPageCheck()
}
private func replace(_ oldString: String, with newString: String, in page: Page, language: ContentLanguage) {
do {
let pageContent = try content.storage.pageContent(for: page.id, language: language)
.replacingOccurrences(of: oldString, with: newString)
try content.storage.save(pageContent: pageContent, for: page.id, language: language)
print("Replaced \(oldString) with \(newString) in page \(page.id) (\(language))")
} catch {
print("Failed to replace in page \(page.id) (\(language)): \(error)")
}
}
private func findOccurrences(of searchString: String, in page: Page, language: ContentLanguage) -> [String] {
let parts: [String]
do {
parts = try content.storage.pageContent(for: page.id, language: language)
.components(separatedBy: searchString)
} catch {
print("Failed to get page content to find occurrences: \(error.localizedDescription)")
return []
}
var occurrences: [String] = []
for index in parts.indices.dropLast() {
let start = parts[index].suffix(10)
let end = parts[index+1].prefix(10)
let full = "...\(start)\(searchString)\(end)...".replacingOccurrences(of: "\n", with: "\\n")
occurrences.append(full)
}
return occurrences
}
}