| @@ -0,0 +1,209 @@ |
| |
1 | import CryptoKit |
| |
2 | import Foundation |
| |
3 | import OrgCore |
| |
4 | |
| |
5 | public 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. |
| |
14 | public 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 | |
| |
26 | public struct TagRow: Sendable, Equatable { |
| |
27 | public let name: String |
| |
28 | public let inherited: Bool |
| |
29 | } |
| |
30 | |
| |
31 | public struct PropertyRow: Sendable, Equatable { |
| |
32 | /// Upper-cased. |
| |
33 | public let key: String |
| |
34 | public let value: String |
| |
35 | public let inherited: Bool |
| |
36 | } |
| |
37 | |
| |
38 | public 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 | |
| |
50 | public struct ClockRow: Sendable, Equatable { |
| |
51 | public let start: String |
| |
52 | public let end: String? |
| |
53 | public let minutes: Int? |
| |
54 | } |
| |
55 | |
| |
56 | public 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 | |
| |
62 | public 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. |
| |
88 | public 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 | } |