580 lines
19 KiB
Swift
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()
|
|
}
|
|
}
|