diff --git a/CHDataManagement.xcodeproj/project.pbxproj b/CHDataManagement.xcodeproj/project.pbxproj index 7975c4c..4b729b1 100644 --- a/CHDataManagement.xcodeproj/project.pbxproj +++ b/CHDataManagement.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ E20BCC9F2D53851400B8DBEB /* SelectableListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCC9E2D53850A00B8DBEB /* SelectableListItem.swift */; }; E20BCCA32D5398AA00B8DBEB /* LocalizedAudioSettingsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCCA22D5398AA00B8DBEB /* LocalizedAudioSettingsDetailView.swift */; }; E20BCCAB2D53B86900B8DBEB /* GenerationResultsIssueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCCAA2D53B85300B8DBEB /* GenerationResultsIssueView.swift */; }; + E20BCCAD2D53F48100B8DBEB /* IssueStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCCAC2D53F48100B8DBEB /* IssueStatus.swift */; }; + E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20BCCAE2D53F4A500B8DBEB /* GenerationStringIssuesView.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 */; }; @@ -274,6 +276,8 @@ E20BCC9E2D53850A00B8DBEB /* SelectableListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableListItem.swift; sourceTree = ""; }; E20BCCA22D5398AA00B8DBEB /* LocalizedAudioSettingsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedAudioSettingsDetailView.swift; sourceTree = ""; }; E20BCCAA2D53B85300B8DBEB /* GenerationResultsIssueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationResultsIssueView.swift; sourceTree = ""; }; + E20BCCAC2D53F48100B8DBEB /* IssueStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueStatus.swift; sourceTree = ""; }; + E20BCCAE2D53F4A500B8DBEB /* GenerationStringIssuesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationStringIssuesView.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 = ""; }; @@ -542,6 +546,8 @@ E20BCCA02D53985500B8DBEB /* Generation */ = { isa = PBXGroup; children = ( + E20BCCAE2D53F4A500B8DBEB /* GenerationStringIssuesView.swift */, + E20BCCAC2D53F48100B8DBEB /* IssueStatus.swift */, E20BCCAA2D53B85300B8DBEB /* GenerationResultsIssueView.swift */, E25DA5702D01015400AEF16D /* GenerationContentView.swift */, ); @@ -879,9 +885,9 @@ E2B85F462C42C7CA0047CD0C /* Views */ = { isa = PBXGroup; children = ( - E20BCCA02D53985500B8DBEB /* Generation */, E2FD1D1A2D2DC62C00B48627 /* LinkPreviewDetailView.swift */, E22990142D0E2B74009F8D77 /* ItemSelectionView.swift */, + E20BCCA02D53985500B8DBEB /* Generation */, E2A21C372CB9A4F10060935B /* Generic */, E2B85F4B2C4B8B7F0047CD0C /* Posts */, E2A21C322CB5BCAC0060935B /* Pages */, @@ -1291,6 +1297,7 @@ E22990242D0EDBD0009F8D77 /* HeaderElement.swift in Sources */, E29D31BC2D0DB5120051B7F4 /* CommandProcessor.swift in Sources */, E2FE0F662D2C3B3A002963B7 /* LabelsBlock.swift in Sources */, + E20BCCAF2D53F4A500B8DBEB /* GenerationStringIssuesView.swift in Sources */, E29D312C2D039DB80051B7F4 /* PageDetailView.swift in Sources */, E29D31432D0488960051B7F4 /* MainContentView.swift in Sources */, E29D31282D0371930051B7F4 /* ContentPageVideo.swift in Sources */, @@ -1375,6 +1382,7 @@ E2FD1D642D47EF4200B48627 /* DetailListItem.swift in Sources */, E2FE0F0B2D2689FF002963B7 /* FeedGeneratorSource.swift in Sources */, E2DD04742C276F31003BFF1F /* MainView.swift in Sources */, + E20BCCAD2D53F48100B8DBEB /* IssueStatus.swift in Sources */, E29D31452D0488CB0051B7F4 /* SelectedContentView.swift in Sources */, E2A37D1B2CEA45560000979F /* Tag+Mock.swift in Sources */, E2A21C482CBAF88B0060935B /* String+Extensions.swift in Sources */, diff --git a/CHDataManagement/Main/MainView.swift b/CHDataManagement/Main/MainView.swift index badffb1..708d25f 100644 --- a/CHDataManagement/Main/MainView.swift +++ b/CHDataManagement/Main/MainView.swift @@ -23,7 +23,6 @@ import SFSafeSymbols **Fixes** - Files: Id change: Check all page contents for links to the renamed file and replace occurences - - Database: Show errors during loading - Investigate issue with spaces in content file names */ diff --git a/CHDataManagement/Main/StorageErrorView.swift b/CHDataManagement/Main/StorageErrorView.swift index 248982d..9f53b4d 100644 --- a/CHDataManagement/Main/StorageErrorView.swift +++ b/CHDataManagement/Main/StorageErrorView.swift @@ -20,6 +20,11 @@ struct StorageErrorView: View { } } .frame(minHeight: 300) + if content.saveState == .savingPausedDueToLoadErrors { + Button("Allow saving", action: { content.resumeSavingAfterLoadingErrors() }) + .padding() + Text("Saving has been disabled to prevent data corruption due to loading errors. Enable saving to save the partially loaded data.") + } Button("Dismiss", action: { isPresented = false }) .padding() } diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index 7f0a9a5..7ae1ce9 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -16,9 +16,13 @@ extension Content { } func saveIfNeeded() { - guard saveState != .isSaved else { + switch saveState { + case .isSaved, .savingPausedDueToLoadErrors, .storageNotInitialized: return + default: + break } + if Date.now.timeIntervalSince(lastModification) < 5 { // Additional modification made // Wait for next scheduled invocation of saveIfNeeded() @@ -43,7 +47,6 @@ extension Content { } private func saveToDisk() -> Bool { - guard didLoadContent else { return false } guard storage.contentScope != nil else { print("Storage not initialized, not saving content") return false diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index f6a45e6..f682d99 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -4,9 +4,6 @@ import Combine final class Content: ObservableObject { - @Published - var didLoadContent = false - @ObservedObject var storage: Storage @@ -132,31 +129,37 @@ final class Content: ObservableObject { 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 - self.storageErrors.append(contentsOf: result.errors) - callback() - } - return - } - - DispatchQueue.main.async { - self.files = result.files - self.posts = result.posts - self.pages = result.pages - self.tags = result.tags - self.settings = result.settings - self.tagOverview = result.tagOverview - self.didLoadContent = true - callback() - self.generateMissingVideoThumbnails() - } + self.loadInBackground(callback: callback) } } + private func loadInBackground(callback: @escaping () -> ()) { + let loader = ModelLoader(content: self, storage: self.storage) + let result = loader.load() + + DispatchQueue.main.async { + self.files = result.files + self.posts = result.posts + self.pages = result.pages + self.tags = result.tags + self.settings = result.settings + self.tagOverview = result.tagOverview + self.storageErrors.append(contentsOf: result.errors) + if !result.errors.isEmpty { + self.saveState = .savingPausedDueToLoadErrors + } else { + self.saveState = .isSaved + } + callback() + self.generateMissingVideoThumbnails() + } + } + + func resumeSavingAfterLoadingErrors() { + saveState = .needsSave + saveIfNeeded() + } + func generateMissingVideoThumbnails() { Task { for file in self.files { diff --git a/CHDataManagement/Storage/SaveState.swift b/CHDataManagement/Storage/SaveState.swift index 901b3cd..5372b24 100644 --- a/CHDataManagement/Storage/SaveState.swift +++ b/CHDataManagement/Storage/SaveState.swift @@ -3,6 +3,7 @@ import SwiftUICore enum SaveState { case storageNotInitialized + case savingPausedDueToLoadErrors case isSaved case needsSave case failedToSave @@ -11,6 +12,8 @@ enum SaveState { switch self { case .storageNotInitialized: return .folderCircleFill + case .savingPausedDueToLoadErrors: + return .exclamationmarkCircleFill case .isSaved: return .checkmarkCircleFill case .needsSave: @@ -28,7 +31,7 @@ enum SaveState { return .green case .needsSave: return .yellow - case .failedToSave: + case .failedToSave, .savingPausedDueToLoadErrors: return .red } } diff --git a/CHDataManagement/Views/Generation/GenerationResultsIssueView.swift b/CHDataManagement/Views/Generation/GenerationResultsIssueView.swift index 6c04bdc..55806a0 100644 --- a/CHDataManagement/Views/Generation/GenerationResultsIssueView.swift +++ b/CHDataManagement/Views/Generation/GenerationResultsIssueView.swift @@ -1,91 +1,6 @@ import SwiftUI import SFSafeSymbols -enum IssueStatus { - case nominal - case warning - case error - - var symbol: SFSymbol { - switch self { - case .nominal: .checkmarkCircleFill - case .warning, .error: .exclamationmarkTriangle - } - } - - var color: Color { - switch self { - case .nominal: .green - case .warning: .yellow - case .error: .red - } - } -} - -struct GenerationStringIssuesView: View where T: Hashable { - - let text: String - - let statusWhenNonEmpty: IssueStatus - - @Binding - var items: Set - - let map: (T) -> String - - @State - private var showList = false - - var status: IssueStatus { - items.isEmpty ? .nominal : statusWhenNonEmpty - } - - init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding>, map: @escaping (T) -> String) { - self.text = text - self.statusWhenNonEmpty = statusWhenNonEmpty - self._items = items - self.map = map - } - - var body: some View { - HStack { - Button(action: showListIfNonEmpty) { - Image(systemSymbol: status.symbol) - .foregroundStyle(status.color) - }.buttonStyle(.plain) - Text("\(items.count) \(text)") - } - .sheet(isPresented: $showList) { - VStack { - Text("\(items.count) \(text)") - .font(.title) - List(items.map(map).sorted(), id: \.self) { item in - Text(item) - } - .frame(minHeight: 400) - Button("Close") { showList = false } - }.padding() - } - } - - private func showListIfNonEmpty() { - guard !items.isEmpty else { - return - } - showList = true - } -} - -extension GenerationStringIssuesView where T == String { - - init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding>) { - self.text = text - self.statusWhenNonEmpty = statusWhenNonEmpty - self._items = items - self.map = { $0 } - } -} - struct GenerationResultsIssueView: View { @State @@ -117,9 +32,9 @@ struct GenerationResultsIssueView: View { } private func showListIfNonEmpty() { -// guard !items.isEmpty else { -// return -// } + guard !items().isEmpty else { + return + } showList = true } } diff --git a/CHDataManagement/Views/Generation/GenerationStringIssuesView.swift b/CHDataManagement/Views/Generation/GenerationStringIssuesView.swift new file mode 100644 index 0000000..fd00898 --- /dev/null +++ b/CHDataManagement/Views/Generation/GenerationStringIssuesView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct GenerationStringIssuesView: View where T: Hashable { + + let text: String + + let statusWhenNonEmpty: IssueStatus + + @Binding + var items: Set + + let map: (T) -> String + + @State + private var showList = false + + var status: IssueStatus { + items.isEmpty ? .nominal : statusWhenNonEmpty + } + + init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding>, map: @escaping (T) -> String) { + self.text = text + self.statusWhenNonEmpty = statusWhenNonEmpty + self._items = items + self.map = map + } + + var body: some View { + HStack { + Button(action: showListIfNonEmpty) { + Image(systemSymbol: status.symbol) + .foregroundStyle(status.color) + }.buttonStyle(.plain) + Text("\(items.count) \(text)") + } + .sheet(isPresented: $showList) { + VStack { + Text("\(items.count) \(text)") + .font(.title) + List(items.map(map).sorted(), id: \.self) { item in + Text(item) + } + .frame(minHeight: 400) + Button("Close") { showList = false } + }.padding() + } + } + + private func showListIfNonEmpty() { + guard !items.isEmpty else { + return + } + showList = true + } +} + +extension GenerationStringIssuesView where T == String { + + init(text: String, statusWhenNonEmpty: IssueStatus = .error, items: Binding>) { + self.text = text + self.statusWhenNonEmpty = statusWhenNonEmpty + self._items = items + self.map = { $0 } + } +} diff --git a/CHDataManagement/Views/Generation/IssueStatus.swift b/CHDataManagement/Views/Generation/IssueStatus.swift new file mode 100644 index 0000000..4e551c9 --- /dev/null +++ b/CHDataManagement/Views/Generation/IssueStatus.swift @@ -0,0 +1,23 @@ +import SFSafeSymbols +import SwiftUICore + +enum IssueStatus { + case nominal + case warning + case error + + var symbol: SFSymbol { + switch self { + case .nominal: .checkmarkCircleFill + case .warning, .error: .exclamationmarkTriangle + } + } + + var color: Color { + switch self { + case .nominal: .green + case .warning: .yellow + case .error: .red + } + } +}