krz/orgstar

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

Sources/OrgIndex/FileRecord.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgIndex/FileRecord.swift history · blame · raw

264 lines · 11089 bytes

  1import CryptoKit
  2import Foundation
  3import OrgCore
  4
  5public enum FileKind: String, Sendable {
  6    case org
  7    case archive
  8    /// A Syncthing conflict copy: listed, never indexed for search, agenda or IDs.
  9    case conflict
 10    /// Any other file: listed and openable, never read by the index.
 11    case other
 12}
 13
 14/// Everything that decides what an index row means. Bump `version` when a semantic setting
 15/// changes, so files indexed under the old settings are indexed again.
 16public struct IndexSettings: Sendable, Equatable {
 17    public var version: Int
 18    public var org: OrgSettings
 19    public var semantic: SemanticSettings
 20
 21    /// Settings whose version follows the TODO keywords, so changing them reindexes.
 22    public static func with(todoKeywords org: OrgSettings) -> IndexSettings {
 23        let names = org.todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }.joined(separator: ";")
 24        var hash: UInt64 = 0xcbf29ce484222325
 25        for byte in names.utf8 {
 26            hash ^= UInt64(byte)
 27            hash = hash &* 0x100000001b3
 28        }
 29        return IndexSettings(version: 1 + Int(hash % 1_000_000), org: org)
 30    }
 31
 32    public init(version: Int = 1, org: OrgSettings = .default, semantic: SemanticSettings = .default) {
 33        self.version = version
 34        self.org = org
 35        self.semantic = semantic
 36    }
 37}
 38
 39public struct TagRow: Sendable, Equatable {
 40    public let name: String
 41    public let inherited: Bool
 42}
 43
 44public struct PropertyRow: Sendable, Equatable {
 45    /// Upper-cased.
 46    public let key: String
 47    public let value: String
 48    public let inherited: Bool
 49}
 50
 51public struct TimestampRow: Sendable, Equatable {
 52    public enum Kind: String, Sendable { case scheduled, deadline, closed, active, inactive }
 53    public let kind: Kind
 54    /// `2026-10-04` or `2026-10-04T10:00`.
 55    public let start: String
 56    public let end: String?
 57    /// As written: `+1w`, `.+2d/3d`.
 58    public let repeater: String?
 59    /// As written: `-2d`, `--1w`.
 60    public let warning: String?
 61}
 62
 63public struct ClockRow: Sendable, Equatable {
 64    public let start: String
 65    public let end: String?
 66    public let minutes: Int?
 67}
 68
 69public struct LinkRow: Sendable, Equatable {
 70    /// The scheme before the first colon (`id`, `file`, `https`), or `fuzzy`.
 71    public let type: String
 72    public let target: String
 73}
 74
 75public struct HeadingRecord: Sendable, Equatable {
 76    /// Position in document order; parents refer to it.
 77    public let ordinal: Int
 78    public let parent: Int?
 79    /// The section's UTF-16 range in the file's text.
 80    public let start: Int
 81    public let end: Int
 82    public let level: Int
 83    public let todo: String?
 84    public let isDone: Bool
 85    public let priority: String?
 86    public let title: String
 87    public let outlinePath: [String]
 88    public let orgID: String?
 89    /// Tagged `ARCHIVE`, directly or by inheritance.
 90    public let archived: Bool
 91    /// The heading's own text, without the heading line and child sections.
 92    public let body: String
 93    public let tags: [TagRow]
 94    public let properties: [PropertyRow]
 95    public let timestamps: [TimestampRow]
 96    public let clocks: [ClockRow]
 97    public let links: [LinkRow]
 98}
 99
