Commit 2d92459d2a
Verified · cmc
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +2
| @@ -847,6 +847,8 @@ public final class DocumentSession { | ||
| 847 | 847 | let offset: Int |
| 848 | 848 | if !isDirty, hash == location.contentHash { |
| 849 | 849 | offset = location.start |
| 850 | } else if location.ordinal == HeadingLocation.fileOrdinal { | |
| 851 | offset = 0 | |
| 850 | 852 | } else if location.ordinal < outline.count, outline[location.ordinal].title == location.title { |
| 851 | 853 | offset = outline[location.ordinal].start |
| 852 | 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 | 11 | public private(set) var fileProperties: [Property] = [] |
| 12 | 12 | public private(set) var tables: [TableModel] = [] |
| 13 | 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 | 17 | /// Always inherited, whatever the inheritance setting. |
| 16 | 18 | static let alwaysInherited: Set<String> = ["CATEGORY", "ARCHIVE", "COLUMNS", "LOGGING"] |
| @@ -130,7 +132,7 @@ public struct DocumentModel: Sendable { | ||
| 130 | 132 | case .timestamp: |
| 131 | 133 | if let heading, let stamp = Timestamp.parse(child.text) { headings[heading].timestamps.append(stamp) } |
| 132 | 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 | 136 | case .keyword: |
| 135 | 137 | fileKeyword(child) |
| 136 | 138 | case .table: |
Sources/OrgIndex/FileRecord.swift +14 −8
| @@ -108,6 +108,8 @@ public struct FileRecord: Sendable, Equatable { | ||
| 108 | 108 | public let hash: String |
| 109 | 109 | public let settingsVersion: Int |
| 110 | 110 | public let headings: [HeadingRecord] |
| 111 | /// Links before the first heading, the file's own. | |
| 112 | public let links: [LinkRow] | |
| 111 | 113 | |
| 112 | 114 | public static func hash(_ bytes: [UInt8]) -> String { |
| 113 | 115 | SHA256.hash(data: Data(bytes)).map { String(format: "%02x", $0) }.joined() |
| @@ -124,7 +126,7 @@ public struct FileRecord: Sendable, Equatable { | ||
| 124 | 126 | var resolved = settings |
| 125 | 127 | resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile) |
| 126 | 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 | 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 | 159 | self.hash = Self.unreadHash(size: size, mtime: mtime) |
| 158 | 160 | self.settingsVersion = settings.version |
| 159 | 161 | headings = [] |
| 162 | links = [] | |
| 160 | 163 | } |
| 161 | 164 | |
| 162 | 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 | 168 | let model = DocumentModel(tree: OrgParser.parse(text, defaults: settings.org), settings: settings.semantic) |
| 166 | 169 | let utf16 = text.utf16 |
| 167 | 170 | func slice(_ range: Range<Int>) -> String { |
| @@ -174,7 +177,7 @@ public struct FileRecord: Sendable, Equatable { | ||
| 174 | 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 | 181 | let bodyEnd = firstChild[index].map { model.headings[$0].sectionRange.lowerBound } ?? heading.sectionRange.upperBound |
| 179 | 182 | let tags = model.tags(of: index) |
| 180 | 183 | return HeadingRecord( |
| @@ -197,13 +200,16 @@ public struct FileRecord: Sendable, Equatable { | ||
| 197 | 200 | clocks: heading.clocks.map { |
| 198 | 201 | ClockRow(start: format($0.start), end: $0.end.map(format), minutes: $0.minutes) |
| 199 | 202 | }, |
| 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) | |
| 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 | 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 | |
| 22 | 22 | public struct HeadingLocation: Sendable, Hashable { |
| 23 | 23 | public let path: String |
| 24 | /// `fileOrdinal` for the file itself, before its first heading. | |
| 24 | 25 | public let ordinal: Int |
| 25 | 26 | public let title: String |
| 26 | 27 | public let start: Int |
| @@ -33,6 +34,13 @@ public struct HeadingLocation: Sendable, Hashable { | ||
| 33 | 34 | self.start = start |
| 34 | 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 | 46 | /// One reconciliation's worth of changes, applied in a single transaction. |
| @@ -134,6 +142,18 @@ public final class IndexStore: Sendable { | ||
| 134 | 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 | 157 | return migrator |
| 138 | 158 | } |
| 139 | 159 | |
| @@ -174,6 +194,9 @@ public final class IndexStore: Sendable { | ||
| 174 | 194 | record.settingsVersion, Date().timeIntervalSince1970] |
| 175 | 195 | ) |
| 176 | 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 | 200 | for heading in record.headings { |
| 178 | 201 | try db.execute( |
| 179 | 202 | sql: """ |
| @@ -316,20 +339,29 @@ public final class IndexStore: Sendable { | ||
| 316 | 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 | 344 | public func links(overlay: [String: FileRecord] = [:]) throws -> [(source: HeadingLocation, target: String)] { |
| 321 | 345 | let indexed = try database.read { db in |
| 322 | 346 | try Row.fetchAll( |
| 323 | 347 | db, |
| 324 | 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 | 352 | SELECT f.path, f.hash, h.ordinal, h.title, h.start_offset, l.target |
| 326 | 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 | 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 | 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 | 107 | let notes = try Folder() |
| 108 | 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 | 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 | 112 | let model = try WorkspaceModel(directory: nil) |
| 111 | 113 | try await model.addRoot(notes.url) |
| 112 | 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 | 116 | 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"]) | |
| 116 | 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 | 128 | @Test func createRenameAndTrashFiles() async throws { |
Tests/OrgIndexTests/FileRecordTests.swift +6
| @@ -54,6 +54,12 @@ struct FileRecordTests { | ||
| 54 | 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 | 63 | @Test func archiveTag() { |
| 58 | 64 | let headings = record("/notes/a.org", "* a :ARCHIVE:\n** b\n* c\n").headings |
| 59 | 65 | #expect(headings.map(\.archived) == [true, true, false]) |