Sources/OrgWorkspace/Reconciler.swift
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}