100/// One file's index rows, computed without touching the database.
101public struct FileRecord: Sendable, Equatable {
102    public let path: String
103    public let root: String
104    public let kind: FileKind
105    public let size: Int
106    public let mtime: Double
107    /// SHA-256 of the bytes, hex.
108    public let hash: String
109    public let settingsVersion: Int
110    public let headings: [HeadingRecord]
111    /// Links before the first heading, the file's own.
112    public let links: [LinkRow]
113
114    public static func hash(_ bytes: [UInt8]) -> String {
115        SHA256.hash(data: Data(bytes)).map { String(format: "%02x", $0) }.joined()
116    }
117
118    public init(path: String, root: String, kind: FileKind, bytes: [UInt8], mtime: Double, settings: IndexSettings) {
119        self.path = path
120        self.root = root
121        self.kind = kind
122        self.size = bytes.count
123        self.mtime = mtime
124        self.hash = Self.hash(bytes)
125        let source = SourceText(bytes: bytes)
126        var resolved = settings
127        resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile)
128        self.settingsVersion = Self.settingsVersion(settings, text: source.text, setupKeywords: resolved.org.setupKeywords)
129        (headings, links) = kind == .conflict ? ([], []) : Self.rows(source.text, settings: resolved)
130    }
131
132    /// The settings version a file's rows are built under: the index's, with the keywords of
133    /// the setup files it names mixed in, so a changed setup file indexes it again.
134    public static func settingsVersion(_ settings: IndexSettings, text: String, setupKeywords: String) -> Int {
135        guard text.range(of: "#+setupfile:", options: .caseInsensitive) != nil else { return settings.version }
136        var hash: UInt64 = 0xcbf29ce484222325
137        for byte in setupKeywords.utf8 {
138            hash ^= UInt64(byte)
139            hash = hash &* 0x100000001b3
140        }
141        return settings.version + 1_000_000 * (1 + Int(hash % 1_000_000))
142    }
143
144    /// `settingsVersion` for a file's bytes, reading the setup files it names.
145    public static func settingsVersion(_ settings: IndexSettings, bytes: [UInt8], path: String) -> Int {
146        let text = SourceText(bytes: bytes).text
147        guard text.range(of: "#+setupfile:", options: .caseInsensitive) != nil else { return settings.version }
148        let keywords = settings.org.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile).setupKeywords
149        return settingsVersion(settings, text: text, setupKeywords: keywords)
150    }
151
152    /// A file the index lists without reading: `hash` stands for its size and mtime.
153    public init(path: String, root: String, unreadSize size: Int, mtime: Double, settings: IndexSettings) {
154        self.path = path
155        self.root = root
156        self.kind = .other
157        self.size = size
158        self.mtime = mtime
159        self.hash = Self.unreadHash(size: size, mtime: mtime)
160        self.settingsVersion = settings.version
161        headings = []
162        links = []
163    }
164
165    public static func unreadHash(size: Int, mtime: Double) -> String { "unread:\(size):\(mtime)" }
166
167    static func rows(_ text: String, settings: IndexSettings) -> (headings: [HeadingRecord], links: [LinkRow]) {
168        let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic)
169        let utf16 = text.utf16
170        func slice(_ range: Range<Int>) -> String {
171            let start = utf16.index(utf16.startIndex, offsetBy: range.lowerBound)
172            let end = utf16.index(utf16.startIndex, offsetBy: range.upperBound)
173            return String(text[start..<end])
174        }
175        var firstChild: [Int: Int] = [:]
176        for (index, heading) in model.headings.enumerated() {
177            if let parent = heading.parent, firstChild[parent] == nil { firstChild[parent] = index }
178        }
179
180        let headings = model.headings.enumerated().map { index, heading in
181            let bodyEnd = firstChild[index].map { model.headings[$0].sectionRange.lowerBound } ?? heading.sectionRange.upperBound
182            let tags = model.tags(of: index)
183            return HeadingRecord(
184                ordinal: index,
185                parent: heading.parent,
186                start: heading.sectionRange.lowerBound,
187                end: heading.sectionRange.upperBound,
188                level: heading.level,
189                todo: heading.todo,
190                isDone: heading.isDone,
191                priority: heading.priority,
192                title: heading.title,
193                outlinePath: model.outlinePath(of: index),
194                orgID: heading.id,
195                archived: tags.contains { $0.value == "ARCHIVE" },
196                body: slice(heading.headingRange.upperBound..<bodyEnd),
197                tags: tags.map { TagRow(name: $0.value, inherited: $0.source != .heading(index)) },
198                properties: properties(model, index),
199                timestamps: timestamps(heading),
200                clocks: heading.clocks.map {
201                    ClockRow(start: format($0.start), end: $0.end.map(format), minutes: $0.minutes)
202                },
203                links: heading.links.map(linkRow)
204            )
205        }
206        return (headings, model.links.map(linkRow))
207    }
208
209    static func linkRow(_ target: String) -> LinkRow {
210        let scheme = target.prefix { $0 != ":" }
211        let isScheme = scheme.count < target.count && !scheme.isEmpty && scheme.allSatisfy { $0.isLetter || $0 == "-" }
212        return LinkRow(type: isScheme ? scheme.lowercased() : "fuzzy", target: target)
213    }
214
215    /// Own properties, plus inherited ones for keys that inherit.
216    static func properties(_ model: DocumentModel, _ index: Int) -> [PropertyRow] {
217        var keys: [String] = []
218        func add(_ entries: [Property], inheritedOnly: Bool) {
219            for entry in entries {
220                let key = entry.key.uppercased()
221                if !keys.contains(key), !inheritedOnly || model.inherits(key) { keys.append(key) }
222            }
223        }
224        add(model.headings[index].properties, inheritedOnly: false)
225        add(model.fileProperties, inheritedOnly: true)
226        for ancestor in model.ancestors(of: index) { add(model.headings[ancestor].properties, inheritedOnly: true) }
227        return keys.compactMap { key in
228            model.property(key, of: index).map {
229                PropertyRow(key: key, value: $0.value, inherited: $0.source != .heading(index))
230            }
231        }
232    }
233
234    static func timestamps(_ heading: HeadingInfo) -> [TimestampRow] {
235        var rows: [TimestampRow] = []
236        func add(_ stamp: Timestamp?, _ kind: TimestampRow.Kind) {
237            guard let stamp else { return }
238            rows.append(TimestampRow(
239                kind: kind,
240                start: format(stamp.start),
241                end: stamp.end.map(format),
242                repeater: stamp.repeater.map {
243                    $0.kind.rawValue + format($0.interval) + ($0.habitDeadline.map { "/" + format($0) } ?? "")
244                },
245                warning: stamp.warning.map { ($0.firstOccurrenceOnly ? "--" : "-") + format($0.interval) }
246            ))
247        }
248        add(heading.scheduled, .scheduled)
249        add(heading.deadline, .deadline)
250        add(heading.closed, .closed)
251        for stamp in heading.timestamps { add(stamp, stamp.active ? .active : .inactive) }
252        return rows
253    }
254
255    static func format(_ point: Timestamp.Point) -> String {
256        let date = String(format: "%04d-%02d-%02d", point.year, point.month, point.day)
257        guard let hour = point.hour, let minute = point.minute else { return date }
258        return date + String(format: "T%02d:%02d", hour, minute)
259    }
260
261    static func format(_ interval: Timestamp.Interval) -> String {
262        "\(interval.value)\(interval.unit.rawValue)"
263    }
264}