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