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 {
847 let offset: Int 847 let offset: Int
848 if !isDirty, hash == location.contentHash { 848 if !isDirty, hash == location.contentHash {
849 offset = location.start 849 offset = location.start
850 } else if location.ordinal == HeadingLocation.fileOrdinal {
851 offset = 0
850 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { 852 } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title {
851 offset = outline[location.ordinal].start 853 offset = outline[location.ordinal].start
852 } else if let entry = outline.first(where: { $0.title == location.title }) { 854 } 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 {
11 public private(set) var fileProperties: [Property] = [] 11 public private(set) var fileProperties: [Property] = []
12 public private(set) var tables: [TableModel] = [] 12 public private(set) var tables: [TableModel] = []
13 public private(set) var srcBlocks: [SrcBlockInfo] = [] 13 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] = []
14 16
15 /// Always inherited, whatever the inheritance setting. 17 /// Always inherited, whatever the inheritance setting.
16 static let alwaysInherited: Set<String> = ["CATEGORY", "ARCHIVE", "COLUMNS", "LOGGING"] 18 static let alwaysInherited: Set<String> = ["CATEGORY", "ARCHIVE", "COLUMNS", "LOGGING"]
@@ -130,7 +132,7 @@ public struct DocumentModel: Sendable {
130 case .timestamp: 132 case .timestamp:
131 if let heading, let stamp = Timestamp.parse(child.text) { headings[heading].timestamps.append(stamp) } 133 if let heading, let stamp = Timestamp.parse(child.text) { headings[heading].timestamps.append(stamp) }
132 case .link: 134 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)) }
134 case .keyword: 136 case .keyword:
135 fileKeyword(child) 137 fileKeyword(child)
136 case .table: 138 case .table:
Sources/OrgIndex/FileRecord.swift +14 −8
@@ -108,6 +108,8 @@ public struct FileRecord: Sendable, Equatable {
108 public let hash: String 108 public let hash: String
109 public let settingsVersion: Int 109 public let settingsVersion: Int
110 public let headings: [HeadingRecord] 110 public let headings: [HeadingRecord]
111 /// Links before the first heading, the file's own.
112 public let links: [LinkRow]
111 113
112 public static func hash(_ bytes: [UInt8]) -> String { 114 public static func hash(_ bytes: [UInt8]) -> String {
113 SHA256.hash(data: Data(bytes)).map { String(format: "%02x", $0) }.joined() 115 SHA256.hash(data: Data(bytes)).map { String(format: "%02x", $0) }.joined()
@@ -124,7 +126,7 @@ public struct FileRecord: Sendable, Equatable {
124 var resolved = settings 126 var resolved = settings
125 resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile) 127 resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile)
126 self.settingsVersion = Self.settingsVersion(settings, text: source.text, setupKeywords: resolved.org.setupKeywords) 128 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)
128 } 130 }
129 131
130 /// The settings version a file's rows are built under: the index's, with the keywords of 132 /// 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 {
157 self.hash = Self.unreadHash(size: size, mtime: mtime) 159 self.hash = Self.unreadHash(size: size, mtime: mtime)
158 self.settingsVersion = settings.version 160 self.settingsVersion = settings.version
159 headings = [] 161 headings = []
162 links = []
160 } 163 }
161 164
162 public static func unreadHash(size: Int, mtime: Double) -> String { "unread:\(size):\(mtime)" } 165 public static func unreadHash(size: Int, mtime: Double) -> String { "unread:\(size):\(mtime)" }
163 166
164 static func headings(_ text: String, settings: IndexSettings) -> [HeadingRecord] { 167 static func rows(_ text: String, settings: IndexSettings) -> (headings: [HeadingRecord], links: [LinkRow]) {
165 let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic) 168 let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic)
166 let utf16 = text.utf16 169 let utf16 = text.utf16
167 func slice(_ range: Range<Int>) -> String { 170 func slice(_ range: Range<Int>) -> String {
@@ -174,7 +177,7 @@ public struct FileRecord: Sendable, Equatable {
174 if let parent = heading.parent, firstChild[parent] == nil { firstChild[parent] = index } 177 if let parent = heading.parent, firstChild[parent] == nil { firstChild[parent] = index }
175 } 178 }
176 179
177 return model.headings.enumerated().map { index, heading in 180 let headings = model.headings.enumerated().map { index, heading in
178 let bodyEnd = firstChild[index].map { model.headings[$0].sectionRange.lowerBound } ?? heading.sectionRange.upperBound 181 let bodyEnd = firstChild[index].map { model.headings[$0].sectionRange.lowerBound } ?? heading.sectionRange.upperBound
179 let tags = model.tags(of: index) 182 let tags = model.tags(of: index)
180 return HeadingRecord( 183 return HeadingRecord(
@@ -197,13 +200,16 @@ public struct FileRecord: Sendable, Equatable {
197 clocks: heading.clocks.map { 200 clocks: heading.clocks.map {
198 ClockRow(start: format($0.start), end: $0.end.map(format), minutes: $0.minutes) 201 ClockRow(start: format($0.start), end: $0.end.map(format), minutes: $0.minutes)
199 }, 202 },
200 links: heading.links.map { target in 203 links: heading.links.map(linkRow)
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 }
205 ) 204 )
206 } 205 }
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)
207 } 213 }
208 214
209 /// Own properties, plus inherited ones for keys that inherit. 215 /// Own properties, plus inherited ones for keys that inherit.
Sources/OrgIndex/IndexStore.swift +36 −4
@@ -21,6 +21,7 @@ public struct IndexedHeading: Sendable, Hashable {
21 21
22public struct HeadingLocation: Sendable, Hashable { 22public struct HeadingLocation: Sendable, Hashable {
23 public let path: String 23 public let path: String
24 /// `fileOrdinal` for the file itself, before its first heading.
24 public let ordinal: Int 25 public let ordinal: Int
25 public let title: String 26 public let title: String
26 public let start: Int 27 public let start: Int
@@ -33,6 +34,13 @@ public struct HeadingLocation: Sendable, Hashable {
33 self.start = start 34 self.start = start
34 self.contentHash = contentHash 35 self.contentHash = contentHash
35 } 36 }
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 }
36} 44}
37 45
38/// One reconciliation's worth of changes, applied in a single transaction. 46/// One reconciliation's worth of changes, applied in a single transaction.
@@ -134,6 +142,18 @@ public final class IndexStore: Sendable {
134 END; 142 END;
135 """) 143 """)
136 } 144 }
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 }
137 return migrator 157 return migrator
138 } 158 }
139 159
@@ -174,6 +194,9 @@ public final class IndexStore: Sendable {
174 record.settingsVersion, Date().timeIntervalSince1970] 194 record.settingsVersion, Date().timeIntervalSince1970]
175 ) 195 )
176 let fileID = db.lastInsertedRowID 196 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 }
177 for heading in record.headings { 200 for heading in record.headings {
178 try db.execute( 201 try db.execute(
179 sql: """ 202 sql: """
@@ -316,20 +339,29 @@ public final class IndexStore: Sendable {
316 return indexed.filter { overlay[$0.path] == nil } + live 339 return indexed.filter { overlay[$0.path] == nil } + live
317 } 340 }
318 341
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'.
320 public func links(overlay: [String: FileRecord] = [:]) throws -> [(source: HeadingLocation, target: String)] { 344 public func links(overlay: [String: FileRecord] = [:]) throws -> [(source: HeadingLocation, target: String)] {
321 let indexed = try database.read { db in 345 let indexed = try database.read { db in
322 try Row.fetchAll( 346 try Row.fetchAll(
323 db, 347 db,
324 sql: """ 348 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
325 SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset, l.target 352 SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset, l.target
326 FROM links l JOIN headings h ON h.id = l.heading_id JOIN files f ON f.id = h.file_id 353 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
328 """ 355 """
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 }
330 } 361 }
331 let live = overlay.values.sorted { $0.path < $1.path }.flatMap { record in 362 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) } }
333 } 365 }
334 return indexed.filter { overlay[$0.source.path] == nil } + live 366 return indexed.filter { overlay[$0.source.path] == nil } + live
335 } 367 }
Tests/OrgAppTests/AppTests.swift +11 −3
@@ -107,14 +107,22 @@ struct WorkspaceModelTests {
107 let notes = try Folder() 107 let notes = try Folder()
108 try notes.write("a.org", "* Sink\n:PROPERTIES:\n:ID: s-1\n:CUSTOM_ID: sink\n:END:\nsee [[*Taps]]\n* Taps\n") 108 try notes.write("a.org", "* Sink\n:PROPERTIES:\n:ID: s-1\n:CUSTOM_ID: sink\n:END:\nsee [[*Taps]]\n* Taps\n")
109 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") 109 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")
110 let model = try WorkspaceModel(directory: nil) 112 let model = try WorkspaceModel(directory: nil)
111 try await model.addRoot(notes.url) 113 try await model.addRoot(notes.url)
112 let a = try #require(model.files.first { $0.path.hasSuffix("/a.org") }).path 114 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
113 let sink = Backlinks.Target(path: a, title: "Sink", id: "s-1", customID: "sink") 116 let sink = Backlinks.Target(path: a, title: "Sink", id: "s-1", customID: "sink")
114 #expect(model.backlinks(to: sink).map(\.title) == ["Plumbing", "Other"]) 117 #expect(model.backlinks(to: sink).map(\.title) == ["Plumbing", "Other", "c.org"])
115 #expect(model.backlinks(to: Backlinks.Target(path: a)).map(\.title) == ["Plumbing"]) 118 #expect(model.backlinks(to: Backlinks.Target(path: a)).map(\.title) == ["Plumbing", "c.org"])
116 #expect(model.backlinks(to: Backlinks.Target(path: a, title: "Taps")).map(\.title) == ["Sink"]) 119 #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)
118 } 126 }
119 127
120 @Test func createRenameAndTrashFiles() async throws { 128 @Test func createRenameAndTrashFiles() async throws {
Tests/OrgIndexTests/FileRecordTests.swift +6
@@ -54,6 +54,12 @@ struct FileRecordTests {
54 #expect(child.properties.contains(PropertyRow(key: "OWNER", value: "team", inherited: true))) 54 #expect(child.properties.contains(PropertyRow(key: "OWNER", value: "team", inherited: true)))
55 } 55 }
56 56
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
57 @Test func archiveTag() { 63 @Test func archiveTag() {
58 let headings = record("/notes/a.org", "* a :ARCHIVE:\n** b\n* c\n").headings 64 let headings = record("/notes/a.org", "* a :ARCHIVE:\n** b\n* c\n").headings
59 #expect(headings.map(\.archived) == [true, true, false]) 65 #expect(headings.map(\.archived) == [true, true, false])