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 outputPathFileName = "outputPath.bin" private let settingsDataFileName = "settings.json" private let contentPathBookmarkKey = "contentPathBookmark" // MARK: Properties @Published var contentScope: SecurityBookmark? @Published var outputScope: SecurityBookmark? /** 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, language: ContentLanguage) -> Bool { guard let contentScope else { return false } let path = pageContentPath(page: pageId, language: language) return contentScope.write(pageContent, to: path) } func save(pageMetadata: PageFile, for pageId: String) -> Bool { guard let contentScope else { return false } let path = pageMetadataPath(page: pageId) return contentScope.encode(pageMetadata, to: path) } func loadAllPages() -> [String : PageFile]? { 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: PostFile, for postId: String) -> Bool { guard let contentScope else { return false } let path = postFilePath(post: postId) return contentScope.encode(post, to: path) } func loadAllPosts() -> [String : PostFile]? { 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(tagMetadata: TagFile, for tagId: String) -> Bool { guard let contentScope else { return false } let path = tagFilePath(tag: tagId) return contentScope.encode(tagMetadata, to: path) } func loadAllTags() -> [String : TagFile]? { 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 } return contentScope.deleteFile(at: fileInfoPath(file: 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 } return contentScope.deleteFile(at: filePath(file: 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. */ func move(file fileId: String, to newId: String) -> Bool { guard let contentScope else { return false } return contentScope.move(filePath(file: fileId), to: filePath(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 } 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: FileResourceFile, isExternal: Bool)]? { guard let contentScope else { return nil } guard let list: [String : FileResourceFile] = 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(fileInfo: FileResourceFile, for fileId: String) -> Bool { guard let contentScope else { return false } let path = fileInfoPath(file: fileId) return contentScope.encode(fileInfo, 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) } // MARK: Settings func loadSettings() -> SettingsFile? { guard let contentScope else { return nil } return contentScope.decode(at: settingsDataFileName) } func save(settings: SettingsFile) -> Bool { guard let contentScope else { return false } return contentScope.encode(settings, to: settingsDataFileName) } // MARK: Image generation data func calculateImages(generatedBy imageSet: Set, in folder: String) -> [String : Set] { #warning("TODO: Move to file resource") guard let outputScope else { return [:] } guard let allImages = outputScope.fileNames(inRelativeFolder: folder) else { print("Failed to get list of generated images in output folder") return [:] } guard !allImages.isEmpty else { print("No images found in output folder \(folder)") return [:] } print("Found \(allImages.count) generated images") let images = Set(allImages) return imageSet.reduce(into: [:]) { result, imageName in let prefix = imageName.fileNameWithoutExtension + "@" let versions = images.filter { $0.hasPrefix(prefix) } if !versions.isEmpty { result[imageName] = Set(versions) } } } // 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 } 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 } 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) 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) 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) return true } }