krz/orgstar

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

Sources/OrgIndex/FileRecord.swift

85c633a83a4744bef9dde8b52462f9c2a1c037e4
orgstar/Sources/OrgIndex/FileRecord.swift history · blame · raw

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