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 externalFileListName = "external-files.json" private let fileDescriptionFilename = "file-descriptions.json" private let generatedImagesListName = "generated-images.json" private let outputPathFileName = "outputPath.bin" private let settingsDataFileName = "settings.json" private let tagOverviewFileName = "tag-overview.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) } /** 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(pageFileName(pageId), to: pageFileName(newId)) else { 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( pageContentFileName(pageId, language), to: pageContentFileName(newId, language), failIfMissing: false) { 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(tagId: String) -> String { tagsFolderName + "/" + tagFileName(tagId: tagId) } func save(tagMetadata: TagFile, for tagId: String) -> Bool { guard let contentScope else { return false } let path = tagFilePath(tagId: 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 } // MARK: File descriptions func loadFileDescriptions() -> [FileDescriptions]? { contentScope?.decode(at: fileDescriptionFilename) } func save(fileDescriptions: [FileDescriptions]) -> Bool { guard let contentScope else { return false } return contentScope.encode(fileDescriptions, to: fileDescriptionFilename) } // MARK: Tag overview func loadTagOverview() -> TagOverviewFile? { contentScope?.decode(at: tagOverviewFileName) } func save(tagOverview: TagOverviewFile?) -> Bool { guard let contentScope else { return false } return contentScope.encode(tagOverview, to: tagOverviewFileName) } // MARK: Files private func filePath(file fileId: String) -> String { filesFolderName + "/" + fileId } /** 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)) } 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)) } 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) } func loadAllFiles() -> [String]? { contentScope?.fileNames(inRelativeFolder: filesFolderName) } /** 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: External file list func loadExternalFileList() -> [String]? { guard let contentScope else { return nil } return contentScope.decode(at: externalFileListName) } func save(externalFileList: [String]) -> Bool { guard let contentScope else { return false } return contentScope.encode(externalFileList.sorted(), to: externalFileListName) } // 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 loadListOfGeneratedImages() -> [String : Set]? { guard let contentScope else { return nil } return contentScope.decode(at: generatedImagesListName) } func save(listOfGeneratedImages: [String : Set]) -> Bool { guard let contentScope else { return false } return contentScope.encode(listOfGeneratedImages, to: generatedImagesListName) } func calculateImages(generatedBy imageSet: Set, in folder: String) -> [String : Set] { 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 func write(_ content: String, to relativeOutputPath: String) -> Bool { guard let outputScope else { return false } return outputScope.write(content, to: relativeOutputPath) } 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 } }