445 lines
14 KiB
Swift
445 lines
14 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|