import Foundation /** A class that handles the storage of the website data. BaseFolder - pages: Contains the markdown files of the localized pages, file name is the url - images: Contains the raw images - files: Contains additional files - videos: Contains raw video files - posts: Contains the markdown files for localized posts, file name is the post id - Note: The base folder and output folder are stored as security-scoped bookmarks in user defaults. */ 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() private let fm = FileManager.default @Published var hasContentFolders = false @Published 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 private func files(in folder: URL) throws -> [URL] { do { return try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey]) .filter { !$0.hasDirectoryPath } } catch { print("Failed to get files in folder \(folder.path): \(error)") throw error } } // MARK: Folders @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 /// 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) } private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String { "\(id)-\(language.rawValue).md" } private func pageContentPath(page pageId: String, language: ContentLanguage) -> String { pagesFolderName + "/" + pageContentFileName(pageId, language) } private func pageMetadataPath(page pageId: String) -> String { pagesFolderName + "/" + pageId + ".json" } private func pageFileName(_ id: String) -> String { id + ".json" } private func pageContentUrl(page pageId: String, language: ContentLanguage, in folder: URL) -> URL { let fileName = pageContentFileName(pageId, language) return pagesFolder(in: folder).appending(path: fileName, directoryHint: .notDirectory) } private func pageMetadataUrl(page pageId: String, in folder: URL) -> URL { let fileName = pageFileName(pageId) return pagesFolder(in: folder).appending(path: fileName, directoryHint: .notDirectory) } func save(pageContent: String, for pageId: String, language: ContentLanguage) throws { let path = pageContentPath(page: pageId, language: language) try writeIfChanged(content: pageContent, to: path) } func save(pageMetadata: PageFile, for pageId: String) throws { let path = pageMetadataPath(page: pageId) try writeIfChanged(pageMetadata, to: path) } func loadAllPages() throws -> [String : PageFile] { try decodeAllFromJson(in: pagesFolderName) } func pageContent(for pageId: String, language: ContentLanguage) throws -> String { let path = pageContentPath(page: pageId, language: language) return try readString(at: path, defaultValue: "") } /** Delete all files associated with pages that are not in the given set - Note: This function requires a security scope for the content path */ func deletePageFiles(notIn pages: [String]) throws { var files = Set(pages.map(pageFileName)) for language in ContentLanguage.allCases { files.formUnion(pages.map { pageContentFileName($0, language) }) } try deleteFiles(in: pagesFolderName, notIn: files) } func move(page pageId: String, to newFile: String) -> Bool { do { try operate(in: .contentPath) { contentPath in // Move the metadata file let source = pageMetadataUrl(page: pageId, in: contentPath) let destination = pageMetadataUrl(page: newFile, in: contentPath) try fm.moveItem(at: source, to: destination) // Move the existing content files for language in ContentLanguage.allCases { let source = pageContentUrl(page: pageId, language: language, in: contentPath) guard source.exists else { continue } let destination = pageContentUrl(page: newFile, language: language, in: contentPath) try fm.moveItem(at: source, to: destination) } } return true } catch { print("Failed to move page file \(pageId) to \(newFile): \(error)") return false } } // MARK: Posts private func postFileName(_ postId: String) -> String { postId + ".json" } /// The folder path where the markdown files of the posts are stored (by their unique id/url component) private func postsFolder(in folder: URL) -> URL { folder.appending(path: postsFolderName, directoryHint: .isDirectory) } private func postFileUrl(post postId: String, in folder: URL) -> URL { let path = postFilePath(post: postId) return folder.appending(path: path, directoryHint: .notDirectory) } private func postFilePath(post postId: String) -> String { postsFolderName + "/" + postFileName(postId) } func save(post: PostFile, for postId: String) throws { let path = postFilePath(post: postId) try writeIfChanged(post, to: path) } func loadAllPosts() throws -> [String : PostFile] { try decodeAllFromJson(in: postsFolderName) } /** Delete all files associated with posts that are not in the given set - Note: This function requires a security scope for the content path */ func deletePostFiles(notIn posts: [String]) throws { let files = Set(posts.map(postFileName)) try deleteFiles(in: postsFolderName, notIn: files) } func move(post postId: String, to newFile: String) throws { try operate(in: .contentPath) { contentPath in let source = postFileUrl(post: postId, in: contentPath) let destination = postFileUrl(post: newFile, in: contentPath) try fm.moveItem(at: source, to: destination) } } // MARK: Tags private func tagFileName(tagId: String) -> String { tagId + ".json" } /// The folder path where the source images are stored (by their unique name) private func tagsFolder(in folder: URL) -> URL { folder.appending(path: tagsFolderName) } private func relativeTagFilePath(tagId: String) -> String { tagsFolderName + "/" + tagFileName(tagId: tagId) } func save(tagMetadata: TagFile, for tagId: String) throws { let path = relativeTagFilePath(tagId: tagId) try writeIfChanged(tagMetadata, to: path) } func loadAllTags() throws -> [String : TagFile] { try decodeAllFromJson(in: tagsFolderName) } /** Delete all files associated with tags that are not in the given set - Note: This function requires a security scope for the content path */ func deleteTagFiles(notIn tags: [String]) throws { let files = Set(tags.map { $0 + ".json" }) try deleteFiles(in: tagsFolderName, notIn: files) } // MARK: File descriptions func loadFileDescriptions() throws -> [FileDescriptions] { guard let descriptions: [FileDescriptions] = try read(at: fileDescriptionFilename) else { print("Storage: No file descriptions loaded") return [] } return descriptions } func save(fileDescriptions: [FileDescriptions]) throws { try writeIfChanged(fileDescriptions, to: fileDescriptionFilename) } // MARK: Tag overview func loadTagOverview() throws -> TagOverviewFile? { try read(at: tagOverviewFileName) } func save(tagOverview: TagOverviewFile?) throws { try writeIfChanged(tagOverview, to: tagOverviewFileName) } // MARK: Files private func filePath(file fileId: String) -> String { filesFolderName + "/" + fileId } /// The folder path where other files are stored (by their unique name) private func filesFolder(in folder: URL) -> URL { folder.appending(path: filesFolderName, directoryHint: .isDirectory) } private func fileUrl(file: String, in folder: URL) -> URL { filesFolder(in: folder).appending(path: file, directoryHint: .notDirectory) } /** Copy an external file to the content folder */ func copyFile(at url: URL, fileId: String) throws { try operate(in: .contentPath) { contentPath in let destination = fileUrl(file: fileId, in: contentPath) try fm.copyItem(at: url, to: destination) } } func move(file fileId: String, to newFile: String) throws { try operate(in: .contentPath) { contentPath in let source = fileUrl(file: fileId, in: contentPath) let destination = fileUrl(file: newFile, in: contentPath) try fm.moveItem(at: source, to: destination) } } func copy(file fileId: String, to relativeOutputPath: String) throws { let path = filePath(file: fileId) try withScopedContent(file: path) { input in try operate(in: .outputPath) { outputPath in let output = outputPath.appending(path: relativeOutputPath, directoryHint: .notDirectory) if output.exists { return } try output.createParentFolderIfNeeded() try FileManager.default.copyItem(at: input, to: output) } } } func loadAllFiles() throws -> [String] { try inContentFolder(relativePath: filesFolderName) { try $0.containedFileNames() } } /** Delete all file resources that are not in the given set - Note: This function requires a security scope for the content path */ func deleteFileResources(notIn fileSet: [String]) throws { try deleteFiles(in: filesFolderName, notIn: Set(fileSet)) } func fileContent(for fileId: String) throws -> String { let path = filePath(file: fileId) return try readString(at: path) } func fileData(for fileId: String) throws -> Data { let path = filePath(file: fileId) return try readExistingFile(at: path) } // MARK: External file list private let externalFileListName = "external-files.json" func loadExternalFileList() throws -> [String] { 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: Settings private let settingsDataFileName: String = "settings.json" func loadSettings() throws -> SettingsFile { guard let settings: SettingsFile = try read(at: settingsDataFileName) else { print("Storage: Loaded default settings") return .default } return settings } func save(settings: SettingsFile) throws { try writeIfChanged(settings, to: settingsDataFileName) } // MARK: Image generation data func loadListOfGeneratedImages() throws -> [String : [String]] { 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 { try writeIfChanged(listOfGeneratedImages, to: generatedImagesListName) } // MARK: Output files func write(content: String, to relativeOutputPath: String) throws { try writeIfChanged(content: content, to: relativeOutputPath, in: .outputPath) } // MARK: Folder access 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.createIfNeeded() return true } catch { print("Failed to create folder \(url.path()): \(error)") return false } } } func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool { do { return try operate(in: scope, operation: operation) } catch { print(error) return false } } private func withScopedContent(file relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T { try withScopedContent(relativePath, in: scope, directoryHint: .notDirectory, operation) } private func withScopedContent(folder relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T { try withScopedContent(relativePath, in: scope, directoryHint: .isDirectory, operation) } private func withScopedContent(_ relativePath: String, in scope: SecurityScopeBookmark, directoryHint: URL.DirectoryHint, _ operation: (URL) throws -> T) throws -> T { try operate(in: scope) { let url = $0.appending(path: relativePath, directoryHint: directoryHint) return try operation(url) } } func operate(in scope: SecurityScopeBookmark, operation: (URL) throws -> T) throws -> T { guard let bookmarkData = UserDefaults.standard.data(forKey: scope.rawValue) else { throw StorageAccessError.noBookmarkData } var isStale = false let folderUrl: URL do { // Resolve the bookmark to get the folder URL folderUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) } catch { throw StorageAccessError.bookmarkDataCorrupted(error) } if isStale { print("Bookmark is stale, consider saving a new bookmark.") #warning("Show warning about stale bookmark") } // Start accessing the security-scoped resource guard folderUrl.startAccessingSecurityScopedResource() else { throw StorageAccessError.folderAccessFailed(folderUrl) } defer { folderUrl.stopAccessingSecurityScopedResource() } 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 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 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 } 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: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) return (url, isStale) } catch { 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 } } @discardableResult 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 } } // MARK: Writing files /** Delete files in a subPath of the content folder which are not in the given set of files - Note: This function requires a security scope for the content path */ private func deleteFiles(in folder: String, notIn fileSet: Set) throws { try withScopedContent(folder: folder) { folderUrl in let filesToDelete = try files(in: folderUrl) .filter { !fileSet.contains($0.lastPathComponent) } for file in filesToDelete { try fm.removeItem(at: file) print("Deleted \(file.path())") } } } /** Write the data of an encodable value to a relative path in the content folder, or delete the file if nil is passed. - Note: This function requires a security scope for the content path */ private func writeIfChanged(_ value: T?, to relativePath: String) throws where T: Encodable { guard let value else { try deleteFile(at: relativePath) return } return try writeIfChanged(value, to: relativePath) } /** Write the data of an encodable value to a relative path in the content folder - Note: This function requires a security scope for the content path */ private func writeIfChanged(_ value: T, to relativePath: String) throws where T: Encodable { let data = try encoder.encode(value) try writeIfChanged(data: data, to: relativePath) } /** Write the data of a string to a relative path in the content folder - Note: This function requires a security scope for the content path */ private func writeIfChanged(content: String, to relativePath: String, in scope: SecurityScopeBookmark = .contentPath) throws { guard let data = content.data(using: .utf8) else { print("Failed to convert string to data for file at \(relativePath)") throw StorageAccessError.stringConversionFailed } try writeIfChanged(data: data, to: relativePath, in: scope) } /** Write the data to a relative path in the content folder - Note: This function requires a security scope for the content path */ private func writeIfChanged(data: Data, to relativePath: String, in scope: SecurityScopeBookmark = .contentPath) throws { try withScopedContent(file: relativePath, in: scope) { url in if fm.fileExists(atPath: url.path()) { // Check if content is the same, to prevent unnecessary writes do { let oldData = try Data(contentsOf: url) if data == oldData { // File is the same, don't write return } } catch { print("Failed to read file \(url.path()) for equality check: \(error)") // No check possible, write file } } else { print("Writing new file \(url.path())") try url.createParentFolderIfNeeded() } try data.write(to: url) print("Saved file \(url.path())") } } /** Read an object from a file, if the file exists */ private func read(at relativePath: String) throws -> T? where T: Decodable { guard let data = try readData(at: relativePath) else { return nil } do { return try decoder.decode(T.self, from: data) } catch { print("Failed to decode file \(relativePath): \(error)") throw error } } /** - Note: This function requires a security scope for the content path */ private func readString(at relativePath: String, defaultValue: String? = nil) throws -> String { try withScopedContent(file: relativePath) { url in guard url.exists else { guard let defaultValue else { throw StorageAccessError.fileNotFound(relativePath) } return defaultValue } return try String(contentsOf: url, encoding: .utf8) } } private func readExistingFile(at relativePath: String) throws -> Data { guard let data = try readData(at: relativePath) else { throw StorageAccessError.fileNotFound(relativePath) } return data } /** - Note: This function requires a security scope for the content path */ private func readData(at relativePath: String) throws -> Data? { try withScopedContent(file: relativePath) { url in guard url.exists else { return nil } do { return try Data(contentsOf: url) } catch { print("Storage: Failed to read file \(relativePath): \(error)") throw error } } } /** - Note: This function requires a security scope for the content path */ private func decodeAllFromJson(in folder: String) throws -> [String : T] where T: Decodable { 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 } } } /** - Note: This function requires a security scope for the content path */ private func copy(file: URL, to relativePath: String) throws { try withScopedContent(file: relativePath) { destination in try destination.createParentFolderIfNeeded() try fm.copyItem(at: file, to: destination) } } private func deleteFile(at relativePath: String) throws { try withScopedContent(file: relativePath) { destination in guard fm.fileExists(atPath: destination.path()) else { return } try fm.removeItem(at: destination) } } }