2025-02-24 19:12:15 +01:00

580 lines
19 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 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<T>(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<String> {
guard let outputScope else { return [] }
return outputScope.getAllFiles()
}
}