krz/orgstar

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

Commit 2d92459d2a

2d92459d2a2588994fff5b0a415e8775cfb6aed8

parent: 578d60d689

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:57 UTC

Backlinks include links before a file's first heading

DocumentModel and FileRecord keep the file's own links; the index stores them in a new file_links table (migration v2 reindexes every file). Their source is the file, a HeadingLocation with ordinal -1 titled by the file name, shown at the file's start.

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +2
@@ -847,6 +847,8 @@ public final class DocumentSession {
847847 let offset: Int
848848 if !isDirty, hash == location.contentHash {
849849 offset = location.start
850 } else if location.ordinal == HeadingLocation.fileOrdinal {
851 offset = 0
850852 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
851853 offset = outline[location.ordinal].start
852854 } else if let entry = outline.first(where: { $0.title == location.title }) {
Sources/OrgCore/Semantic/DocumentModel.swift +3 −1
@@ -11,6 +11,8 @@ public struct DocumentModel: Sendable {
1111 public private(set) var fileProperties: [Property] = []
1212 public private(set) var tables: [TableModel] = []
1313 public private(set) var srcBlocks: [SrcBlockInfo] = []
14 /// Link targets before the first heading, as `HeadingInfo.links` has a heading's.
15 public private(set) var links: [String] = []
1416
1517 /// Always inherited, whatever the inheritance setting.
1618 static let alwaysInherited: Set<String> = ["CATEGORY", "ARCHIVE", "COLUMNS", "LOGGING"]
@@ -130,7 +132,7 @@ public struct DocumentModel: Sendable {
130132 case .timestamp:
131133 if let heading, let stamp = Timestamp.parse(child.text) { headings[heading].timestamps.append(stamp) }
132134 case .link:
133 if let heading { headings[heading].links.append(linkTarget(child)) }
135 if let heading { headings[heading].links.append(linkTarget(child)) } else { links.append(linkTarget(child)) }
134136 case .keyword:
135137 fileKeyword(child)
136138 case .table:
Sources/OrgIndex/FileRecord.swift +14 −8
@@ -108,6 +108,8 @@ public struct FileRecord: Sendable, Equatable {
108108 public let hash: String
109109 public let settingsVersion: Int
110110 public let headings: [HeadingRecord]
111 /// Links before the first heading, the file's own.
112 public let links: [LinkRow]
111113
112114 public static func hash(_ bytes: [UInt8]) -> String {
113115 SHA256.hash(data: Data(bytes)).map { String(format: "%02x", $0) }.joined()
@@ -124,7 +126,7 @@ public struct FileRecord: Sendable, Equatable {
124126 var resolved = settings
125127 resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile)
126128 self.settingsVersion = Self.settingsVersion(settings, text: source.text, setupKeywords: resolved.org.setupKeywords)
127 headings = kind == .conflict ? [] : Self.headings(source.text, settings: resolved)
129 (headings, links) = kind == .conflict ? ([], []) : Self.rows(source.text, settings: resolved)
128130 }
129131
130132 /// The settings version a file's rows are built under: the index's, with the keywords of
@@ -157,11 +159,12 @@ public struct FileRecord: Sendable, Equatable {
157159 self.hash = Self.unreadHash(size: size, mtime: mtime)
158160 self.settingsVersion = settings.version
159161 headings = []
162 links = []
160163 }
161164
162165 public static func unreadHash(size: Int, mtime: Double) -> String { "unread:\(size):\(mtime)" }
163166
164 static func headings(_ text: String, settings: IndexSettings) -> [HeadingRecord] {
167 static func rows(_ text: String, settings: IndexSettings) -> (headings: [HeadingRecord], links: [LinkRow]) {
165168 let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic)
166169 let utf16 = text.utf16
167170 func slice(_ range: Range<Int>) -> String {
@@ -174,7 +177,7 @@ public struct FileRecord: Sendable, Equatable {
174177 if let parent = heading.parent, firstChild[parent] == nil { firstChild[parent] = index }
175178 }
176179
177 return model.headings.enumerated().map { index, heading in
180 let headings = model.headings.enumerated().map { index, heading in
178181 let bodyEnd = firstChild[index].map { model.headings[$0].sectionRange.lowerBound } ?? heading.sectionRange.upperBound
179182 let tags = model.tags(of: index)
180183 return HeadingRecord(
@@ -197,13 +200,16 @@ public struct FileRecord: Sendable, Equatable {
197200 clocks: heading.clocks.map {
198201 ClockRow(start: format($0.start), end: $0.end.map(format), minutes: $0.minutes)
199202 },
200 links: heading.links.map { target in
201 let scheme = target.prefix { $0 != ":" }
202 let isScheme = scheme.count < target.count && !scheme.isEmpty && scheme.allSatisfy { $0.isLetter || $0 == "-" }
203 return LinkRow(type: isScheme ? scheme.lowercased() : "fuzzy", target: target)
204 }
203 links: heading.links.map(linkRow)
205204 )
206205 }
206 return (headings, model.links.map(linkRow))
207 }
208
209 static func linkRow(_ target: String) -> LinkRow {
210 let scheme = target.prefix { $0 != ":" }
211 let isScheme = scheme.count < target.count && !scheme.isEmpty && scheme.allSatisfy { $0.isLetter || $0 == "-" }
212 return LinkRow(type: isScheme ? scheme.lowercased() : "fuzzy", target: target)
207213 }
208214
209215 /// Own properties, plus inherited ones for keys that inherit.
Sources/OrgIndex/IndexStore.swift +36 −4
@@ -21,6 +21,7 @@ public struct IndexedHeading: Sendable, Hashable {
2121
2222public struct HeadingLocation: Sendable, Hashable {
2323 public let path: String
24 /// `fileOrdinal` for the file itself, before its first heading.
2425 public let ordinal: Int
2526 public let title: String
2627 public let start: Int
@@ -33,6 +34,13 @@ public struct HeadingLocation: Sendable, Hashable {
3334 self.start = start
3435 self.contentHash = contentHash
3536 }
37
38 public static let fileOrdinal = -1
39
40 /// The file before its first heading, titled by its name.
41 public static func file(_ path: String, contentHash: String) -> HeadingLocation {
42 HeadingLocation(path: path, ordinal: fileOrdinal, title: (path as NSString).lastPathComponent, start: 0, contentHash: contentHash)
43 }
3644}
3745
3846/// One reconciliation's worth of changes, applied in a single transaction.
@@ -134,6 +142,18 @@ public final class IndexStore: Sendable {
134142 END;
135143 """)
136144 }
145 migrator.registerMigration("v2") { db in
146 // Links before a file's first heading; files are read again to fill it.
147 try db.execute(sql: """
148 CREATE TABLE file_links (
149 file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
150 type TEXT NOT NULL,
151 target TEXT NOT NULL
152 );
153 CREATE INDEX file_links_file ON file_links(file_id);
154 UPDATE files SET settings_version = -1;
155 """)
156 }
137157 return migrator
138158 }
139159
@@ -174,6 +194,9 @@ public final class IndexStore: Sendable {
174194 record.settingsVersion, Date().timeIntervalSince1970]
175195 )
176196 let fileID = db.lastInsertedRowID
197 for link in record.links {
198 try db.execute(sql: "INSERT INTO file_links VALUES (?, ?, ?)", arguments: [fileID, link.type, link.target])
199 }
177200 for heading in record.headings {
178201 try db.execute(
179202 sql: """
@@ -316,20 +339,29 @@ public final class IndexStore: Sendable {
316339 return indexed.filter { overlay[$0.path] == nil } + live
317340 }
318341
319 /// Every link and the heading it's in, unsaved buffers' in place of their files'.
342 /// Every link and the heading it's in, or the file for links before its first heading,
343 /// unsaved buffers' in place of their files'.
320344 public func links(overlay: [String: FileRecord] = [:]) throws -> [(source: HeadingLocation, target: String)] {
321345 let indexed = try database.read { db in
322346 try Row.fetchAll(
323347 db,
324348 sql: """
349 SELECT f.path, f.hash, -1 AS ordinal, '' AS title, 0 AS start_offset, l.target
350 FROM file_links l JOIN files f ON f.id = l.file_id WHERE f.kind = 'org'
351 UNION ALL
325352 SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset, l.target
326353 FROM links l JOIN headings h ON h.id = l.heading_id JOIN files f ON f.id = h.file_id
327 WHERE f.kind = 'org' ORDER BY f.path, h.ordinal
354 WHERE f.kind = 'org' ORDER BY 1, 3
328355 """
329 ).map { (source: location($0), target: $0["target"] as String) }
356 ).map { row in
357 let location = location(row)
358 return (source: location.ordinal == HeadingLocation.fileOrdinal ? .file(location.path, contentHash: location.contentHash) : location,
359 target: row["target"] as String)
360 }
330361 }
331362 let live = overlay.values.sorted { $0.path < $1.path }.flatMap { record in
332 record.headings.flatMap { heading in heading.links.map { (source: location(record, heading), target: $0.target) } }
363 record.links.map { (source: HeadingLocation.file(record.path, contentHash: record.hash), target: $0.target) }
364 + record.headings.flatMap { heading in heading.links.map { (source: location(record, heading), target: $0.target) } }
333365 }
334366 return indexed.filter { overlay[$0.source.path] == nil } + live
335367 }
Tests/OrgAppTests/AppTests.swift +11 −3
@@ -107,14 +107,22 @@ struct WorkspaceModelTests {
107107 let notes = try Folder()
108108 try notes.write("a.org", "* Sink\n:PROPERTIES:\n:ID: s-1\n:CUSTOM_ID: sink\n:END:\nsee [[*Taps]]\n* Taps\n")
109109 try notes.write("b.org", "* Plumbing\n[[id:s-1][the sink]] and [[file:a.org]]\n* Other\n[[file:a.org::#sink]] [[https://x.org]]\n")
110 // Links before the first heading come from the file itself.
111 try notes.write("c.org", "#+TITLE: C\nAbout [[file:a.org::*Sink]] and [[file:a.org]].\n* Later\n")
110112 let model = try WorkspaceModel(directory: nil)
111113 try await model.addRoot(notes.url)
112114 let a = try #require(model.files.first { $0.path.hasSuffix("/a.org") }).path
115 let c = try #require(model.files.first { $0.path.hasSuffix("/c.org") }).path
113116 let sink = Backlinks.Target(path: a, title: "Sink", id: "s-1", customID: "sink")
114 #expect(model.backlinks(to: sink).map(\.title) == ["Plumbing", "Other"])
115 #expect(model.backlinks(to: Backlinks.Target(path: a)).map(\.title) == ["Plumbing"])
117 #expect(model.backlinks(to: sink).map(\.title) == ["Plumbing", "Other", "c.org"])
118 #expect(model.backlinks(to: Backlinks.Target(path: a)).map(\.title) == ["Plumbing", "c.org"])
116119 #expect(model.backlinks(to: Backlinks.Target(path: a, title: "Taps")).map(\.title) == ["Sink"])
117 #expect(model.backlinks(to: sink, excluding: 0).map(\.title) == ["Plumbing", "Other"])
120 #expect(model.backlinks(to: sink, excluding: 0).map(\.title) == ["Plumbing", "Other", "c.org"])
121 let fromC = try #require(model.backlinks(to: sink).last)
122 #expect(fromC == .file(c, contentHash: fromC.contentHash))
123 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
124 session.show(fromC)
125 #expect(session.path == c && session.revealRequest?.offset == 0)
118126 }
119127
120128 @Test func createRenameAndTrashFiles() async throws {
Tests/OrgIndexTests/FileRecordTests.swift +6
@@ -54,6 +54,12 @@ struct FileRecordTests {
5454 #expect(child.properties.contains(PropertyRow(key: "OWNER", value: "team", inherited: true)))
5555 }
5656
57 @Test func linksBeforeTheFirstHeadingBelongToTheFile() {
58 let file = record("/notes/a.org", "#+TITLE: A\nSee [[file:b.org]] and https://x.org.\n* H\n[[id:h]]\n")
59 #expect(file.links == [LinkRow(type: "file", target: "file:b.org"), LinkRow(type: "https", target: "https://x.org")])
60 #expect(file.headings[0].links == [LinkRow(type: "id", target: "id:h")])
61 }
62
5763 @Test func archiveTag() {
5864 let headings = record("/notes/a.org", "* a :ARCHIVE:\n** b\n* c\n").headings
5965 #expect(headings.map(\.archived) == [true, true, false])