Improve storage
This commit is contained in:
393
CHDataManagement/Storage/SecurityBookmark.swift
Normal file
393
CHDataManagement/Storage/SecurityBookmark.swift
Normal file
@ -0,0 +1,393 @@
|
||||
import Foundation
|
||||
|
||||
struct SecurityBookmark {
|
||||
|
||||
enum OverwriteBehaviour {
|
||||
case skip
|
||||
case write
|
||||
case writeIfChanged
|
||||
case fail
|
||||
}
|
||||
|
||||
let url: URL
|
||||
|
||||
let isStale: Bool
|
||||
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
private let fm = FileManager.default
|
||||
|
||||
init(url: URL, isStale: Bool) {
|
||||
self.url = url
|
||||
self.isStale = isStale
|
||||
|
||||
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
}
|
||||
|
||||
// MARK: Write
|
||||
|
||||
/**
|
||||
Write the data of an encodable value to a relative path in the content folder,
|
||||
or delete the file if nil is passed.
|
||||
*/
|
||||
func encode<T>(_ value: T?, to relativePath: String) -> Bool where T: Encodable {
|
||||
guard let value else {
|
||||
return deleteFile(at: relativePath)
|
||||
}
|
||||
return encode(value, to: relativePath)
|
||||
}
|
||||
|
||||
/**
|
||||
Write the data of an encodable value to a relative path in the content folder
|
||||
*/
|
||||
func encode<T>(_ value: T, to relativePath: String) -> Bool where T: Encodable {
|
||||
let data: Data
|
||||
do {
|
||||
data = try encoder.encode(value)
|
||||
} catch {
|
||||
print("Failed to encode \(value): \(error)")
|
||||
return false
|
||||
}
|
||||
return write(data, to: relativePath)
|
||||
}
|
||||
|
||||
func write(_ string: String,
|
||||
to relativePath: String,
|
||||
createParentFolder: Bool = true,
|
||||
ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool {
|
||||
guard let data = string.data(using: .utf8) else {
|
||||
return false
|
||||
}
|
||||
return write(data, to: relativePath, createParentFolder: createParentFolder, ifFileExists: overwrite)
|
||||
}
|
||||
|
||||
func write(_ data: Data,
|
||||
to relativePath: String,
|
||||
createParentFolder: Bool = true,
|
||||
ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool {
|
||||
perform { url in
|
||||
let file = url.appending(path: relativePath, directoryHint: .notDirectory)
|
||||
|
||||
if exists(file) {
|
||||
switch overwrite {
|
||||
case .fail: return false
|
||||
case .skip: return true
|
||||
case .write: break
|
||||
case .writeIfChanged:
|
||||
if let existingData = try? Data(contentsOf: file),
|
||||
existingData == data {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
do {
|
||||
try createParentIfNeeded(of: file)
|
||||
try data.write(to: file)
|
||||
} catch {
|
||||
print("Failed to write to file \(url.path()): \(error)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func create(folder: String) -> Bool {
|
||||
with(relativePath: folder, perform: create)
|
||||
}
|
||||
|
||||
// MARK: Read
|
||||
|
||||
func hasFile(at relativePath: String) -> Bool {
|
||||
with(relativePath: relativePath, perform: exists)
|
||||
}
|
||||
|
||||
func readString(at relativePath: String) -> String? {
|
||||
guard let data = readData(at: relativePath) else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func readData(at relativePath: String) -> Data? {
|
||||
with(relativePath: relativePath) { file in
|
||||
guard exists(file) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try Data(contentsOf: file)
|
||||
} catch {
|
||||
print("Storage: Failed to read file \(relativePath): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decode<T>(at relativePath: String) -> T? where T: Decodable {
|
||||
guard let data = readData(at: relativePath) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
print("Failed to decode file \(relativePath): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Modify
|
||||
|
||||
func move(_ relativeSource: String,
|
||||
to relativeDestination: String,
|
||||
failIfMissing: Bool = true,
|
||||
createParentFolder: Bool = true,
|
||||
ifFileExists overwrite: OverwriteBehaviour = .fail) -> Bool {
|
||||
with(relativePath: relativeSource) { source in
|
||||
if !exists(source) {
|
||||
return failIfMissing
|
||||
}
|
||||
|
||||
let destination = url.appending(path: relativeDestination)
|
||||
if exists(destination) {
|
||||
switch overwrite {
|
||||
case .fail: return false
|
||||
case .skip: return true
|
||||
case .write: break
|
||||
case .writeIfChanged:
|
||||
if let existingData = try? Data(contentsOf: destination),
|
||||
let newData = try? Data(contentsOf: source),
|
||||
existingData == newData {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
do {
|
||||
if createParentFolder {
|
||||
try createParentIfNeeded(of: destination)
|
||||
}
|
||||
try fm.moveItem(at: source, to: destination)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to move \(source.path()) to \(destination.path())")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copy(externalFile: URL,
|
||||
to relativePath: String,
|
||||
createParentFolder: Bool = true,
|
||||
ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool {
|
||||
with(relativePath: relativePath) { destination in
|
||||
do {
|
||||
if destination.exists {
|
||||
switch overwrite {
|
||||
case .fail: return false
|
||||
case .skip: return true
|
||||
case .write: break
|
||||
case .writeIfChanged:
|
||||
if let existingData = try? Data(contentsOf: destination),
|
||||
let newData = try? Data(contentsOf: externalFile),
|
||||
existingData == newData {
|
||||
return true
|
||||
}
|
||||
}
|
||||
try fm.removeItem(at: destination)
|
||||
}
|
||||
try createParentIfNeeded(of: destination)
|
||||
try fm.copyItem(at: externalFile, to: destination)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to copy \(externalFile.path()) to \(destination.path())")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteFile(at relativePath: String) -> Bool {
|
||||
with(relativePath: relativePath) { file in
|
||||
guard exists(file) else {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try fm.removeItem(at: file)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to delete file \(file.path()): \(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
|
||||
*/
|
||||
func deleteFiles(in relativePath: String, notIn fileSet: Set<String>) -> [String]? {
|
||||
with(relativePath: relativePath) { folder in
|
||||
if !exists(folder) {
|
||||
return []
|
||||
}
|
||||
guard let files = files(in: folder) else {
|
||||
return []
|
||||
}
|
||||
return files.compactMap { file in
|
||||
guard !fileSet.contains(file.lastPathComponent) else {
|
||||
return nil
|
||||
}
|
||||
guard remove(file) else {
|
||||
return nil
|
||||
}
|
||||
return file.lastPathComponent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Transfer
|
||||
|
||||
func transfer(file sourcePath: String,
|
||||
to relativePath: String,
|
||||
of scope: SecurityBookmark,
|
||||
createParentFolder: Bool = true,
|
||||
ifFileExists: OverwriteBehaviour = .writeIfChanged) -> Bool {
|
||||
with(relativePath: sourcePath) { source in
|
||||
scope.copy(
|
||||
externalFile: source,
|
||||
to: relativePath,
|
||||
createParentFolder: createParentFolder,
|
||||
ifFileExists: ifFileExists)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Batch operations
|
||||
|
||||
func fileNames(inRelativeFolder relativePath: String) -> [String]? {
|
||||
files(inRelativeFolder: relativePath)?.map { $0.lastPathComponent }
|
||||
}
|
||||
|
||||
func files(inRelativeFolder relativePath: String) -> [URL]? {
|
||||
with(relativePath: relativePath) { folder in
|
||||
files(in: folder)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
- Note: This function requires a security scope for the content path
|
||||
*/
|
||||
func decodeJsonFiles<T>(in relativeFolder: String) -> [String : T]? where T: Decodable {
|
||||
with(relativePath: relativeFolder) { folder in
|
||||
guard let files = files(in: folder) else {
|
||||
return nil
|
||||
}
|
||||
return files.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)")
|
||||
return
|
||||
}
|
||||
do {
|
||||
items[id] = try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
print("Storage: Failed to decode file \(url.path()): \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Generic operations
|
||||
|
||||
func with(relativePath: String, perform operation: (URL) -> Bool) -> Bool {
|
||||
perform { operation($0.appending(path: relativePath)) }
|
||||
}
|
||||
|
||||
func with<T>(relativePath: String, perform operation: (URL) -> T?) -> T? {
|
||||
perform { operation($0.appending(path: relativePath)) }
|
||||
}
|
||||
|
||||
/**
|
||||
Run an operation in the security scope of a url.
|
||||
*/
|
||||
func perform(_ operation: (URL) -> Bool) -> Bool {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
print("Failed to start security scope")
|
||||
return false
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
return operation(url)
|
||||
}
|
||||
|
||||
/**
|
||||
Run an operation in the content folder
|
||||
*/
|
||||
func perform<T>(_ operation: (URL) -> T?) -> T? {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
print("Failed to start security scope")
|
||||
return nil
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
return operation(url)
|
||||
}
|
||||
|
||||
// MARK: Unscoped helpers
|
||||
|
||||
private func create(folder: URL) -> Bool {
|
||||
do {
|
||||
try createIfNeeded(folder)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to create folder \(folder.path())")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func exists(_ url: URL) -> Bool {
|
||||
fm.fileExists(atPath: url.path())
|
||||
}
|
||||
|
||||
private func remove(_ file: URL) -> Bool {
|
||||
guard exists(url) else {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try fm.removeItem(at: file)
|
||||
} catch {
|
||||
print("Failed to remove \(file.path()): \(error)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func createParentIfNeeded(of file: URL) throws {
|
||||
try createIfNeeded(file.deletingLastPathComponent())
|
||||
}
|
||||
|
||||
private func createIfNeeded(_ folder: URL) throws {
|
||||
if !exists(folder) {
|
||||
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func files(in folder: URL) throws -> [URL] {
|
||||
try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
|
||||
.filter { !$0.hasDirectoryPath }
|
||||
}
|
||||
|
||||
|
||||
private func files(in folder: URL) -> [URL]? {
|
||||
do {
|
||||
return try files(in: folder).filter { !$0.lastPathComponent.hasPrefix(".") }
|
||||
} catch {
|
||||
print("Failed to read list of files in \(folder.path())")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
@ -24,88 +24,38 @@ final class Storage: ObservableObject {
|
||||
|
||||
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
|
||||
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
private let fm = FileManager.default
|
||||
@Published
|
||||
var contentScope: SecurityBookmark?
|
||||
|
||||
@Published
|
||||
var hasContentFolders = false
|
||||
|
||||
@Published
|
||||
var contentPath: URL?
|
||||
|
||||
@Published
|
||||
var outputPath: URL?
|
||||
|
||||
@Published
|
||||
var contentPathUrlIsStale = false
|
||||
|
||||
@Published
|
||||
var outputPathUrlIsStale = false
|
||||
var outputScope: SecurityBookmark?
|
||||
|
||||
/**
|
||||
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
|
||||
loadContentScope()
|
||||
loadOutputScope()
|
||||
}
|
||||
|
||||
// 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"
|
||||
@ -123,68 +73,64 @@ final class Storage: ObservableObject {
|
||||
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 {
|
||||
func save(pageContent: String, for pageId: String, language: ContentLanguage) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = pageContentPath(page: pageId, language: language)
|
||||
try writeIfChanged(content: pageContent, to: path)
|
||||
return contentScope.write(pageContent, to: path)
|
||||
}
|
||||
|
||||
func save(pageMetadata: PageFile, for pageId: String) throws {
|
||||
func save(pageMetadata: PageFile, for pageId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = pageMetadataPath(page: pageId)
|
||||
try writeIfChanged(pageMetadata, to: path)
|
||||
return contentScope.encode(pageMetadata, to: path)
|
||||
}
|
||||
|
||||
func loadAllPages() throws -> [String : PageFile] {
|
||||
try decodeAllFromJson(in: pagesFolderName)
|
||||
func loadAllPages() -> [String : PageFile]? {
|
||||
contentScope?.decodeJsonFiles(in: pagesFolderName)
|
||||
}
|
||||
|
||||
func pageContent(for pageId: String, language: ContentLanguage) throws -> String {
|
||||
func pageContent(for pageId: String, language: ContentLanguage) -> String? {
|
||||
guard let contentScope else { return nil }
|
||||
let path = pageContentPath(page: pageId, language: language)
|
||||
return try readString(at: path, defaultValue: "")
|
||||
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]) throws {
|
||||
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) })
|
||||
}
|
||||
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)")
|
||||
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
|
||||
@ -193,44 +139,37 @@ final class Storage: ObservableObject {
|
||||
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 {
|
||||
func save(post: PostFile, for postId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = postFilePath(post: postId)
|
||||
try writeIfChanged(post, to: path)
|
||||
return contentScope.encode(post, to: path)
|
||||
}
|
||||
|
||||
func loadAllPosts() throws -> [String : PostFile] {
|
||||
try decodeAllFromJson(in: postsFolderName)
|
||||
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]) throws {
|
||||
func deletePostFiles(notIn posts: [String]) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let files = Set(posts.map(postFileName))
|
||||
try deleteFiles(in: postsFolderName, notIn: files)
|
||||
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 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)
|
||||
}
|
||||
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
|
||||
@ -239,55 +178,54 @@ final class Storage: ObservableObject {
|
||||
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 {
|
||||
private func tagFilePath(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 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() throws -> [String : TagFile] {
|
||||
try decodeAllFromJson(in: tagsFolderName)
|
||||
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]) throws {
|
||||
func deleteTagFiles(notIn tags: [String]) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let files = Set(tags.map { $0 + ".json" })
|
||||
try deleteFiles(in: tagsFolderName, notIn: files)
|
||||
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() throws -> [FileDescriptions] {
|
||||
guard let descriptions: [FileDescriptions] = try read(at: fileDescriptionFilename) else {
|
||||
print("Storage: No file descriptions loaded")
|
||||
return []
|
||||
}
|
||||
return descriptions
|
||||
func loadFileDescriptions() -> [FileDescriptions]? {
|
||||
contentScope?.decode(at: fileDescriptionFilename)
|
||||
}
|
||||
|
||||
func save(fileDescriptions: [FileDescriptions]) throws {
|
||||
try writeIfChanged(fileDescriptions, to: fileDescriptionFilename)
|
||||
func save(fileDescriptions: [FileDescriptions]) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
return contentScope.encode(fileDescriptions, to: fileDescriptionFilename)
|
||||
}
|
||||
|
||||
// MARK: Tag overview
|
||||
|
||||
func loadTagOverview() throws -> TagOverviewFile? {
|
||||
try read(at: tagOverviewFileName)
|
||||
func loadTagOverview() -> TagOverviewFile? {
|
||||
contentScope?.decode(at: tagOverviewFileName)
|
||||
}
|
||||
|
||||
func save(tagOverview: TagOverviewFile?) throws {
|
||||
try writeIfChanged(tagOverview, to: tagOverviewFileName)
|
||||
func save(tagOverview: TagOverviewFile?) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
return contentScope.encode(tagOverview, to: tagOverviewFileName)
|
||||
}
|
||||
|
||||
// MARK: Files
|
||||
@ -296,185 +234,127 @@ final class Storage: ObservableObject {
|
||||
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 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 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 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) 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 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() throws -> [String] {
|
||||
try inContentFolder(relativePath: filesFolderName) { try $0.containedFileNames() }
|
||||
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]) throws {
|
||||
try deleteFiles(in: filesFolderName, notIn: Set(fileSet))
|
||||
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) throws -> String {
|
||||
func fileContent(for fileId: String) -> String? {
|
||||
guard let contentScope else { return nil }
|
||||
let path = filePath(file: fileId)
|
||||
return try readString(at: path)
|
||||
return contentScope.readString(at: path)
|
||||
}
|
||||
|
||||
func fileData(for fileId: String) throws -> Data {
|
||||
func fileData(for fileId: String) -> Data? {
|
||||
guard let contentScope else { return nil }
|
||||
let path = filePath(file: fileId)
|
||||
return try readExistingFile(at: path)
|
||||
return contentScope.readData(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 loadExternalFileList() -> [String]? {
|
||||
guard let contentScope else { return nil }
|
||||
return contentScope.decode(at: externalFileListName)
|
||||
}
|
||||
|
||||
func save(externalFileList: [String]) throws {
|
||||
try writeIfChanged(externalFileList.sorted(), to: externalFileListName)
|
||||
func save(externalFileList: [String]) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
return contentScope.encode(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 loadSettings() -> SettingsFile? {
|
||||
guard let contentScope else { return nil }
|
||||
return contentScope.decode(at: settingsDataFileName)
|
||||
}
|
||||
|
||||
func save(settings: SettingsFile) throws {
|
||||
try writeIfChanged(settings, to: settingsDataFileName)
|
||||
func save(settings: SettingsFile) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
return contentScope.encode(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")
|
||||
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 [:]
|
||||
}
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func write(in scope: SecurityScopeBookmark, operation: (URL) -> Bool) -> Bool {
|
||||
do {
|
||||
return try operate(in: scope, operation: operation)
|
||||
} catch {
|
||||
print(error)
|
||||
return false
|
||||
}
|
||||
// MARK: Output files
|
||||
|
||||
func write(_ content: String, to relativeOutputPath: String) -> Bool {
|
||||
guard let outputScope else { return false }
|
||||
return outputScope.write(content, to: relativeOutputPath)
|
||||
}
|
||||
|
||||
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)
|
||||
func write(_ data: Data, to relativeOutputPath: String) -> Bool {
|
||||
guard let outputScope else { return false }
|
||||
return outputScope.write(data, to: relativeOutputPath)
|
||||
}
|
||||
|
||||
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)
|
||||
func hasFileInOutputFolder(_ relativeOutputPath: String) -> Bool {
|
||||
guard let outputScope else { return false }
|
||||
return outputScope.hasFile(at: relativeOutputPath)
|
||||
}
|
||||
|
||||
// MARK: Security bookmarks
|
||||
@ -493,10 +373,7 @@ final class Storage: ObservableObject {
|
||||
return false
|
||||
}
|
||||
UserDefaults.standard.set(bookmarkData, forKey: contentPathBookmarkKey)
|
||||
guard loadContentPath() else {
|
||||
return false
|
||||
}
|
||||
return createFolderStructure()
|
||||
return loadContentScope()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -508,32 +385,36 @@ final class Storage: ObservableObject {
|
||||
- Returns: `true`, if the url was loaded.
|
||||
*/
|
||||
@discardableResult
|
||||
private func loadContentPath() -> Bool {
|
||||
private func loadContentScope() -> Bool {
|
||||
guard let bookmarkData = UserDefaults.standard.data(forKey: contentPathBookmarkKey) else {
|
||||
print("No content path bookmark found")
|
||||
contentPath = nil
|
||||
contentPathUrlIsStale = false
|
||||
contentScope = nil
|
||||
return false
|
||||
}
|
||||
let (url, isStale) = decode(bookmark: bookmarkData)
|
||||
contentPath = url
|
||||
contentPathUrlIsStale = isStale
|
||||
return url != nil
|
||||
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)
|
||||
contentPath = nil
|
||||
contentPathUrlIsStale = false
|
||||
hasContentFolders = false
|
||||
outputPath = nil
|
||||
outputPathUrlIsStale = false
|
||||
contentScope = nil
|
||||
outputScope = nil
|
||||
}
|
||||
|
||||
/**
|
||||
Decode the security scope data to get a url.
|
||||
*/
|
||||
private func decode(bookmark: Data) -> (url: URL?, isStale: Bool) {
|
||||
private func decode(bookmark: Data) -> SecurityBookmark? {
|
||||
do {
|
||||
var isStale = false
|
||||
let url = try URL(
|
||||
@ -541,10 +422,10 @@ final class Storage: ObservableObject {
|
||||
options: .withSecurityScope,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale)
|
||||
return (url, isStale)
|
||||
return SecurityBookmark(url: url, isStale: isStale)
|
||||
} catch {
|
||||
print("Failed to resolve bookmark: \(error)")
|
||||
return (nil, false)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -562,257 +443,14 @@ final class Storage: ObservableObject {
|
||||
|
||||
@discardableResult
|
||||
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")
|
||||
guard let contentScope,
|
||||
let bookmarkData = encode(url: outputPath),
|
||||
contentScope.write(bookmarkData, to: outputPathFileName) else {
|
||||
outputScope = nil
|
||||
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)
|
||||
}
|
||||
// TODO: Check if stale
|
||||
outputScope = SecurityBookmark(url: outputPath, isStale: false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user