krz/orgstar

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

Sources/OrgstarMobile/SpotlightIndexer.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgstarMobile/SpotlightIndexer.swift history · blame · raw

83 lines · 3611 bytes

5 symbols in this file
 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