krz/orgstar

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

Sources/OrgstarMobile/SpotlightIndexer.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/OrgstarMobile/SpotlightIndexer.swift history · blame · raw

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