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
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user