krz/orgstar

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

Sources/OrgWorkspace/Reconciler.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgWorkspace/Reconciler.swift history · blame · raw

101 lines · 4551 bytes

  1import Foundation
  2import OrgIndex
  3
  4public struct ReconcileReport: Sendable, Equatable {
  5    public var indexed: [String] = []
  6    public var touched: [String] = []
  7    public var moved: [String] = []
  8    public var removed: [String] = []
  9    public var unchanged = 0
 10    /// iCloud files asked to download; they are indexed once they arrive.
 11    public var placeholders: [URL] = []
 12}
 13
 14/// Brings the index in line with the files under a root. File events are only hints, so this
 15/// is what runs at launch, after dropped events, and for every batch of changed paths.
 16public struct Reconciler: Sendable {
 17    public let index: IndexStore
 18    public let settings: IndexSettings
 19
 20    public init(index: IndexStore, settings: IndexSettings) {
 21        self.index = index
 22        self.settings = settings
 23    }
 24
 25    /// Reconciles the whole root.
 26    public func reconcile(root: URL) throws -> ReconcileReport {
 27        let discovery = try WorkspaceScanner.scan(root)
 28        for placeholder in discovery.placeholders {
 29            try? FileManager.default.startDownloadingUbiquitousItem(at: placeholder)
 30        }
 31        var report = try reconcile(root: root, found: discovery.files, known: index.fileStates(root: root.path))
 32        report.placeholders = discovery.placeholders
 33        return report
 34    }
 35
 36    /// Reconciles only `paths` under the root, for a batch of file events.
 37    public func reconcile(root: URL, paths: [URL]) throws -> ReconcileReport {
 38        let wanted = Set(paths.map(\.standardizedFileURL.path))
 39        let known = try index.fileStates(root: root.path).filter { wanted.contains($0.key) }
 40        var found: [DiscoveredFile] = []
 41        for url in paths.map(\.standardizedFileURL) {
 42            guard case .file(let kind) = WorkspaceScanner.classify(url.lastPathComponent),
 43                  let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isDirectoryKey]),
 44                  values.isDirectory != true else { continue }
 45            found.append(DiscoveredFile(
 46                url: url, kind: kind, size: values.fileSize ?? 0,
 47                mtime: values.contentModificationDate?.timeIntervalSince1970 ?? 0
 48            ))
 49        }
 50        return try reconcile(root: root, found: found, known: known)
 51    }
 52
 53    private func reconcile(root: URL, found: [DiscoveredFile], known: [String: FileState]) throws -> ReconcileReport {
 54        var report = ReconcileReport()
 55        var change = IndexChange()
 56        var candidates: [(file: DiscoveredFile, bytes: [UInt8], hash: String)] = []
 57
 58        for file in found {
 59            let path = file.url.path
 60            if let state = known[path], state.size == file.size, state.mtime == file.mtime,
 61               state.settingsVersion == settings.version, state.kind == file.kind {
 62                report.unchanged += 1
 63                continue
 64            }
 65            guard let data = try? Data(contentsOf: file.url) else { continue }
 66            let bytes = [UInt8](data)
 67            let hash = FileRecord.hash(bytes)
 68            if let state = known[path], state.hash == hash, state.settingsVersion == settings.version, state.kind == file.kind {
 69                change.touches.append((path, file.mtime))
 70                report.touched.append(path)
 71            } else {
 72                candidates.append((file, bytes, hash))
 73            }
 74        }
 75
 76        // A file that vanished and one that appeared with the same content is a rename.
 77        let foundPaths = Set(found.map(\.url.path))
 78        var missing = known.filter { !foundPaths.contains($0.key) }
 79        for candidate in candidates {
 80            let path = candidate.file.url.path
 81            if known[path] == nil,
 82               let (oldPath, _) = missing.first(where: {
 83                   $0.value.hash == candidate.hash && $0.value.settingsVersion == settings.version && $0.value.kind == candidate.file.kind
 84               }) {
 85                missing[oldPath] = nil
 86                change.moves.append((oldPath, path, candidate.file.mtime))
 87                report.moved.append(path)
 88            } else {
 89                change.records.append(FileRecord(
 90                    path: path, root: root.path, kind: candidate.file.kind,
 91                    bytes: candidate.bytes, mtime: candidate.file.mtime, settings: settings
 92                ))
 93                report.indexed.append(path)
 94            }
 95        }
 96        change.removals = missing.keys.sorted()
 97        report.removed = change.removals
 98        try index.apply(change)
 99        return report
100    }
101}