Sources/OrgstarMobile/SpotlightIndexer.swift
83 lines · 3611 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 private func index(_ files: [FileEntry]) async {
44 let generation = Data(state.generation.uuidString.utf8)
45 let stamps = (try? await index.fetchLastClientState()) == generation ? state.stamps : [:]
46 var current: [String: Date] = [:]
47 var items: [CSSearchableItem] = []
48 for file in files where file.kind == .org {
49 guard let date = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.modificationDate] as? Date else { continue }
50 if stamps[file.path] == date {
51 current[file.path] = date
52 } else if let text = try? String(contentsOfFile: file.path, encoding: .utf8) {
53 items.append(Self.item(file, text: text))
54 current[file.path] = date
55 }
56 }
57 let gone = stamps.keys.filter { current[$0] == nil }
58 guard !items.isEmpty || !gone.isEmpty else { return }
59 index.beginBatch()
60 if !items.isEmpty { index.indexSearchableItems(items, completionHandler: nil) }
61 if !gone.isEmpty { index.deleteSearchableItems(withIdentifiers: Array(gone), completionHandler: nil) }
62 do {
63 try await index.endBatch(withClientState: generation)
64 } catch {
65 return
66 }
67 state.stamps = current
68 try? JSONEncoder().encode(state).write(to: stateFile, options: .atomic)
69 }
70
71 private static func item(_ file: FileEntry, text: String) -> CSSearchableItem {
72 let summary = FileSummary(text)
73 let attributes = CSSearchableItemAttributeSet(contentType: .text)
74 attributes.title = summary.title ?? (file.name as NSString).deletingPathExtension
75 attributes.displayName = attributes.title
76 attributes.contentDescription = summary.description ?? file.relativePath
77 attributes.textContent = text
78 if let author = summary.author { attributes.authorNames = [author] }
79 attributes.keywords = summary.tags + summary.headings
80 return CSSearchableItem(uniqueIdentifier: file.path, domainIdentifier: "files", attributeSet: attributes)
81 }
82}
83#endif