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