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 fileInfoFolderName = "file-metadata" private let pagesFolderName = "pages" private let postsFolderName = "posts" private let tagsFolderName = "tags" private let videoThumbnailFolderName = "thumbnails" private let outputPathFileName = "outputPath.bin" private let settingsDataFileName = "settings.json" private let contentPathBookmarkKey = "contentPathBookmark" // MARK: Properties #warning("Rework to make this non-optional by creating a wrapper class") @Published var contentScope: SecurityBookmark? @Published var outputScope: SecurityBookmark? var errorNotification: StorageErrorCallback? var writeNotification: ((String) -> Void)? /** Create the storage. */ init() { loadContentScope() loadOutputScope() } // MARK: Pages 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" } func save(pageContent: String, for pageId: String, in language: ContentLanguage) -> Bool { guard let contentScope else { return false } let path = pageContentPath(page: pageId, language: language) return contentScope.write(pageContent, to: path) } func remove(pageContent pageId: String, in language: ContentLanguage) -> Bool { guard let contentScope else { return false } let path = pageContentPath(page: pageId, language: language) return contentScope.deleteFile(at: path) } func save(page: Page.Data, for pageId: String) -> Bool { guard let contentScope else { return false } let path = pageMetadataPath(page: pageId) return contentScope.encode(page, to: path) } func loadAllPages() -> [String : Page.Data]? { contentScope?.decodeJsonFiles(in: pagesFolderName) } func pageContent(for pageId: String, language: ContentLanguage) -> String? { guard let contentScope else { return nil } let path = pageContentPath(page: pageId, language: language) return contentScope.readString(at: path) } func hasPageContent(for pageId: String, language: ContentLanguage) -> Bool { guard let contentScope else { return false } let path = pageContentPath(page: pageId, language: language) return contentScope.hasFile(at: path) } /** 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]) -> Bool { guard let contentScope else { return false } var files = Set(pages.map(pageFileName)) for language in ContentLanguage.allCases { files.formUnion(pages.map { pageContentFileName($0, language) }) } guard let deleted = contentScope.deleteFiles(in: pagesFolderName, notIn: files) else { return false } deleted.forEach { print("Deleted unused page file \($0)") } return true } func move(page pageId: String, to newId: String) -> Bool { guard let contentScope else { return false } guard contentScope.move(pageMetadataPath(page: pageId), to: pageMetadataPath(page: newId)) else { print("Failed to move page file \(pageId)") return false } // Move the existing content files var result = true for language in ContentLanguage.allCases { // Copy as many files as possible, since metadata was already moved // Don't fail early if !contentScope.move( pageContentPath(page: pageId, language: language), to: pageContentPath(page: newId, language: language), failIfMissing: false) { print("Failed to move content file \(language) of page \(pageId)") result = false } } return result } // MARK: Posts private func postFileName(_ postId: String) -> String { postId + ".json" } private func postFilePath(post postId: String) -> String { postsFolderName + "/" + postFileName(postId) } func save(post: Post.Data, for postId: String) -> Bool { guard let contentScope else { return false } let path = postFilePath(post: postId) return contentScope.encode(post, to: path) } func loadAllPosts() -> [String : Post.Data]? { contentScope?.decodeJsonFiles(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]) -> Bool { guard let contentScope else { return false } let files = Set(posts.map(postFileName)) guard let deleted = contentScope.deleteFiles(in: postsFolderName, notIn: files) else { return false } deleted.forEach { print("Deleted unused post file \($0)") } return true } func move(post postId: String, to newId: String) -> Bool { guard let contentScope else { return false } return contentScope.move(postFilePath(post: postId), to: postFilePath(post: newId)) } // MARK: Tags private func tagFileName(tagId: String) -> String { tagId + ".json" } private func tagFilePath(tag tagId: String) -> String { tagsFolderName + "/" + tagFileName(tagId: tagId) } func save(tag: Tag.Data, for tagId: String) -> Bool { guard let contentScope else { return false } let path = tagFilePath(tag: tagId) return contentScope.encode(tag, to: path) } func loadAllTags() -> [String : Tag.Data]? { contentScope?.decodeJsonFiles(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]) -> Bool { guard let contentScope else { return false } let files = Set(tags.map { $0 + ".json" }) guard let deleted = contentScope.deleteFiles(in: tagsFolderName, notIn: files) else { return false } deleted.forEach { print("Deleted unused tag file \($0)") } return true } func move(tag tagId: String, to newId: String) -> Bool { guard let contentScope else { return false } return contentScope.move(tagFilePath(tag: tagId), to: tagFilePath(tag: newId)) } // MARK: Files func size(of file: String) -> Int? { contentScope?.size(of: filePath(file: file)) } /** The full path to a resource file in the content folder - Parameter file: The filename of the file - Note: Only for resource files, since path is relative to files folder */ func path(toFile file: String) -> URL? { contentScope?.fullPath(to: filePath(file: file)) } /** Completely delete a file resource from the content folder */ func delete(file fileId: String) -> Bool { guard let contentScope else { return false } guard contentScope.deleteFile(at: filePath(file: fileId)) else { return false } guard contentScope.deleteFile(at: fileInfoPath(file: fileId)) else { return false } return contentScope.deleteFile(at: videoThumbnailPath(fileId)) } /** Delete a file resource from the content folder, making it an external file */ func removeFileContent(file fileId: String) -> Bool { guard let contentScope else { return false } guard contentScope.deleteFile(at: filePath(file: fileId)) else { return false } // Delete video thumbnail, which may not exist (not generated / not a video) return contentScope.deleteFile(at: videoThumbnailPath(fileId)) } /** The full file path to a file in the output folder - Parameter relativePath: The path of the file relative to the output folder */ func outputPath(to relativePath: String) -> URL? { outputScope?.fullPath(to: relativePath) } func openFinderWindow(withSelectedFile file: String) { contentScope?.openFinderWindow(relativePath: filePath(file: file)) } private func filePath(file fileId: String) -> String { filesFolderName + "/" + fileId } /// The path to a metadata file for a file resource private func fileInfoPath(file fileId: String) -> String { fileInfoFolderName + "/" + fileId + ".json" } @discardableResult func deleteInOutputFolder(_ relativePath: String) -> Bool { guard let outputScope else { return false } return outputScope.deleteFile(at: relativePath) } /** Copy an external file to the content folder */ func importExternalFile(at url: URL, fileId: String) -> Bool { guard let contentScope else { return false } return contentScope.copy(externalFile: url, to: filePath(file: fileId)) } /** Move (rename) a file resource (moves file content and file info) */ func move(file fileId: String, to newId: String) -> Bool { guard let contentScope else { return false } guard contentScope.move(filePath(file: fileId), to: filePath(file: newId)) else { return false } return contentScope.move(fileInfoPath(file: fileId), to: fileInfoPath(file: newId)) } /** Copy a file resource to a path relative to the output folder */ func copy(file fileId: String, to relativeOutputPath: String) -> Bool { guard let contentScope, let outputScope else { return false } didWrite(outputFile: relativeOutputPath) return contentScope.transfer( file: filePath(file: fileId), to: relativeOutputPath, of: outputScope) } /** Load the file metadata from the content folder. - Returns: A dictionary with the file ids as keys and the metadata file as a value. */ func loadAllFiles() -> [String : (data: FileResource.Data, isExternal: Bool)]? { guard let contentScope else { return nil } guard let list: [String : FileResource.Data] = contentScope.decodeJsonFiles(in: fileInfoFolderName) else { return nil } guard let existingFiles = contentScope.fileNames(inRelativeFolder: filesFolderName).map(Set.init) else { return nil } return Dictionary(uniqueKeysWithValues: list.map { fileId, data in let isExternal = !existingFiles.contains(fileId) return (fileId, (data: data, isExternal: isExternal)) }) } @discardableResult func save(fileResource: FileResource.Data, for fileId: String) -> Bool { guard let contentScope else { return false } let path = fileInfoPath(file: fileId) return contentScope.encode(fileResource, to: path) } /** 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]) -> Bool { guard let contentScope else { return false } guard let deleted = contentScope.deleteFiles(in: filesFolderName, notIn: Set(fileSet)) else { return false } deleted.forEach { print("Deleted unused file \($0)") } return true } func fileContent(for fileId: String) -> String? { guard let contentScope else { return nil } let path = filePath(file: fileId) return contentScope.readString(at: path) } func fileData(for fileId: String) -> Data? { guard let contentScope else { return nil } let path = filePath(file: fileId) return contentScope.readData(at: path) } func save(fileContent: String, for fileId: String) -> Bool { guard let contentScope else { return false } let path = filePath(file: fileId) return contentScope.write(fileContent, to: path) } func with(file fileId: String, perform operation: (URL) async -> T?) async -> T? { guard let contentScope else { return nil } let path = filePath(file: fileId) return await contentScope.with(relativePath: path, perform: operation) } // MARK: Video thumbnails func hasVideoThumbnail(for videoId: String) -> Bool { guard let contentScope else { return false } let path = videoThumbnailPath(videoId) return contentScope.hasFile(at: path) } private func videoThumbnailPath(_ videoId: String) -> String { guard !videoId.hasSuffix("jpg") else { return videoThumbnailFolderName + "/" + videoId } return videoThumbnailFolderName + "/" + videoId + ".jpg" } func save(thumbnail: Data, for videoId: String) -> Bool { guard let contentScope else { return false } let path = videoThumbnailPath(videoId) return contentScope.write(thumbnail, to: path) } func getVideoThumbnail(for videoId: String) -> Data? { guard let contentScope else { return nil } let path = videoThumbnailPath(videoId) return contentScope.readData(at: path) } // MARK: Settings func loadSettings() -> Settings.Data? { guard let contentScope else { return nil } return contentScope.decode(at: settingsDataFileName) } func save(settings: Settings.Data) -> Bool { guard let contentScope else { return false } return contentScope.encode(settings, to: settingsDataFileName) } // MARK: Output files /** Write the content of a file to a relative path of the output folder. */ @discardableResult func write(_ content: String, to relativeOutputPath: String) -> Bool { guard let outputScope else { return false } didWrite(outputFile: relativeOutputPath) return outputScope.write(content, to: relativeOutputPath) } /** Write the data of a file to a relative path of the output folder. */ func write(_ data: Data, to relativeOutputPath: String) -> Bool { guard let outputScope else { return false } didWrite(outputFile: relativeOutputPath) return outputScope.write(data, to: relativeOutputPath) } func hasFileInOutputFolder(_ relativeOutputPath: String) -> Bool { guard let outputScope else { return false } return outputScope.hasFile(at: relativeOutputPath) } // 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) return loadContentScope() } /** 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 loadContentScope() -> Bool { guard let bookmarkData = UserDefaults.standard.data(forKey: contentPathBookmarkKey) else { print("No content path bookmark found") contentScope = nil return false } contentScope = decode(bookmark: bookmarkData) // Propagate errors contentScope?.errorNotification = { [weak self] error in self?.errorNotification?(error) } return contentScope != nil } @discardableResult private func loadOutputScope() -> Bool { guard let contentScope else { return false } guard let data = contentScope.readData(at: outputPathFileName) else { return false } outputScope = decode(bookmark: data) // Propagate errors outputScope?.errorNotification = { [weak self] error in self?.errorNotification?(error) } return outputScope != nil } func clearContentPath() { UserDefaults.standard.removeObject(forKey: contentPathBookmarkKey) contentScope = nil outputScope = nil } /** Decode the security scope data to get a url. */ private func decode(bookmark: Data) -> SecurityBookmark? { do { var isStale = false let url = try URL( resolvingBookmarkData: bookmark, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) return SecurityBookmark(url: url, isStale: isStale) } catch { print("Failed to resolve bookmark: \(error)") return nil } } 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 contentScope, let bookmarkData = encode(url: outputPath), contentScope.write(bookmarkData, to: outputPathFileName) else { outputScope = nil return false } // TODO: Check if stale outputScope = SecurityBookmark(url: outputPath, isStale: false) // Propagate errors outputScope?.errorNotification = { [weak self] error in self?.errorNotification?(error) } return true } // MARK: Output notifications func didWrite(outputFile: String) { writeNotification?(outputFile) } func getAllOutputFiles() -> Set { guard let outputScope else { return [] } return outputScope.getAllFiles() } }