Commit 2d92459d2a
Verified · cmc
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 | ||
| 22 | public struct HeadingLocation: Sendable, Hashable { | 22 | public 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]) |