Sources/OrgstarMobile/SpotlightIndexer.swift
89 lines · 3882 bytes
1#if os(iOS)
2import CoreSpotlight
3import Foundation
4import OrgApp
5import OrgCore
6import UniformTypeIdentifiers
7
8/// The workspace's org files in Spotlight with what the Mac's importer gives it: the text,
9/// title, author, description, tags and headings. A file is indexed again when it changes.
10@MainActor
11final class SpotlightIndexer {
12 private struct State: Codable {
13 /// Also kept as the index's client state; the system deleting the index (as it does
14 /// when the app is installed again) takes it away, and every file is indexed again.
15 var generation = UUID()
16 /// Modification dates of the files as indexed.
17 var stamps: [String: Date] = [:]
18 }
19
20 private let index = CSSearchableIndex(name: "files")
21 private let stateFile = WorkspaceModel.defaultDirectory.appendingPathComponent("spotlight.json")
22 private var state: State
23 /// Files to index once the running update finishes; a batch can't start while one is open.
24 private var waiting: [FileEntry]?
25 private var running = false
26
27 init() {
28 state = (try? JSONDecoder().decode(State.self, from: Data(contentsOf: stateFile))) ?? State()
29 }
30
31 func update(_ files: [FileEntry]) async {
32 guard !running else { return waiting = files }
33 running = true
34 defer { running = false }
35 var next: [FileEntry]? = files
36 while let files = next {
37 await index(files)
38 next = waiting
39 waiting = nil
40 }
41 }
42
43 /// Opens a batch with the changes; `endBatch` commits them. Not async: in a batch the
44 /// calls' completion handlers wait for the batch to end.
45 private func submit(_ items: [CSSearchableItem], removing gone: [String]) {
46 index.beginBatch()
47 if !items.isEmpty { index.indexSearchableItems(items, completionHandler: nil) }
48 if !gone.isEmpty { index.deleteSearchableItems(withIdentifiers: gone, completionHandler: nil) }
49 }
50
51 private func index(_ files: [FileEntry]) async {
52 let generation = Data(state.generation.uuidString.utf8)
53 let stamps = (try? await index.fetchLastClientState()) == generation ? state.stamps : [:]
54 var current: [String: Date] = [:]
55 var items: [CSSearchableItem] = []
56 for file in files where file.kind == .org {
57 guard let date = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.modificationDate] as? Date else { continue }
58 if stamps[file.path] == date {
59 current[file.path] = date
60 } else if let text = try? String(contentsOfFile: file.path, encoding: .utf8) {
61 items.append(Self.item(file, text: text))
62 current[file.path] = date
63 }
64 }
65 let gone = stamps.keys.filter { current[$0] == nil }
66 guard !items.isEmpty || !gone.isEmpty else { return }
67 submit(items, removing: gone)
68 do {
69 try await index.endBatch(withClientState: generation)
70 } catch {
71 return
72 }
73 state.stamps = current
74 try? JSONEncoder().encode(state).write(to: stateFile, options: .atomic)
75 }
76
77 private static func item(_ file: FileEntry, text: String) -> CSSearchableItem {
78 let summary = FileSummary(text)
79 let attributes = CSSearchableItemAttributeSet(contentType: .text)
80 attributes.title = summary.title ?? (file.name as NSString).deletingPathExtension
81 attributes.displayName = attributes.title
82 attributes.contentDescription = summary.description ?? file.relativePath
83 attributes.textContent = text
84 if let author = summary.author { attributes.authorNames = [author] }
85 attributes.keywords = summary.tags + summary.headings
86 return CSSearchableItem(uniqueIdentifier: file.path, domainIdentifier: "files", attributeSet: attributes)
87 }
88}
89#endif