krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 1c136d4370

1c136d4370bf53258c19e96d4027f6d2a4166b11

parent: e8677bdf4d

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 00:15 UTC

Add index file records

Layout: unified · split

Package.resolved added +15
@@ -0,0 +1,15 @@
1{
2 "originHash" : "c31b92d42ae4ae7fcf15c68db14883c5ded79cfac8c0d3c5f597eeac28e9899f",
3 "pins" : [
4 {
5 "identity" : "grdb.swift",
6 "kind" : "remoteSourceControl",
7 "location" : "https://github.com/groue/GRDB.swift",
8 "state" : {
9 "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212",
10 "version" : "7.11.1"
11 }
12 }
13 ],
14 "version" : 3
15}
Package.swift +8 −2
@@ -6,12 +6,18 @@ let package = Package(
66 platforms: [.macOS(.v26), .iOS(.v26)],
77 products: [
88 .library(name: "OrgCore", targets: ["OrgCore"]),
9 .library(name: "OrgDocument", targets: ["OrgDocument"])
9 .library(name: "OrgDocument", targets: ["OrgDocument"]),
10 .library(name: "OrgIndex", targets: ["OrgIndex"])
11 ],
12 dependencies: [
13 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
1014 ],
1115 targets: [
1216 .target(name: "OrgCore"),
1317 .target(name: "OrgDocument", dependencies: ["OrgCore"]),
18 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]),
1419 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
15 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"])
20 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
21 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"])
1622 ]
1723)
Sources/OrgIndex/FileRecord.swift added +209
@@ -0,0 +1,209 @@
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}
Tests/OrgIndexTests/FileRecordTests.swift added +65
@@ -0,0 +1,65 @@
1import OrgCore
2import Testing
3@testable import OrgIndex
4
5func record(_ path: String, _ text: String, kind: FileKind = .org, settings: IndexSettings = IndexSettings()) -> FileRecord {
6 FileRecord(path: path, root: "/notes", kind: kind, bytes: Array(text.utf8), mtime: 1, settings: settings)
7}
8
9struct FileRecordTests {
10 let text = """
11 #+FILETAGS: :work:
12 #+PROPERTY: OWNER team
13 * TODO [#A] Plan the launch :big:
14 SCHEDULED: <2026-10-05 Mon +1w -2d>
15 :PROPERTIES:
16 :ID: plan
17 :CATEGORY: launch
18 :END:
19 Draft the [[https://example.com][brief]] and see [[id:other]].
20 ** DONE Child
21 CLOSED: [2026-10-04 Sun 09:00]
22 :LOGBOOK:
23 CLOCK: [2026-10-04 Sun 08:00]--[2026-10-04 Sun 09:00] => 1:00
24 :END:
25 <2026-10-06 Tue>
26
27 """
28
29 @Test func headings() throws {
30 let headings = record("/notes/a.org", text).headings
31 #expect(headings.count == 2)
32 let plan = headings[0]
33 #expect(plan.todo == "TODO" && !plan.isDone && plan.priority == "A")
34 #expect(plan.orgID == "plan")
35 #expect(plan.outlinePath == ["Plan the launch"])
36 #expect(plan.body.contains("Draft the"))
37 #expect(!plan.body.contains("Child"))
38 #expect(plan.tags == [TagRow(name: "work", inherited: true), TagRow(name: "big", inherited: false)])
39 #expect(plan.links == [LinkRow(type: "https", target: "https://example.com"), LinkRow(type: "id", target: "id:other")])
40 #expect(plan.timestamps == [TimestampRow(kind: .scheduled, start: "2026-10-05", end: nil, repeater: "+1w", warning: "-2d")])
41
42 let child = headings[1]
43 #expect(child.parent == 0 && child.isDone)
44 #expect(child.outlinePath == ["Plan the launch", "Child"])
45 #expect(child.tags.map(\.name) == ["work", "big"])
46 #expect(child.clocks == [ClockRow(start: "2026-10-04T08:00", end: "2026-10-04T09:00", minutes: 60)])
47 #expect(child.timestamps.map(\.kind) == [.closed, .active])
48 #expect(child.properties == [PropertyRow(key: "CATEGORY", value: "launch", inherited: true)])
49 }
50
51 @Test func propertyInheritanceFollowsSettings() {
52 let settings = IndexSettings(semantic: SemanticSettings(propertyInheritance: .all))
53 let child = record("/notes/a.org", text, settings: settings).headings[1]
54 #expect(child.properties.contains(PropertyRow(key: "OWNER", value: "team", inherited: true)))
55 }
56
57 @Test func archiveTag() {
58 let headings = record("/notes/a.org", "* a :ARCHIVE:\n** b\n* c\n").headings
59 #expect(headings.map(\.archived) == [true, true, false])
60 }
61
62 @Test func conflictCopiesHaveNoHeadings() {
63 #expect(record("/notes/a.sync-conflict-1.org", "* a\n", kind: .conflict).headings.isEmpty)
64 }
65}