Save automatically, improve mocks

This commit is contained in:
Christoph Hagen 2025-02-05 12:24:33 +01:00
parent d41c54d174
commit 5abe6e1a9f
55 changed files with 701 additions and 381 deletions

View File

@ -7,6 +7,9 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
E20BCC972D53454C00B8DBEB /* StorageItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC962D53454500B8DBEB /* StorageItem.swift */; };
E20BCC992D53597D00B8DBEB /* SaveState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC982D53597D00B8DBEB /* SaveState.swift */; };
E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.swift */; };
E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850082CEE01BF0090B18B /* PagePickerView.swift */; }; E21850092CEE01C30090B18B /* PagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850082CEE01BF0090B18B /* PagePickerView.swift */; };
E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500A2CEE02FA0090B18B /* Content+Mock.swift */; }; E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500A2CEE02FA0090B18B /* Content+Mock.swift */; };
E21850172CEE55FC0090B18B /* FileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850162CEE55FB0090B18B /* FileType.swift */; }; E21850172CEE55FC0090B18B /* FileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21850162CEE55FB0090B18B /* FileType.swift */; };
@ -46,6 +49,8 @@
E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; }; E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24252092C52C9260029FF16 /* ContentLanguage.swift */; };
E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521DFB2D501DAE00C56662 /* PostContentGenerator.swift */; }; E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521DFB2D501DAE00C56662 /* PostContentGenerator.swift */; };
E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521DFF2D50BB6E00C56662 /* ItemLinkResults.swift */; }; E2521E002D50BB6E00C56662 /* ItemLinkResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521DFF2D50BB6E00C56662 /* ItemLinkResults.swift */; };
E2521E022D51776300C56662 /* StorageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521E012D51776000C56662 /* StorageError.swift */; };
E2521E042D51796000C56662 /* StorageErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521E032D51795B00C56662 /* StorageErrorView.swift */; };
E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; }; E2581DED2C75202400F1F079 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2581DEC2C75202400F1F079 /* Tag.swift */; };
E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.swift */; }; E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.swift */; };
E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */; }; E25DA50B2CFD988100AEF16D /* PageTagAssignmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA50A2CFD988100AEF16D /* PageTagAssignmentView.swift */; };
@ -143,7 +148,6 @@
E2A21C082CB17B870060935B /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C072CB17B810060935B /* TagView.swift */; }; E2A21C082CB17B870060935B /* TagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C072CB17B810060935B /* TagView.swift */; };
E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.swift */; }; E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.swift */; };
E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0F2CB18B390060935B /* FlowHStack.swift */; }; E2A21C102CB18B3A0060935B /* FlowHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0F2CB18B390060935B /* FlowHStack.swift */; };
E2A21C202CB28ED20060935B /* MockImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C1F2CB28ED20060935B /* MockImage.swift */; };
E2A21C282CB29B2A0060935B /* FeedEntryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C272CB29B290060935B /* FeedEntryData.swift */; }; E2A21C282CB29B2A0060935B /* FeedEntryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C272CB29B290060935B /* FeedEntryData.swift */; };
E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C292CB2AA4C0060935B /* Post+Mock.swift */; }; E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C292CB2AA4C0060935B /* Post+Mock.swift */; };
E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C2F2CB490F90060935B /* HorizontalCenter.swift */; }; E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C2F2CB490F90060935B /* HorizontalCenter.swift */; };
@ -183,7 +187,6 @@
E2FD1D212D2EB22900B48627 /* ModelLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D202D2EB22700B48627 /* ModelLoader.swift */; }; E2FD1D212D2EB22900B48627 /* ModelLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D202D2EB22700B48627 /* ModelLoader.swift */; };
E2FD1D232D2EB27000B48627 /* LoadingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D222D2EB26C00B48627 /* LoadingResult.swift */; }; E2FD1D232D2EB27000B48627 /* LoadingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D222D2EB26C00B48627 /* LoadingResult.swift */; };
E2FD1D252D2EBA8000B48627 /* TagOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D242D2EBA7C00B48627 /* TagOverview.swift */; }; E2FD1D252D2EBA8000B48627 /* TagOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D242D2EBA7C00B48627 /* TagOverview.swift */; };
E2FD1D282D2F2DAD00B48627 /* ErrorPrinter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D272D2F2D9100B48627 /* ErrorPrinter.swift */; };
E2FD1D2A2D35B74C00B48627 /* TextWithPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D292D35B74C00B48627 /* TextWithPopup.swift */; }; E2FD1D2A2D35B74C00B48627 /* TextWithPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D292D35B74C00B48627 /* TextWithPopup.swift */; };
E2FD1D2C2D35B76D00B48627 /* ListPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D2B2D35B76D00B48627 /* ListPopup.swift */; }; E2FD1D2C2D35B76D00B48627 /* ListPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D2B2D35B76D00B48627 /* ListPopup.swift */; };
E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */; }; E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */; };
@ -264,6 +267,9 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
E20BCC962D53454500B8DBEB /* StorageItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageItem.swift; sourceTree = "<group>"; };
E20BCC982D53597D00B8DBEB /* SaveState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveState.swift; sourceTree = "<group>"; };
E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeObservableItem.swift; sourceTree = "<group>"; };
E21850082CEE01BF0090B18B /* PagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagePickerView.swift; sourceTree = "<group>"; }; E21850082CEE01BF0090B18B /* PagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagePickerView.swift; sourceTree = "<group>"; };
E218500A2CEE02FA0090B18B /* Content+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Mock.swift"; sourceTree = "<group>"; }; E218500A2CEE02FA0090B18B /* Content+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Mock.swift"; sourceTree = "<group>"; };
E21850162CEE55FB0090B18B /* FileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileType.swift; sourceTree = "<group>"; }; E21850162CEE55FB0090B18B /* FileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileType.swift; sourceTree = "<group>"; };
@ -302,6 +308,8 @@
E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = "<group>"; }; E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = "<group>"; };
E2521DFB2D501DAE00C56662 /* PostContentGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostContentGenerator.swift; sourceTree = "<group>"; }; E2521DFB2D501DAE00C56662 /* PostContentGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostContentGenerator.swift; sourceTree = "<group>"; };
E2521DFF2D50BB6E00C56662 /* ItemLinkResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLinkResults.swift; sourceTree = "<group>"; }; E2521DFF2D50BB6E00C56662 /* ItemLinkResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLinkResults.swift; sourceTree = "<group>"; };
E2521E012D51776000C56662 /* StorageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageError.swift; sourceTree = "<group>"; };
E2521E032D51795B00C56662 /* StorageErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageErrorView.swift; sourceTree = "<group>"; };
E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; }; E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = "<group>"; }; E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = "<group>"; };
E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = "<group>"; }; E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = "<group>"; };
@ -395,7 +403,6 @@
E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; }; E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = "<group>"; };
E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = "<group>"; }; E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = "<group>"; };
E2A21C0F2CB18B390060935B /* FlowHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHStack.swift; sourceTree = "<group>"; }; E2A21C0F2CB18B390060935B /* FlowHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHStack.swift; sourceTree = "<group>"; };
E2A21C1F2CB28ED20060935B /* MockImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImage.swift; sourceTree = "<group>"; };
E2A21C272CB29B290060935B /* FeedEntryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEntryData.swift; sourceTree = "<group>"; }; E2A21C272CB29B290060935B /* FeedEntryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEntryData.swift; sourceTree = "<group>"; };
E2A21C292CB2AA4C0060935B /* Post+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+Mock.swift"; sourceTree = "<group>"; }; E2A21C292CB2AA4C0060935B /* Post+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+Mock.swift"; sourceTree = "<group>"; };
E2A21C2F2CB490F90060935B /* HorizontalCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalCenter.swift; sourceTree = "<group>"; }; E2A21C2F2CB490F90060935B /* HorizontalCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalCenter.swift; sourceTree = "<group>"; };
@ -435,7 +442,6 @@
E2FD1D202D2EB22700B48627 /* ModelLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelLoader.swift; sourceTree = "<group>"; }; E2FD1D202D2EB22700B48627 /* ModelLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelLoader.swift; sourceTree = "<group>"; };
E2FD1D222D2EB26C00B48627 /* LoadingResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingResult.swift; sourceTree = "<group>"; }; E2FD1D222D2EB26C00B48627 /* LoadingResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingResult.swift; sourceTree = "<group>"; };
E2FD1D242D2EBA7C00B48627 /* TagOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOverview.swift; sourceTree = "<group>"; }; E2FD1D242D2EBA7C00B48627 /* TagOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOverview.swift; sourceTree = "<group>"; };
E2FD1D272D2F2D9100B48627 /* ErrorPrinter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPrinter.swift; sourceTree = "<group>"; };
E2FD1D292D35B74C00B48627 /* TextWithPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithPopup.swift; sourceTree = "<group>"; }; E2FD1D292D35B74C00B48627 /* TextWithPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithPopup.swift; sourceTree = "<group>"; };
E2FD1D2B2D35B76D00B48627 /* ListPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPopup.swift; sourceTree = "<group>"; }; E2FD1D2B2D35B76D00B48627 /* ListPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPopup.swift; sourceTree = "<group>"; };
E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettings.swift; sourceTree = "<group>"; }; E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettings.swift; sourceTree = "<group>"; };
@ -607,6 +613,7 @@
E29D31372D043EB80051B7F4 /* Main */ = { E29D31372D043EB80051B7F4 /* Main */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E2521E032D51795B00C56662 /* StorageErrorView.swift */,
E2FD1D332D3BA2DE00B48627 /* SelectedContent.swift */, E2FD1D332D3BA2DE00B48627 /* SelectedContent.swift */,
E229904B2D10BE59009F8D77 /* InitialSetupView.swift */, E229904B2D10BE59009F8D77 /* InitialSetupView.swift */,
E29D31422D0488950051B7F4 /* MainContentView.swift */, E29D31422D0488950051B7F4 /* MainContentView.swift */,
@ -751,7 +758,9 @@
E2A37D0F2CE5375E0000979F /* Storage */ = { E2A37D0F2CE5375E0000979F /* Storage */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E2FD1D272D2F2D9100B48627 /* ErrorPrinter.swift */, E20BCC982D53597D00B8DBEB /* SaveState.swift */,
E20BCC962D53454500B8DBEB /* StorageItem.swift */,
E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.swift */,
E229904D2D135349009F8D77 /* SecurityBookmark.swift */, E229904D2D135349009F8D77 /* SecurityBookmark.swift */,
E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */, E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */,
E22990472D10B7B7009F8D77 /* StorageAccessError.swift */, E22990472D10B7B7009F8D77 /* StorageAccessError.swift */,
@ -788,6 +797,7 @@
E25DA5162CFF00F200AEF16D /* Content+Save.swift */, E25DA5162CFF00F200AEF16D /* Content+Save.swift */,
E24252092C52C9260029FF16 /* ContentLanguage.swift */, E24252092C52C9260029FF16 /* ContentLanguage.swift */,
E2FD1D3C2D463CD800B48627 /* ContentLabel.swift */, E2FD1D3C2D463CD800B48627 /* ContentLabel.swift */,
E2521E012D51776000C56662 /* StorageError.swift */,
E25DA59A2D024A2900AEF16D /* DateItem.swift */, E25DA59A2D024A2900AEF16D /* DateItem.swift */,
E21850162CEE55FB0090B18B /* FileType.swift */, E21850162CEE55FB0090B18B /* FileType.swift */,
E2A21C502CBBD53C0060935B /* FileResource.swift */, E2A21C502CBBD53C0060935B /* FileResource.swift */,
@ -913,7 +923,6 @@
E25DA5762D018B9500AEF16D /* File+Mock.swift */, E25DA5762D018B9500AEF16D /* File+Mock.swift */,
E218500A2CEE02FA0090B18B /* Content+Mock.swift */, E218500A2CEE02FA0090B18B /* Content+Mock.swift */,
E2A37D1A2CEA45530000979F /* Tag+Mock.swift */, E2A37D1A2CEA45530000979F /* Tag+Mock.swift */,
E2A21C1F2CB28ED20060935B /* MockImage.swift */,
E2A21C292CB2AA4C0060935B /* Post+Mock.swift */, E2A21C292CB2AA4C0060935B /* Post+Mock.swift */,
E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */, E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */,
E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */, E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */,
@ -1156,6 +1165,7 @@
E218502B2CF790B30090B18B /* PostContentView.swift in Sources */, E218502B2CF790B30090B18B /* PostContentView.swift in Sources */,
E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */, E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */,
E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */, E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */,
E2521E042D51796000C56662 /* StorageErrorView.swift in Sources */,
E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */, E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */,
E2FE0F552D2BCFC4002963B7 /* ContentBlock.swift in Sources */, E2FE0F552D2BCFC4002963B7 /* ContentBlock.swift in Sources */,
E2A37D212CEA94EC0000979F /* Sequence+Sorted.swift in Sources */, E2A37D212CEA94EC0000979F /* Sequence+Sorted.swift in Sources */,
@ -1258,7 +1268,6 @@
E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */, E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */,
E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */, E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */,
E2FE0F622D2C0D8D002963B7 /* VersionedVideo.swift in Sources */, E2FE0F622D2C0D8D002963B7 /* VersionedVideo.swift in Sources */,
E2A21C202CB28ED20060935B /* MockImage.swift in Sources */,
E2FE0EEE2D1C22F3002963B7 /* MarkdownLinkProcessor.swift in Sources */, E2FE0EEE2D1C22F3002963B7 /* MarkdownLinkProcessor.swift in Sources */,
E2FE0F602D2C0422002963B7 /* VideoBlock.swift in Sources */, E2FE0F602D2C0422002963B7 /* VideoBlock.swift in Sources */,
E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */, E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */,
@ -1308,8 +1317,8 @@
E2FE0EF62D1D6DF1002963B7 /* Icon.swift in Sources */, E2FE0EF62D1D6DF1002963B7 /* Icon.swift in Sources */,
E29D31A52D0CD03F0051B7F4 /* FileSelectionView.swift in Sources */, E29D31A52D0CD03F0051B7F4 /* FileSelectionView.swift in Sources */,
E22990322D0F767B009F8D77 /* DatePropertyView.swift in Sources */, E22990322D0F767B009F8D77 /* DatePropertyView.swift in Sources */,
E20BCC992D53597D00B8DBEB /* SaveState.swift in Sources */,
E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */, E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */,
E2FD1D282D2F2DAD00B48627 /* ErrorPrinter.swift in Sources */,
E2FD1D1F2D2E9CC200B48627 /* ItemId.swift in Sources */, E2FD1D1F2D2E9CC200B48627 /* ItemId.swift in Sources */,
E2FE0EFA2D25AFBA002963B7 /* PageHeader.swift in Sources */, E2FE0EFA2D25AFBA002963B7 /* PageHeader.swift in Sources */,
E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */, E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */,
@ -1326,6 +1335,7 @@
E29D31712D08234D0051B7F4 /* GenerationDetailView.swift in Sources */, E29D31712D08234D0051B7F4 /* GenerationDetailView.swift in Sources */,
E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */, E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */,
E29D31C32D0DBEF20051B7F4 /* Song.swift in Sources */, E29D31C32D0DBEF20051B7F4 /* Song.swift in Sources */,
E2521E022D51776300C56662 /* StorageError.swift in Sources */,
E229902A2D0F5A14009F8D77 /* DetailTitle.swift in Sources */, E229902A2D0F5A14009F8D77 /* DetailTitle.swift in Sources */,
E29D31532D0618740051B7F4 /* AddPageView.swift in Sources */, E29D31532D0618740051B7F4 /* AddPageView.swift in Sources */,
E2FE0F2C2D2B119A002963B7 /* ImageCommand.swift in Sources */, E2FE0F2C2D2B119A002963B7 /* ImageCommand.swift in Sources */,
@ -1338,6 +1348,7 @@
E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */, E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */,
E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */, E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */,
E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */, E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */,
E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */,
E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */,
E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */, E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */,
E2A37D0E2CE527070000979F /* Storage.swift in Sources */, E2A37D0E2CE527070000979F /* Storage.swift in Sources */,
@ -1364,6 +1375,7 @@
E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */, E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */,
E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */, E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */,
E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */, E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */,
E20BCC972D53454C00B8DBEB /* StorageItem.swift in Sources */,
E22990362D0F79D2009F8D77 /* OptionalStringPropertyView.swift in Sources */, E22990362D0F79D2009F8D77 /* OptionalStringPropertyView.swift in Sources */,
E2FD1D602D47EEEF00B48627 /* LocalizedPostContentView.swift in Sources */, E2FD1D602D47EEEF00B48627 /* LocalizedPostContentView.swift in Sources */,
E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */, E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */,

View File

@ -58,7 +58,8 @@ struct InitialSetupView: View {
let loader = ModelLoader(content: content, storage: content.storage) let loader = ModelLoader(content: content, storage: content.storage)
let result = loader.load() let result = loader.load()
guard result.errors.isEmpty else { guard result.errors.isEmpty else {
let message = "Failed to load database\n" + result.errors.sorted().joined(separator: "\n") let message = "Failed to load database"
#warning("Show load errors")
set(message: message) set(message: message)
return return
} }

View File

@ -4,13 +4,11 @@ import SFSafeSymbols
/** /**
**Content** **Content**
- iPhone Backgrounds: Add page, html - iPhone Backgrounds: Add page, html
- CV: Update PDF
**UI** **UI**
- Image search: Add view to see all images and filter - Image search: Add view to see all images and filter
- Page Content: Show all results of `PageGenerationResults` - Page Content: Show all results of `PageGenerationResults`
- Files: Show usages of file - Files: Show usages of file
- Buttons to insert special commands (images, page links, ...)
**Features** **Features**
- Posts: Generate separate pages for posts to link to - Posts: Generate separate pages for posts to link to
@ -26,9 +24,7 @@ import SFSafeSymbols
**Fixes** **Fixes**
- Files: Id change: Check all page contents for links to the renamed file and replace occurences - Files: Id change: Check all page contents for links to the renamed file and replace occurences
- Database: Show errors during loading - Database: Show errors during loading
- Mock content: Clean and improve
- Investigate issue with spaces in content file names - Investigate issue with spaces in content file names
- Check assignment of blog posts to tags
*/ */
@main @main
@ -54,10 +50,7 @@ struct MainView: App {
private var showInitialSetupSheet = false private var showInitialSetupSheet = false
@State @State
private var showLoadErrorSheet = false private var showStorageErrorSheet = false
@State
private var loadErrors: [String] = []
@ViewBuilder @ViewBuilder
var sidebar: some View { var sidebar: some View {
@ -158,15 +151,9 @@ struct MainView: App {
}.pickerStyle(.segmented) }.pickerStyle(.segmented)
} }
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
if content.storage.contentScope != nil { Button(action: saveButtonPressed) {
Button(action: save) { Image(systemSymbol: content.saveState.symbol)
Text("Save") .foregroundStyle(content.saveState.color)
}
} else {
Button(action: showInitialSheet) {
Text("Setup")
}
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red))
} }
} }
} }
@ -175,9 +162,6 @@ struct MainView: App {
.environmentObject(content) .environmentObject(content)
.environmentObject(selection) .environmentObject(selection)
.onAppear(perform: loadContent) .onAppear(perform: loadContent)
.onReceive(Timer.publish(every: 60.0, on: .main, in: .common).autoconnect()) { _ in
save()
}
.sheet(isPresented: $showAddSheet) { .sheet(isPresented: $showAddSheet) {
addItemSheet addItemSheet
.environment(\.language, language) .environment(\.language, language)
@ -190,30 +174,23 @@ struct MainView: App {
.environmentObject(content) .environmentObject(content)
.environmentObject(selection) .environmentObject(selection)
} }
.sheet(isPresented: $showLoadErrorSheet) { .sheet(isPresented: $showStorageErrorSheet) {
VStack { StorageErrorView(isPresented: $showStorageErrorSheet)
Text("Failed to load database") .environmentObject(content)
.font(.headline)
List(loadErrors, id: \.self) { error in
HStack {
Text(error)
Spacer()
}
}
.frame(minHeight: 200)
Button("Dismiss", action: { showLoadErrorSheet = false })
.padding()
}
.padding()
} }
} }
} }
private func save() { private func saveButtonPressed() {
guard content.saveToDisk() else { switch content.saveState {
print("Failed to save content") case .storageNotInitialized:
#warning("Show error message") showInitialSheet()
return case .isSaved:
content.saveUnconditionally()
case .needsSave:
content.saveUnconditionally()
case .failedToSave:
showStorageErrorSheet = true
} }
} }
@ -222,13 +199,11 @@ struct MainView: App {
showInitialSheet() showInitialSheet()
return return
} }
content.loadFromDisk { errors in content.loadFromDisk {
prepareAfterLoad() prepareAfterLoad()
guard !errors.isEmpty else { if !content.storageErrors.isEmpty {
return self.showStorageErrorSheet = true
} }
self.loadErrors = errors
self.showLoadErrorSheet = true
} }
} }

