| @@ -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 | } |