ChWebsiteApp/CHDataManagement/Storage/SecurityBookmark.swift
2025-02-16 14:53:00 +01:00

475 lines
15 KiB
Swift

import Foundation
import AppKit
typealias StorageErrorCallback = (StorageError) -> Void
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
var errorNotification: StorageErrorCallback?
init(url: URL, isStale: Bool) {
self.url = url
self.isStale = isStale
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
}
private func reportError(_ error: String) {
guard let errorNotification else {
print(error)
return
}
errorNotification(.init(message: error))
}
// MARK: Write
func openFinderWindow(relativePath: String) {
with(relativePath: relativePath) { path in
NSWorkspace.shared.activateFileViewerSelecting([path])
}
}
func fullPath(to relativePath: String) -> URL {
return url.appending(path: relativePath.withLeadingSlashRemoved, directoryHint: .notDirectory)
}
/**
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 {
reportError("Failed to encode \(value): \(error)")
return false
}
return write(data, to: relativePath)
}
/**
Write text to a file at the relative path.
*/
func write(_ content: String,
to relativePath: String,
createParentFolder: Bool = true,
ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool {
guard let data = content.data(using: .utf8) else {
reportError("Failed to encode content to write to \(relativePath)")
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 = fullPath(to: relativePath)
if exists(file) {
switch overwrite {
case .fail:
reportError("Failed to write \(relativePath): File exists")
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 {
reportError("Failed to write \(relativePath): \(error)")
return false
}
return true
}
}
func create(folder: String) -> Bool {
with(relativePath: folder, perform: create)
}
// MARK: Read
func size(of relativePath: String) -> Int? {
with(relativePath: relativePath) { $0.size }
}
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
}
guard let result = String(data: data, encoding: .utf8) else {
reportError("Failed to read \(relativePath): invalid UTF-8")
return nil
}
return result
}
func readData(at relativePath: String) -> Data? {
with(relativePath: relativePath) { file in
guard exists(file) else {
return nil
}
do {
return try Data(contentsOf: file)
} catch {
reportError("Failed to read \(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 {
reportError("Failed to decode \(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) {
if !failIfMissing { return true }
reportError("Failed to move \(relativeSource): File does not exist")
return false
}
let destination = url.appending(path: relativeDestination.withLeadingSlashRemoved)
if exists(destination) {
switch overwrite {
case .fail:
reportError("Failed to move to \(relativeDestination): File already exists")
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 {
reportError("Failed to move \(source.path()) to \(destination.path()): \(error)")
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:
reportError("Failed to copy to \(relativePath): File already exists")
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 {
reportError("Failed to copy \(externalFile.path()) to \(relativePath): \(error)")
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 {
reportError("Failed to delete \(relativePath): \(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, perform: files)
}
/**
- 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 {
reportError("Failed to read \(url.path()): \(error)")
return
}
do {
items[id] = try decoder.decode(T.self, from: data)
} catch {
reportError("Failed to decode \(url.path()): \(error)")
return
}
}
}
}
// MARK: Generic operations
func with(relativePath: String, perform operation: (URL) -> Bool) -> Bool {
perform { operation($0.appending(path: relativePath.withLeadingSlashRemoved)) }
}
func with<T>(relativePath: String, perform operation: (URL) -> T?) -> T? {
perform { operation($0.appending(path: relativePath.withLeadingSlashRemoved)) }
}
func with<T>(relativePath: String, perform operation: (URL) async -> T?) async -> T? {
let path = url.appending(path: relativePath.withLeadingSlashRemoved)
guard url.startAccessingSecurityScopedResource() else {
reportError("Failed to start security scope")
return nil
}
defer { url.stopAccessingSecurityScopedResource() }
return await operation(path)
}
/**
Run an operation in the security scope of a url.
*/
func perform(_ operation: (URL) -> Bool) -> Bool {
guard url.startAccessingSecurityScopedResource() else {
reportError("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 {
reportError("Failed to start security scope")
return nil
}
defer { url.stopAccessingSecurityScopedResource() }
return operation(url)
}
func getAllFiles() -> Set<String> {
guard url.startAccessingSecurityScopedResource() else {
reportError("Failed to start security scope")
return []
}
guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else {
reportError("Failed to get folder enumerator")
return []
}
var relativePaths = Set<String>()
let prefix = url.path().withTrailingSlash
for case let fileURL as URL in enumerator {
guard !fileURL.hasDirectoryPath else {
continue
}
let fullPath = fileURL.path()
guard fullPath.hasPrefix(prefix) else {
print("Expected prefix \(prefix) for \(fullPath)")
return []
}
let relativePath = fullPath.replacingOccurrences(of: prefix, with: "")
relativePaths.insert(relativePath)
}
url.stopAccessingSecurityScopedResource()
return relativePaths
}
// MARK: Unscoped helpers
private func create(folder: URL) -> Bool {
do {
try createIfNeeded(folder)
return true
} catch {
reportError("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 {
reportError("Failed to delete \(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 {
reportError("Failed to read list of files in \(folder.path())")
return nil
}
}
}