View File

@ -0,0 +1,28 @@
import SwiftUI
struct StorageErrorView: View {
@EnvironmentObject
private var content: Content
@Binding
var isPresented: Bool
var body: some View {
VStack {
Text("Failed to load database")
.font(.headline)
List(content.storageErrors) { error in
VStack {
Text(error.message)
Text(error.date.formatted())
.font(.footnote)
}
}
.frame(minHeight: 300)
Button("Dismiss", action: { isPresented = false })
.padding()
}
.padding()
}
}

View File

@ -2,7 +2,47 @@ import Foundation
extension Content { extension Content {
func saveToDisk() -> Bool { func needsSave() {
setModificationTimestamp()
if saveState == .isSaved {
update(saveState: saveState)
}
// Wait a few seconds for a save, to allow additional changes
// Reduces the number of saves
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) {
self.saveIfNeeded()
}
}
func saveIfNeeded() {
guard saveState != .isSaved else {
return
}
if Date.now.timeIntervalSince(lastModification) < 5 {
// Additional modification made
// Wait for next scheduled invocation of saveIfNeeded()
// if the overall unsaved time is not too long
if Date.now.timeIntervalSince(lastSave) < 30 {
//print("Waiting while modifying")
return
}
print("Saving after 30 seconds of modifications")
}
saveUnconditionally()
}
func saveUnconditionally() {
guard saveToDisk() else {
update(saveState: .failedToSave)
// TODO: Try to save again
return
}
update(saveState: .isSaved)
setLastSaveTimestamp()
}
private func saveToDisk() -> Bool {
guard didLoadContent else { return false } guard didLoadContent else { return false }
guard storage.contentScope != nil else { guard storage.contentScope != nil else {
print("Storage not initialized, not saving content") print("Storage not initialized, not saving content")
@ -10,12 +50,28 @@ extension Content {
} }
var failedSaves = 0 var failedSaves = 0
failedSaves += pages.count { !storage.save(pageMetadata: $0.data, for: $0.id) } var saves = 0
failedSaves += posts.count { !storage.save(post: $0.data, for: $0.id) } let pageSaves = saveChanged(pages)
failedSaves += tags.count { !storage.save(tagMetadata: $0.data, for: $0.id) } failedSaves += pageSaves.unsaved
failedSaves.increment(!storage.save(settings: settings.data(tagOverview: tagOverview))) saves += pageSaves.saved
failedSaves += files.count { !storage.save(fileInfo: $0.data, for: $0.id) }
let postSaves = saveChanged(posts)
failedSaves += postSaves.unsaved
saves += postSaves.saved
let tagSaves = saveChanged(tags)
failedSaves += tagSaves.unsaved
saves += tagSaves.saved
failedSaves.increment(!storage.save(settings: settings.data(tagOverview: tagOverview)))
let fileSaves = saveChanged(files)
failedSaves += fileSaves.unsaved
saves += fileSaves.saved
if saves > 0 {
print("Saved \(saves) changed items")
}
if failedSaves > 0 { if failedSaves > 0 {
print("Save partially failed with \(failedSaves) errors") print("Save partially failed with \(failedSaves) errors")
return false return false
@ -39,4 +95,22 @@ extension Content {
} }
return success return success
} }
private func saveChanged<S>(_ items: S) -> (saved: Int, unsaved: Int, unchanged: Int) where S: Sequence, S.Element: StorageItem {
var failed = 0
var saved = 0
var unchanged = 0
for item in items {
guard let wasSaved = item.saveIfNeeded() else {
unchanged += 1
continue
}
if wasSaved {
saved += 1
} else {
failed += 1
}
}
return (saved, failed, unchanged)
}
} }

View File

@ -11,7 +11,7 @@ final class Content: ObservableObject {
var storage: Storage var storage: Storage
@Published @Published
var settings: Settings var settings: Settings!
@Published @Published
var posts: [Post] var posts: [Post]
@ -31,6 +31,9 @@ final class Content: ObservableObject {
@Published @Published
var results: GenerationResults var results: GenerationResults
@Published
var storageErrors: [StorageError] = []
@Published @Published
var generationStatus: String = "Ready to generate" var generationStatus: String = "Ready to generate"
@ -40,28 +43,12 @@ final class Content: ObservableObject {
@Published @Published
private(set) var shouldGenerateWebsite = false private(set) var shouldGenerateWebsite = false
@Published
private(set) var saveState: SaveState = .isSaved
let imageGenerator: ImageGenerator let imageGenerator: ImageGenerator
init(settings: Settings, var errorCallback: ((StorageError) -> Void)?
posts: [Post],
pages: [Page],
tags: [Tag],
files: [FileResource],
tagOverview: Tag?) {
self.settings = settings
self.posts = posts
self.pages = pages
self.tags = tags
self.files = files
self.tagOverview = tagOverview
self.results = .init()
let storage = Storage()
self.storage = storage
self.imageGenerator = ImageGenerator(
storage: storage,
settings: settings)
}
init() { init() {
let settings = Settings.default let settings = Settings.default
@ -78,6 +65,10 @@ final class Content: ObservableObject {
self.imageGenerator = ImageGenerator( self.imageGenerator = ImageGenerator(
storage: storage, storage: storage,
settings: settings) settings: settings)
storage.errorNotification = { [weak self] error in
self?.storageErrors.append(error)
}
settings.content = self
} }
private func clear() { private func clear() {
@ -112,7 +103,7 @@ final class Content: ObservableObject {
pages.insert(page, at: 0) pages.insert(page, at: 0)
} }
func update(contentPath: URL, callback: @escaping ([String]) -> ()) { func update(contentPath: URL, callback: @escaping () -> ()) {
guard storage.save(contentPath: contentPath) else { guard storage.save(contentPath: contentPath) else {
return return
} }
@ -139,19 +130,15 @@ final class Content: ObservableObject {
files.first { $0.absoluteUrl == withOutputPath } files.first { $0.absoluteUrl == withOutputPath }
} }
private let errorPrinter = ErrorPrinter() func loadFromDisk(callback: @escaping () -> ()) {
func loadFromDisk(callback: @escaping (_ errors: [String]) -> ()) {
defer {
storage.contentScope?.delegate = errorPrinter
}
DispatchQueue.global().async { DispatchQueue.global().async {
let loader = ModelLoader(content: self, storage: self.storage) let loader = ModelLoader(content: self, storage: self.storage)
let result = loader.load() let result = loader.load()
guard result.errors.isEmpty else { guard result.errors.isEmpty else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.didLoadContent = false self.didLoadContent = false
callback(result.errors.sorted()) self.storageErrors.append(contentsOf: result.errors)
callback()
} }
return return
} }
@ -164,7 +151,7 @@ final class Content: ObservableObject {
self.settings = result.settings self.settings = result.settings
self.tagOverview = result.tagOverview self.tagOverview = result.tagOverview
self.didLoadContent = true self.didLoadContent = true
callback([]) callback()
self.generateMissingVideoThumbnails() self.generateMissingVideoThumbnails()
} }
} }
@ -183,4 +170,22 @@ final class Content: ObservableObject {
} }
} }
} }
// MARK: Saving
private(set) var lastSave: Date = .now
private(set) var lastModification: Date = .now
func update(saveState: SaveState) {
self.saveState = saveState
}
func setModificationTimestamp() {
self.lastModification = .now
}
func setLastSaveTimestamp() {
self.lastSave = .now
}
} }

