From 5abe6e1a9f4b717e8e5a173c14d5c4c6cdea7fc3 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Wed, 5 Feb 2025 12:24:33 +0100 Subject: [PATCH] Save automatically, improve mocks --- CHDataManagement.xcodeproj/project.pbxproj | 28 +++- CHDataManagement/Main/InitialSetupView.swift | 3 +- CHDataManagement/Main/MainView.swift | 65 +++----- CHDataManagement/Main/StorageErrorView.swift | 28 ++++ CHDataManagement/Model/Content+Save.swift | 86 ++++++++++- CHDataManagement/Model/Content.swift | 65 ++++---- CHDataManagement/Model/ContentLabel.swift | 2 +- CHDataManagement/Model/FileResource.swift | 15 +- CHDataManagement/Model/Item/Item.swift | 11 +- CHDataManagement/Model/Item/ItemId.swift | 4 + CHDataManagement/Model/LinkPreview.swift | 2 +- .../Model/Loading/LoadingContext.swift | 2 +- .../Model/Loading/LoadingResult.swift | 2 +- .../Model/Loading/ModelLoader.swift | 17 --- CHDataManagement/Model/LocalizedPage.swift | 2 +- CHDataManagement/Model/LocalizedPost.swift | 2 +- CHDataManagement/Model/LocalizedTag.swift | 2 +- CHDataManagement/Model/Page.swift | 11 +- CHDataManagement/Model/Post.swift | 13 +- .../Model/Settings/AudioPlayerSettings.swift | 2 +- .../Model/Settings/GeneralSettings.swift | 2 +- .../LocalizedAudioPlayerSettings.swift | 2 +- .../LocalizedNavigationSettings.swift | 2 +- .../Settings/LocalizedPageSettings.swift | 2 +- .../Settings/LocalizedPostSettings.swift | 2 +- .../Model/Settings/NavigationSettings.swift | 2 +- .../Model/Settings/PageSettings.swift | 2 +- .../Model/Settings/PathSettings.swift | 2 +- .../Model/Settings/PostSettings.swift | 2 +- .../Model/Settings/Settings.swift | 18 ++- CHDataManagement/Model/StorageError.swift | 37 +++++ CHDataManagement/Model/Tag.swift | 11 +- .../Preview Content/Content+Mock.swift | 17 ++- .../Preview Content/File+Mock.swift | 25 +++- .../Preview Content/MockImage.swift | 19 --- .../Preview Content/Page+Mock.swift | 66 ++++---- .../Preview Content/Post+Mock.swift | 141 +++++++++++------- .../Preview Content/Tag+Mock.swift | 118 ++++++++------- .../Storage/ChangeObservableItem.swift | 31 ++++ CHDataManagement/Storage/ErrorPrinter.swift | 10 -- CHDataManagement/Storage/SaveState.swift | 35 +++++ .../Storage/SecurityBookmark.swift | 57 +++---- CHDataManagement/Storage/Storage.swift | 26 +++- CHDataManagement/Storage/StorageItem.swift | 38 +++++ .../Views/Pages/LocalizedPageDetailView.swift | 11 +- .../Views/Pages/PageContentView.swift | 2 +- .../Views/Pages/PageDetailView.swift | 2 +- .../Views/Posts/PostContentView.swift | 4 +- .../Views/Posts/PostDetailView.swift | 2 +- .../Views/Posts/TagSelectionView.swift | 4 +- .../Views/Settings/PathSettingsView.swift | 20 +-- .../Views/Tags/LocalizedTagDetailView.swift | 2 +- .../Views/Tags/PageTagAssignmentView.swift | 2 +- .../Views/Tags/PostTagAssignmentView.swift | 2 +- .../Views/Tags/TagContentView.swift | 2 +- 55 files changed, 701 insertions(+), 381 deletions(-) create mode 100644 CHDataManagement/Main/StorageErrorView.swift create mode 100644 CHDataManagement/Model/StorageError.swift delete mode 100644 CHDataManagement/Preview Content/MockImage.swift create mode 100644 CHDataManagement/Storage/ChangeObservableItem.swift delete mode 100644 CHDataManagement/Storage/ErrorPrinter.swift create mode 100644 CHDataManagement/Storage/SaveState.swift create mode 100644 CHDataManagement/Storage/StorageItem.swift diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 0a62408..60fe72e 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* 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 */; }; E218500B2CEE02FD0090B18B /* Content+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E218500A2CEE02FA0090B18B /* Content+Mock.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 */; }; E2521DFC2D5020BE00C56662 /* PostContentGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2521DFB2D501DAE00C56662 /* PostContentGenerator.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 */; }; E25DA5092CFD964E00AEF16D /* TagContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25DA5082CFD964E00AEF16D /* TagContentView.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 */; }; E2A21C0E2CB189DC0060935B /* Color+RGB.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C0D2CB189D70060935B /* Color+RGB.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 */; }; E2A21C2A2CB2AA4F0060935B /* Post+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2A21C292CB2AA4C0060935B /* Post+Mock.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 */; }; E2FD1D232D2EB27000B48627 /* LoadingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D222D2EB26C00B48627 /* LoadingResult.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 */; }; E2FD1D2C2D35B76D00B48627 /* ListPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D2B2D35B76D00B48627 /* ListPopup.swift */; }; E2FD1D2E2D37180900B48627 /* GeneralSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */; }; @@ -264,6 +267,9 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + E20BCC962D53454500B8DBEB /* StorageItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageItem.swift; sourceTree = ""; }; + E20BCC982D53597D00B8DBEB /* SaveState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveState.swift; sourceTree = ""; }; + E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeObservableItem.swift; sourceTree = ""; }; E21850082CEE01BF0090B18B /* PagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagePickerView.swift; sourceTree = ""; }; E218500A2CEE02FA0090B18B /* Content+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Content+Mock.swift"; sourceTree = ""; }; E21850162CEE55FB0090B18B /* FileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileType.swift; sourceTree = ""; }; @@ -302,6 +308,8 @@ E24252092C52C9260029FF16 /* ContentLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLanguage.swift; sourceTree = ""; }; E2521DFB2D501DAE00C56662 /* PostContentGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostContentGenerator.swift; sourceTree = ""; }; E2521DFF2D50BB6E00C56662 /* ItemLinkResults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemLinkResults.swift; sourceTree = ""; }; + E2521E012D51776000C56662 /* StorageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageError.swift; sourceTree = ""; }; + E2521E032D51795B00C56662 /* StorageErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageErrorView.swift; sourceTree = ""; }; E2581DEC2C75202400F1F079 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; E25A0B882CE4021400F33674 /* LocalizedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedPage.swift; sourceTree = ""; }; E25DA5082CFD964E00AEF16D /* TagContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagContentView.swift; sourceTree = ""; }; @@ -395,7 +403,6 @@ E2A21C072CB17B810060935B /* TagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagView.swift; sourceTree = ""; }; E2A21C0D2CB189D70060935B /* Color+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+RGB.swift"; sourceTree = ""; }; E2A21C0F2CB18B390060935B /* FlowHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowHStack.swift; sourceTree = ""; }; - E2A21C1F2CB28ED20060935B /* MockImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImage.swift; sourceTree = ""; }; E2A21C272CB29B290060935B /* FeedEntryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEntryData.swift; sourceTree = ""; }; E2A21C292CB2AA4C0060935B /* Post+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+Mock.swift"; sourceTree = ""; }; E2A21C2F2CB490F90060935B /* HorizontalCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HorizontalCenter.swift; sourceTree = ""; }; @@ -435,7 +442,6 @@ E2FD1D202D2EB22700B48627 /* ModelLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelLoader.swift; sourceTree = ""; }; E2FD1D222D2EB26C00B48627 /* LoadingResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingResult.swift; sourceTree = ""; }; E2FD1D242D2EBA7C00B48627 /* TagOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOverview.swift; sourceTree = ""; }; - E2FD1D272D2F2D9100B48627 /* ErrorPrinter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorPrinter.swift; sourceTree = ""; }; E2FD1D292D35B74C00B48627 /* TextWithPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithPopup.swift; sourceTree = ""; }; E2FD1D2B2D35B76D00B48627 /* ListPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPopup.swift; sourceTree = ""; }; E2FD1D2D2D37180600B48627 /* GeneralSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettings.swift; sourceTree = ""; }; @@ -607,6 +613,7 @@ E29D31372D043EB80051B7F4 /* Main */ = { isa = PBXGroup; children = ( + E2521E032D51795B00C56662 /* StorageErrorView.swift */, E2FD1D332D3BA2DE00B48627 /* SelectedContent.swift */, E229904B2D10BE59009F8D77 /* InitialSetupView.swift */, E29D31422D0488950051B7F4 /* MainContentView.swift */, @@ -751,7 +758,9 @@ E2A37D0F2CE5375E0000979F /* Storage */ = { isa = PBXGroup; children = ( - E2FD1D272D2F2D9100B48627 /* ErrorPrinter.swift */, + E20BCC982D53597D00B8DBEB /* SaveState.swift */, + E20BCC962D53454500B8DBEB /* StorageItem.swift */, + E20BCC9A2D535C3100B8DBEB /* ChangeObservableItem.swift */, E229904D2D135349009F8D77 /* SecurityBookmark.swift */, E22990492D10BB90009F8D77 /* SecurityScopeBookmark.swift */, E22990472D10B7B7009F8D77 /* StorageAccessError.swift */, @@ -788,6 +797,7 @@ E25DA5162CFF00F200AEF16D /* Content+Save.swift */, E24252092C52C9260029FF16 /* ContentLanguage.swift */, E2FD1D3C2D463CD800B48627 /* ContentLabel.swift */, + E2521E012D51776000C56662 /* StorageError.swift */, E25DA59A2D024A2900AEF16D /* DateItem.swift */, E21850162CEE55FB0090B18B /* FileType.swift */, E2A21C502CBBD53C0060935B /* FileResource.swift */, @@ -913,7 +923,6 @@ E25DA5762D018B9500AEF16D /* File+Mock.swift */, E218500A2CEE02FA0090B18B /* Content+Mock.swift */, E2A37D1A2CEA45530000979F /* Tag+Mock.swift */, - E2A21C1F2CB28ED20060935B /* MockImage.swift */, E2A21C292CB2AA4C0060935B /* Post+Mock.swift */, E2E06DFF2CA4A8EB0019C2AF /* Page+Mock.swift */, E2DD047D2C276F32003BFF1F /* Preview Assets.xcassets */, @@ -1156,6 +1165,7 @@ E218502B2CF790B30090B18B /* PostContentView.swift in Sources */, E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */, E25DA56F2D00F9A100AEF16D /* PostFeedSettingsView.swift in Sources */, + E2521E042D51796000C56662 /* StorageErrorView.swift in Sources */, E29D313B2D04464A0051B7F4 /* LocalizedTagDetailView.swift in Sources */, E2FE0F552D2BCFC4002963B7 /* ContentBlock.swift in Sources */, E2A37D212CEA94EC0000979F /* Sequence+Sorted.swift in Sources */, @@ -1258,7 +1268,6 @@ E29D31A32D0CC98C0051B7F4 /* Item.swift in Sources */, E25DA57A2D01C64400AEF16D /* PageContentGenerator.swift in Sources */, E2FE0F622D2C0D8D002963B7 /* VersionedVideo.swift in Sources */, - E2A21C202CB28ED20060935B /* MockImage.swift in Sources */, E2FE0EEE2D1C22F3002963B7 /* MarkdownLinkProcessor.swift in Sources */, E2FE0F602D2C0422002963B7 /* VideoBlock.swift in Sources */, E2FE0F022D266FCB002963B7 /* LocalizedNavigationSettings.swift in Sources */, @@ -1308,8 +1317,8 @@ E2FE0EF62D1D6DF1002963B7 /* Icon.swift in Sources */, E29D31A52D0CD03F0051B7F4 /* FileSelectionView.swift in Sources */, E22990322D0F767B009F8D77 /* DatePropertyView.swift in Sources */, + E20BCC992D53597D00B8DBEB /* SaveState.swift in Sources */, E2A21C302CB490F90060935B /* HorizontalCenter.swift in Sources */, - E2FD1D282D2F2DAD00B48627 /* ErrorPrinter.swift in Sources */, E2FD1D1F2D2E9CC200B48627 /* ItemId.swift in Sources */, E2FE0EFA2D25AFBA002963B7 /* PageHeader.swift in Sources */, E25DA5252CFF73AB00AEF16D /* NSSize+Scaling.swift in Sources */, @@ -1326,6 +1335,7 @@ E29D31712D08234D0051B7F4 /* GenerationDetailView.swift in Sources */, E2A37D1F2CEA94370000979F /* Optional+Extensions.swift in Sources */, E29D31C32D0DBEF20051B7F4 /* Song.swift in Sources */, + E2521E022D51776300C56662 /* StorageError.swift in Sources */, E229902A2D0F5A14009F8D77 /* DetailTitle.swift in Sources */, E29D31532D0618740051B7F4 /* AddPageView.swift in Sources */, E2FE0F2C2D2B119A002963B7 /* ImageCommand.swift in Sources */, @@ -1338,6 +1348,7 @@ E2FE0F0F2D268D4F002963B7 /* BoxCommand.swift in Sources */, E22990482D10B7B7009F8D77 /* StorageAccessError.swift in Sources */, E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */, + E20BCC9B2D535C3500B8DBEB /* ChangeObservableItem.swift in Sources */, E2A21C512CBBD53F0060935B /* FileResource.swift in Sources */, E22990152D0E2B7F009F8D77 /* ItemSelectionView.swift in Sources */, E2A37D0E2CE527070000979F /* Storage.swift in Sources */, @@ -1364,6 +1375,7 @@ E25DA58F2D02368D00AEF16D /* PageSettings.swift in Sources */, E25DA50D2CFD9BA200AEF16D /* PostTagAssignmentView.swift in Sources */, E2FD1D0D2D2DBBA600B48627 /* LinkPreview.swift in Sources */, + E20BCC972D53454C00B8DBEB /* StorageItem.swift in Sources */, E22990362D0F79D2009F8D77 /* OptionalStringPropertyView.swift in Sources */, E2FD1D602D47EEEF00B48627 /* LocalizedPostContentView.swift in Sources */, E229903C2D0F8A7B009F8D77 /* OptionalTextFieldPropertyView.swift in Sources */, diff --git a/CHDataManagement/Main/InitialSetupView.swift b/CHDataManagement/Main/InitialSetupView.swift index 7fe735d..645c262 100644 --- a/CHDataManagement/Main/InitialSetupView.swift +++ b/CHDataManagement/Main/InitialSetupView.swift @@ -58,7 +58,8 @@ struct InitialSetupView: View { let loader = ModelLoader(content: content, storage: content.storage) let result = loader.load() 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) return } diff --git a/CHDataManagement/Main/MainView.swift b/CHDataManagement/Main/MainView.swift index 189f044..8aba1ba 100644 --- a/CHDataManagement/Main/MainView.swift +++ b/CHDataManagement/Main/MainView.swift @@ -4,13 +4,11 @@ import SFSafeSymbols /** **Content** - iPhone Backgrounds: Add page, html - - CV: Update PDF **UI** - Image search: Add view to see all images and filter - Page Content: Show all results of `PageGenerationResults` - Files: Show usages of file - - Buttons to insert special commands (images, page links, ...) **Features** - Posts: Generate separate pages for posts to link to @@ -26,9 +24,7 @@ import SFSafeSymbols **Fixes** - Files: Id change: Check all page contents for links to the renamed file and replace occurences - Database: Show errors during loading - - Mock content: Clean and improve - Investigate issue with spaces in content file names - - Check assignment of blog posts to tags */ @main @@ -54,10 +50,7 @@ struct MainView: App { private var showInitialSetupSheet = false @State - private var showLoadErrorSheet = false - - @State - private var loadErrors: [String] = [] + private var showStorageErrorSheet = false @ViewBuilder var sidebar: some View { @@ -158,15 +151,9 @@ struct MainView: App { }.pickerStyle(.segmented) } ToolbarItem(placement: .primaryAction) { - if content.storage.contentScope != nil { - Button(action: save) { - Text("Save") - } - } else { - Button(action: showInitialSheet) { - Text("Setup") - } - .background(RoundedRectangle(cornerRadius: 8).fill(Color.red)) + Button(action: saveButtonPressed) { + Image(systemSymbol: content.saveState.symbol) + .foregroundStyle(content.saveState.color) } } } @@ -175,9 +162,6 @@ struct MainView: App { .environmentObject(content) .environmentObject(selection) .onAppear(perform: loadContent) - .onReceive(Timer.publish(every: 60.0, on: .main, in: .common).autoconnect()) { _ in - save() - } .sheet(isPresented: $showAddSheet) { addItemSheet .environment(\.language, language) @@ -190,30 +174,23 @@ struct MainView: App { .environmentObject(content) .environmentObject(selection) } - .sheet(isPresented: $showLoadErrorSheet) { - VStack { - 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() + .sheet(isPresented: $showStorageErrorSheet) { + StorageErrorView(isPresented: $showStorageErrorSheet) + .environmentObject(content) } } } - private func save() { - guard content.saveToDisk() else { - print("Failed to save content") - #warning("Show error message") - return + private func saveButtonPressed() { + switch content.saveState { + case .storageNotInitialized: + showInitialSheet() + case .isSaved: + content.saveUnconditionally() + case .needsSave: + content.saveUnconditionally() + case .failedToSave: + showStorageErrorSheet = true } } @@ -222,13 +199,11 @@ struct MainView: App { showInitialSheet() return } - content.loadFromDisk { errors in + content.loadFromDisk { prepareAfterLoad() - guard !errors.isEmpty else { - return + if !content.storageErrors.isEmpty { + self.showStorageErrorSheet = true } - self.loadErrors = errors - self.showLoadErrorSheet = true } } diff --git a/CHDataManagement/Main/StorageErrorView.swift b/CHDataManagement/Main/StorageErrorView.swift new file mode 100644 index 0000000..248982d --- /dev/null +++ b/CHDataManagement/Main/StorageErrorView.swift @@ -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() + } +} diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index 751aa70..7f0a9a5 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -2,7 +2,47 @@ import Foundation 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 storage.contentScope != nil else { print("Storage not initialized, not saving content") @@ -10,12 +50,28 @@ extension Content { } var failedSaves = 0 - failedSaves += pages.count { !storage.save(pageMetadata: $0.data, for: $0.id) } - failedSaves += posts.count { !storage.save(post: $0.data, for: $0.id) } - failedSaves += tags.count { !storage.save(tagMetadata: $0.data, for: $0.id) } - failedSaves.increment(!storage.save(settings: settings.data(tagOverview: tagOverview))) - failedSaves += files.count { !storage.save(fileInfo: $0.data, for: $0.id) } + var saves = 0 + let pageSaves = saveChanged(pages) + failedSaves += pageSaves.unsaved + saves += pageSaves.saved + 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 { print("Save partially failed with \(failedSaves) errors") return false @@ -39,4 +95,22 @@ extension Content { } return success } + + private func saveChanged(_ 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) + } } diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index f03dbf1..f6a45e6 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -11,7 +11,7 @@ final class Content: ObservableObject { var storage: Storage @Published - var settings: Settings + var settings: Settings! @Published var posts: [Post] @@ -31,6 +31,9 @@ final class Content: ObservableObject { @Published var results: GenerationResults + @Published + var storageErrors: [StorageError] = [] + @Published var generationStatus: String = "Ready to generate" @@ -40,28 +43,12 @@ final class Content: ObservableObject { @Published private(set) var shouldGenerateWebsite = false + @Published + private(set) var saveState: SaveState = .isSaved + let imageGenerator: ImageGenerator - init(settings: Settings, - 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) - } + var errorCallback: ((StorageError) -> Void)? init() { let settings = Settings.default @@ -78,6 +65,10 @@ final class Content: ObservableObject { self.imageGenerator = ImageGenerator( storage: storage, settings: settings) + storage.errorNotification = { [weak self] error in + self?.storageErrors.append(error) + } + settings.content = self } private func clear() { @@ -112,7 +103,7 @@ final class Content: ObservableObject { pages.insert(page, at: 0) } - func update(contentPath: URL, callback: @escaping ([String]) -> ()) { + func update(contentPath: URL, callback: @escaping () -> ()) { guard storage.save(contentPath: contentPath) else { return } @@ -139,19 +130,15 @@ final class Content: ObservableObject { files.first { $0.absoluteUrl == withOutputPath } } - private let errorPrinter = ErrorPrinter() - - func loadFromDisk(callback: @escaping (_ errors: [String]) -> ()) { - defer { - storage.contentScope?.delegate = errorPrinter - } + func loadFromDisk(callback: @escaping () -> ()) { DispatchQueue.global().async { let loader = ModelLoader(content: self, storage: self.storage) let result = loader.load() guard result.errors.isEmpty else { DispatchQueue.main.async { self.didLoadContent = false - callback(result.errors.sorted()) + self.storageErrors.append(contentsOf: result.errors) + callback() } return } @@ -164,7 +151,7 @@ final class Content: ObservableObject { self.settings = result.settings self.tagOverview = result.tagOverview self.didLoadContent = true - callback([]) + callback() 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 + } } diff --git a/CHDataManagement/Model/ContentLabel.swift b/CHDataManagement/Model/ContentLabel.swift index 5a4654d..d438e05 100644 --- a/CHDataManagement/Model/ContentLabel.swift +++ b/CHDataManagement/Model/ContentLabel.swift @@ -42,7 +42,7 @@ extension ContentLabel { self.init(icon: icon, value: data.value) } - struct Data: Codable { + struct Data: Codable, Equatable { let icon: String let value: String } diff --git a/CHDataManagement/Model/FileResource.swift b/CHDataManagement/Model/FileResource.swift index b2b8423..73dd2be 100644 --- a/CHDataManagement/Model/FileResource.swift +++ b/CHDataManagement/Model/FileResource.swift @@ -51,6 +51,8 @@ final class FileResource: Item, LocalizedItem { @Published var fileSize: Int? = nil + var savedData: Data? + init(content: Content, id: String, isExternallyStored: Bool, @@ -78,7 +80,7 @@ final class FileResource: Item, LocalizedItem { /** Only for bundle images */ - init(resourceImage: String, type: FileType) { + init(content: Content, resourceImage: String, type: FileType) { self.type = type self.english = "A test image included in the bundle" self.german = "Ein Testbild aus dem Bundle" @@ -89,7 +91,7 @@ final class FileResource: Item, LocalizedItem { self.customOutputPath = nil self.addedDate = 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 @@ -349,7 +351,7 @@ extension FileResource: CustomStringConvertible { } } -extension FileResource { +extension FileResource: StorageItem { convenience init(content: Content, id: String, data: FileResource.Data, isExternalFile: Bool) { self.init( @@ -364,6 +366,7 @@ extension FileResource { customOutputPath: data.customOutputPath, addedDate: data.addedDate, modifiedDate: data.modifiedDate) + savedData = 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. - struct Data: Codable { + struct Data: Codable, Equatable { let englishDescription: String? let germanDescription: String? let generatedImages: [String]? @@ -389,4 +392,8 @@ extension FileResource { let addedDate: Date let modifiedDate: Date } + + func saveToDisk(_ data: Data) -> Bool { + content.storage.save(fileResource: data, for: id) + } } diff --git a/CHDataManagement/Model/Item/Item.swift b/CHDataManagement/Model/Item/Item.swift index 9cc8755..9323d8d 100644 --- a/CHDataManagement/Model/Item/Item.swift +++ b/CHDataManagement/Model/Item/Item.swift @@ -1,6 +1,7 @@ import Foundation +import Combine -class Item: ObservableObject, Identifiable { +class Item: ObservableContentItem, Identifiable { unowned let content: Content @@ -11,17 +12,25 @@ class Item: ObservableObject, Identifiable { @Published var id: String + var cancellables = Set() + init(content: Content, id: String) { self.content = content self.id = id + + observeChanges() } + // MARK: Change observation + func didChange() { DispatchQueue.main.async { self.changeToggle.toggle() } } + // MARK: Paths + func makeCleanAbsolutePath(_ path: String) -> String { "/" + makeCleanRelativePath(path) } diff --git a/CHDataManagement/Model/Item/ItemId.swift b/CHDataManagement/Model/Item/ItemId.swift index 11712a7..ddf8fe0 100644 --- a/CHDataManagement/Model/Item/ItemId.swift +++ b/CHDataManagement/Model/Item/ItemId.swift @@ -9,3 +9,7 @@ struct ItemId { extension ItemId: Codable { } + +extension ItemId: Equatable { + +} diff --git a/CHDataManagement/Model/LinkPreview.swift b/CHDataManagement/Model/LinkPreview.swift index a11f287..e6d0298 100644 --- a/CHDataManagement/Model/LinkPreview.swift +++ b/CHDataManagement/Model/LinkPreview.swift @@ -50,7 +50,7 @@ final class LinkPreview: ObservableObject { extension LinkPreview { /// The object to serialize a link preview for storage - struct Data: Codable { + struct Data: Codable, Equatable { let title: String? let description: String? let image: String? diff --git a/CHDataManagement/Model/Loading/LoadingContext.swift b/CHDataManagement/Model/Loading/LoadingContext.swift index e1af776..33644d2 100644 --- a/CHDataManagement/Model/Loading/LoadingContext.swift +++ b/CHDataManagement/Model/Loading/LoadingContext.swift @@ -29,7 +29,7 @@ final class LoadingContext { tags: tags.values.sorted(), files: files.values.sorted { $0.id }, tagOverview: tagOverview, - errors: errors.sorted()) + errors: errors.sorted().map { StorageError(message: $0) }) } func error(_ message: String) { diff --git a/CHDataManagement/Model/Loading/LoadingResult.swift b/CHDataManagement/Model/Loading/LoadingResult.swift index cc8f819..8da17a4 100644 --- a/CHDataManagement/Model/Loading/LoadingResult.swift +++ b/CHDataManagement/Model/Loading/LoadingResult.swift @@ -13,5 +13,5 @@ struct LoadingResult { let tagOverview: Tag? - let errors: [String] + let errors: [StorageError] } diff --git a/CHDataManagement/Model/Loading/ModelLoader.swift b/CHDataManagement/Model/Loading/ModelLoader.swift index 9df2a85..55af55e 100644 --- a/CHDataManagement/Model/Loading/ModelLoader.swift +++ b/CHDataManagement/Model/Loading/ModelLoader.swift @@ -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 { let content: Content @@ -20,14 +7,10 @@ final class ModelLoader { let context: LoadingContext - let errorHandler: LoadingErrorHandler - init(content: Content, storage: Storage) { self.content = content self.storage = storage self.context = .init(content: content) - self.errorHandler = .init(context: context) - storage.contentScope?.delegate = errorHandler } func load() -> LoadingResult { diff --git a/CHDataManagement/Model/LocalizedPage.swift b/CHDataManagement/Model/LocalizedPage.swift index 5027b53..95a940f 100644 --- a/CHDataManagement/Model/LocalizedPage.swift +++ b/CHDataManagement/Model/LocalizedPage.swift @@ -80,7 +80,7 @@ extension LocalizedPage { } /// The structure to store the metadata of a localized page - struct Data: Codable { + struct Data: Codable, Equatable { let url: String let title: String let linkPreview: LinkPreview.Data diff --git a/CHDataManagement/Model/LocalizedPost.swift b/CHDataManagement/Model/LocalizedPost.swift index 09670f8..144b2cd 100644 --- a/CHDataManagement/Model/LocalizedPost.swift +++ b/CHDataManagement/Model/LocalizedPost.swift @@ -108,7 +108,7 @@ extension LocalizedPost { } /// The structure to store the metadata of a localized post - struct Data: Codable { + struct Data: Codable, Equatable { let images: [String] let labels: [ContentLabel.Data]? let title: String? diff --git a/CHDataManagement/Model/LocalizedTag.swift b/CHDataManagement/Model/LocalizedTag.swift index 35c48af..f7e78a8 100644 --- a/CHDataManagement/Model/LocalizedTag.swift +++ b/CHDataManagement/Model/LocalizedTag.swift @@ -54,7 +54,7 @@ extension LocalizedTag { originalUrl: data.originalUrl) } - struct Data: Codable { + struct Data: Codable, Equatable { let urlComponent: String let name: String let linkPreview: LinkPreview.Data diff --git a/CHDataManagement/Model/Page.swift b/CHDataManagement/Model/Page.swift index 11068ce..b120247 100644 --- a/CHDataManagement/Model/Page.swift +++ b/CHDataManagement/Model/Page.swift @@ -44,6 +44,8 @@ final class Page: Item, DateItem, LocalizedItem { @Published var requiredFiles: [FileResource] + var savedData: Data? + init(content: Content, id: String, externalLink: String?, @@ -186,7 +188,7 @@ final class Page: Item, DateItem, LocalizedItem { // MARK: Storage -extension Page { +extension Page: StorageItem { convenience init(context: LoadingContext, id: String, data: Data) { self.init( @@ -202,10 +204,11 @@ extension Page { english: .init(context: context, data: data.english), tags: data.tags.compactMap(context.tag), requiredFiles: data.requiredFiles?.compactMap(context.file) ?? []) + savedData = data } /// The structure to store the metadata of a page on disk - struct Data: Codable { + struct Data: Codable, Equatable { let isDraft: Bool let externalLink: String? let tags: [String] @@ -232,4 +235,8 @@ extension Page { english: english.data, requiredFiles: requiredFiles.nonEmpty?.map { $0.id }.sorted()) } + + func saveToDisk(_ data: Data) -> Bool { + content.storage.save(page: data, for: id) + } } diff --git a/CHDataManagement/Model/Post.swift b/CHDataManagement/Model/Post.swift index 869f7d8..8e8a20d 100644 --- a/CHDataManagement/Model/Post.swift +++ b/CHDataManagement/Model/Post.swift @@ -60,6 +60,10 @@ final class Post: Item, DateItem, LocalizedItem { super.init(content: content, id: id) } + // MARK: Storage + + var savedData: Data? + // MARK: Tags 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) { self.init( @@ -187,9 +191,10 @@ extension Post { german: .init(context: context, data: data.german), english: .init(context: context, data: data.english), linkedPage: data.linkedPageId.map(context.page)) + savedData = data } - struct Data: Codable { + struct Data: Codable, Equatable { let isDraft: Bool let createdDate: Date let startDate: Date @@ -211,4 +216,8 @@ extension Post { english: english.data, linkedPageId: linkedPage?.id) } + + func saveToDisk(_ data: Data) -> Bool { + content.storage.save(post: data, for: id) + } } diff --git a/CHDataManagement/Model/Settings/AudioPlayerSettings.swift b/CHDataManagement/Model/Settings/AudioPlayerSettings.swift index fc0aeb2..4a15bed 100644 --- a/CHDataManagement/Model/Settings/AudioPlayerSettings.swift +++ b/CHDataManagement/Model/Settings/AudioPlayerSettings.swift @@ -67,7 +67,7 @@ extension AudioPlayerSettings { english: english.data) } - struct Data: Codable { + struct Data: Codable, Equatable { let playlistCoverImageSize: Int let smallCoverImageSize: Int let audioPlayerJsFile: String? diff --git a/CHDataManagement/Model/Settings/GeneralSettings.swift b/CHDataManagement/Model/Settings/GeneralSettings.swift index 21ed926..b99c75b 100644 --- a/CHDataManagement/Model/Settings/GeneralSettings.swift +++ b/CHDataManagement/Model/Settings/GeneralSettings.swift @@ -34,7 +34,7 @@ extension GeneralSettings { linkPreviewImageHeight: linkPreviewImageHeight) } - struct Data: Codable { + struct Data: Codable, Equatable { let url: String let linkPreviewImageWidth: Int let linkPreviewImageHeight: Int diff --git a/CHDataManagement/Model/Settings/LocalizedAudioPlayerSettings.swift b/CHDataManagement/Model/Settings/LocalizedAudioPlayerSettings.swift index a6166e4..4de50a3 100644 --- a/CHDataManagement/Model/Settings/LocalizedAudioPlayerSettings.swift +++ b/CHDataManagement/Model/Settings/LocalizedAudioPlayerSettings.swift @@ -22,7 +22,7 @@ extension LocalizedAudioPlayerSettings { .init(playlistText: playlistText) } - struct Data: Codable { + struct Data: Codable, Equatable { let playlistText: String } } diff --git a/CHDataManagement/Model/Settings/LocalizedNavigationSettings.swift b/CHDataManagement/Model/Settings/LocalizedNavigationSettings.swift index bc3a160..df8fe89 100644 --- a/CHDataManagement/Model/Settings/LocalizedNavigationSettings.swift +++ b/CHDataManagement/Model/Settings/LocalizedNavigationSettings.swift @@ -18,7 +18,7 @@ extension LocalizedNavigationSettings { self.init(rootUrl: data.rootUrl) } - struct Data: Codable { + struct Data: Codable, Equatable { let rootUrl: String } diff --git a/CHDataManagement/Model/Settings/LocalizedPageSettings.swift b/CHDataManagement/Model/Settings/LocalizedPageSettings.swift index 96ce355..d003efd 100644 --- a/CHDataManagement/Model/Settings/LocalizedPageSettings.swift +++ b/CHDataManagement/Model/Settings/LocalizedPageSettings.swift @@ -31,7 +31,7 @@ extension LocalizedPageSettings { emptyPageText: emptyPageText) } - struct Data: Codable { + struct Data: Codable, Equatable { let emptyPageTitle: String let emptyPageText: String } diff --git a/CHDataManagement/Model/Settings/LocalizedPostSettings.swift b/CHDataManagement/Model/Settings/LocalizedPostSettings.swift index 73c8e6d..4000b59 100644 --- a/CHDataManagement/Model/Settings/LocalizedPostSettings.swift +++ b/CHDataManagement/Model/Settings/LocalizedPostSettings.swift @@ -41,7 +41,7 @@ extension LocalizedPostSettings { linkPreview: linkPreview.data) } - struct Data: Codable { + struct Data: Codable, Equatable { let feedUrlPrefix: String let defaultPageLinkText: String let linkPreview: LinkPreview.Data diff --git a/CHDataManagement/Model/Settings/NavigationSettings.swift b/CHDataManagement/Model/Settings/NavigationSettings.swift index 4813a54..cc1f7a0 100644 --- a/CHDataManagement/Model/Settings/NavigationSettings.swift +++ b/CHDataManagement/Model/Settings/NavigationSettings.swift @@ -32,7 +32,7 @@ extension NavigationSettings { english: LocalizedNavigationSettings(data: data.english)) } - struct Data: Codable { + struct Data: Codable, Equatable { let navigationItems: [ItemId] let german: LocalizedNavigationSettings.Data let english: LocalizedNavigationSettings.Data diff --git a/CHDataManagement/Model/Settings/PageSettings.swift b/CHDataManagement/Model/Settings/PageSettings.swift index 056e122..0524e02 100644 --- a/CHDataManagement/Model/Settings/PageSettings.swift +++ b/CHDataManagement/Model/Settings/PageSettings.swift @@ -104,7 +104,7 @@ extension PageSettings { english: english.data) } - struct Data: Codable { + struct Data: Codable, Equatable { let contentWidth: Int let largeImageWidth: Int let pageLinkImageSize: Int diff --git a/CHDataManagement/Model/Settings/PathSettings.swift b/CHDataManagement/Model/Settings/PathSettings.swift index 141667c..5e46734 100644 --- a/CHDataManagement/Model/Settings/PathSettings.swift +++ b/CHDataManagement/Model/Settings/PathSettings.swift @@ -64,7 +64,7 @@ extension PathSettings { tagsOutputFolderPath: tagsOutputFolderPath) } - struct Data: Codable { + struct Data: Codable, Equatable { let assetsOutputFolderPath: String let pagesOutputFolderPath: String let imagesOutputFolderPath: String diff --git a/CHDataManagement/Model/Settings/PostSettings.swift b/CHDataManagement/Model/Settings/PostSettings.swift index 5680622..e626f95 100644 --- a/CHDataManagement/Model/Settings/PostSettings.swift +++ b/CHDataManagement/Model/Settings/PostSettings.swift @@ -79,7 +79,7 @@ extension PostSettings { english: english.data) } - struct Data: Codable { + struct Data: Codable, Equatable { let postsPerPage: Int let contentWidth: Int let swiperCssFile: String? diff --git a/CHDataManagement/Model/Settings/Settings.swift b/CHDataManagement/Model/Settings/Settings.swift index b8ec491..b18d498 100644 --- a/CHDataManagement/Model/Settings/Settings.swift +++ b/CHDataManagement/Model/Settings/Settings.swift @@ -1,6 +1,7 @@ import Foundation +import Combine -final class Settings: ObservableObject { +final class Settings: ChangeObservableItem { @Published var general: GeneralSettings @@ -21,6 +22,10 @@ final class Settings: ObservableObject { @Published var audioPlayer: AudioPlayerSettings + weak var content: Content? + + var cancellables: Set = [] + init(general: GeneralSettings, paths: PathSettings, navigation: NavigationSettings, @@ -40,6 +45,10 @@ final class Settings: ObservableObject { posts.remove(file) audioPlayer.remove(file) } + + func needsSaving() { + content?.needsSave() + } } // MARK: Storage @@ -54,6 +63,7 @@ extension Settings { posts: .init(context: context, data: data.posts), pages: .init(context: context, data: data.pages), audioPlayer: .init(context: context, data: data.audioPlayer)) + content = context.content } func data(tagOverview: Tag?) -> Data { @@ -67,7 +77,7 @@ extension Settings { tagOverview: tagOverview?.data) } - struct Data: Codable { + struct Data: Codable, Equatable { let general: GeneralSettings.Data let paths: PathSettings.Data let navigation: NavigationSettings.Data @@ -76,6 +86,10 @@ extension Settings { let audioPlayer: AudioPlayerSettings.Data let tagOverview: Tag.Data? } + + func saveToDisk(_ data: Data) -> Bool { + content?.storage.save(settings: data) ?? false + } } extension Settings { diff --git a/CHDataManagement/Model/StorageError.swift b/CHDataManagement/Model/StorageError.swift new file mode 100644 index 0000000..d65b56f --- /dev/null +++ b/CHDataManagement/Model/StorageError.swift @@ -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) + } +} diff --git a/CHDataManagement/Model/Tag.swift b/CHDataManagement/Model/Tag.swift index 0b60f4d..4dcb586 100644 --- a/CHDataManagement/Model/Tag.swift +++ b/CHDataManagement/Model/Tag.swift @@ -13,6 +13,8 @@ class Tag: Item, LocalizedItem { @Published var english: LocalizedTag + var savedData: Data? + override init(content: Content, id: String) { self.isVisible = true self.english = .init(content: content, urlComponent: id, name: id) @@ -77,7 +79,7 @@ class Tag: Item, LocalizedItem { // MARK: Storage -extension Tag { +extension Tag: StorageItem { convenience init(context: LoadingContext, id: String, data: Data) { self.init( @@ -86,9 +88,10 @@ extension Tag { isVisible: data.isVisible ?? true, german: .init(context: context, data: data.german), english: .init(context: context, data: data.english)) + savedData = data } - struct Data: Codable { + struct Data: Codable, Equatable { // Defaults to true if unset let isVisible: Bool? let german: LocalizedTag.Data @@ -101,4 +104,8 @@ extension Tag { german: german.data, english: english.data) } + + func saveToDisk(_ data: Data) -> Bool { + content.storage.save(tag: data, for: id) + } } diff --git a/CHDataManagement/Preview Content/Content+Mock.swift b/CHDataManagement/Preview Content/Content+Mock.swift index ab78586..d835612 100644 --- a/CHDataManagement/Preview Content/Content+Mock.swift +++ b/CHDataManagement/Preview Content/Content+Mock.swift @@ -12,11 +12,14 @@ extension FileManager { extension Content { - static let mock: Content = Content( - settings: .default, - posts: [.empty, .mock, .fullMock], - pages: [.empty], - tags: [.hiking, .mountains, .nature, .sports], - files: [], - tagOverview: nil) + static let mock: Content = { + let content = Content() + + content.files = FileResource.Mock.mockData(content: content) + content.tags = Tag.Mock.mockData(content: content) + content.posts = Post.Mock.mockData(content: content) + content.pages = Page.Mock.mockData(content: content) + + return content + }() } diff --git a/CHDataManagement/Preview Content/File+Mock.swift b/CHDataManagement/Preview Content/File+Mock.swift index 0e9e671..831eb56 100644 --- a/CHDataManagement/Preview Content/File+Mock.swift +++ b/CHDataManagement/Preview Content/File+Mock.swift @@ -1,12 +1,25 @@ 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 { - .init( - content: .mock, - id: "my-file.txt", - isExternallyStored: true, - english: "Some text file", - german: "Eine Textdatei") + Content.mock.files.first(where: { $0.id == "my-file.txt" })! } } diff --git a/CHDataManagement/Preview Content/MockImage.swift b/CHDataManagement/Preview Content/MockImage.swift deleted file mode 100644 index 79a7ca6..0000000 --- a/CHDataManagement/Preview Content/MockImage.swift +++ /dev/null @@ -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 - } -} diff --git a/CHDataManagement/Preview Content/Page+Mock.swift b/CHDataManagement/Preview Content/Page+Mock.swift index db0a4ef..3bf69b6 100644 --- a/CHDataManagement/Preview Content/Page+Mock.swift +++ b/CHDataManagement/Preview Content/Page+Mock.swift @@ -2,36 +2,40 @@ import Foundation extension Page { - static var empty: Page { - .init( - content: .mock, - id: "my-id", - externalLink: nil, - isDraft: true, - createdDate: Date(), - hideDate: false, - startDate: Date().addingTimeInterval(-86400), - endDate: nil, - german: .german, - english: .english, - tags: [.mock], - requiredFiles: []) + enum Mock { + + static func mockData(content: Content) -> [Page] { + [ + Page( + content: content, + id: "my-id", + externalLink: nil, + isDraft: true, + createdDate: Date(), + hideDate: false, + startDate: Date().addingTimeInterval(-86400), + endDate: nil, + 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") -} diff --git a/CHDataManagement/Preview Content/Post+Mock.swift b/CHDataManagement/Preview Content/Post+Mock.swift index 5b372ee..544377f 100644 --- a/CHDataManagement/Preview Content/Post+Mock.swift +++ b/CHDataManagement/Preview Content/Post+Mock.swift @@ -1,65 +1,90 @@ extension Post { - static var empty: Post { - .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) - } + enum Mock { - static var mock: Post { - Post( - content: Content.mock, - id: "mock", - isDraft: false, - createdDate: .now, - startDate: .now, - endDate: nil, - tags: [.nature, .sports, .hiking], - german: .init( - content: .mock, - title: "Der 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."), - english: .init( - content: .mock, - title: "The title", - text: "Very nice and solitary hike from Oberau to Krottenkopf. Challenging and rewarding, due to the length and height") - ) - } + static func mockData(content: Content) -> [Post] { + [ + Post(content: content, + id: "empty", + isDraft: true, + createdDate: .now, + startDate: .now, + endDate: nil, + tags: [], + german: .init( + content: content, + text: "Text"), + english: .init( + content: content, + text: "Text"), + linkedPage: nil), + 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 { - .init( - content: Content.mock, - id: "full", - isDraft: true, - createdDate: .now, - startDate: .now.addingTimeInterval(-86400), endDate: .now, - tags: [.nature, .sports, .hiking, .mountains], - german: .german, - english: .english) + static var empty: Post { + Content.mock.posts.first(where: { $0.id == "empty" })! + } + + static var hike: Post { + Content.mock.posts.first(where: { $0.id == "hike" })! + } + + static var hike2: Post { + 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) -} diff --git a/CHDataManagement/Preview Content/Tag+Mock.swift b/CHDataManagement/Preview Content/Tag+Mock.swift index 302d02c..0682117 100644 --- a/CHDataManagement/Preview Content/Tag+Mock.swift +++ b/CHDataManagement/Preview Content/Tag+Mock.swift @@ -2,56 +2,74 @@ import Foundation extension Tag { - static let mock = Tag( - content: .mock, - id: "electronics", - german: .german, - english: .english) + enum Mock { - static let nature = Tag( - content: .mock, - id: "nature", - german: .init(content: .mock, urlComponent: "natur", name: "Natur"), - english: .init(content: .mock, urlComponent: "nature", name: "Nature") - ) + static func mockData(content: Content) -> [Tag] { + [ + Tag(content: content, + id: "electronics", + 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( - content: .mock, - id: "sports", - german: .init(content: .mock, urlComponent: "sport", name: "Sport"), - english: .init(content: .mock, urlComponent: "sports", name: "Sports") - ) - - static let hiking = Tag( - content: .mock, - id: "hiking", - german: .init(content: .mock, urlComponent: "wandern", name: "Wandern"), - english: .init(content: .mock, urlComponent: "hiking", name: "Hiking") - ) - - static let mountains = Tag( - content: .mock, - id: "mountains", - german: .init(content: .mock, urlComponent: "berge", name: "Berge"), - 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") + static var electronics: Tag { + Content.mock.tags.first(where: { $0.id == "electronics" })! + } + + static var nature: Tag { + Content.mock.tags.first(where: { $0.id == "nature" })! + } + + static var sports: Tag { + Content.mock.tags.first(where: { $0.id == "sports" })! + } + + static var hiking: Tag { + Content.mock.tags.first(where: { $0.id == "hiking" })! + } + + static var mountains: Tag { + Content.mock.tags.first(where: { $0.id == "mountains" })! + } + } } diff --git a/CHDataManagement/Storage/ChangeObservableItem.swift b/CHDataManagement/Storage/ChangeObservableItem.swift new file mode 100644 index 0000000..67095e6 --- /dev/null +++ b/CHDataManagement/Storage/ChangeObservableItem.swift @@ -0,0 +1,31 @@ +import Combine + +protocol ChangeObservableItem: ObservableObject { + + var cancellables: Set { 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) + } +} diff --git a/CHDataManagement/Storage/ErrorPrinter.swift b/CHDataManagement/Storage/ErrorPrinter.swift deleted file mode 100644 index c248e09..0000000 --- a/CHDataManagement/Storage/ErrorPrinter.swift +++ /dev/null @@ -1,10 +0,0 @@ -final class ErrorPrinter { - -} - -extension ErrorPrinter: SecurityBookmarkErrorDelegate { - - func securityBookmark(error: String) { - print(error) - } -} diff --git a/CHDataManagement/Storage/SaveState.swift b/CHDataManagement/Storage/SaveState.swift new file mode 100644 index 0000000..901b3cd --- /dev/null +++ b/CHDataManagement/Storage/SaveState.swift @@ -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 + } + } +} diff --git a/CHDataManagement/Storage/SecurityBookmark.swift b/CHDataManagement/Storage/SecurityBookmark.swift index 71c276d..9bcfb08 100644 --- a/CHDataManagement/Storage/SecurityBookmark.swift +++ b/CHDataManagement/Storage/SecurityBookmark.swift @@ -1,10 +1,7 @@ import Foundation import AppKit -protocol SecurityBookmarkErrorDelegate: AnyObject { - - func securityBookmark(error: String) -} +typealias StorageErrorCallback = (StorageError) -> Void struct SecurityBookmark { @@ -25,7 +22,7 @@ struct SecurityBookmark { private let fm = FileManager.default - weak var delegate: SecurityBookmarkErrorDelegate? + var errorNotification: StorageErrorCallback? init(url: URL, isStale: Bool) { self.url = url @@ -34,6 +31,14 @@ struct SecurityBookmark { self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } + private func reportError(_ error: String) { + guard let errorNotification else { + print(error) + return + } + errorNotification(.init(message: error)) + } + // MARK: Write func openFinderWindow(relativePath: String) { @@ -65,7 +70,7 @@ struct SecurityBookmark { do { data = try encoder.encode(value) } catch { - delegate?.securityBookmark(error: "Failed to encode \(value): \(error)") + reportError("Failed to encode \(value): \(error)") return false } return write(data, to: relativePath) @@ -79,7 +84,7 @@ struct SecurityBookmark { createParentFolder: Bool = true, ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool { 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 write(data, to: relativePath, createParentFolder: createParentFolder, ifFileExists: overwrite) @@ -95,7 +100,7 @@ struct SecurityBookmark { if exists(file) { switch overwrite { case .fail: - delegate?.securityBookmark(error: "Failed to write \(relativePath): File exists") + reportError("Failed to write \(relativePath): File exists") return false case .skip: return true case .write: break @@ -110,7 +115,7 @@ struct SecurityBookmark { try createParentIfNeeded(of: file) try data.write(to: file) } catch { - delegate?.securityBookmark(error: "Failed to write \(relativePath): \(error)") + reportError("Failed to write \(relativePath): \(error)") return false } return true @@ -136,7 +141,7 @@ struct SecurityBookmark { return nil } 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 result @@ -150,7 +155,7 @@ struct SecurityBookmark { do { return try Data(contentsOf: file) } catch { - delegate?.securityBookmark(error: "Failed to read \(relativePath) \(error)") + reportError("Failed to read \(relativePath) \(error)") return nil } } @@ -163,7 +168,7 @@ struct SecurityBookmark { do { return try decoder.decode(T.self, from: data) } catch { - delegate?.securityBookmark(error: "Failed to decode \(relativePath): \(error)") + reportError("Failed to decode \(relativePath): \(error)") return nil } } @@ -178,7 +183,7 @@ struct SecurityBookmark { with(relativePath: relativeSource) { source in if !exists(source) { 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 } @@ -186,7 +191,7 @@ struct SecurityBookmark { if exists(destination) { switch overwrite { case .fail: - delegate?.securityBookmark(error: "Failed to move to \(relativeDestination): File already exists") + reportError("Failed to move to \(relativeDestination): File already exists") return false case .skip: return true case .write: break @@ -205,7 +210,7 @@ struct SecurityBookmark { try fm.moveItem(at: source, to: destination) return true } 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 } } @@ -220,7 +225,7 @@ struct SecurityBookmark { if destination.exists { switch overwrite { case .fail: - delegate?.securityBookmark(error: "Failed to copy to \(relativePath): File already exists") + reportError("Failed to copy to \(relativePath): File already exists") return false case .skip: return true case .write: break @@ -237,7 +242,7 @@ struct SecurityBookmark { try fm.copyItem(at: externalFile, to: destination) return true } catch { - delegate?.securityBookmark(error: "Failed to copy \(externalFile.path()) to \(relativePath): \(error)") + reportError("Failed to copy \(externalFile.path()) to \(relativePath): \(error)") return false } } @@ -252,7 +257,7 @@ struct SecurityBookmark { try fm.removeItem(at: file) return true } catch { - delegate?.securityBookmark(error: "Failed to delete \(relativePath): \(error)") + reportError("Failed to delete \(relativePath): \(error)") return false } } @@ -326,13 +331,13 @@ struct SecurityBookmark { do { data = try Data(contentsOf: url) } catch { - delegate?.securityBookmark(error: "Failed to read \(url.path()): \(error)") + reportError("Failed to read \(url.path()): \(error)") return } do { items[id] = try decoder.decode(T.self, from: data) } catch { - delegate?.securityBookmark(error: "Failed to decode \(url.path()): \(error)") + reportError("Failed to decode \(url.path()): \(error)") return } } @@ -352,7 +357,7 @@ struct SecurityBookmark { func with(relativePath: String, perform operation: (URL) async -> T?) async -> T? { let path = url.appending(path: relativePath.withLeadingSlashRemoved) guard url.startAccessingSecurityScopedResource() else { - delegate?.securityBookmark(error: "Failed to start security scope") + reportError("Failed to start security scope") return nil } defer { url.stopAccessingSecurityScopedResource() } @@ -364,7 +369,7 @@ struct SecurityBookmark { */ func perform(_ operation: (URL) -> Bool) -> Bool { guard url.startAccessingSecurityScopedResource() else { - delegate?.securityBookmark(error: "Failed to start security scope") + reportError("Failed to start security scope") return false } defer { url.stopAccessingSecurityScopedResource() } @@ -376,7 +381,7 @@ struct SecurityBookmark { */ func perform(_ operation: (URL) -> T?) -> T? { guard url.startAccessingSecurityScopedResource() else { - delegate?.securityBookmark(error: "Failed to start security scope") + reportError("Failed to start security scope") return nil } defer { url.stopAccessingSecurityScopedResource() } @@ -390,7 +395,7 @@ struct SecurityBookmark { try createIfNeeded(folder) return true } catch { - delegate?.securityBookmark(error: "Failed to create folder \(folder.path())") + reportError("Failed to create folder \(folder.path())") return false } } @@ -406,7 +411,7 @@ struct SecurityBookmark { do { try fm.removeItem(at: file) } catch { - delegate?.securityBookmark(error: "Failed to delete \(file.path()): \(error)") + reportError("Failed to delete \(file.path()): \(error)") return false } return true @@ -432,7 +437,7 @@ struct SecurityBookmark { do { return try files(in: folder).filter { !$0.lastPathComponent.hasPrefix(".") } } 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 } } diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index 45ff067..cd1c7e9 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -43,6 +43,8 @@ final class Storage: ObservableObject { @Published var outputScope: SecurityBookmark? + var errorNotification: StorageErrorCallback? + /** Create the storage. */ @@ -75,10 +77,10 @@ final class Storage: ObservableObject { 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 } let path = pageMetadataPath(page: pageId) - return contentScope.encode(pageMetadata, to: path) + return contentScope.encode(page, to: path) } func loadAllPages() -> [String : Page.Data]? { @@ -186,10 +188,10 @@ final class Storage: ObservableObject { 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 } let path = tagFilePath(tag: tagId) - return contentScope.encode(tagMetadata, to: path) + return contentScope.encode(tag, to: path) } func loadAllTags() -> [String : Tag.Data]? { @@ -338,10 +340,10 @@ final class Storage: ObservableObject { } @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 } 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 } contentScope = decode(bookmark: bookmarkData) + // Propagate errors + contentScope?.errorNotification = { [weak self] error in + self?.errorNotification?(error) + } return contentScope != nil } @@ -513,6 +519,10 @@ final class Storage: ObservableObject { return false } outputScope = decode(bookmark: data) + // Propagate errors + outputScope?.errorNotification = { [weak self] error in + self?.errorNotification?(error) + } return outputScope != nil } @@ -562,6 +572,10 @@ final class Storage: ObservableObject { } // TODO: Check if stale outputScope = SecurityBookmark(url: outputPath, isStale: false) + // Propagate errors + outputScope?.errorNotification = { [weak self] error in + self?.errorNotification?(error) + } return true } } diff --git a/CHDataManagement/Storage/StorageItem.swift b/CHDataManagement/Storage/StorageItem.swift new file mode 100644 index 0000000..a00576d --- /dev/null +++ b/CHDataManagement/Storage/StorageItem.swift @@ -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 + } +} diff --git a/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift b/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift index 5ffa347..0829175 100644 --- a/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift +++ b/CHDataManagement/Views/Pages/LocalizedPageDetailView.swift @@ -40,7 +40,12 @@ struct LocalizedPageDetailView: View { } } -#Preview { - LocalizedPageDetailView(isExternalPage: false, page: .english, transferImage: nil) - .environmentObject(Content.mock) +#Preview(traits: .fixedLayout(width: 400, height: 600)) { + LocalizedPageDetailView( + isExternalPage: false, + page: Page.Mock.empty.english, + transferImage: nil + ) + .padding() + .environmentObject(Content.mock) } diff --git a/CHDataManagement/Views/Pages/PageContentView.swift b/CHDataManagement/Views/Pages/PageContentView.swift index 1c47fae..9bda8e7 100644 --- a/CHDataManagement/Views/Pages/PageContentView.swift +++ b/CHDataManagement/Views/Pages/PageContentView.swift @@ -62,5 +62,5 @@ extension PageContentView: MainContentView { } #Preview { - PageContentView(page: .empty) + PageContentView(page: Page.Mock.empty) } diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift index ca71683..17b9092 100644 --- a/CHDataManagement/Views/Pages/PageDetailView.swift +++ b/CHDataManagement/Views/Pages/PageDetailView.swift @@ -146,5 +146,5 @@ extension PageDetailView: MainContentView { #Preview { - PageDetailView(page: .empty) + PageDetailView(page: Page.Mock.empty) } diff --git a/CHDataManagement/Views/Posts/PostContentView.swift b/CHDataManagement/Views/Posts/PostContentView.swift index 431d898..4fe0c66 100644 --- a/CHDataManagement/Views/Posts/PostContentView.swift +++ b/CHDataManagement/Views/Posts/PostContentView.swift @@ -34,10 +34,10 @@ extension PostContentView: MainContentView { #Preview(traits: .fixedLayout(width: 450, height: 600)) { List { - PostContentView(post: .fullMock) + PostContentView(post: .Mock.hike2) .listRowSeparator(.hidden) .environment(\.language, ContentLanguage.german) - PostContentView(post: .mock) + PostContentView(post: .Mock.hike) .listRowSeparator(.hidden) } .environmentObject(Content.mock) diff --git a/CHDataManagement/Views/Posts/PostDetailView.swift b/CHDataManagement/Views/Posts/PostDetailView.swift index 46aacd4..0d77cd3 100644 --- a/CHDataManagement/Views/Posts/PostDetailView.swift +++ b/CHDataManagement/Views/Posts/PostDetailView.swift @@ -103,5 +103,5 @@ extension PostDetailView: MainContentView { #Preview(traits: .fixedLayout(width: 270, height: 500)) { - PostDetailView(post: .fullMock) + PostDetailView(post: .Mock.hike2) } diff --git a/CHDataManagement/Views/Posts/TagSelectionView.swift b/CHDataManagement/Views/Posts/TagSelectionView.swift index 4023912..3b0cfbb 100644 --- a/CHDataManagement/Views/Posts/TagSelectionView.swift +++ b/CHDataManagement/Views/Posts/TagSelectionView.swift @@ -83,6 +83,6 @@ struct TagSelectionView: View { #Preview { TagSelectionView( presented: .constant(true), - selected: .constant([.hiking, .nature]), - tags: .constant([.sports, .mock])) + selected: .constant([.Mock.hiking, .Mock.nature]), + tags: .constant([.Mock.sports, .Mock.electronics])) } diff --git a/CHDataManagement/Views/Settings/PathSettingsView.swift b/CHDataManagement/Views/Settings/PathSettingsView.swift index e0f9971..28498ac 100644 --- a/CHDataManagement/Views/Settings/PathSettingsView.swift +++ b/CHDataManagement/Views/Settings/PathSettingsView.swift @@ -72,29 +72,15 @@ struct PathSettingsView: View { } .padding() .sheet(isPresented: $showLoadErrorSheet) { - VStack { - 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() + StorageErrorView(isPresented: $showLoadErrorSheet) } } } - private func showLoadErrors(errors: [String]) { - guard !errors.isEmpty else { + private func showLoadErrors() { + guard !content.storageErrors.isEmpty else { return } - loadErrors = errors showLoadErrorSheet = true } } diff --git a/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift b/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift index 4a3826b..5ec8039 100644 --- a/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift +++ b/CHDataManagement/Views/Tags/LocalizedTagDetailView.swift @@ -42,5 +42,5 @@ struct LocalizedTagDetailView: View { } #Preview { - LocalizedTagDetailView(tag: Tag.mock.english, transferImage: nil) + LocalizedTagDetailView(tag: Tag.Mock.electronics.english, transferImage: nil) } diff --git a/CHDataManagement/Views/Tags/PageTagAssignmentView.swift b/CHDataManagement/Views/Tags/PageTagAssignmentView.swift index a9eaa15..9bc91de 100644 --- a/CHDataManagement/Views/Tags/PageTagAssignmentView.swift +++ b/CHDataManagement/Views/Tags/PageTagAssignmentView.swift @@ -55,6 +55,6 @@ struct PageTagAssignmentView: View { } #Preview { - PageTagAssignmentView(tag: .hiking) + PageTagAssignmentView(tag: .Mock.hiking) .environmentObject(Content.mock) } diff --git a/CHDataManagement/Views/Tags/PostTagAssignmentView.swift b/CHDataManagement/Views/Tags/PostTagAssignmentView.swift index fe37ffa..0fa3fc4 100644 --- a/CHDataManagement/Views/Tags/PostTagAssignmentView.swift +++ b/CHDataManagement/Views/Tags/PostTagAssignmentView.swift @@ -55,6 +55,6 @@ struct PostTagAssignmentView: View { } #Preview { - PostTagAssignmentView(tag: .hiking) + PostTagAssignmentView(tag: .Mock.hiking) .environmentObject(Content.mock) } diff --git a/CHDataManagement/Views/Tags/TagContentView.swift b/CHDataManagement/Views/Tags/TagContentView.swift index a2a391b..c0ae398 100644 --- a/CHDataManagement/Views/Tags/TagContentView.swift +++ b/CHDataManagement/Views/Tags/TagContentView.swift @@ -67,6 +67,6 @@ extension TagContentView: MainContentView { } #Preview { - TagContentView(tag: .hiking) + TagContentView(tag: .Mock.hiking) .environmentObject(Content.mock) }