2024-12-17 23:05:45 +01:00

818 lines
28 KiB
Swift

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<T>(file relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T {
try withScopedContent(relativePath, in: scope, directoryHint: .notDirectory, operation)
}
private func withScopedContent<T>(folder relativePath: String, in scope: SecurityScopeBookmark = .contentPath, _ operation: (URL) throws -> T) throws -> T {
try withScopedContent(relativePath, in: scope, directoryHint: .isDirectory, operation)
}
private func withScopedContent<T>(_ 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<T>(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
}
}
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<T>(perform operation: (URL) throws -> T) throws -> T {
try inSecurityScope(of: contentPath, perform: operation)
}
/**
Run an operation in the output folder
*/
func inOutputFolder<T>(perform operation: (URL) throws -> T) throws -> T {
try inSecurityScope(of: outputPath, perform: operation)
}
func inContentFolder<T>(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<T>(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<String>) 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<T>(_ 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<T>(_ 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<T>(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<T>(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)
}
}
}