Sources/OrgIndex/FileRecord.swift
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}