From 9a53e020a7ea45af75a5a774dfa2986adb6669c2 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Tue, 17 Dec 2024 23:05:45 +0100 Subject: [PATCH] Rework path storage, add start screen --- .../Extensions/URL+Extensions.swift | 18 +- .../Generator/ImageGenerator.swift | 2 +- .../Generator/LocalizedWebsiteGenerator.swift | 5 +- CHDataManagement/Main/InitialSetupView.swift | 69 +++- CHDataManagement/Main/MainView.swift | 25 +- .../Model/Content+Generation.swift | 8 +- CHDataManagement/Model/Content+Load.swift | 18 +- CHDataManagement/Model/Content+Save.swift | 2 +- CHDataManagement/Model/Content.swift | 47 +-- .../Model/Settings/PathSettings.swift | 12 +- .../Model/Settings/Settings.swift | 4 - .../Model/Settings/PathSettingsFile.swift | 12 +- .../Storage/SecurityScopeStatus.swift | 7 + CHDataManagement/Storage/Storage.swift | 376 ++++++++++++------ .../Views/Files/AddFileView.swift | 3 +- .../Generic/FolderOnDiskPropertyView.swift | 6 +- .../Views/Pages/AddPageView.swift | 2 +- .../Views/Pages/PageDetailView.swift | 3 +- .../Content/Pages/PageIssueView.swift | 2 +- .../Settings/GenerationContentView.swift | 3 +- .../Views/Settings/PathSettingsView.swift | 13 +- 21 files changed, 408 insertions(+), 229 deletions(-) diff --git a/CHDataManagement/Extensions/URL+Extensions.swift b/CHDataManagement/Extensions/URL+Extensions.swift index ce0767a..dda7f28 100644 --- a/CHDataManagement/Extensions/URL+Extensions.swift +++ b/CHDataManagement/Extensions/URL+Extensions.swift @@ -2,11 +2,11 @@ import Foundation extension URL { - func ensureParentFolderExistence() throws { - try deletingLastPathComponent().ensureFolderExistence() + func createParentFolderIfNeeded() throws { + try deletingLastPathComponent().createIfNeeded() } - func ensureFolderExistence() throws { + func createIfNeeded() throws { guard !exists else { return } @@ -42,7 +42,7 @@ extension URL { if url.exists { try url.delete() } - try url.ensureParentFolderExistence() + try url.createParentFolderIfNeeded() try FileManager.default.copyItem(at: self, to: url) } @@ -69,4 +69,14 @@ extension URL { } return URL(string: components.joined(separator: "/")) } + + func containedFiles() throws -> [URL] { + try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil, options: []) + .filter { !$0.hasDirectoryPath } + } + + func containedFileNames() throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: path()) + .filter { !$0.hasPrefix(".") } + } } diff --git a/CHDataManagement/Generator/ImageGenerator.swift b/CHDataManagement/Generator/ImageGenerator.swift index b7479c4..1ad5dfa 100644 --- a/CHDataManagement/Generator/ImageGenerator.swift +++ b/CHDataManagement/Generator/ImageGenerator.swift @@ -31,7 +31,7 @@ final class ImageGenerator { func prepareForGeneration() -> Bool { inOutputImagesFolder { imagesFolder in do { - try imagesFolder.ensureFolderExistence() + try imagesFolder.createIfNeeded() return true } catch { print("Failed to create output images folder: \(error)") diff --git a/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift b/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift index efe6532..a20666c 100644 --- a/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift +++ b/CHDataManagement/Generator/LocalizedWebsiteGenerator.swift @@ -17,11 +17,10 @@ final class LocalizedWebsiteGenerator { self.imageGenerator = ImageGenerator( storage: content.storage, settings: content.settings) + self.outputDirectory = content.storage.outputPath! } - private var outputDirectory: URL { - content.settings.outputDirectory - } + private let outputDirectory: URL private var postsPerPage: Int { content.settings.posts.postsPerPage diff --git a/CHDataManagement/Main/InitialSetupView.swift b/CHDataManagement/Main/InitialSetupView.swift index 3c743b9..d36a36b 100644 --- a/CHDataManagement/Main/InitialSetupView.swift +++ b/CHDataManagement/Main/InitialSetupView.swift @@ -1,8 +1,73 @@ import SwiftUI struct InitialSetupView: View { - + + @EnvironmentObject + private var content: Content + + @Environment(\.dismiss) + private var dismiss + + @State + private var message: String? + var body: some View { - /*@START_MENU_TOKEN@*//*@PLACEHOLDER=Hello, world!@*/Text("Hello, world!")/*@END_MENU_TOKEN@*/ + VStack { + Text("No Database Loaded") + .font(.title) + .padding() + Text("To start editing the content of a website, create a new database or load an existing one. Open a folder with an existing database, or choose an empty folder to create a new project.") + .multilineTextAlignment(.center) + Button("Select folder", action: selectContentPath) + .padding() + if let message { + Text(message) + .padding(.bottom) + } + } + .padding() + .frame(maxWidth: 350) + } + + private func selectContentPath() { + let panel = NSOpenPanel() + // Sets up so user can only select a single directory + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.showsHiddenFiles = false + panel.title = "Select the database folder" + + let response = panel.runModal() + guard response == .OK else { + set(message: "Failed to select a folder: \(response)") + return + } + guard let url = panel.url else { + set(message: "No folder url found") + return + } + + guard content.storage.save(contentPath: url) else { + set(message: "Failed to set content path") + return + } + print("Selected folder, initializing storage") + DispatchQueue.main.async { + do { + print("Loading disk content") + try content.loadFromDisk() + } catch { + set(message: "Failed to load database: \(error)") + return + } + dismiss() + } + } + + private func set(message: String) { + DispatchQueue.main.async { + self.message = message + } } } diff --git a/CHDataManagement/Main/MainView.swift b/CHDataManagement/Main/MainView.swift index 1fbca46..bc3a1ad 100644 --- a/CHDataManagement/Main/MainView.swift +++ b/CHDataManagement/Main/MainView.swift @@ -162,18 +162,15 @@ struct MainView: App { }.pickerStyle(.segmented) } ToolbarItem(placement: .primaryAction) { - if content.storageIsInitialized { + if content.storage.hasContentFolders { Button(action: save) { Text("Save") } } else { - Button { - selectedSection = .folders - selectedTab = .generation - } label: { + Button(action: showInitialSheet) { Text("Setup") } - .foregroundColor(.red) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.red)) } } } @@ -206,10 +203,10 @@ struct MainView: App { } private func loadContent() { - guard content.storageIsInitialized else { - DispatchQueue.main.async { - self.showInitialSetupSheet = true - } + #warning("Remove") + content.storage.clearContentPath() + guard content.storage.hasContentFolders else { + showInitialSheet() return } do { @@ -218,5 +215,13 @@ struct MainView: App { print("Failed to load content: \(error.localizedDescription)") } } + + private func showInitialSheet() { + DispatchQueue.main.async { + selectedSection = .folders + selectedTab = .generation + showInitialSetupSheet = true + } + } } diff --git a/CHDataManagement/Model/Content+Generation.swift b/CHDataManagement/Model/Content+Generation.swift index dfc11e9..f77832d 100644 --- a/CHDataManagement/Model/Content+Generation.swift +++ b/CHDataManagement/Model/Content+Generation.swift @@ -81,16 +81,12 @@ extension Content { } // TODO: Fix bug where multiple generating operations can be started // due to dispatch of locking property on main queue - DispatchQueue.main.async { - self.isGeneratingWebsite = true - } + self.set(isGenerating: true) return true } private func endGenerating() { - DispatchQueue.main.async { - self.isGeneratingWebsite = false - } + set(isGenerating: false) } private func generateInternal(_ page: Page, in language: ContentLanguage) -> Bool { diff --git a/CHDataManagement/Model/Content+Load.swift b/CHDataManagement/Model/Content+Load.swift index 717219f..e63c820 100644 --- a/CHDataManagement/Model/Content+Load.swift +++ b/CHDataManagement/Model/Content+Load.swift @@ -41,12 +41,13 @@ extension Content { } func loadFromDisk() throws { - guard storageIsInitialized else { + guard storage.hasContentFolders else { print("Storage not initialized, not loading content") - return + throw StorageAccessError.noBookmarkData } - let settings = try storage.loadSettings() + let settings = try storage.loadSettings() // Uses defaults if missing + print("Loaded settings") let imageDescriptions = try storage.loadFileDescriptions().reduce(into: [:]) { descriptions, description in descriptions[description.fileId] = description } @@ -58,6 +59,16 @@ extension Content { let externalFiles = try storage.loadExternalFileList() let tagOverviewData = try storage.loadTagOverview() + if tagData.isEmpty { print("No tags loaded") } + if pagesData.isEmpty { print("No pages loaded") } + if postsData.isEmpty { print("No posts loaded") } + if fileList.isEmpty { print("No files loaded") } + if externalFiles.isEmpty { print("No external files loaded") } + if tagOverviewData == nil { print("No tag overview loaded") } + + print("Loaded data from disk, processing...") + // All data loaded from storage, start constructing the data model + var files: [String : FileResource] = fileList.reduce(into: [:]) { files, fileId in let descriptions = imageDescriptions[fileId] files[fileId] = FileResource( @@ -122,6 +133,7 @@ extension Content { self.posts = posts.sorted(ascending: false) { $0.startDate } self.tagOverview = tagOverview self.settings = makeSettings(settings, tags: tags, pages: pages, files: files) + print("Content loaded") } private func makeSettings(_ settings: SettingsFile, tags: [String : Tag], pages: [String : Page], files: [String : FileResource]) -> Settings { diff --git a/CHDataManagement/Model/Content+Save.swift b/CHDataManagement/Model/Content+Save.swift index aa24a66..82cd2b3 100644 --- a/CHDataManagement/Model/Content+Save.swift +++ b/CHDataManagement/Model/Content+Save.swift @@ -3,7 +3,7 @@ import Foundation extension Content { func saveToDisk() throws { - guard storageIsInitialized else { + guard storage.hasContentFolders else { print("Storage not initialized, not saving content") return } diff --git a/CHDataManagement/Model/Content.swift b/CHDataManagement/Model/Content.swift index 24f8602..b92fe1d 100644 --- a/CHDataManagement/Model/Content.swift +++ b/CHDataManagement/Model/Content.swift @@ -4,10 +4,8 @@ import Combine final class Content: ObservableObject { - let storage = Storage() - - @Published - var storageIsInitialized = false + @ObservedObject + var storage = Storage() @Published var settings: Settings @@ -28,10 +26,10 @@ final class Content: ObservableObject { var tagOverview: TagOverviewPage? @Published - var results: [ItemId : PageGenerationResults] = [:] + private(set) var results: [ItemId : PageGenerationResults] = [:] @Published - var isGeneratingWebsite = false + private(set) var isGeneratingWebsite = false init(settings: Settings, posts: [Post], @@ -45,8 +43,6 @@ final class Content: ObservableObject { self.tags = tags self.files = files self.tagOverview = tagOverview - - initialize() } init() { @@ -56,28 +52,25 @@ final class Content: ObservableObject { self.tags = [] self.files = [] self.tagOverview = nil - - initialize() - } - - private func initialize() { - guard storage.check(contentPath: settings.paths.contentDirectoryPath) == .nominal else { - storageIsInitialized = false - return - } - - storage.check(outputPath: settings.paths.outputDirectoryPath) - - do { - try storage.createFolderStructure() - storageIsInitialized = true - } catch { - print("Failed to initialize storage: \(error)") - storageIsInitialized = false - } } var images: [FileResource] { files.filter { $0.type.isImage } } + + func set(isGenerating: Bool) { + DispatchQueue.main.async { + self.isGeneratingWebsite = isGenerating + } + } + + func add(_ file: FileResource) { + // TODO: Insert at correct index? + files.insert(file, at: 0) + } + + func add(_ page: Page) { + // TODO: Insert at correct index? + pages.insert(page, at: 0) + } } diff --git a/CHDataManagement/Model/Settings/PathSettings.swift b/CHDataManagement/Model/Settings/PathSettings.swift index 5452e94..ea77f15 100644 --- a/CHDataManagement/Model/Settings/PathSettings.swift +++ b/CHDataManagement/Model/Settings/PathSettings.swift @@ -2,12 +2,6 @@ import Foundation final class PathSettings: ObservableObject { - @Published - var contentDirectoryPath: String - - @Published - var outputDirectoryPath: String - @Published var assetsOutputFolderPath: String @@ -27,9 +21,7 @@ final class PathSettings: ObservableObject { var tagsOutputFolderPath: String init(file: PathSettingsFile) { - self.contentDirectoryPath = file.contentDirectoryPath self.assetsOutputFolderPath = file.assetsOutputFolderPath - self.outputDirectoryPath = file.outputDirectoryPath self.pagesOutputFolderPath = file.pagesOutputFolderPath self.imagesOutputFolderPath = file.imagesOutputFolderPath self.filesOutputFolderPath = file.filesOutputFolderPath @@ -38,9 +30,7 @@ final class PathSettings: ObservableObject { } var file: PathSettingsFile { - .init(contentDirectoryPath: contentDirectoryPath, - outputDirectoryPath: outputDirectoryPath, - assetsOutputFolderPath: assetsOutputFolderPath, + .init(assetsOutputFolderPath: assetsOutputFolderPath, pagesOutputFolderPath: pagesOutputFolderPath, imagesOutputFolderPath: imagesOutputFolderPath, filesOutputFolderPath: filesOutputFolderPath, diff --git a/CHDataManagement/Model/Settings/Settings.swift b/CHDataManagement/Model/Settings/Settings.swift index 3d8a36b..896570e 100644 --- a/CHDataManagement/Model/Settings/Settings.swift +++ b/CHDataManagement/Model/Settings/Settings.swift @@ -36,8 +36,4 @@ final class Settings: ObservableObject { case .german: return german } } - - var outputDirectory: URL { - URL(fileURLWithPath: paths.outputDirectoryPath) - } } diff --git a/CHDataManagement/Storage/Model/Settings/PathSettingsFile.swift b/CHDataManagement/Storage/Model/Settings/PathSettingsFile.swift index 63a0dde..df579d4 100644 --- a/CHDataManagement/Storage/Model/Settings/PathSettingsFile.swift +++ b/CHDataManagement/Storage/Model/Settings/PathSettingsFile.swift @@ -1,10 +1,6 @@ struct PathSettingsFile { - let contentDirectoryPath: String - - let outputDirectoryPath: String - let assetsOutputFolderPath: String let pagesOutputFolderPath: String @@ -17,16 +13,12 @@ struct PathSettingsFile { let tagsOutputFolderPath: String - init(contentDirectoryPath: String, - outputDirectoryPath: String, - assetsOutputFolderPath: String, + init(assetsOutputFolderPath: String, pagesOutputFolderPath: String, imagesOutputFolderPath: String, filesOutputFolderPath: String, videosOutputFolderPath: String, tagsOutputFolderPath: String) { - self.contentDirectoryPath = contentDirectoryPath - self.outputDirectoryPath = outputDirectoryPath self.assetsOutputFolderPath = assetsOutputFolderPath self.pagesOutputFolderPath = pagesOutputFolderPath self.imagesOutputFolderPath = imagesOutputFolderPath @@ -44,8 +36,6 @@ extension PathSettingsFile { static var `default`: PathSettingsFile { PathSettingsFile( - contentDirectoryPath: "", - outputDirectoryPath: "build", assetsOutputFolderPath: "asset", pagesOutputFolderPath: "page", imagesOutputFolderPath: "image", diff --git a/CHDataManagement/Storage/SecurityScopeStatus.swift b/CHDataManagement/Storage/SecurityScopeStatus.swift index 112d860..57fd1ee 100644 --- a/CHDataManagement/Storage/SecurityScopeStatus.swift +++ b/CHDataManagement/Storage/SecurityScopeStatus.swift @@ -12,4 +12,11 @@ enum SecurityScopeStatus { case stale case nominal + + var isUsable: Bool { + switch self { + case .nominal, .stale: return true + default: return false + } + } } diff --git a/CHDataManagement/Storage/Storage.swift b/CHDataManagement/Storage/Storage.swift index cfc3ab3..1703a87 100644 --- a/CHDataManagement/Storage/Storage.swift +++ b/CHDataManagement/Storage/Storage.swift @@ -14,6 +14,28 @@ import Foundation */ final class Storage: ObservableObject { + // MARK: Content folder structure + + private let filesFolderName = "files" + + private let pagesFolderName = "pages" + + private let postsFolderName = "posts" + + private let tagsFolderName = "tags" + + private let fileDescriptionFilename = "file-descriptions.json" + + private let generatedImagesListName = "generated-images.json" + + private let outputPathFileName = "outputPath.bin" + + private let tagOverviewFileName = "tag-overview.json" + + private let contentPathBookmarkKey = "contentPathBookmark" + + // MARK: Properties + private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -21,16 +43,27 @@ final class Storage: ObservableObject { private let fm = FileManager.default @Published - var contentFolderStatus: SecurityScopeStatus = .noBookmark + var hasContentFolders = false @Published - var outputFolderStatus: SecurityScopeStatus = .noBookmark + var contentPath: URL? + + @Published + var outputPath: URL? + + @Published + var contentPathUrlIsStale = false + + @Published + var outputPathUrlIsStale = false /** Create the storage. */ init() { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + loadContentPath() + createFolderStructure() } // MARK: Helper @@ -45,42 +78,30 @@ final class Storage: ObservableObject { } } - private func fileNames(in folder: URL) throws -> [String] { - try fm.contentsOfDirectory(atPath: folder.path()) - .filter { !$0.hasPrefix(".") } - .sorted() - } - - private func files(in folder: URL, type: String) throws -> [URL] { - try files(in: folder).filter { $0.pathExtension == type } - } - // MARK: Folders - func updateBaseFolder() throws { - try createFolderStructure() - } - - private func create(folder: URL) throws { - guard !FileManager.default.fileExists(atPath: folder.path) else { - return - } - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - } - - func createFolderStructure() throws { - try operate(in: .contentPath) { contentPath in - try create(folder: pagesFolder(in: contentPath)) - try create(folder: filesFolder(in: contentPath)) - try create(folder: postsFolder(in: contentPath)) - try create(folder: tagsFolder(in: contentPath)) + @discardableResult + func createFolderStructure() -> Bool { + do { + try inContentFolder { contentPath in + try pagesFolder(in: contentPath).createIfNeeded() + try filesFolder(in: contentPath).createIfNeeded() + try postsFolder(in: contentPath).createIfNeeded() + try tagsFolder(in: contentPath).createIfNeeded() + } + hasContentFolders = true + return true + } catch StorageAccessError.noBookmarkData { + hasContentFolders = false + } catch { + print("Failed to create storage folders: \(error)") + hasContentFolders = false } + return false } // MARK: Pages - private let pagesFolderName = "pages" - /// The folder path where the markdown and metadata files of the pages are stored (by their id/url component) private func pagesFolder(in folder: URL) -> URL { folder.appending(path: pagesFolderName, directoryHint: .isDirectory) @@ -168,8 +189,6 @@ final class Storage: ObservableObject { // MARK: Posts - private let postsFolderName = "posts" - private func postFileName(_ postId: String) -> String { postId + ".json" } @@ -197,11 +216,6 @@ final class Storage: ObservableObject { try decodeAllFromJson(in: postsFolderName) } - private func postContent(for postId: String) throws -> PostFile { - let path = postFilePath(post: postId) - return try read(at: path) - } - /** Delete all files associated with posts that are not in the given set - Note: This function requires a security scope for the content path @@ -221,8 +235,6 @@ final class Storage: ObservableObject { // MARK: Tags - private let tagsFolderName = "tags" - private func tagFileName(tagId: String) -> String { tagId + ".json" } @@ -256,10 +268,12 @@ final class Storage: ObservableObject { // MARK: File descriptions - private let fileDescriptionFilename = "file-descriptions.json" - func loadFileDescriptions() throws -> [FileDescriptions] { - try read(at: fileDescriptionFilename, defaultValue: []) + guard let descriptions: [FileDescriptions] = try read(at: fileDescriptionFilename) else { + print("Storage: No file descriptions loaded") + return [] + } + return descriptions } func save(fileDescriptions: [FileDescriptions]) throws { @@ -268,8 +282,6 @@ final class Storage: ObservableObject { // MARK: Tag overview - private let tagOverviewFileName = "tag-overview.json" - func loadTagOverview() throws -> TagOverviewFile? { try read(at: tagOverviewFileName) } @@ -280,8 +292,6 @@ final class Storage: ObservableObject { // MARK: Files - private let filesFolderName = "files" - private func filePath(file fileId: String) -> String { filesFolderName + "/" + fileId } @@ -321,7 +331,7 @@ final class Storage: ObservableObject { if output.exists { return } - try output.ensureParentFolderExistence() + try output.createParentFolderIfNeeded() try FileManager.default.copyItem(at: input, to: output) } @@ -329,8 +339,7 @@ final class Storage: ObservableObject { } func loadAllFiles() throws -> [String] { - try self.existingFiles(in: filesFolderName) - .map { $0.lastPathComponent } + try inContentFolder(relativePath: filesFolderName) { try $0.containedFileNames() } } /** @@ -356,19 +365,27 @@ final class Storage: ObservableObject { private let externalFileListName = "external-files.json" func loadExternalFileList() throws -> [String] { - try read(at: externalFileListName, defaultValue: []) + guard let files: [String] = try read(at: externalFileListName) else { + print("Storage: No external file list found") + return [] + } + return files } func save(externalFileList: [String]) throws { try writeIfChanged(externalFileList.sorted(), to: externalFileListName) } - // MARK: Website data + // MARK: Settings private let settingsDataFileName: String = "settings.json" func loadSettings() throws -> SettingsFile { - try read(at: settingsDataFileName, defaultValue: .default) + guard let settings: SettingsFile = try read(at: settingsDataFileName) else { + print("Storage: Loaded default settings") + return .default + } + return settings } func save(settings: SettingsFile) throws { @@ -377,10 +394,12 @@ final class Storage: ObservableObject { // MARK: Image generation data - private let generatedImagesListName = "generated-images.json" - func loadListOfGeneratedImages() throws -> [String : [String]] { - try read(at: generatedImagesListName, defaultValue: [:]) + guard let images: [String : [String]] = try read(at: generatedImagesListName) else { + print("Storage: No generated images found") + return [:] + } + return images } func save(listOfGeneratedImages: [String : [String]]) throws { @@ -395,23 +414,11 @@ final class Storage: ObservableObject { // MARK: Folder access - @discardableResult - func save(folderUrl url: URL, in bookmark: SecurityScopeBookmark) -> Bool { - do { - let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) - UserDefaults.standard.set(bookmarkData, forKey: bookmark.rawValue) - return true - } catch { - print("Failed to create security-scoped bookmark: \(error)") - return false - } - } - - func create(folder relativePath: String, in scopr: SecurityScopeBookmark) -> Bool { - return write(in: .outputPath) { folder in + func create(folder relativePath: String, in scope: SecurityScopeBookmark) -> Bool { + return write(in: scope) { folder in let url = folder.appendingPathComponent(relativePath, isDirectory: true) do { - try url.ensureFolderExistence() + try url.createIfNeeded() return true } catch { print("Failed to create folder \(url.path()): \(error)") @@ -470,42 +477,153 @@ final class Storage: ObservableObject { return try operation(folderUrl) } + // MARK: Security bookmarks + + /** + Save the content path url from a folder selection dialog, + which contains a security scope. + + The security scope bookmark is saved in UserDefaults under the ``contentPathBookmarkKey`` key + - Returns: True, if the bookmark was saved + - Note: Updates ``canSave``, ``contentPathUrlIsStale``, and ``contentPath`` + */ @discardableResult - func check(contentPath: String) -> SecurityScopeStatus { - contentFolderStatus = Storage.ensure(securityScope: .contentPath, matches: contentPath) - return contentFolderStatus + func save(contentPath: URL) -> Bool { + guard let bookmarkData = encode(url: contentPath) else { + return false + } + UserDefaults.standard.set(bookmarkData, forKey: contentPathBookmarkKey) + guard loadContentPath() else { + return false + } + return createFolderStructure() } + /** + Attempts to load the content path url from UserDefaults. + + The url is loaded from UserDefaults under the ``contentPathBookmarkKey`` key + + - Note: Updates ``canSave``, ``contentPathUrlIsStale``, and ``contentPath`` + - Returns: `true`, if the url was loaded. + */ @discardableResult - func check(outputPath: String) -> SecurityScopeStatus { - outputFolderStatus = Storage.ensure(securityScope: .outputPath, matches: outputPath) - return outputFolderStatus + private func loadContentPath() -> Bool { + guard let bookmarkData = UserDefaults.standard.data(forKey: contentPathBookmarkKey) else { + print("No content path bookmark found") + contentPath = nil + contentPathUrlIsStale = false + return false + } + let (url, isStale) = decode(bookmark: bookmarkData) + contentPath = url + contentPathUrlIsStale = isStale + return url != nil } - private static func ensure(securityScope: SecurityScopeBookmark, matches path: String) -> SecurityScopeStatus { - guard path != "" else { - return .noPath - } - guard let bookmarkData = UserDefaults.standard.data(forKey: securityScope.rawValue) else { - return .noBookmark - } + func clearContentPath() { + UserDefaults.standard.removeObject(forKey: contentPathBookmarkKey) + contentPath = nil + contentPathUrlIsStale = false + hasContentFolders = false + outputPath = nil + outputPathUrlIsStale = false + } + /** + Decode the security scope data to get a url. + */ + private func decode(bookmark: Data) -> (url: URL?, isStale: Bool) { do { var isStale = false let url = try URL( - resolvingBookmarkData: bookmarkData, + resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) - guard !isStale else { - return .stale - } - guard url.path() == path else { - return .urlMismatch - } - return .nominal + return (url, isStale) } catch { - return .bookmarkCorrupted + print("Failed to resolve bookmark: \(error)") + return (nil, false) + } + } + + private func encode(url: URL) -> Data? { + do { + return try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil) + } catch { + print("Failed to create security-scoped bookmark: \(error)") + return nil + } + } + + func save(outputPath: URL) -> Bool { + guard let contentPath else { return false } + guard let bookmarkData = encode(url: outputPath) else { return false } + return write(bookmarkData, to: outputPathFileName, in: contentPath, onlyIfChanged: false) + } + + /** + Run an operation in the content folder + */ + func inContentFolder(perform operation: (URL) throws -> T) throws -> T { + try inSecurityScope(of: contentPath, perform: operation) + } + + /** + Run an operation in the output folder + */ + func inOutputFolder(perform operation: (URL) throws -> T) throws -> T { + try inSecurityScope(of: outputPath, perform: operation) + } + + func inContentFolder(relativePath: String, perform operation: (URL) throws -> T) throws -> T { + try inContentFolder { url in + try operation(url.appendingPathComponent(relativePath)) + } + } + + /** + Run an operation in the security scope of a url. + */ + private func inSecurityScope(of url: URL?, perform: (URL) throws -> T) throws -> T { + guard let url else { + throw StorageAccessError.noBookmarkData + } + guard url.startAccessingSecurityScopedResource() else { + throw StorageAccessError.folderAccessFailed(url) + } + defer { url.stopAccessingSecurityScopedResource() } + return try perform(url) + } + + private func writeContent(_ data: Data, to relativePath: String, onlyIfChanged: Bool = true) -> Bool { + guard let contentPath else { return false } + return write(data, to: relativePath, in: contentPath, onlyIfChanged: onlyIfChanged) + } + + private func write(_ data: Data, to relativePath: String, in folder: URL, onlyIfChanged: Bool = true) -> Bool { + do { + try inSecurityScope(of: folder) { url in + let file = url.appending(path: relativePath, directoryHint: .notDirectory) + + // Load previous file and compare + if onlyIfChanged, + fm.fileExists(atPath: file.path()), + let oldData = try? Data(contentsOf: file), // Write file again in case of read error + oldData == data { + return + } + try data.write(to: file) + } + return true + } catch { + print("Failed to write to file: \(error)") + #warning("Report error") + return false } } @@ -581,7 +699,7 @@ final class Storage: ObservableObject { } } else { print("Writing new file \(url.path())") - try url.ensureParentFolderExistence() + try url.createParentFolderIfNeeded() } try data.write(to: url) print("Saved file \(url.path())") @@ -595,21 +713,12 @@ final class Storage: ObservableObject { guard let data = try readData(at: relativePath) else { return nil } - return try decoder.decode(T.self, from: data) - } - - /** - - - Note: This function requires a security scope for the content path - */ - private func read(at relativePath: String, defaultValue: T? = nil) throws -> T where T: Decodable { - guard let data = try readData(at: relativePath) else { - guard let defaultValue else { - throw StorageAccessError.fileNotFound(relativePath) - } - return defaultValue + do { + return try decoder.decode(T.self, from: data) + } catch { + print("Failed to decode file \(relativePath): \(error)") + throw error } - return try decoder.decode(T.self, from: data) } /** @@ -644,32 +753,45 @@ final class Storage: ObservableObject { guard url.exists else { return nil } - return try Data(contentsOf: url) + do { + return try Data(contentsOf: url) + } catch { + print("Storage: Failed to read file \(relativePath): \(error)") + throw error + } } } - private func getFiles(in folder: URL) throws -> [URL] { - try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey]) - .filter { !$0.hasDirectoryPath } - } - - private func existingFiles(in folder: String) throws -> [URL] { - try withScopedContent(folder: folder, getFiles) - } - /** - Note: This function requires a security scope for the content path */ private func decodeAllFromJson(in folder: String) throws -> [String : T] where T: Decodable { - try withScopedContent(folder: folder) { folderUrl in - try getFiles(in: folderUrl) - .filter { $0.pathExtension.lowercased() == "json" } - .reduce(into: [:]) { items, url in - let id = url.deletingPathExtension().lastPathComponent - let data = try Data(contentsOf: url) - items[id] = try decoder.decode(T.self, from: data) - } + try inContentFolder(relativePath: folder) { folderUrl in + do { + return try folderUrl + .containedFiles() + .filter { $0.pathExtension.lowercased() == "json" } + .reduce(into: [:]) { items, url in + let id = url.deletingPathExtension().lastPathComponent + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + print("Storage: Failed to read file \(url.path()): \(error)") + throw error + } + do { + items[id] = try decoder.decode(T.self, from: data) + } catch { + print("Storage: Failed to decode file \(url.path()): \(error)") + throw error + } + } + } catch { + print("Storage: Failed to decode files in \(folder): \(error)") + throw error + } } } @@ -679,7 +801,7 @@ final class Storage: ObservableObject { */ private func copy(file: URL, to relativePath: String) throws { try withScopedContent(file: relativePath) { destination in - try destination.ensureParentFolderExistence() + try destination.createParentFolderIfNeeded() try fm.copyItem(at: file, to: destination) } } diff --git a/CHDataManagement/Views/Files/AddFileView.swift b/CHDataManagement/Views/Files/AddFileView.swift index 18d9c81..1115885 100644 --- a/CHDataManagement/Views/Files/AddFileView.swift +++ b/CHDataManagement/Views/Files/AddFileView.swift @@ -105,8 +105,7 @@ struct AddFileView: View { id: file.uniqueId, isExternallyStored: file.url == nil, en: "", de: "") - // TODO: Insert at correct index? - content.files.insert(resource, at: 0) + content.add(resource) selectedFile = resource } dismiss() diff --git a/CHDataManagement/Views/Generic/FolderOnDiskPropertyView.swift b/CHDataManagement/Views/Generic/FolderOnDiskPropertyView.swift index d798965..1803496 100644 --- a/CHDataManagement/Views/Generic/FolderOnDiskPropertyView.swift +++ b/CHDataManagement/Views/Generic/FolderOnDiskPropertyView.swift @@ -5,13 +5,13 @@ struct FolderOnDiskPropertyView: View { let title: LocalizedStringKey @Binding - var folder: String + var folder: URL? let footer: LocalizedStringKey let update: (URL) -> Void - init(title: LocalizedStringKey, folder: Binding, footer: LocalizedStringKey, update: @escaping (URL) -> Void) { + init(title: LocalizedStringKey, folder: Binding, footer: LocalizedStringKey, update: @escaping (URL) -> Void) { self.title = title self._folder = folder self.footer = footer @@ -21,7 +21,7 @@ struct FolderOnDiskPropertyView: View { var body: some View { GenericPropertyView(title: title, footer: footer) { HStack(alignment: .firstTextBaseline) { - Text(folder) + Text(folder?.path() ?? "No folder selected") Spacer() Button("Select") { guard let url = openFolderSelectionPanel() else { diff --git a/CHDataManagement/Views/Pages/AddPageView.swift b/CHDataManagement/Views/Pages/AddPageView.swift index 72ebd44..968ae46 100644 --- a/CHDataManagement/Views/Pages/AddPageView.swift +++ b/CHDataManagement/Views/Pages/AddPageView.swift @@ -81,7 +81,7 @@ struct AddPageView: View { urlString: "page", title: "A Title"), tags: []) - content.pages.insert(page, at: 0) + content.add(page) selectedPage = page dismissSheet() } diff --git a/CHDataManagement/Views/Pages/PageDetailView.swift b/CHDataManagement/Views/Pages/PageDetailView.swift index 610cbaf..c8e8b83 100644 --- a/CHDataManagement/Views/Pages/PageDetailView.swift +++ b/CHDataManagement/Views/Pages/PageDetailView.swift @@ -82,11 +82,10 @@ struct PageDetailView: View { } private func generate() { - guard content.settings.paths.outputDirectoryPath != "" else { + guard let url = content.storage.outputPath else { print("Invalid output path") return } - let url = content.settings.outputDirectory guard FileManager.default.fileExists(atPath: url.path) else { print("Missing output folder") diff --git a/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift b/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift index 641416f..ad4d8a4 100644 --- a/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift +++ b/CHDataManagement/Views/Settings/Content/Pages/PageIssueView.swift @@ -184,7 +184,7 @@ struct PageIssueView: View { isExternallyStored: true, en: "", de: "") - content.files.append(file) + content.add(file) retryPageCheck() } diff --git a/CHDataManagement/Views/Settings/GenerationContentView.swift b/CHDataManagement/Views/Settings/GenerationContentView.swift index 66b83bc..8395c46 100644 --- a/CHDataManagement/Views/Settings/GenerationContentView.swift +++ b/CHDataManagement/Views/Settings/GenerationContentView.swift @@ -59,11 +59,10 @@ struct GenerationContentView: View { } private func generateFeed() { - guard content.settings.paths.outputDirectoryPath != "" else { + guard let url = content.storage.outputPath else { print("Invalid output path") return } - let url = content.settings.outputDirectory guard FileManager.default.fileExists(atPath: url.path) else { print("Missing output folder") diff --git a/CHDataManagement/Views/Settings/PathSettingsView.swift b/CHDataManagement/Views/Settings/PathSettingsView.swift index 3fa2d54..a91c11f 100644 --- a/CHDataManagement/Views/Settings/PathSettingsView.swift +++ b/CHDataManagement/Views/Settings/PathSettingsView.swift @@ -17,22 +17,19 @@ struct PathSettingsView: View { FolderOnDiskPropertyView( title: "Content Folder", - folder: $content.settings.paths.contentDirectoryPath, + folder: $content.storage.contentPath, footer: "The folder where the raw content of the website is stored") { url in - guard content.storage.save(folderUrl: url, in: .contentPath) else { + guard content.storage.save(contentPath: url) else { return } - content.settings.paths.contentDirectoryPath = url.path() + #warning("Reload database") } FolderOnDiskPropertyView( title: "Output Folder", - folder: $content.settings.paths.outputDirectoryPath, + folder: $content.storage.outputPath, footer: "The folder where the generated website is stored") { url in - guard content.storage.save(folderUrl: url, in: .outputPath) else { - return - } - content.settings.paths.outputDirectoryPath = url.path() + content.storage.save(outputPath: url) } StringPropertyView(