View File

@ -42,7 +42,7 @@ extension ContentLabel {
self.init(icon: icon, value: data.value) self.init(icon: icon, value: data.value)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let icon: String let icon: String
let value: String let value: String
} }

View File

@ -51,6 +51,8 @@ final class FileResource: Item, LocalizedItem {
@Published @Published
var fileSize: Int? = nil var fileSize: Int? = nil
var savedData: Data?
init(content: Content, init(content: Content,
id: String, id: String,
isExternallyStored: Bool, isExternallyStored: Bool,
@ -78,7 +80,7 @@ final class FileResource: Item, LocalizedItem {
/** /**
Only for bundle images Only for bundle images
*/ */
init(resourceImage: String, type: FileType) { init(content: Content, resourceImage: String, type: FileType) {
self.type = type self.type = type
self.english = "A test image included in the bundle" self.english = "A test image included in the bundle"
self.german = "Ein Testbild aus dem Bundle" self.german = "Ein Testbild aus dem Bundle"
@ -89,7 +91,7 @@ final class FileResource: Item, LocalizedItem {
self.customOutputPath = nil self.customOutputPath = nil
self.addedDate = Date.now self.addedDate = Date.now
self.modifiedDate = Date.now self.modifiedDate = Date.now
super.init(content: .mock, id: resourceImage) // TODO: Add images to mock super.init(content: content, id: resourceImage)
} }
// MARK: Text // MARK: Text
@ -349,7 +351,7 @@ extension FileResource: CustomStringConvertible {
} }
} }
extension FileResource { extension FileResource: StorageItem {
convenience init(content: Content, id: String, data: FileResource.Data, isExternalFile: Bool) { convenience init(content: Content, id: String, data: FileResource.Data, isExternalFile: Bool) {
self.init( self.init(
@ -364,6 +366,7 @@ extension FileResource {
customOutputPath: data.customOutputPath, customOutputPath: data.customOutputPath,
addedDate: data.addedDate, addedDate: data.addedDate,
modifiedDate: data.modifiedDate) modifiedDate: data.modifiedDate)
savedData = data
} }
var data: Data { var data: Data {
@ -379,7 +382,7 @@ extension FileResource {
} }
/// This struct holds metadata about a file resource that is stored in the content folder. /// This struct holds metadata about a file resource that is stored in the content folder.
struct Data: Codable { struct Data: Codable, Equatable {
let englishDescription: String? let englishDescription: String?
let germanDescription: String? let germanDescription: String?
let generatedImages: [String]? let generatedImages: [String]?
@ -389,4 +392,8 @@ extension FileResource {
let addedDate: Date let addedDate: Date
let modifiedDate: Date let modifiedDate: Date
} }
func saveToDisk(_ data: Data) -> Bool {
content.storage.save(fileResource: data, for: id)
}
} }

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import Combine
class Item: ObservableObject, Identifiable { class Item: ObservableContentItem, Identifiable {
unowned let content: Content unowned let content: Content
@ -11,17 +12,25 @@ class Item: ObservableObject, Identifiable {
@Published @Published
var id: String var id: String
var cancellables = Set<AnyCancellable>()
init(content: Content, id: String) { init(content: Content, id: String) {
self.content = content self.content = content
self.id = id self.id = id
observeChanges()
} }
// MARK: Change observation
func didChange() { func didChange() {
DispatchQueue.main.async { DispatchQueue.main.async {
self.changeToggle.toggle() self.changeToggle.toggle()
} }
} }
// MARK: Paths
func makeCleanAbsolutePath(_ path: String) -> String { func makeCleanAbsolutePath(_ path: String) -> String {
"/" + makeCleanRelativePath(path) "/" + makeCleanRelativePath(path)
} }

View File

@ -9,3 +9,7 @@ struct ItemId {
extension ItemId: Codable { extension ItemId: Codable {
} }
extension ItemId: Equatable {
}

View File

@ -50,7 +50,7 @@ final class LinkPreview: ObservableObject {
extension LinkPreview { extension LinkPreview {
/// The object to serialize a link preview for storage /// The object to serialize a link preview for storage
struct Data: Codable { struct Data: Codable, Equatable {
let title: String? let title: String?
let description: String? let description: String?
let image: String? let image: String?

View File

@ -29,7 +29,7 @@ final class LoadingContext {
tags: tags.values.sorted(), tags: tags.values.sorted(),
files: files.values.sorted { $0.id }, files: files.values.sorted { $0.id },
tagOverview: tagOverview, tagOverview: tagOverview,
errors: errors.sorted()) errors: errors.sorted().map { StorageError(message: $0) })
} }
func error(_ message: String) { func error(_ message: String) {

View File

@ -13,5 +13,5 @@ struct LoadingResult {
let tagOverview: Tag? let tagOverview: Tag?
let errors: [String] let errors: [StorageError]
} }

View File

@ -1,17 +1,4 @@
final class LoadingErrorHandler: SecurityBookmarkErrorDelegate {
let context: LoadingContext
init(context: LoadingContext) {
self.context = context
}
func securityBookmark(error: String) {
context.error("\(error)")
}
}
final class ModelLoader { final class ModelLoader {
let content: Content let content: Content
@ -20,14 +7,10 @@ final class ModelLoader {
let context: LoadingContext let context: LoadingContext
let errorHandler: LoadingErrorHandler
init(content: Content, storage: Storage) { init(content: Content, storage: Storage) {
self.content = content self.content = content
self.storage = storage self.storage = storage
self.context = .init(content: content) self.context = .init(content: content)
self.errorHandler = .init(context: context)
storage.contentScope?.delegate = errorHandler
} }
func load() -> LoadingResult { func load() -> LoadingResult {

View File

@ -80,7 +80,7 @@ extension LocalizedPage {
} }
/// The structure to store the metadata of a localized page /// The structure to store the metadata of a localized page
struct Data: Codable { struct Data: Codable, Equatable {
let url: String let url: String
let title: String let title: String
let linkPreview: LinkPreview.Data let linkPreview: LinkPreview.Data

View File

@ -108,7 +108,7 @@ extension LocalizedPost {
} }
/// The structure to store the metadata of a localized post /// The structure to store the metadata of a localized post
struct Data: Codable { struct Data: Codable, Equatable {
let images: [String] let images: [String]
let labels: [ContentLabel.Data]? let labels: [ContentLabel.Data]?
let title: String? let title: String?

View File

@ -54,7 +54,7 @@ extension LocalizedTag {
originalUrl: data.originalUrl) originalUrl: data.originalUrl)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let urlComponent: String let urlComponent: String
let name: String let name: String
let linkPreview: LinkPreview.Data let linkPreview: LinkPreview.Data

View File

@ -44,6 +44,8 @@ final class Page: Item, DateItem, LocalizedItem {
@Published @Published
var requiredFiles: [FileResource] var requiredFiles: [FileResource]
var savedData: Data?
init(content: Content, init(content: Content,
id: String, id: String,
externalLink: String?, externalLink: String?,
@ -186,7 +188,7 @@ final class Page: Item, DateItem, LocalizedItem {
// MARK: Storage // MARK: Storage
extension Page { extension Page: StorageItem {
convenience init(context: LoadingContext, id: String, data: Data) { convenience init(context: LoadingContext, id: String, data: Data) {
self.init( self.init(
@ -202,10 +204,11 @@ extension Page {
english: .init(context: context, data: data.english), english: .init(context: context, data: data.english),
tags: data.tags.compactMap(context.tag), tags: data.tags.compactMap(context.tag),
requiredFiles: data.requiredFiles?.compactMap(context.file) ?? []) requiredFiles: data.requiredFiles?.compactMap(context.file) ?? [])
savedData = data
} }
/// The structure to store the metadata of a page on disk /// The structure to store the metadata of a page on disk
struct Data: Codable { struct Data: Codable, Equatable {
let isDraft: Bool let isDraft: Bool
let externalLink: String? let externalLink: String?
let tags: [String] let tags: [String]
@ -232,4 +235,8 @@ extension Page {
english: english.data, english: english.data,
requiredFiles: requiredFiles.nonEmpty?.map { $0.id }.sorted()) requiredFiles: requiredFiles.nonEmpty?.map { $0.id }.sorted())
} }
func saveToDisk(_ data: Data) -> Bool {
content.storage.save(page: data, for: id)
}
} }

View File

@ -60,6 +60,10 @@ final class Post: Item, DateItem, LocalizedItem {
super.init(content: content, id: id) super.init(content: content, id: id)
} }
// MARK: Storage
var savedData: Data?
// MARK: Tags // MARK: Tags
func usedTags() -> [Tag] { func usedTags() -> [Tag] {
@ -173,7 +177,7 @@ final class Post: Item, DateItem, LocalizedItem {
} }
} }
extension Post { extension Post: StorageItem {
convenience init(context: LoadingContext, id: String, data: Data) { convenience init(context: LoadingContext, id: String, data: Data) {
self.init( self.init(
@ -187,9 +191,10 @@ extension Post {
german: .init(context: context, data: data.german), german: .init(context: context, data: data.german),
english: .init(context: context, data: data.english), english: .init(context: context, data: data.english),
linkedPage: data.linkedPageId.map(context.page)) linkedPage: data.linkedPageId.map(context.page))
savedData = data
} }
struct Data: Codable { struct Data: Codable, Equatable {
let isDraft: Bool let isDraft: Bool
let createdDate: Date let createdDate: Date
let startDate: Date let startDate: Date
@ -211,4 +216,8 @@ extension Post {
english: english.data, english: english.data,
linkedPageId: linkedPage?.id) linkedPageId: linkedPage?.id)
} }
func saveToDisk(_ data: Data) -> Bool {
content.storage.save(post: data, for: id)
}
} }

View File

@ -67,7 +67,7 @@ extension AudioPlayerSettings {
english: english.data) english: english.data)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let playlistCoverImageSize: Int let playlistCoverImageSize: Int
let smallCoverImageSize: Int let smallCoverImageSize: Int
let audioPlayerJsFile: String? let audioPlayerJsFile: String?

View File

@ -34,7 +34,7 @@ extension GeneralSettings {
linkPreviewImageHeight: linkPreviewImageHeight) linkPreviewImageHeight: linkPreviewImageHeight)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let url: String let url: String
let linkPreviewImageWidth: Int let linkPreviewImageWidth: Int
let linkPreviewImageHeight: Int let linkPreviewImageHeight: Int

View File

@ -22,7 +22,7 @@ extension LocalizedAudioPlayerSettings {
.init(playlistText: playlistText) .init(playlistText: playlistText)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let playlistText: String let playlistText: String
} }
} }

View File

@ -18,7 +18,7 @@ extension LocalizedNavigationSettings {
self.init(rootUrl: data.rootUrl) self.init(rootUrl: data.rootUrl)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let rootUrl: String let rootUrl: String
} }

View File

@ -31,7 +31,7 @@ extension LocalizedPageSettings {
emptyPageText: emptyPageText) emptyPageText: emptyPageText)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let emptyPageTitle: String let emptyPageTitle: String
let emptyPageText: String let emptyPageText: String
} }

View File

@ -41,7 +41,7 @@ extension LocalizedPostSettings {
linkPreview: linkPreview.data) linkPreview: linkPreview.data)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let feedUrlPrefix: String let feedUrlPrefix: String
let defaultPageLinkText: String let defaultPageLinkText: String
let linkPreview: LinkPreview.Data let linkPreview: LinkPreview.Data

View File

@ -32,7 +32,7 @@ extension NavigationSettings {
english: LocalizedNavigationSettings(data: data.english)) english: LocalizedNavigationSettings(data: data.english))
} }
struct Data: Codable { struct Data: Codable, Equatable {
let navigationItems: [ItemId] let navigationItems: [ItemId]
let german: LocalizedNavigationSettings.Data let german: LocalizedNavigationSettings.Data
let english: LocalizedNavigationSettings.Data let english: LocalizedNavigationSettings.Data

View File

@ -104,7 +104,7 @@ extension PageSettings {
english: english.data) english: english.data)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let contentWidth: Int let contentWidth: Int
let largeImageWidth: Int let largeImageWidth: Int
let pageLinkImageSize: Int let pageLinkImageSize: Int

View File

@ -64,7 +64,7 @@ extension PathSettings {
tagsOutputFolderPath: tagsOutputFolderPath) tagsOutputFolderPath: tagsOutputFolderPath)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let assetsOutputFolderPath: String let assetsOutputFolderPath: String
let pagesOutputFolderPath: String let pagesOutputFolderPath: String
let imagesOutputFolderPath: String let imagesOutputFolderPath: String

View File

@ -79,7 +79,7 @@ extension PostSettings {
english: english.data) english: english.data)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let postsPerPage: Int let postsPerPage: Int
let contentWidth: Int let contentWidth: Int
let swiperCssFile: String? let swiperCssFile: String?

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import Combine
final class Settings: ObservableObject { final class Settings: ChangeObservableItem {
@Published @Published
var general: GeneralSettings var general: GeneralSettings
@ -21,6 +22,10 @@ final class Settings: ObservableObject {
@Published @Published
var audioPlayer: AudioPlayerSettings var audioPlayer: AudioPlayerSettings
weak var content: Content?
var cancellables: Set<AnyCancellable> = []
init(general: GeneralSettings, init(general: GeneralSettings,
paths: PathSettings, paths: PathSettings,
navigation: NavigationSettings, navigation: NavigationSettings,
@ -40,6 +45,10 @@ final class Settings: ObservableObject {
posts.remove(file) posts.remove(file)
audioPlayer.remove(file) audioPlayer.remove(file)
} }
func needsSaving() {
content?.needsSave()
}
} }
// MARK: Storage // MARK: Storage
@ -54,6 +63,7 @@ extension Settings {
posts: .init(context: context, data: data.posts), posts: .init(context: context, data: data.posts),
pages: .init(context: context, data: data.pages), pages: .init(context: context, data: data.pages),
audioPlayer: .init(context: context, data: data.audioPlayer)) audioPlayer: .init(context: context, data: data.audioPlayer))
content = context.content
} }
func data(tagOverview: Tag?) -> Data { func data(tagOverview: Tag?) -> Data {
@ -67,7 +77,7 @@ extension Settings {
tagOverview: tagOverview?.data) tagOverview: tagOverview?.data)
} }
struct Data: Codable { struct Data: Codable, Equatable {
let general: GeneralSettings.Data let general: GeneralSettings.Data
let paths: PathSettings.Data let paths: PathSettings.Data
let navigation: NavigationSettings.Data let navigation: NavigationSettings.Data
@ -76,6 +86,10 @@ extension Settings {
let audioPlayer: AudioPlayerSettings.Data let audioPlayer: AudioPlayerSettings.Data
let tagOverview: Tag.Data? let tagOverview: Tag.Data?
} }
func saveToDisk(_ data: Data) -> Bool {
content?.storage.save(settings: data) ?? false
}
} }
extension Settings { extension Settings {

View File

@ -0,0 +1,37 @@
import Foundation
struct StorageError {
let date: Date
let message: String
init(date: Date = .now, message: String) {
self.date = date
self.message = message
}
}
extension StorageError: Identifiable {
var id: String {
date.description + message
}
}
extension StorageError: Comparable {
static func < (lhs: StorageError, rhs: StorageError) -> Bool {
guard lhs.date == rhs.date else {
return lhs.date < rhs.date
}
return lhs.message < rhs.message
}
}
extension StorageError: ExpressibleByStringLiteral {
init(stringLiteral value: StringLiteralType) {
self.init(message: value)
}
}

View File

@ -13,6 +13,8 @@ class Tag: Item, LocalizedItem {
@Published @Published
var english: LocalizedTag var english: LocalizedTag
var savedData: Data?
override init(content: Content, id: String) { override init(content: Content, id: String) {
self.isVisible = true self.isVisible = true
self.english = .init(content: content, urlComponent: id, name: id) self.english = .init(content: content, urlComponent: id, name: id)
@ -77,7 +79,7 @@ class Tag: Item, LocalizedItem {
// MARK: Storage // MARK: Storage
extension Tag { extension Tag: StorageItem {
convenience init(context: LoadingContext, id: String, data: Data) { convenience init(context: LoadingContext, id: String, data: Data) {
self.init( self.init(
@ -86,9 +88,10 @@ extension Tag {
isVisible: data.isVisible ?? true, isVisible: data.isVisible ?? true,
german: .init(context: context, data: data.german), german: .init(context: context, data: data.german),
english: .init(context: context, data: data.english)) english: .init(context: context, data: data.english))
savedData = data
} }
struct Data: Codable { struct Data: Codable, Equatable {
// Defaults to true if unset // Defaults to true if unset
let isVisible: Bool? let isVisible: Bool?
let german: LocalizedTag.Data let german: LocalizedTag.Data
@ -101,4 +104,8 @@ extension Tag {
german: german.data, german: german.data,
english: english.data) english: english.data)
} }
func saveToDisk(_ data: Data) -> Bool {
content.storage.save(tag: data, for: id)
}
} }

View File

@ -12,11 +12,14 @@ extension FileManager {
extension Content { extension Content {
static let mock: Content = Content( static let mock: Content = {
settings: .default, let content = Content()
posts: [.empty, .mock, .fullMock],
pages: [.empty], content.files = FileResource.Mock.mockData(content: content)
tags: [.hiking, .mountains, .nature, .sports], content.tags = Tag.Mock.mockData(content: content)
files: [], content.posts = Post.Mock.mockData(content: content)
tagOverview: nil) content.pages = Page.Mock.mockData(content: content)
return content
}()
} }

View File

@ -1,12 +1,25 @@
extension FileResource { extension FileResource {
enum Mock {
static func mockData(content: Content) -> [FileResource] {
[
FileResource(content: content, resourceImage: "image1", type: .jpg),
FileResource(content: content, resourceImage: "image2", type: .jpg),
FileResource(content: content, resourceImage: "image3", type: .jpg),
FileResource(content: content, resourceImage: "image4", type: .jpg),
FileResource(
content: content,
id: "my-file.txt",
isExternallyStored: true,
english: "Some text file",
german: "Eine Textdatei")
]
}
}
static var mock: FileResource { static var mock: FileResource {
.init( Content.mock.files.first(where: { $0.id == "my-file.txt" })!
content: .mock,
id: "my-file.txt",
isExternallyStored: true,
english: "Some text file",
german: "Eine Textdatei")
} }
} }

View File

@ -1,19 +0,0 @@
import SwiftUI
/// An image loaded from the app resources for test purposes
struct MockImage {
let name: String
static var images: [FileResource] {
["image1", "image2", "image3", "image4"]
.map { FileResource(resourceImage: $0, type: .jpg) }
}
}
extension MockImage: ExpressibleByStringLiteral {
init(stringLiteral value: StringLiteralType) {
self.name = value
}
}

View File

@ -2,36 +2,40 @@ import Foundation
extension Page { extension Page {
static var empty: Page { enum Mock {
.init(
content: .mock, static func mockData(content: Content) -> [Page] {
id: "my-id", [
externalLink: nil, Page(
isDraft: true, content: content,
createdDate: Date(), id: "my-id",
hideDate: false, externalLink: nil,
startDate: Date().addingTimeInterval(-86400), isDraft: true,
endDate: nil, createdDate: Date(),
german: .german, hideDate: false,
english: .english, startDate: Date().addingTimeInterval(-86400),
tags: [.mock], endDate: nil,
requiredFiles: []) german: LocalizedPage(
content: content,
urlString: "mein-projekt",
title: "Mein Erstes Projekt",
lastModified: nil,
originalUrl: "projects/electronics/my-first-project/de.html"),
english: LocalizedPage(
content: content,
urlString: "my-project",
title: "My First Project",
lastModified: nil,
originalUrl: "projects/electronics/my-first-project/en.html"),
tags: [
content.tags.first(where: { $0.id == "electronics" })!
],
requiredFiles: [])
]
}
static var empty: Page {
Content.mock.pages.first(where: { $0.id == "my-id" })!
}
} }
} }
extension LocalizedPage {
static let english = LocalizedPage(
content: .mock,
urlString: "my-project",
title: "My First Project",
lastModified: nil,
originalUrl: "projects/electronics/my-first-project/en.html")
static let german = LocalizedPage(
content: .mock,
urlString: "mein-projekt",
title: "Mein Erstes Projekt",
lastModified: nil,
originalUrl: "projects/electronics/my-first-project/de.html")
}

View File

@ -1,65 +1,90 @@
extension Post { extension Post {
static var empty: Post { enum Mock {
.init(content: Content.mock,
id: "empty",
isDraft: true,
createdDate: .now,
startDate: .now,
endDate: nil,
tags: [],
german: .init(content: .mock,
text: "Text"),
english: .init(content: .mock,
text: "Text"),
linkedPage: nil)
}
static var mock: Post { static func mockData(content: Content) -> [Post] {
Post( [
content: Content.mock, Post(content: content,
id: "mock", id: "empty",
isDraft: false, isDraft: true,
createdDate: .now, createdDate: .now,
startDate: .now, startDate: .now,
endDate: nil, endDate: nil,
tags: [.nature, .sports, .hiking], tags: [],
german: .init( german: .init(
content: .mock, content: content,
title: "Der Titel", text: "Text"),
text: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend."), english: .init(
english: .init( content: content,
content: .mock, text: "Text"),
title: "The title", linkedPage: nil),
text: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height") Post(
) content: content,
} id: "hike",
isDraft: false,
createdDate: .now,
startDate: .now,
endDate: nil,
tags: [
content.tags.first(where: { $0.id == "nature" })!,
content.tags.first(where: { $0.id == "sports" })!,
content.tags.first(where: { $0.id == "hiking" })!
],
german: .init(
content: content,
title: "Eine Wanderung",
text: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend."),
english: .init(
content: content,
title: "A hike",
text: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height")
),
Post(
content: content,
id: "hike2",
isDraft: true,
createdDate: .now,
startDate: .now.addingTimeInterval(-86400), endDate: .now,
tags: [
content.tags.first(where: { $0.id == "nature" })!,
content.tags.first(where: { $0.id == "sports" })!,
content.tags.first(where: { $0.id == "hiking" })!,
content.tags.first(where: { $0.id == "mountains" })!
],
german: LocalizedPost(
content: content,
title: "Eine lange Wanderung",
text: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend.",
images: [
content.files.first(where: { $0.id == "image1" })!,
content.files.first(where: { $0.id == "image2" })!,
content.files.first(where: { $0.id == "image3" })!,
content.files.first(where: { $0.id == "image4" })!
]),
english: LocalizedPost(
content: content,
title: "A longer hike",
text: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.",
images: [
content.files.first(where: { $0.id == "image1" })!,
content.files.first(where: { $0.id == "image2" })!,
content.files.first(where: { $0.id == "image3" })!,
content.files.first(where: { $0.id == "image4" })!
]))
]
}
static var fullMock: Post { static var empty: Post {
.init( Content.mock.posts.first(where: { $0.id == "empty" })!
content: Content.mock, }
id: "full",
isDraft: true, static var hike: Post {
createdDate: .now, Content.mock.posts.first(where: { $0.id == "hike" })!
startDate: .now.addingTimeInterval(-86400), endDate: .now, }
tags: [.nature, .sports, .hiking, .mountains],
german: .german, static var hike2: Post {
english: .english) Content.mock.posts.first(where: { $0.id == "hike2" })!
}
} }
} }
extension LocalizedPost {
static let german = LocalizedPost(
content: .mock,
title: "Ein langer Titel",
text: "Sehr schöne und einsame Tour von Oberau zum Krottenkopf. Abwechslungsreich und stellenweise fordernd, aufgrund der Länge und der Höhenmeter auch anstrengend.",
images: MockImage.images)
static let english = LocalizedPost(
content: .mock,
title: "A longer title",
text: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height.",
images: MockImage.images)
}

View File

@ -2,56 +2,74 @@ import Foundation
extension Tag { extension Tag {
static let mock = Tag( enum Mock {
content: .mock,
id: "electronics",
german: .german,
english: .english)
static let nature = Tag( static func mockData(content: Content) -> [Tag] {
content: .mock, [
id: "nature", Tag(content: content,
german: .init(content: .mock, urlComponent: "natur", name: "Natur"), id: "electronics",
english: .init(content: .mock, urlComponent: "nature", name: "Nature") german: .init(
) content: content,
urlComponent: "elektronik",
name: "Elektronik",
linkPreview: .init(description: "Eine Beschreibung des Tags",
image: content.files.first(where: { $0.id == "image2" })!),
originalUrl: "projects/electronics"
),
english: .init(
content: content,
urlComponent: "electronics",
name: "Electronics",
linkPreview: .init(
description: "Some description of the tag",
image: content.files.first(where: { $0.id == "image1" })!),
originalUrl: "projects/electronics")
),
Tag(
content: content,
id: "nature",
german: .init(content: content, urlComponent: "natur", name: "Natur"),
english: .init(content: content, urlComponent: "nature", name: "Nature")
),
Tag(
content: content,
id: "sports",
german: .init(content: content, urlComponent: "sport", name: "Sport"),
english: .init(content: content, urlComponent: "sports", name: "Sports")
),
Tag(
content: content,
id: "hiking",
german: .init(content: content, urlComponent: "wandern", name: "Wandern"),
english: .init(content: content, urlComponent: "hiking", name: "Hiking")
),
Tag(
content: content,
id: "mountains",
german: .init(content: content, urlComponent: "berge", name: "Berge"),
english: .init(content: content, urlComponent: "mountains", name: "Mountains")
)
]
}
static let sports = Tag( static var electronics: Tag {
content: .mock, Content.mock.tags.first(where: { $0.id == "electronics" })!
id: "sports", }
german: .init(content: .mock, urlComponent: "sport", name: "Sport"),
english: .init(content: .mock, urlComponent: "sports", name: "Sports") static var nature: Tag {
) Content.mock.tags.first(where: { $0.id == "nature" })!
}
static let hiking = Tag(
content: .mock, static var sports: Tag {
id: "hiking", Content.mock.tags.first(where: { $0.id == "sports" })!
german: .init(content: .mock, urlComponent: "wandern", name: "Wandern"), }
english: .init(content: .mock, urlComponent: "hiking", name: "Hiking")
) static var hiking: Tag {
Content.mock.tags.first(where: { $0.id == "hiking" })!
static let mountains = Tag( }
content: .mock,
id: "mountains", static var mountains: Tag {
german: .init(content: .mock, urlComponent: "berge", name: "Berge"), Content.mock.tags.first(where: { $0.id == "mountains" })!
english: .init(content: .mock, urlComponent: "mountains", name: "Mountains") }
) }
}
extension LocalizedTag {
static let english = LocalizedTag(
content: .mock,
urlComponent: "electronics",
name: "Electronics",
linkPreview: .init(description: "Some description of the tag",
image: FileResource(resourceImage: "image1", type: .jpg)),
originalUrl: "projects/electronics")
static let german = LocalizedTag(
content: .mock,
urlComponent: "elektronik",
name: "Elektronik",
linkPreview: .init(description: "Eine Beschreibung des Tags",
image: FileResource(resourceImage: "image2", type: .jpg)),
originalUrl: "projects/electronics")
} }

View File

@ -0,0 +1,31 @@
import Combine
protocol ChangeObservableItem: ObservableObject {
var cancellables: Set<AnyCancellable> { get set }
func needsSaving()
}
protocol ObservableContentItem: ChangeObservableItem {
var content: Content { get }
}
extension ObservableContentItem {
func needsSaving() {
content.needsSave()
}
}
extension ChangeObservableItem {
func observeChanges() {
objectWillChange
.sink { [weak self] _ in
self?.needsSaving()
}
.store(in: &cancellables)
}
}

View File

@ -1,10 +0,0 @@
final class ErrorPrinter {
}
extension ErrorPrinter: SecurityBookmarkErrorDelegate {
func securityBookmark(error: String) {
print(error)
}
}

View File

@ -0,0 +1,35 @@
import SFSafeSymbols
import SwiftUICore
enum SaveState {
case storageNotInitialized
case isSaved
case needsSave
case failedToSave
var symbol: SFSymbol {
switch self {
case .storageNotInitialized:
return .folderCircleFill
case .isSaved:
return .checkmarkCircleFill
case .needsSave:
return .hourglassCircleFill
case .failedToSave:
return .exclamationmarkTriangleFill
}
}
var color: Color {
switch self {
case .storageNotInitialized:
return .red
case .isSaved:
return .green
case .needsSave:
return .yellow
case .failedToSave:
return .red
}
}
}

View File

@ -1,10 +1,7 @@
import Foundation import Foundation
import AppKit import AppKit
protocol SecurityBookmarkErrorDelegate: AnyObject { typealias StorageErrorCallback = (StorageError) -> Void
func securityBookmark(error: String)
}
struct SecurityBookmark { struct SecurityBookmark {
@ -25,7 +22,7 @@ struct SecurityBookmark {
private let fm = FileManager.default private let fm = FileManager.default
weak var delegate: SecurityBookmarkErrorDelegate? var errorNotification: StorageErrorCallback?
init(url: URL, isStale: Bool) { init(url: URL, isStale: Bool) {
self.url = url self.url = url
@ -34,6 +31,14 @@ struct SecurityBookmark {
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
} }
private func reportError(_ error: String) {
guard let errorNotification else {
print(error)
return
}
errorNotification(.init(message: error))
}
// MARK: Write // MARK: Write
func openFinderWindow(relativePath: String) { func openFinderWindow(relativePath: String) {
@ -65,7 +70,7 @@ struct SecurityBookmark {
do { do {
data = try encoder.encode(value) data = try encoder.encode(value)
} catch { } catch {
delegate?.securityBookmark(error: "Failed to encode \(value): \(error)") reportError("Failed to encode \(value): \(error)")
return false return false
} }
return write(data, to: relativePath) return write(data, to: relativePath)
@ -79,7 +84,7 @@ struct SecurityBookmark {
createParentFolder: Bool = true, createParentFolder: Bool = true,
ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool { ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool {
guard let data = content.data(using: .utf8) else { guard let data = content.data(using: .utf8) else {
delegate?.securityBookmark(error: "Failed to encode content to write to \(relativePath)") reportError("Failed to encode content to write to \(relativePath)")
return false return false
} }
return write(data, to: relativePath, createParentFolder: createParentFolder, ifFileExists: overwrite) return write(data, to: relativePath, createParentFolder: createParentFolder, ifFileExists: overwrite)
@ -95,7 +100,7 @@ struct SecurityBookmark {
if exists(file) { if exists(file) {
switch overwrite { switch overwrite {
case .fail: case .fail:
delegate?.securityBookmark(error: "Failed to write \(relativePath): File exists") reportError("Failed to write \(relativePath): File exists")
return false return false
case .skip: return true case .skip: return true
case .write: break case .write: break
@ -110,7 +115,7 @@ struct SecurityBookmark {
try createParentIfNeeded(of: file) try createParentIfNeeded(of: file)
try data.write(to: file) try data.write(to: file)
} catch { } catch {
delegate?.securityBookmark(error: "Failed to write \(relativePath): \(error)") reportError("Failed to write \(relativePath): \(error)")
return false return false
} }
return true return true
@ -136,7 +141,7 @@ struct SecurityBookmark {
return nil return nil
} }
guard let result = String(data: data, encoding: .utf8) else { guard let result = String(data: data, encoding: .utf8) else {
delegate?.securityBookmark(error: "Failed to read \(relativePath): invalid UTF-8") reportError("Failed to read \(relativePath): invalid UTF-8")
return nil return nil
} }
return result return result
@ -150,7 +155,7 @@ struct SecurityBookmark {
do { do {
return try Data(contentsOf: file) return try Data(contentsOf: file)
} catch { } catch {
delegate?.securityBookmark(error: "Failed to read \(relativePath) \(error)") reportError("Failed to read \(relativePath) \(error)")
return nil return nil
} }
} }
@ -163,7 +168,7 @@ struct SecurityBookmark {
do { do {
return try decoder.decode(T.self, from: data) return try decoder.decode(T.self, from: data)
} catch { } catch {
delegate?.securityBookmark(error: "Failed to decode \(relativePath): \(error)") reportError("Failed to decode \(relativePath): \(error)")
return nil return nil
} }
} }
@ -178,7 +183,7 @@ struct SecurityBookmark {
with(relativePath: relativeSource) { source in with(relativePath: relativeSource) { source in
if !exists(source) { if !exists(source) {
if !failIfMissing { return true } if !failIfMissing { return true }
delegate?.securityBookmark(error: "Failed to move \(relativeSource): File does not exist") reportError("Failed to move \(relativeSource): File does not exist")
return false return false
} }
@ -186,7 +191,7 @@ struct SecurityBookmark {
if exists(destination) { if exists(destination) {
switch overwrite { switch overwrite {
case .fail: case .fail:
delegate?.securityBookmark(error: "Failed to move to \(relativeDestination): File already exists") reportError("Failed to move to \(relativeDestination): File already exists")
return false return false
case .skip: return true case .skip: return true
case .write: break case .write: break
@ -205,7 +210,7 @@ struct SecurityBookmark {
try fm.moveItem(at: source, to: destination) try fm.moveItem(at: source, to: destination)
return true return true
} catch { } catch {
delegate?.securityBookmark(error: "Failed to move \(source.path()) to \(destination.path()): \(error)") reportError("Failed to move \(source.path()) to \(destination.path()): \(error)")
return false return false
} }
} }
@ -220,7 +225,7 @@ struct SecurityBookmark {
if destination.exists { if destination.exists {
switch overwrite { switch overwrite {
case .fail: case .fail:
delegate?.securityBookmark(error: "Failed to copy to \(relativePath): File already exists") reportError("Failed to copy to \(relativePath): File already exists")
return false return false
case .skip: return true case .skip: return true
case .write: break case .write: break
@ -237,7 +242,7 @@ struct SecurityBookmark {
try fm.copyItem(at: externalFile, to: destination) try fm.copyItem(at: externalFile, to: destination)
return true return true
} catch { } catch {
delegate?.securityBookmark(error: "Failed to copy \(externalFile.path()) to \(relativePath): \(error)") reportError("Failed to copy \(externalFile.path()) to \(relativePath): \(error)")
return false return false
} }
} }
@ -252,7 +257,7 @@ struct SecurityBookmark {
try fm.removeItem(at: file) try fm.removeItem(at: file)
return true return true
} catch { } catch {
delegate?.securityBookmark(error: "Failed to delete \(relativePath): \(error)") reportError("Failed to delete \(relativePath): \(error)")
return false return false
} }
} }
@ -326,13 +331,13 @@ struct SecurityBookmark {
do { do {
data = try Data(contentsOf: url) data = try Data(contentsOf: url)
} catch { } catch {
delegate?.securityBookmark(error: "Failed to read \(url.path()): \(error)") reportError("Failed to read \(url.path()): \(error)")
return return
} }
do { do {
items[id] = try decoder.decode(T.self, from: data) items[id] = try decoder.decode(T.self, from: data)
} catch { } catch {
delegate?.securityBookmark(error: "Failed to decode \(url.path()): \(error)") reportError("Failed to decode \(url.path()): \(error)")
return return
} }
} }
@ -352,7 +357,7 @@ struct SecurityBookmark {
func with<T>(relativePath: String, perform operation: (URL) async -> T?) async -> T? { func with<T>(relativePath: String, perform operation: (URL) async -> T?) async -> T? {
let path = url.appending(path: relativePath.withLeadingSlashRemoved) let path = url.appending(path: relativePath.withLeadingSlashRemoved)
guard url.startAccessingSecurityScopedResource() else { guard url.startAccessingSecurityScopedResource() else {
delegate?.securityBookmark(error: "Failed to start security scope") reportError("Failed to start security scope")
return nil return nil
} }
defer { url.stopAccessingSecurityScopedResource() } defer { url.stopAccessingSecurityScopedResource() }
@ -364,7 +369,7 @@ struct SecurityBookmark {
*/ */
func perform(_ operation: (URL) -> Bool) -> Bool { func perform(_ operation: (URL) -> Bool) -> Bool {
guard url.startAccessingSecurityScopedResource() else { guard url.startAccessingSecurityScopedResource() else {
delegate?.securityBookmark(error: "Failed to start security scope") reportError("Failed to start security scope")
return false return false
} }
defer { url.stopAccessingSecurityScopedResource() } defer { url.stopAccessingSecurityScopedResource() }
@ -376,7 +381,7 @@ struct SecurityBookmark {
*/ */
func perform<T>(_ operation: (URL) -> T?) -> T? { func perform<T>(_ operation: (URL) -> T?) -> T? {
guard url.startAccessingSecurityScopedResource() else { guard url.startAccessingSecurityScopedResource() else {
delegate?.securityBookmark(error: "Failed to start security scope") reportError("Failed to start security scope")
return nil return nil
} }
defer { url.stopAccessingSecurityScopedResource() } defer { url.stopAccessingSecurityScopedResource() }
@ -390,7 +395,7 @@ struct SecurityBookmark {
try createIfNeeded(folder) try createIfNeeded(folder)
return true return true
} catch { } catch {
delegate?.securityBookmark(error: "Failed to create folder \(folder.path())") reportError("Failed to create folder \(folder.path())")
return false return false
} }
} }
@ -406,7 +411,7 @@ struct SecurityBookmark {
do { do {
try fm.removeItem(at: file) try fm.removeItem(at: file)
} catch { } catch {
delegate?.securityBookmark(error: "Failed to delete \(file.path()): \(error)") reportError("Failed to delete \(file.path()): \(error)")
return false return false
} }
return true return true
@ -432,7 +437,7 @@ struct SecurityBookmark {
do { do {
return try files(in: folder).filter { !$0.lastPathComponent.hasPrefix(".") } return try files(in: folder).filter { !$0.lastPathComponent.hasPrefix(".") }
} catch { } catch {
delegate?.securityBookmark(error: "Failed to read list of files in \(folder.path())") reportError("Failed to read list of files in \(folder.path())")
return nil return nil
} }
} }

View File

@ -43,6 +43,8 @@ final class Storage: ObservableObject {
@Published @Published
var outputScope: SecurityBookmark? var outputScope: SecurityBookmark?
var errorNotification: StorageErrorCallback?
/** /**
Create the storage. Create the storage.
*/ */
@ -75,10 +77,10 @@ final class Storage: ObservableObject {
return contentScope.write(pageContent, to: path) return contentScope.write(pageContent, to: path)
} }
func save(pageMetadata: Page.Data, for pageId: String) -> Bool { func save(page: Page.Data, for pageId: String) -> Bool {
guard let contentScope else { return false } guard let contentScope else { return false }
let path = pageMetadataPath(page: pageId) let path = pageMetadataPath(page: pageId)
return contentScope.encode(pageMetadata, to: path) return contentScope.encode(page, to: path)
} }
func loadAllPages() -> [String : Page.Data]? { func loadAllPages() -> [String : Page.Data]? {
@ -186,10 +188,10 @@ final class Storage: ObservableObject {
tagsFolderName + "/" + tagFileName(tagId: tagId) tagsFolderName + "/" + tagFileName(tagId: tagId)
} }
func save(tagMetadata: Tag.Data, for tagId: String) -> Bool { func save(tag: Tag.Data, for tagId: String) -> Bool {
guard let contentScope else { return false } guard let contentScope else { return false }
let path = tagFilePath(tag: tagId) let path = tagFilePath(tag: tagId)
return contentScope.encode(tagMetadata, to: path) return contentScope.encode(tag, to: path)
} }
func loadAllTags() -> [String : Tag.Data]? { func loadAllTags() -> [String : Tag.Data]? {
@ -338,10 +340,10 @@ final class Storage: ObservableObject {
} }
@discardableResult @discardableResult
func save(fileInfo: FileResource.Data, for fileId: String) -> Bool { func save(fileResource: FileResource.Data, for fileId: String) -> Bool {
guard let contentScope else { return false } guard let contentScope else { return false }
let path = fileInfoPath(file: fileId) let path = fileInfoPath(file: fileId)
return contentScope.encode(fileInfo, to: path) return contentScope.encode(fileResource, to: path)
} }
/** /**
@ -503,6 +505,10 @@ final class Storage: ObservableObject {
return false return false
} }
contentScope = decode(bookmark: bookmarkData) contentScope = decode(bookmark: bookmarkData)
// Propagate errors
contentScope?.errorNotification = { [weak self] error in
self?.errorNotification?(error)
}
return contentScope != nil return contentScope != nil
} }
@ -513,6 +519,10 @@ final class Storage: ObservableObject {
return false return false
} }
outputScope = decode(bookmark: data) outputScope = decode(bookmark: data)
// Propagate errors
outputScope?.errorNotification = { [weak self] error in
self?.errorNotification?(error)
}
return outputScope != nil return outputScope != nil
} }
@ -562,6 +572,10 @@ final class Storage: ObservableObject {
} }
// TODO: Check if stale // TODO: Check if stale
outputScope = SecurityBookmark(url: outputPath, isStale: false) outputScope = SecurityBookmark(url: outputPath, isStale: false)
// Propagate errors
outputScope?.errorNotification = { [weak self] error in
self?.errorNotification?(error)
}
return true return true
} }
} }

View File

@ -0,0 +1,38 @@
protocol StorageItem: AnyObject {
associatedtype Data: Equatable
var savedData: Data? { get set }
var data: Data { get }
func saveToDisk(_ data: Data) -> Bool
}
extension StorageItem {
/**
Get the data to save, if the object has changed
*/
func dataToSave() -> Data? {
guard let savedData else {
return data
}
if savedData != data {
return data
}
return nil
}
func saveIfNeeded() -> Bool? {
guard let data = dataToSave() else {
return nil
}
guard saveToDisk(data) else {
return false
}
savedData = data
return true
}
}

View File

@ -40,7 +40,12 @@ struct LocalizedPageDetailView: View {
} }
} }
#Preview { #Preview(traits: .fixedLayout(width: 400, height: 600)) {
LocalizedPageDetailView(isExternalPage: false, page: .english, transferImage: nil) LocalizedPageDetailView(
.environmentObject(Content.mock) isExternalPage: false,
page: Page.Mock.empty.english,
transferImage: nil
)
.padding()
.environmentObject(Content.mock)
} }

View File

@ -62,5 +62,5 @@ extension PageContentView: MainContentView {
} }
#Preview { #Preview {
PageContentView(page: .empty) PageContentView(page: Page.Mock.empty)
} }

View File

@ -146,5 +146,5 @@ extension PageDetailView: MainContentView {
#Preview { #Preview {
PageDetailView(page: .empty) PageDetailView(page: Page.Mock.empty)
} }

View File

@ -34,10 +34,10 @@ extension PostContentView: MainContentView {
#Preview(traits: .fixedLayout(width: 450, height: 600)) { #Preview(traits: .fixedLayout(width: 450, height: 600)) {
List { List {
PostContentView(post: .fullMock) PostContentView(post: .Mock.hike2)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.environment(\.language, ContentLanguage.german) .environment(\.language, ContentLanguage.german)
PostContentView(post: .mock) PostContentView(post: .Mock.hike)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
} }
.environmentObject(Content.mock) .environmentObject(Content.mock)

View File

@ -103,5 +103,5 @@ extension PostDetailView: MainContentView {
#Preview(traits: .fixedLayout(width: 270, height: 500)) { #Preview(traits: .fixedLayout(width: 270, height: 500)) {
PostDetailView(post: .fullMock) PostDetailView(post: .Mock.hike2)
} }

View File

@ -83,6 +83,6 @@ struct TagSelectionView: View {
#Preview { #Preview {
TagSelectionView( TagSelectionView(
presented: .constant(true), presented: .constant(true),
selected: .constant([.hiking, .nature]), selected: .constant([.Mock.hiking, .Mock.nature]),
tags: .constant([.sports, .mock])) tags: .constant([.Mock.sports, .Mock.electronics]))
} }

View File

@ -72,29 +72,15 @@ struct PathSettingsView: View {
} }
.padding() .padding()
.sheet(isPresented: $showLoadErrorSheet) { .sheet(isPresented: $showLoadErrorSheet) {
VStack { StorageErrorView(isPresented: $showLoadErrorSheet)
Text("Failed to load database")
.font(.headline)
List(loadErrors, id: \.self) { error in
HStack {
Text(error)
Spacer()
}
}
.frame(minHeight: 200)
Button("Dismiss", action: { showLoadErrorSheet = false })
.padding()
}
.padding()
} }
} }
} }
private func showLoadErrors(errors: [String]) { private func showLoadErrors() {
guard !errors.isEmpty else { guard !content.storageErrors.isEmpty else {
return return
} }
loadErrors = errors
showLoadErrorSheet = true showLoadErrorSheet = true
} }
} }

View File

@ -42,5 +42,5 @@ struct LocalizedTagDetailView: View {
} }
#Preview { #Preview {
LocalizedTagDetailView(tag: Tag.mock.english, transferImage: nil) LocalizedTagDetailView(tag: Tag.Mock.electronics.english, transferImage: nil)
} }

View File

@ -55,6 +55,6 @@ struct PageTagAssignmentView: View {
} }
#Preview { #Preview {
PageTagAssignmentView(tag: .hiking) PageTagAssignmentView(tag: .Mock.hiking)
.environmentObject(Content.mock) .environmentObject(Content.mock)
} }

View File

@ -55,6 +55,6 @@ struct PostTagAssignmentView: View {
} }
#Preview { #Preview {
PostTagAssignmentView(tag: .hiking) PostTagAssignmentView(tag: .Mock.hiking)
.environmentObject(Content.mock) .environmentObject(Content.mock)
} }

View File

@ -67,6 +67,6 @@ extension TagContentView: MainContentView {
} }
#Preview { #Preview {
TagContentView(tag: .hiking) TagContentView(tag: .Mock.hiking)
.environmentObject(Content.mock) .environmentObject(Content.mock)
} }