krz/orgstar

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

Sources/OrgIndex/FileRecord.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/OrgIndex/FileRecord.swift history · blame · raw

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