import Foundation enum SecurityScopeBookmark: String { case outputPath = "outputPathBookmark" case contentPath = "contentPathBookmark" } enum StorageAccessError: Error { case noBookmarkData case bookmarkDataCorrupted(Error) case folderAccessFailed(URL) } extension StorageAccessError: CustomStringConvertible { var description: String { switch self { case .noBookmarkData: return "No bookmark data to access resources in folder" case .bookmarkDataCorrupted(let error): return "Failed to resolve bookmark: \(error)" case .folderAccessFailed(let url): return "Failed to access folder: \(url.path())" } } } /** 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 - */ final class Storage { private(set) var baseFolder: URL private let encoder = JSONEncoder() private let decoder = JSONDecoder() private let fm = FileManager.default /** Create the storage. */ init(baseFolder: URL) { self.baseFolder = baseFolder encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } // MARK: Helper private func subFolder(_ name: String) -> URL { baseFolder.appending(path: name, directoryHint: .isDirectory) } 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 } } 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 update(baseFolder: URL) throws { self.baseFolder = baseFolder 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 create(folder: pagesFolder) try create(folder: filesFolder) try create(folder: postsFolder) try create(folder: tagsFolder) } // MARK: Pages /// The folder path where the markdown and metadata files of the pages are stored (by their id/url component) private var pagesFolder: URL { subFolder("pages") } private func pageContentFileName(_ id: String, _ language: ContentLanguage) -> String { "\(id)-\(language.rawValue).md" } private func pageFileName(_ id: String) -> String { id + ".json" } private func pageContentUrl(pageId: String, language: ContentLanguage) -> URL { pagesFolder.appending(path: pageContentFileName(pageId, language), directoryHint: .notDirectory) } private func pageMetadataUrl(pageId: String) -> URL { pagesFolder.appending(path: pageFileName(pageId), directoryHint: .notDirectory) } @discardableResult func save(pageContent: String, for pageId: String, language: ContentLanguage) -> Bool { let contentUrl = pageContentUrl(pageId: pageId, language: language) return write(content: pageContent, to: contentUrl, type: "page", id: pageId) } @discardableResult func save(pageMetadata: PageFile, for pageId: String) -> Bool { let contentUrl = pageMetadataUrl(pageId: pageId) return write(pageMetadata, type: "page", id: pageId, to: contentUrl) } @discardableResult func copyPageContent(from url: URL, for pageId: String, language: ContentLanguage) -> Bool { let contentUrl = pageContentUrl(pageId: pageId, language: language) return copy(file: url, to: contentUrl, type: "page content", id: pageId) } func loadAllPages() throws -> [String : PageFile] { try loadAll(in: pagesFolder) } func pageContent(for pageId: String, language: ContentLanguage) -> String { let contentUrl = pageContentUrl(pageId: pageId, language: language) guard fm.fileExists(atPath: contentUrl.path()) else { print("No file at \(contentUrl.path())") return "" } do { return try String(contentsOf: contentUrl, encoding: .utf8) } catch { print("Failed to load page content for \(pageId) (\(language)): \(error)") return error.localizedDescription } } 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: pagesFolder, notIn: files) } // MARK: Posts /// The folder path where the markdown files of the posts are stored (by their unique id/url component) private var postsFolder: URL { subFolder("posts") } private func postFileUrl(postId: String) -> URL { postsFolder.appending(path: postId, directoryHint: .notDirectory).appendingPathExtension("json") } @discardableResult func save(post: PostFile, for postId: String) -> Bool { let contentUrl = postFileUrl(postId: postId) return write(post, type: "post", id: postId, to: contentUrl) } func loadAllPosts() throws -> [String : PostFile] { try loadAll(in: postsFolder) } private func post(at url: URL) throws -> PostFile { try read(at: url) } private func postContent(for postId: String) throws -> PostFile { let url = postFileUrl(postId: postId) return try post(at: url) } func deletePostFiles(notIn posts: [String]) throws { let files = Set(posts.map { $0 + ".json" }) try deleteFiles(in: postsFolder, notIn: files) } // MARK: Tags /// The folder path where the source images are stored (by their unique name) private var tagsFolder: URL { subFolder("tags") } private func tagFileUrl(tagId: String) -> URL { tagsFolder.appending(path: tagId, directoryHint: .notDirectory) } private func tagMetadataUrl(tagId: String) -> URL { tagFileUrl(tagId: tagId).appendingPathExtension("json") } @discardableResult func save(tagMetadata: TagFile, for tagId: String) -> Bool { let contentUrl = tagMetadataUrl(tagId: tagId) return write(tagMetadata, type: "tag", id: tagId, to: contentUrl) } func loadAllTags() throws -> [String : TagFile] { try loadAll(in: tagsFolder) } func deleteTagFiles(notIn tags: [String]) throws { let files = Set(tags.map { $0 + ".json" }) try deleteFiles(in: tagsFolder, notIn: files) } // MARK: Files private var imageDescriptionFilename: String { "image-descriptions.json" } private var imageDescriptionUrl: URL { baseFolder.appending(path: "image-descriptions.json") } func loadImageDescriptions() -> [ImageDescriptions] { do { return try read(relativePath: imageDescriptionFilename) } catch { print("Failed to read image descriptions: \(error)") return [] } } @discardableResult func save(imageDescriptions: [ImageDescriptions]) -> Bool { do { try writeIfChanged(imageDescriptions, to: imageDescriptionFilename) return true } catch { print("Failed to write image descriptions: \(error)") return false } } /// The folder path where other files are stored (by their unique name) var filesFolder: URL { subFolder("files") } private func fileUrl(file: String) -> URL { filesFolder.appending(path: file, directoryHint: .notDirectory) } /** Copy an external file to the content folder */ @discardableResult func copyFile(at url: URL, fileId: String) -> Bool { let contentUrl = fileUrl(file: fileId) return copy(file: url, to: contentUrl, type: "file", id: fileId) } func copy(file fileId: String, to relativeOutputPath: String) -> Bool { do { try operate(in: .contentPath) { contentPath in try operate(in: .outputPath) { outputPath in let output = outputPath.appending(path: relativeOutputPath, directoryHint: .notDirectory) if output.exists { return } let input = contentPath.appending(path: "files/\(fileId)", directoryHint: .notDirectory) try output.ensureParentFolderExistence() try FileManager.default.copyItem(at: input, to: output) } } return true } catch { print("Failed to copy file \(fileId) to output folder: \(error)") return false } } func loadAllFiles() throws -> [String : URL] { try files(in: filesFolder).reduce(into: [:]) { files, url in files[url.lastPathComponent] = url } } func deleteFiles(notIn fileSet: [String]) throws { try deleteFiles(in: filesFolder, notIn: Set(fileSet)) } func fileContent(for file: String) throws -> String { try operate(in: .contentPath) { folder in let fileUrl = folder .appending(path: "files", directoryHint: .isDirectory) .appending(path: file, directoryHint: .notDirectory) return try String(contentsOf: fileUrl, encoding: .utf8) } } // MARK: Website data private var settingsDataUrl: URL { baseFolder.appending(path: "settings.json", directoryHint: .notDirectory) } func loadSettings() throws -> SettingsFile { try read(at: settingsDataUrl) } @discardableResult func save(settings: SettingsFile) -> Bool { write(settings, type: "Settings", id: "-", to: settingsDataUrl) } // MARK: Image generation data private var generatedImagesListUrl: URL { baseFolder.appending(component: "generated-images.json", directoryHint: .notDirectory) } func loadListOfGeneratedImages() -> [String : [String]] { let url = generatedImagesListUrl guard url.exists else { return [:] } do { return try read(at: url) } catch { print("Failed to read list of generated images: \(error)") return [:] } } func save(listOfGeneratedImages: [String : [String]]) -> Bool { write(listOfGeneratedImages, type: "generated images list", id: "-", to: generatedImagesListUrl) } // MARK: Folder access func save(folderUrl url: URL, in bookmark: SecurityScopeBookmark) { do { let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) UserDefaults.standard.set(bookmarkData, forKey: bookmark.rawValue) } catch { print("Failed to create security-scoped bookmark: \(error)") } } func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool { do { return try operate(in: scope, operation: operation) } catch { print(error) return false } } 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.") } // Start accessing the security-scoped resource guard folderUrl.startAccessingSecurityScopedResource() else { throw StorageAccessError.folderAccessFailed(folderUrl) } defer { folderUrl.stopAccessingSecurityScopedResource() } return try operation(folderUrl) } // MARK: Writing files private func deleteFiles(in folder: URL, notIn fileSet: Set) throws { let filesToDelete = try files(in: folder) .filter { !fileSet.contains($0.lastPathComponent) } for file in filesToDelete { try fm.removeItem(at: file) print("Deleted \(file.path())") } } private func writeIfChanged(_ value: T, to relativePath: String) throws where T: Encodable { try operate(in: .contentPath) { contentPath in let url = contentPath.appending(path: relativePath, directoryHint: .notDirectory) let data = try encoder.encode(value) 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 data.write(to: url) print("Saved file \(url.path())") } } /** Encode a value and write it to a file, if the content changed */ private func write(_ value: T, type: String, id: String, to file: URL) -> Bool where T: Encodable { let content: Data do { content = try encoder.encode(value) } catch { print("Failed to encode content of \(type) '\(id)': \(error)") return false } return write(data: content, type: type, id: id, to: file) } /** Write data to a file if the content changed */ private func write(data: Data, type: String, id: String, to file: URL) -> Bool { if fm.fileExists(atPath: file.path()) { // Check if content is the same, to prevent unnecessary writes do { let oldData = try Data(contentsOf: file) if data == oldData { // File is the same, don't write return true } } catch { print("Failed to read file \(file.path()) for equality check: \(error)") // No check possible, write file } } else { print("Writing new file \(file.path())") } do { try data.write(to: file, options: .atomic) print("Saved file \(file.path())") return true } catch { print("Failed to save content for \(type) '\(id)': \(error)") return false } } private func copy(file: URL, to destination: URL, type: String, id: String) -> Bool { do { try fm.copyItem(at: file, to: destination) return true } catch { print("Failed to copy content file for \(type) '\(id)': \(error)") return false } } private func write(content: String, to file: URL, type: String, id: String) -> Bool { guard let data = content.data(using: .utf8) else { print("Failed to convert string to data for \(type) '\(id)'") return false } return write(data: data, type: type, id: id, to: file) } private func read(relativePath: String) throws -> T where T: Decodable { try operate(in: .contentPath) { baseFolder in let url = baseFolder.appending(path: relativePath, directoryHint: .notDirectory) let data = try Data(contentsOf: url) return try decoder.decode(T.self, from: data) } } private func read(at url: URL) throws -> T where T: Decodable { let data = try Data(contentsOf: url) return try decoder.decode(T.self, from: data) } private func loadAll(in folder: URL) throws -> [String : T] where T: Decodable { try files(in: folder, type: "json").reduce(into: [:]) { items, url in let id = url.deletingPathExtension().lastPathComponent let item: T = try read(at: url) items[id] = item } } }