krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgDocument/FileStorage.swift

10c33661279a1fb0e8d3dd51509db9637778769e
orgstar/Sources/OrgDocument/FileStorage.swift history · blame · raw

92 lines · 4275 bytes

 1import CryptoKit
 2import Foundation
 3
 4/// Reads and replaces files under `NSFileCoordinator`, so iCloud and other coordinating
 5/// writers see a consistent file.
 6public struct CoordinatedFileSystem: FileSystem {
 7    public init() {}
 8
 9    public func read(_ url: URL) throws -> [UInt8]? {
10        try coordinate(reading: url) { url in
11            FileManager.default.fileExists(atPath: url.path) ? [UInt8](try Data(contentsOf: url)) : nil
12        }
13    }
14
15    public func replace(_ url: URL, with bytes: [UInt8]) throws -> [UInt8]? {
16        try coordinate(writing: url) { url in
17            let manager = FileManager.default
18            let folder = url.deletingLastPathComponent()
19            let temporary = folder.appendingPathComponent(".\(url.lastPathComponent).orgstar-\(UUID().uuidString)")
20            try Data(bytes).write(to: temporary)
21            guard manager.fileExists(atPath: url.path) else {
22                try manager.moveItem(at: temporary, to: url)
23                return nil
24            }
25            // The backup is the file as it was at the moment of replacement, including any
26            // write that landed after our last check.
27            let backupName = ".\(url.lastPathComponent).orgstar-backup-\(UUID().uuidString)"
28            _ = try manager.replaceItemAt(url, withItemAt: temporary, backupItemName: backupName, options: .withoutDeletingBackupItem)
29            let backup = folder.appendingPathComponent(backupName)
30            defer { try? manager.removeItem(at: backup) }
31            return [UInt8](try Data(contentsOf: backup))
32        }
33    }
34
35    private func coordinate<T>(reading url: URL, _ body: (URL) throws -> T) throws -> T {
36        var coordinationError: NSError?
37        var result: Result<T, Error>?
38        NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: url, options: [], error: &coordinationError) { url in
39            result = Result { try body(url) }
40        }
41        if let coordinationError { throw coordinationError }
42        return try result!.get()
43    }
44
45    private func coordinate<T>(writing url: URL, _ body: (URL) throws -> T) throws -> T {
46        var coordinationError: NSError?
47        var result: Result<T, Error>?
48        NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: url, options: .forReplacing, error: &coordinationError) { url in
49            result = Result { try body(url) }
50        }
51        if let coordinationError { throw coordinationError }
52        return try result!.get()
53    }
54}
55
56/// Keeps the last `limit` displaced versions per file in `directory/<hash of path>/`.
57public struct FileRecoveryStore: RecoveryStore {
58    public let directory: URL
59    public let limit: Int
60
61    public init(directory: URL, limit: Int = 20) {
62        self.directory = directory
63        self.limit = limit
64    }
65
66    public func folder(for url: URL) -> URL {
67        let digest = SHA256.hash(data: Data(url.standardizedFileURL.path.utf8))
68        let name = digest.prefix(8).map { String(format: "%02x", $0) }.joined()
69        return directory.appendingPathComponent(name, isDirectory: true)
70    }
71
72    public func keep(_ bytes: [UInt8], for url: URL, label: String) throws {
73        let folder = folder(for: url)
74        let manager = FileManager.default
75        try manager.createDirectory(at: folder, withIntermediateDirectories: true)
76        // Zero-padded wall-clock nanoseconds sort by time; the UUID keeps same-instant names apart.
77        let stamp = String(format: "%020llu", UInt64(Date().timeIntervalSince1970 * 1_000_000_000))
78        let name = "\(stamp)-\(label)-\(UUID().uuidString.prefix(8))-\(url.lastPathComponent)"
79        try Data(bytes).write(to: folder.appendingPathComponent(name))
80        let kept = try manager.contentsOfDirectory(atPath: folder.path).sorted()
81        for old in kept.dropLast(limit) {
82            try manager.removeItem(at: folder.appendingPathComponent(old))
83        }
84    }
85
86    /// Kept versions, oldest first.
87    public func versions(for url: URL) throws -> [URL] {
88        let folder = folder(for: url)
89        guard FileManager.default.fileExists(atPath: folder.path) else { return [] }
90        return try FileManager.default.contentsOfDirectory(atPath: folder.path).sorted().map { folder.appendingPathComponent($0) }
91    }
92}