2024-12-19 16:25:05 +01:00

457 lines
15 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 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<String>]? {
guard let contentScope else { return nil }
return contentScope.decode(at: generatedImagesListName)
}
func save(listOfGeneratedImages: [String : Set<String>]) -> Bool {
guard let contentScope else { return false }
return contentScope.encode(listOfGeneratedImages, to: generatedImagesListName)
}
func calculateImages(generatedBy imageSet: Set<String>, in folder: String) -> [String : Set<String>] {
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
}
}