Save automatically, improve mocks
This commit is contained in:
31
CHDataManagement/Storage/ChangeObservableItem.swift
Normal file
31
CHDataManagement/Storage/ChangeObservableItem.swift
Normal file
@ -0,0 +1,31 @@
|
||||
import Combine
|
||||
|
||||
protocol ChangeObservableItem: ObservableObject {
|
||||
|
||||
var cancellables: Set<AnyCancellable> { get set }
|
||||
|
||||
func needsSaving()
|
||||
}
|
||||
|
||||
protocol ObservableContentItem: ChangeObservableItem {
|
||||
|
||||
var content: Content { get }
|
||||
}
|
||||
|
||||
extension ObservableContentItem {
|
||||
|
||||
func needsSaving() {
|
||||
content.needsSave()
|
||||
}
|
||||
}
|
||||
|
||||
extension ChangeObservableItem {
|
||||
|
||||
func observeChanges() {
|
||||
objectWillChange
|
||||
.sink { [weak self] _ in
|
||||
self?.needsSaving()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
final class ErrorPrinter {
|
||||
|
||||
}
|
||||
|
||||
extension ErrorPrinter: SecurityBookmarkErrorDelegate {
|
||||
|
||||
func securityBookmark(error: String) {
|
||||
print(error)
|
||||
}
|
||||
}
|
35
CHDataManagement/Storage/SaveState.swift
Normal file
35
CHDataManagement/Storage/SaveState.swift
Normal file
@ -0,0 +1,35 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUICore
|
||||
|
||||
enum SaveState {
|
||||
case storageNotInitialized
|
||||
case isSaved
|
||||
case needsSave
|
||||
case failedToSave
|
||||
|
||||
var symbol: SFSymbol {
|
||||
switch self {
|
||||
case .storageNotInitialized:
|
||||
return .folderCircleFill
|
||||
case .isSaved:
|
||||
return .checkmarkCircleFill
|
||||
case .needsSave:
|
||||
return .hourglassCircleFill
|
||||
case .failedToSave:
|
||||
return .exclamationmarkTriangleFill
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .storageNotInitialized:
|
||||
return .red
|
||||
case .isSaved:
|
||||
return .green
|
||||
case .needsSave:
|
||||
return .yellow
|
||||
case .failedToSave:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,7 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
protocol SecurityBookmarkErrorDelegate: AnyObject {
|
||||
|
||||
func securityBookmark(error: String)
|
||||
}
|
||||
typealias StorageErrorCallback = (StorageError) -> Void
|
||||
|
||||
struct SecurityBookmark {
|
||||
|
||||
@ -25,7 +22,7 @@ struct SecurityBookmark {
|
||||
|
||||
private let fm = FileManager.default
|
||||
|
||||
weak var delegate: SecurityBookmarkErrorDelegate?
|
||||
var errorNotification: StorageErrorCallback?
|
||||
|
||||
init(url: URL, isStale: Bool) {
|
||||
self.url = url
|
||||
@ -34,6 +31,14 @@ struct SecurityBookmark {
|
||||
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) {
|
||||
@ -65,7 +70,7 @@ struct SecurityBookmark {
|
||||
do {
|
||||
data = try encoder.encode(value)
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to encode \(value): \(error)")
|
||||
reportError("Failed to encode \(value): \(error)")
|
||||
return false
|
||||
}
|
||||
return write(data, to: relativePath)
|
||||
@ -79,7 +84,7 @@ struct SecurityBookmark {
|
||||
createParentFolder: Bool = true,
|
||||
ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool {
|
||||
guard let data = content.data(using: .utf8) else {
|
||||
delegate?.securityBookmark(error: "Failed to encode content to write to \(relativePath)")
|
||||
reportError("Failed to encode content to write to \(relativePath)")
|
||||
return false
|
||||
}
|
||||
return write(data, to: relativePath, createParentFolder: createParentFolder, ifFileExists: overwrite)
|
||||
@ -95,7 +100,7 @@ struct SecurityBookmark {
|
||||
if exists(file) {
|
||||
switch overwrite {
|
||||
case .fail:
|
||||
delegate?.securityBookmark(error: "Failed to write \(relativePath): File exists")
|
||||
reportError("Failed to write \(relativePath): File exists")
|
||||
return false
|
||||
case .skip: return true
|
||||
case .write: break
|
||||
@ -110,7 +115,7 @@ struct SecurityBookmark {
|
||||
try createParentIfNeeded(of: file)
|
||||
try data.write(to: file)
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to write \(relativePath): \(error)")
|
||||
reportError("Failed to write \(relativePath): \(error)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@ -136,7 +141,7 @@ struct SecurityBookmark {
|
||||
return nil
|
||||
}
|
||||
guard let result = String(data: data, encoding: .utf8) else {
|
||||
delegate?.securityBookmark(error: "Failed to read \(relativePath): invalid UTF-8")
|
||||
reportError("Failed to read \(relativePath): invalid UTF-8")
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
@ -150,7 +155,7 @@ struct SecurityBookmark {
|
||||
do {
|
||||
return try Data(contentsOf: file)
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to read \(relativePath) \(error)")
|
||||
reportError("Failed to read \(relativePath) \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -163,7 +168,7 @@ struct SecurityBookmark {
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to decode \(relativePath): \(error)")
|
||||
reportError("Failed to decode \(relativePath): \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -178,7 +183,7 @@ struct SecurityBookmark {
|
||||
with(relativePath: relativeSource) { source in
|
||||
if !exists(source) {
|
||||
if !failIfMissing { return true }
|
||||
delegate?.securityBookmark(error: "Failed to move \(relativeSource): File does not exist")
|
||||
reportError("Failed to move \(relativeSource): File does not exist")
|
||||
return false
|
||||
}
|
||||
|
||||
@ -186,7 +191,7 @@ struct SecurityBookmark {
|
||||
if exists(destination) {
|
||||
switch overwrite {
|
||||
case .fail:
|
||||
delegate?.securityBookmark(error: "Failed to move to \(relativeDestination): File already exists")
|
||||
reportError("Failed to move to \(relativeDestination): File already exists")
|
||||
return false
|
||||
case .skip: return true
|
||||
case .write: break
|
||||
@ -205,7 +210,7 @@ struct SecurityBookmark {
|
||||
try fm.moveItem(at: source, to: destination)
|
||||
return true
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to move \(source.path()) to \(destination.path()): \(error)")
|
||||
reportError("Failed to move \(source.path()) to \(destination.path()): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -220,7 +225,7 @@ struct SecurityBookmark {
|
||||
if destination.exists {
|
||||
switch overwrite {
|
||||
case .fail:
|
||||
delegate?.securityBookmark(error: "Failed to copy to \(relativePath): File already exists")
|
||||
reportError("Failed to copy to \(relativePath): File already exists")
|
||||
return false
|
||||
case .skip: return true
|
||||
case .write: break
|
||||
@ -237,7 +242,7 @@ struct SecurityBookmark {
|
||||
try fm.copyItem(at: externalFile, to: destination)
|
||||
return true
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to copy \(externalFile.path()) to \(relativePath): \(error)")
|
||||
reportError("Failed to copy \(externalFile.path()) to \(relativePath): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -252,7 +257,7 @@ struct SecurityBookmark {
|
||||
try fm.removeItem(at: file)
|
||||
return true
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to delete \(relativePath): \(error)")
|
||||
reportError("Failed to delete \(relativePath): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -326,13 +331,13 @@ struct SecurityBookmark {
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to read \(url.path()): \(error)")
|
||||
reportError("Failed to read \(url.path()): \(error)")
|
||||
return
|
||||
}
|
||||
do {
|
||||
items[id] = try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to decode \(url.path()): \(error)")
|
||||
reportError("Failed to decode \(url.path()): \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -352,7 +357,7 @@ struct SecurityBookmark {
|
||||
func with<T>(relativePath: String, perform operation: (URL) async -> T?) async -> T? {
|
||||
let path = url.appending(path: relativePath.withLeadingSlashRemoved)
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
delegate?.securityBookmark(error: "Failed to start security scope")
|
||||
reportError("Failed to start security scope")
|
||||
return nil
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
@ -364,7 +369,7 @@ struct SecurityBookmark {
|
||||
*/
|
||||
func perform(_ operation: (URL) -> Bool) -> Bool {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
delegate?.securityBookmark(error: "Failed to start security scope")
|
||||
reportError("Failed to start security scope")
|
||||
return false
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
@ -376,7 +381,7 @@ struct SecurityBookmark {
|
||||
*/
|
||||
func perform<T>(_ operation: (URL) -> T?) -> T? {
|
||||
guard url.startAccessingSecurityScopedResource() else {
|
||||
delegate?.securityBookmark(error: "Failed to start security scope")
|
||||
reportError("Failed to start security scope")
|
||||
return nil
|
||||
}
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
@ -390,7 +395,7 @@ struct SecurityBookmark {
|
||||
try createIfNeeded(folder)
|
||||
return true
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to create folder \(folder.path())")
|
||||
reportError("Failed to create folder \(folder.path())")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -406,7 +411,7 @@ struct SecurityBookmark {
|
||||
do {
|
||||
try fm.removeItem(at: file)
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to delete \(file.path()): \(error)")
|
||||
reportError("Failed to delete \(file.path()): \(error)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@ -432,7 +437,7 @@ struct SecurityBookmark {
|
||||
do {
|
||||
return try files(in: folder).filter { !$0.lastPathComponent.hasPrefix(".") }
|
||||
} catch {
|
||||
delegate?.securityBookmark(error: "Failed to read list of files in \(folder.path())")
|
||||
reportError("Failed to read list of files in \(folder.path())")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
@ -43,6 +43,8 @@ final class Storage: ObservableObject {
|
||||
@Published
|
||||
var outputScope: SecurityBookmark?
|
||||
|
||||
var errorNotification: StorageErrorCallback?
|
||||
|
||||
/**
|
||||
Create the storage.
|
||||
*/
|
||||
@ -75,10 +77,10 @@ final class Storage: ObservableObject {
|
||||
return contentScope.write(pageContent, to: path)
|
||||
}
|
||||
|
||||
func save(pageMetadata: Page.Data, for pageId: String) -> Bool {
|
||||
func save(page: Page.Data, for pageId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = pageMetadataPath(page: pageId)
|
||||
return contentScope.encode(pageMetadata, to: path)
|
||||
return contentScope.encode(page, to: path)
|
||||
}
|
||||
|
||||
func loadAllPages() -> [String : Page.Data]? {
|
||||
@ -186,10 +188,10 @@ final class Storage: ObservableObject {
|
||||
tagsFolderName + "/" + tagFileName(tagId: tagId)
|
||||
}
|
||||
|
||||
func save(tagMetadata: Tag.Data, for tagId: String) -> Bool {
|
||||
func save(tag: Tag.Data, for tagId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = tagFilePath(tag: tagId)
|
||||
return contentScope.encode(tagMetadata, to: path)
|
||||
return contentScope.encode(tag, to: path)
|
||||
}
|
||||
|
||||
func loadAllTags() -> [String : Tag.Data]? {
|
||||
@ -338,10 +340,10 @@ final class Storage: ObservableObject {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func save(fileInfo: FileResource.Data, for fileId: String) -> Bool {
|
||||
func save(fileResource: FileResource.Data, for fileId: String) -> Bool {
|
||||
guard let contentScope else { return false }
|
||||
let path = fileInfoPath(file: fileId)
|
||||
return contentScope.encode(fileInfo, to: path)
|
||||
return contentScope.encode(fileResource, to: path)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -503,6 +505,10 @@ final class Storage: ObservableObject {
|
||||
return false
|
||||
}
|
||||
contentScope = decode(bookmark: bookmarkData)
|
||||
// Propagate errors
|
||||
contentScope?.errorNotification = { [weak self] error in
|
||||
self?.errorNotification?(error)
|
||||
}
|
||||
return contentScope != nil
|
||||
}
|
||||
|
||||
@ -513,6 +519,10 @@ final class Storage: ObservableObject {
|
||||
return false
|
||||
}
|
||||
outputScope = decode(bookmark: data)
|
||||
// Propagate errors
|
||||
outputScope?.errorNotification = { [weak self] error in
|
||||
self?.errorNotification?(error)
|
||||
}
|
||||
return outputScope != nil
|
||||
}
|
||||
|
||||
@ -562,6 +572,10 @@ final class Storage: ObservableObject {
|
||||
}
|
||||
// TODO: Check if stale
|
||||
outputScope = SecurityBookmark(url: outputPath, isStale: false)
|
||||
// Propagate errors
|
||||
outputScope?.errorNotification = { [weak self] error in
|
||||
self?.errorNotification?(error)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
38
CHDataManagement/Storage/StorageItem.swift
Normal file
38
CHDataManagement/Storage/StorageItem.swift
Normal file
@ -0,0 +1,38 @@
|
||||
|
||||
protocol StorageItem: AnyObject {
|
||||
|
||||
associatedtype Data: Equatable
|
||||
|
||||
var savedData: Data? { get set }
|
||||
|
||||
var data: Data { get }
|
||||
|
||||
func saveToDisk(_ data: Data) -> Bool
|
||||
}
|
||||
|
||||
extension StorageItem {
|
||||
|
||||
/**
|
||||
Get the data to save, if the object has changed
|
||||
*/
|
||||
func dataToSave() -> Data? {
|
||||
guard let savedData else {
|
||||
return data
|
||||
}
|
||||
if savedData != data {
|
||||
return data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveIfNeeded() -> Bool? {
|
||||
guard let data = dataToSave() else {
|
||||
return nil
|
||||
}
|
||||
guard saveToDisk(data) else {
|
||||
return false
|
||||
}
|
||||
savedData = data
|
||||
return true
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user