|
|
|
@ -15,6 +15,10 @@ enum StorageAccessError: Error {
|
|
|
|
|
|
|
|
|
|
case folderAccessFailed(URL)
|
|
|
|
|
|
|
|
|
|
case stringConversionFailed
|
|
|
|
|
|
|
|
|
|
case fileNotFound(String)
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension StorageAccessError: CustomStringConvertible {
|
|
|
|
@ -27,6 +31,10 @@ extension StorageAccessError: CustomStringConvertible {
|
|
|
|
|
return "Failed to resolve bookmark: \(error)"
|
|
|
|
|
case .folderAccessFailed(let url):
|
|
|
|
|
return "Failed to access folder: \(url.path())"
|
|
|
|
|
case .stringConversionFailed:
|
|
|
|
|
return "Failed to convert string to data"
|
|
|
|
|
case .fileNotFound(let path):
|
|
|
|
|
return "File not found: \(path)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@ -102,10 +110,10 @@ final class Storage {
|
|
|
|
|
|
|
|
|
|
func createFolderStructure() throws {
|
|
|
|
|
try operate(in: .contentPath) { contentPath in
|
|
|
|
|
try create(folder: pagesFolder)
|
|
|
|
|
try create(folder: pagesFolder(in: contentPath))
|
|
|
|
|
try create(folder: filesFolder(in: contentPath))
|
|
|
|
|
try create(folder: postsFolder)
|
|
|
|
|
try create(folder: tagsFolder)
|
|
|
|
|
try create(folder: postsFolder(in: contentPath))
|
|
|
|
|
try create(folder: tagsFolder(in: contentPath))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -114,60 +122,59 @@ final class Storage {
|
|
|
|
|
private let pagesFolderName = "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(pagesFolderName) }
|
|
|
|
|
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(pageId: String, language: ContentLanguage) -> URL {
|
|
|
|
|
pagesFolder.appending(path: pageContentFileName(pageId, language), directoryHint: .notDirectory)
|
|
|
|
|
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(pageId: String) -> URL {
|
|
|
|
|
pagesFolder.appending(path: pageFileName(pageId), 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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
func save(pageContent: String, for pageId: String, language: ContentLanguage) throws {
|
|
|
|
|
let path = pageContentPath(page: pageId, language: language)
|
|
|
|
|
try writeIfChanged(content: pageContent, to: path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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 save(pageMetadata: PageFile, for pageId: String) throws {
|
|
|
|
|
let path = pageMetadataPath(page: pageId)
|
|
|
|
|
try writeIfChanged(pageMetadata, to: path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadAllPages() throws -> [String : PageFile] {
|
|
|
|
|
try loadAll(in: pagesFolder)
|
|
|
|
|
try decodeAllFromJson(in: pagesFolderName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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 {
|
|
|
|
@ -176,66 +183,112 @@ final class Storage {
|
|
|
|
|
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 let postsFolderName = "posts"
|
|
|
|
|
|
|
|
|
|
/// The folder path where the markdown files of the posts are stored (by their unique id/url component)
|
|
|
|
|
private var postsFolder: URL { subFolder(postsFolderName) }
|
|
|
|
|
|
|
|
|
|
private func postFileUrl(postId: String) -> URL {
|
|
|
|
|
postsFolder.appending(path: postId, directoryHint: .notDirectory).appendingPathExtension("json")
|
|
|
|
|
private func postFileName(_ postId: String) -> String {
|
|
|
|
|
postId + ".json"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
|
func save(post: PostFile, for postId: String) -> Bool {
|
|
|
|
|
let contentUrl = postFileUrl(postId: postId)
|
|
|
|
|
return write(post, type: "post", id: postId, to: contentUrl)
|
|
|
|
|
/// 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 loadAll(in: postsFolder)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func post(at url: URL) throws -> PostFile {
|
|
|
|
|
try read(at: url)
|
|
|
|
|
try decodeAllFromJson(in: postsFolderName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func postContent(for postId: String) throws -> PostFile {
|
|
|
|
|
let url = postFileUrl(postId: postId)
|
|
|
|
|
return try post(at: url)
|
|
|
|
|
let path = postFilePath(post: postId)
|
|
|
|
|
return try read(at: path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
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 { $0 + ".json" })
|
|
|
|
|
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 let tagsFolderName = "tags"
|
|
|
|
|
|
|
|
|
|
private func tagFileName(tagId: String) -> String {
|
|
|
|
|
tagId + ".json"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The folder path where the source images are stored (by their unique name)
|
|
|
|
|
private var tagsFolder: URL { subFolder(tagsFolderName) }
|
|
|
|
|
|
|
|
|
|
private func tagFileUrl(tagId: String) -> URL {
|
|
|
|
|
tagsFolder.appending(path: tagId, directoryHint: .notDirectory)
|
|
|
|
|
private func tagsFolder(in folder: URL) -> URL {
|
|
|
|
|
folder.appending(path: tagsFolderName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func tagMetadataUrl(tagId: String) -> URL {
|
|
|
|
|
tagFileUrl(tagId: tagId).appendingPathExtension("json")
|
|
|
|
|
private func relativeTagFilePath(tagId: String) -> String {
|
|
|
|
|
tagsFolderName + "/" + tagFileName(tagId: tagId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
|
func save(tagMetadata: TagFile, for tagId: String) -> Bool {
|
|
|
|
|
let contentUrl = tagMetadataUrl(tagId: tagId)
|
|
|
|
|
return write(tagMetadata, type: "tag", id: tagId, to: contentUrl)
|
|
|
|
|
func save(tagMetadata: TagFile, for tagId: String) throws {
|
|
|
|
|
let path = relativeTagFilePath(tagId: tagId)
|
|
|
|
|
try writeIfChanged(tagMetadata, to: path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadAllTags() throws -> [String : TagFile] {
|
|
|
|
|
try loadAll(in: tagsFolder)
|
|
|
|
|
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)
|
|
|
|
@ -245,32 +298,24 @@ final class Storage {
|
|
|
|
|
|
|
|
|
|
private let fileDescriptionFilename = "file-descriptions.json"
|
|
|
|
|
|
|
|
|
|
func loadFileDescriptions() -> [FileDescriptions] {
|
|
|
|
|
do {
|
|
|
|
|
return try read(relativePath: fileDescriptionFilename)
|
|
|
|
|
} catch {
|
|
|
|
|
print("Failed to read file descriptions: \(error)")
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
func loadFileDescriptions() throws -> [FileDescriptions] {
|
|
|
|
|
try read(at: fileDescriptionFilename, defaultValue: [])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
|
func save(fileDescriptions: [FileDescriptions]) -> Bool {
|
|
|
|
|
do {
|
|
|
|
|
try writeIfChanged(fileDescriptions, to: fileDescriptionFilename)
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
print("Failed to write file descriptions: \(error)")
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
func save(fileDescriptions: [FileDescriptions]) throws {
|
|
|
|
|
try writeIfChanged(fileDescriptions, to: fileDescriptionFilename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Files
|
|
|
|
|
|
|
|
|
|
private let filesFolderName = "files"
|
|
|
|
|
|
|
|
|
|
private func filePath(file fileId: String) -> String {
|
|
|
|
|
filesFolderName + "/" + fileId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The folder path where other files are stored (by their unique name)
|
|
|
|
|
func filesFolder(in folder: URL) -> URL {
|
|
|
|
|
private func filesFolder(in folder: URL) -> URL {
|
|
|
|
|
folder.appending(path: filesFolderName, directoryHint: .isDirectory)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@ -281,120 +326,87 @@ final class Storage {
|
|
|
|
|
/**
|
|
|
|
|
Copy an external file to the content folder
|
|
|
|
|
*/
|
|
|
|
|
@discardableResult
|
|
|
|
|
func copyFile(at url: URL, fileId: String) -> Bool {
|
|
|
|
|
do {
|
|
|
|
|
try operate(in: .contentPath) { contentPath in
|
|
|
|
|
let destination = fileUrl(file: fileId, in: contentPath)
|
|
|
|
|
try fm.copyItem(at: url, to: destination)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
print("Failed to copy external file \(url.path()) to \(fileId): \(error)")
|
|
|
|
|
return false
|
|
|
|
|
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) -> Bool {
|
|
|
|
|
do {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
print("Failed to move file \(fileId) to \(newFile): \(error)")
|
|
|
|
|
return false
|
|
|
|
|
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) -> 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
|
|
|
|
|
}
|
|
|
|
|
try output.ensureParentFolderExistence()
|
|
|
|
|
|
|
|
|
|
let input = fileUrl(file: fileId, in: contentPath)
|
|
|
|
|
try FileManager.default.copyItem(at: input, to: output)
|
|
|
|
|
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.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] {
|
|
|
|
|
try operate(in: .contentPath) { contentPath in
|
|
|
|
|
let folder = filesFolder(in: contentPath)
|
|
|
|
|
return try files(in: folder).map { $0.lastPathComponent }
|
|
|
|
|
}
|
|
|
|
|
try self.existingFiles(in: filesFolderName)
|
|
|
|
|
.map { $0.lastPathComponent }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func deleteFiles(notIn fileSet: [String]) throws {
|
|
|
|
|
/**
|
|
|
|
|
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 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)
|
|
|
|
|
}
|
|
|
|
|
func fileContent(for fileId: String) throws -> String {
|
|
|
|
|
let path = filePath(file: fileId)
|
|
|
|
|
return try readString(at: path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fileData(for file: String) throws -> Data {
|
|
|
|
|
try operate(in: .contentPath) { folder in
|
|
|
|
|
let fileUrl = folder
|
|
|
|
|
.appending(path: "files", directoryHint: .isDirectory)
|
|
|
|
|
.appending(path: file, directoryHint: .notDirectory)
|
|
|
|
|
return try Data(contentsOf: fileUrl)
|
|
|
|
|
}
|
|
|
|
|
func fileData(for fileId: String) throws -> Data {
|
|
|
|
|
let path = filePath(file: fileId)
|
|
|
|
|
return try readExistingFile(at: path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Website data
|
|
|
|
|
|
|
|
|
|
private var settingsDataUrl: URL {
|
|
|
|
|
baseFolder.appending(path: "settings.json", directoryHint: .notDirectory)
|
|
|
|
|
}
|
|
|
|
|
private let settingsDataFileName: String = "settings.json"
|
|
|
|
|
|
|
|
|
|
func loadSettings() throws -> SettingsFile {
|
|
|
|
|
try read(at: settingsDataUrl)
|
|
|
|
|
try read(at: settingsDataFileName, defaultValue: .default)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
|
func save(settings: SettingsFile) -> Bool {
|
|
|
|
|
write(settings, type: "Settings", id: "-", to: settingsDataUrl)
|
|
|
|
|
func save(settings: SettingsFile) throws {
|
|
|
|
|
try writeIfChanged(settings, to: settingsDataFileName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Image generation data
|
|
|
|
|
|
|
|
|
|
private var generatedImagesListUrl: URL {
|
|
|
|
|
baseFolder.appending(component: "generated-images.json", directoryHint: .notDirectory)
|
|
|
|
|
private let generatedImagesListName = "generated-images.json"
|
|
|
|
|
|
|
|
|
|
func loadListOfGeneratedImages() throws -> [String : [String]] {
|
|
|
|
|
try read(at: generatedImagesListName, defaultValue: [:])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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]]) throws {
|
|
|
|
|
try writeIfChanged(listOfGeneratedImages, to: generatedImagesListName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func save(listOfGeneratedImages: [String : [String]]) -> Bool {
|
|
|
|
|
write(listOfGeneratedImages, type: "generated images list", id: "-", to: generatedImagesListUrl)
|
|
|
|
|
// MARK: Output files
|
|
|
|
|
|
|
|
|
|
func write(content: String, to relativeOutputPath: String) throws {
|
|
|
|
|
try writeIfChanged(content: content, to: relativeOutputPath, in: .outputPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: Folder access
|
|
|
|
@ -417,6 +429,21 @@ final class Storage {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
@ -444,10 +471,13 @@ final class Storage {
|
|
|
|
|
|
|
|
|
|
// 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 operate(in: .contentPath) { contentPath in
|
|
|
|
|
let subFolder = contentPath.appending(path: folder, directoryHint: .isDirectory)
|
|
|
|
|
let filesToDelete = try files(in: subFolder)
|
|
|
|
|
try withScopedContent(folder: folder) { folderUrl in
|
|
|
|
|
let filesToDelete = try files(in: folderUrl)
|
|
|
|
|
.filter { !fileSet.contains($0.lastPathComponent) }
|
|
|
|
|
|
|
|
|
|
for file in filesToDelete {
|
|
|
|
@ -457,10 +487,33 @@ final class Storage {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
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 {
|
|
|
|
|
try operate(in: .contentPath) { contentPath in
|
|
|
|
|
let url = contentPath.appending(path: relativePath, directoryHint: .notDirectory)
|
|
|
|
|
let data = try encoder.encode(value)
|
|
|
|
|
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 {
|
|
|
|
@ -475,6 +528,7 @@ final class Storage {
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
print("Writing new file \(url.path())")
|
|
|
|
|
try url.ensureParentFolderExistence()
|
|
|
|
|
}
|
|
|
|
|
try data.write(to: url)
|
|
|
|
|
print("Saved file \(url.path())")
|
|
|
|
@ -482,84 +536,88 @@ final class Storage {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
Encode a value and write it to a file, if the content changed
|
|
|
|
|
*/
|
|
|
|
|
private func write<T>(_ 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
|
|
|
|
|
- Note: This function requires a security scope for the content path
|
|
|
|
|
*/
|
|
|
|
|
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
|
|
|
|
|
private func read<T>(at relativePath: String, defaultValue: T? = nil) throws -> T where T: Decodable {
|
|
|
|
|
guard let data = try readData(at: relativePath) else {
|
|
|
|
|
guard let defaultValue else {
|
|
|
|
|
throw StorageAccessError.fileNotFound(relativePath)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
print("Writing new file \(file.path())")
|
|
|
|
|
return defaultValue
|
|
|
|
|
}
|
|
|
|
|
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<T>(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<T>(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<T>(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
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
|
|
- 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
|
|
|
|
|
}
|
|
|
|
|
return try Data(contentsOf: url)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func getFiles(in folder: URL) throws -> [URL] {
|
|
|
|
|
try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
|
|
|
|
|
.filter { !$0.hasDirectoryPath }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func existingFiles(in folder: String) throws -> [URL] {
|
|
|
|
|
try withScopedContent(folder: folder, getFiles)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
|
|
- 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 withScopedContent(folder: folder) { folderUrl in
|
|
|
|
|
try getFiles(in: folderUrl)
|
|
|
|
|
.filter { $0.pathExtension.lowercased() == "json" }
|
|
|
|
|
.reduce(into: [:]) { items, url in
|
|
|
|
|
let id = url.deletingPathExtension().lastPathComponent
|
|
|
|
|
let data = try Data(contentsOf: url)
|
|
|
|
|
items[id] = try decoder.decode(T.self, from: data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
|
|
- 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.ensureParentFolderExistence()
|
|
|
|
|
try fm.copyItem(at: file, to: destination)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|