Table and link fixes from the manual review !160
20 files changed, +360 −57
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/OrgApp/LinkFollow.swift +22 −3
| @@ -12,8 +12,9 @@ public enum LinkFollow: Equatable, Sendable { | ||
| 12 | 12 | |
| 13 | 13 | extension DocumentSession { |
| 14 | 14 | /// Follows a link as `org-link-open` does: files open as buffers (with a `::` search), |
| 15 | /// `id:` links through the index, internal links in the current file, and web and mail | |
| 16 | /// links go to their apps. | |
| 15 | /// `id:` links through the index, internal and radio links in the current file, | |
| 16 | /// `attachment:` links in their entry's attachment folder, and web and mail links go to | |
| 17 | /// their apps. | |
| 17 | 18 | public func follow(_ link: OrgLink, workspace: WorkspaceModel?, isText: (String) -> Bool) -> LinkFollow { |
| 18 | 19 | switch link.type { |
| 19 | 20 | case "http", "https", "ftp", "mailto", "news": |
| @@ -32,6 +33,15 @@ extension DocumentSession { | ||
| 32 | 33 | return searchCurrent("#" + link.path) |
| 33 | 34 | case "fuzzy": |
| 34 | 35 | return searchCurrent(link.path) |
| 36 | case "radio": | |
| 37 | guard let document else { return .message("No file is open") } | |
| 38 | return shown(Links.searchRadio(link.path, tree: document.tree)) | |
| 39 | case "attachment": | |
| 40 | guard let tree = document?.tree, | |
| 41 | let file = Links.attachmentFile(link, tree: tree, path: path, isDirectory: Self.isDirectory) else { | |
| 42 | return .message("Need absolute `org-attach-id-dir' to attach in buffers without filename") | |
| 43 | } | |
| 44 | return followFile(file, isText: isText) | |
| 35 | 45 | case "coderef": |
| 36 | 46 | return .message("Coderef links aren't supported") |
| 37 | 47 | case "shell", "elisp": |
| @@ -73,9 +83,18 @@ extension DocumentSession { | ||
| 73 | 83 | return searchCurrent(option) |
| 74 | 84 | } |
| 75 | 85 | |
| 86 | static func isDirectory(_ path: String) -> Bool { | |
| 87 | var directory: ObjCBool = false | |
| 88 | return FileManager.default.fileExists(atPath: path, isDirectory: &directory) && directory.boolValue | |
| 89 | } | |
| 90 | ||
| 76 | 91 | private func searchCurrent(_ s: String) -> LinkFollow { |
| 77 | 92 | guard let document else { return .message("No file is open") } |
| 78 | switch Links.search(s, text: document.text, tree: document.tree) { | |
| 93 | return shown(Links.search(s, text: document.text, tree: document.tree)) | |
| 94 | } | |
| 95 | ||
| 96 | private func shown(_ result: Links.SearchResult) -> LinkFollow { | |
| 97 | switch result { | |
| 79 | 98 | case .found(let offset): |
| 80 | 99 | reveal(offset) |
| 81 | 100 | return .shown |
Sources/OrgCore/Commands/CtrlCCtrlC.swift +13 −3
| @@ -3,7 +3,8 @@ import Foundation | ||
| 3 | 3 | // C-c C-c (`org-ctrl-c-ctrl-c`, Org 9.8.7) for the contexts no more specific binding takes: |
| 4 | 4 | // headings set tags, clock lines get their duration again, timestamps their weekday, cookies |
| 5 | 5 | // update, items toggle their checkbox, or without one have their list repaired, setup keywords |
| 6 | // refresh, and elsewhere there's nothing to do. | |
| 6 | // refresh, tables evaluate a typed formula, then align or recalculate a row marked `#`, and | |
| 7 | // elsewhere there's nothing to do. | |
| 7 | 8 | |
| 8 | 9 | /// `org-ctrl-c-ctrl-c`. |
| 9 | 10 | public struct CtrlCCtrlC: OrgCommand { |
| @@ -20,10 +21,19 @@ public struct CtrlCCtrlC: OrgCommand { | ||
| 20 | 21 | if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil { |
| 21 | 22 | // At the table's very start the context is the table itself, which org recalculates. |
| 22 | 23 | let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret |
| 23 | return TableRecalculate.tblfmLine(in: context) != nil || atStart ? TableRecalculate(all: true).run(in: context) : commitBuffer(context) { buffer in | |
| 24 | if TableRecalculate.tblfmLine(in: context) != nil || atStart { return TableRecalculate(all: true).run(in: context) } | |
| 25 | // `org-table-maybe-eval-formula`, then `org-table-maybe-recalculate-line` on a row | |
| 26 | // marked `#`, which aligns, or else `org-table-align`. | |
| 27 | let step = commitBuffer(context) { buffer in | |
| 24 | 28 | try buffer.tableMaybeEvalFormula() |
| 25 | buffer.tableAlign() | |
| 29 | guard buffer.lookingAtLine("^[ \\t]*\\| *# *(\\||$)") else { return buffer.tableAlign() } | |
| 30 | do { | |
| 31 | try buffer.tableRecalculate(all: false) | |
| 32 | } catch let error as Calc.Unsupported { | |
| 33 | throw EmacsBuffer.UserError(message: TableRecalculate.emacsMessage + error.reason) | |
| 34 | } | |
| 26 | 35 | } |
| 36 | return TableRecalculate.inEmacs(step, form: "(org-ctrl-c-ctrl-c)", in: context) | |
| 27 | 37 | } |
| 28 | 38 | if buffer.footnoteReferenceAtPoint() != nil |
| 29 | 39 | || buffer.footnoteDefinitions().contains(where: { $0.begin <= context.caret && context.caret < ($0.contentsBegin ?? $0.end) }) { |
Sources/OrgCore/Commands/EmacsBuffer.swift +6 −3
| @@ -38,6 +38,8 @@ final class EmacsBuffer { | ||
| 38 | 38 | var calendar = Calendar.current |
| 39 | 39 | /// `org-last-inserted-timestamp`. |
| 40 | 40 | var lastInsertedTimestamp: String? |
| 41 | /// Shown after the command commits, as `message`. | |
| 42 | var message: String? | |
| 41 | 43 | |
| 42 | 44 | let options: EditingOptions |
| 43 | 45 | |
| @@ -466,7 +468,7 @@ final class EmacsBuffer { | ||
| 466 | 468 | } |
| 467 | 469 | |
| 468 | 470 | /// Runs a buffer-editing body against a context and turns the result into one minimal edit |
| 469 | /// and the new caret. A user error becomes `.failed`. | |
| 471 | /// and the new caret, with the buffer's message. A user error becomes `.failed`. | |
| 470 | 472 | func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { |
| 471 | 473 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) |
| 472 | 474 | buffer.now = context.now |
| @@ -499,9 +501,10 @@ func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) | ||
| 499 | 501 | while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 } |
| 500 | 502 | while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 } |
| 501 | 503 | let caret = buffer.point |
| 504 | let effects: [Effect] = buffer.message.map { [.message($0)] } ?? [] | |
| 502 | 505 | guard old.count != new.count || prefix != old.count else { |
| 503 | return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret])) | |
| 506 | return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret], effects: effects)) | |
| 504 | 507 | } |
| 505 | 508 | let edit = TextEdit(range: (lower + prefix)..<(lower + old.count - suffix), replacement: String(decoding: new[prefix..<(new.count - suffix)], as: UTF16.self)) |
| 506 | return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret])) | |
| 509 | return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret], effects: effects)) | |
| 507 | 510 | } |
Sources/OrgCore/Commands/SubtreeCommands.swift +1 −1
| @@ -111,7 +111,7 @@ public struct PasteSubtree: OrgCommand { | ||
| 111 | 111 | /// them. |
| 112 | 112 | public struct CloneSubtree: OrgCommand { |
| 113 | 113 | let newID: @Sendable () -> String |
| 114 | public init(newID: @escaping @Sendable () -> String = { UUID().uuidString }) { self.newID = newID } | |
| 114 | public init(newID: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }) { self.newID = newID } | |
| 115 | 115 | public var id: String { "org.subtree.clone" } |
| 116 | 116 | public var title: String { "Clone Subtree with Time Shift" } |
| 117 | 117 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } |
Sources/OrgCore/Commands/Table.swift +3 −2
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | 3 | // Org tables (org-table.el, Org 9.8.7): alignment and the editing commands, with |
| 4 | // `org-table-automatic-realign` t, `org-table-tab-jumps-over-hlines` t, no shrunk columns, | |
| 5 | // `org-table-fix-formulas-confirm` nil (formulas are fixed silently). | |
| 4 | // `org-table-automatic-realign` t, `org-table-tab-jumps-over-hlines` t, | |
| 5 | // `org-table-fix-formulas-confirm` nil (formulas are fixed silently). Shrunk columns are only | |
| 6 | // drawn (`TableShrink`); these commands see the full text. | |
| 6 | 7 | |
| 7 | 8 | extension EmacsBuffer { |
| 8 | 9 | static let numberPattern = "^([<>]?[-+^.0-9]*[0-9][-+^.0-9eEdDx()%:]*|[<>]?[-+]?0[xX][0-9a-fA-F.]+|[<>]?[-+]?[0-9]+#[0-9a-zA-Z.]+|nan|[-+u]?inf)$" |
Sources/OrgCore/Compute/TableFormulas.swift +31 −20
| @@ -2,8 +2,9 @@ import Foundation | ||
| 2 | 2 | |
| 3 | 3 | // Table formulas (org-table.el, Org 9.8.7): `org-table-recalculate` and |
| 4 | 4 | // `org-table-eval-formula` with Calc and Lisp evaluated natively. Symbolic results, Calc |
| 5 | // features beyond `Calc`, Lisp beyond `Elisp`, remote tables found by ID and relative field | |
| 6 | // targets throw `Calc.Unsupported`; the caller then recalculates the table in Emacs. | |
| 5 | // features beyond `Calc` and Lisp beyond `Elisp` throw `Calc.Unsupported`; the caller then | |
| 6 | // recalculates the table in Emacs. `remote()` finds tables by name in the file, and by ID | |
| 7 | // here or, through `RemoteTables`, in other files. | |
| 7 | 8 | |
| 8 | 9 | /// Where `remote()` finds tables by ID in other files. |
| 9 | 10 | public enum RemoteTables { |
| @@ -189,11 +190,12 @@ extension EmacsBuffer { | ||
| 189 | 190 | return i == l - 1 ? i - 1 : i |
| 190 | 191 | } |
| 191 | 192 | |
| 192 | /// `org-table--descriptor-line`. | |
| 193 | /// `org-table--descriptor-line`, with `org-table-get-range`'s fallback to the current | |
| 194 | /// line for `@0`, whose `org-table-dlines` entry is nil. | |
| 193 | 195 | static func descriptorLine(_ desc: String, _ cline: Int, analysis: TableAnalysis) throws -> Int { |
| 194 | 196 | if let n = Int(desc), desc.allSatisfy(\.isNumber) { |
| 195 | 197 | guard n < analysis.dlines.count else { throw Calc.Unsupported("row out of range") } |
| 196 | return analysis.dlines[n] | |
| 198 | return n == 0 ? cline : analysis.dlines[n] | |
| 197 | 199 | } |
| 198 | 200 | let ns = desc as NSString |
| 199 | 201 | let regex = try! NSRegularExpression(pattern: "^(([-+])?(I+))?(([-+])?([0-9]+))?") |
| @@ -949,19 +951,24 @@ public struct TableRecalculate: OrgCommand { | ||
| 949 | 951 | throw EmacsBuffer.UserError(message: Self.emacsMessage + error.reason) |
| 950 | 952 | } |
| 951 | 953 | } |
| 952 | guard case .failed(let message) = step, message.hasPrefix(Self.emacsMessage) else { return step } | |
| 953 | // Emacs recalculates the table; its text replaces the table here. | |
| 954 | let form = Self.tblfmLine(in: context) != nil ? "(org-table-calc-current-TBLFM)" : all ? "(org-table-recalculate t)" : "(org-table-recalculate nil)" | |
| 955 | return Self.inEmacs(step, form: form, in: context) | |
| 956 | } | |
| 957 | ||
| 958 | /// A step that failed with `emacsMessage` as a request to run `form` on the table in | |
| 959 | /// Emacs, whose text then replaces the table here; other steps unchanged. | |
| 960 | static func inEmacs(_ step: CommandStep, form: String, in context: EditContext) -> CommandStep { | |
| 961 | guard case .failed(let message) = step, message.hasPrefix(emacsMessage) else { return step } | |
| 954 | 962 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) |
| 955 | let onTblfm = Self.tblfmLine(in: context) != nil | |
| 963 | let onTblfm = tblfmLine(in: context) != nil | |
| 956 | 964 | if onTblfm { |
| 957 | 965 | while buffer.forwardLine(-1) == 0, !buffer.lookingAtLine("^[ \\t]*\\|") {} |
| 958 | 966 | } |
| 959 | 967 | let table = buffer.tableBegin()..<buffer.tableEnd() |
| 960 | let tblfm = onTblfm ? Self.tblfmLine(in: context) ?? "" : buffer.firstTblfm(after: table.upperBound) ?? "" | |
| 961 | let form = onTblfm ? "(org-table-calc-current-TBLFM)" : all ? "(org-table-recalculate t)" : "(org-table-recalculate nil)" | |
| 968 | let tblfm = onTblfm ? tblfmLine(in: context) ?? "" : buffer.firstTblfm(after: table.upperBound) ?? "" | |
| 962 | 969 | return .external(.emacsTable( |
| 963 | 970 | table: table, point: context.caret, form: form, |
| 964 | reason: String(message.dropFirst(Self.emacsMessage.count)), lisp: tblfm.contains("'(") | |
| 971 | reason: String(message.dropFirst(emacsMessage.count)), lisp: tblfm.contains("'(") | |
| 965 | 972 | )) |
| 966 | 973 | } |
| 967 | 974 | } |
| @@ -1011,18 +1018,21 @@ extension EmacsBuffer { | ||
| 1011 | 1018 | } |
| 1012 | 1019 | } |
| 1013 | 1020 | |
| 1014 | /// `org-table-get-formula` with EQUATION given: stores it for the current column, or with | |
| 1015 | /// `named` the current field, and returns it. | |
| 1016 | func tableGetFormula(_ equation: String, named: Bool, analysis: TableAnalysis) throws -> String { | |
| 1021 | /// `org-table-get-formula`: stores EQUATION for the current column, or with `named` the | |
| 1022 | /// current field, and returns it; nil when a blank EQUATION removed the formula. `read` | |
| 1023 | /// when EQUATION is the prompt's answer, which the stored formula never replaces. | |
| 1024 | func tableGetFormula(_ equation: String, named: Bool, read: Bool = false, analysis: TableAnalysis) throws -> String? { | |
| 1017 | 1025 | var stored = try Self.storedFormulas(firstTblfm(after: tableEnd()) ?? "") |
| 1018 | 1026 | let ref = "@\(tableCurrentDataLine())$\(tableCurrentColumn())" |
| 1019 | 1027 | let scol = named ? ref : "$\(tableCurrentColumn())" |
| 1020 | 1028 | let old = stored.first { $0.lhs == scol }?.rhs |
| 1021 | var eq = old != nil && equation.range(of: "^ *=? *$", options: .regularExpression) != nil ? old! : equation | |
| 1029 | var eq = !read && old != nil && equation.range(of: "^ *=? *$", options: .regularExpression) != nil ? old! : equation | |
| 1022 | 1030 | guard eq.contains(where: { !$0.isWhitespace }) else { |
| 1023 | 1031 | stored.removeAll { $0.lhs == scol } |
| 1024 | 1032 | tableStoreFormulas(stored, analysis: analysis) |
| 1025 | throw UserError(message: "Formula removed") | |
| 1033 | // Org's user error, which keeps the stored formulas and stops the command. | |
| 1034 | message = "Formula removed" | |
| 1035 | return nil | |
| 1026 | 1036 | } |
| 1027 | 1037 | eq = eq.replacingOccurrences(of: "^ *=?", with: "", options: .regularExpression) |
| 1028 | 1038 | eq = eq.replacingOccurrences(of: " *$", with: "", options: .regularExpression) |
| @@ -1041,14 +1051,15 @@ extension EmacsBuffer { | ||
| 1041 | 1051 | } |
| 1042 | 1052 | |
| 1043 | 1053 | /// `org-table-eval-formula` for the current field with EQUATION, which is stored first. |
| 1044 | func tableEvalFormula(equation: String, named: Bool) throws { | |
| 1054 | /// A formula only Emacs can evaluate stays stored, the field unchanged. | |
| 1055 | func tableEvalFormula(equation: String, named: Bool, read: Bool = false) throws { | |
| 1045 | 1056 | try checkInsideDataField() |
| 1046 | 1057 | let analysis = try tableAnalyze() |
| 1047 | let formula = try tableGetFormula(equation, named: named, analysis: analysis) | |
| 1058 | guard let formula = try tableGetFormula(equation, named: named, read: read, analysis: analysis) else { return } | |
| 1048 | 1059 | do { |
| 1049 | 1060 | try tableEvalFormula(formula, analysis: analysis, substituted: false) |
| 1050 | 1061 | } catch let error as Calc.Unsupported { |
| 1051 | throw UserError(message: "The formula was stored; recalculating it needs Emacs: " + error.reason) | |
| 1062 | message = "The formula was stored; recalculating it needs Emacs: " + error.reason | |
| 1052 | 1063 | } |
| 1053 | 1064 | tableAlign() |
| 1054 | 1065 | } |
| @@ -1084,7 +1095,7 @@ public struct TableEvalFormula: OrgCommand { | ||
| 1084 | 1095 | let stored = (try? EmacsBuffer.storedFormulas(buffer.firstTblfm(after: buffer.tableEnd()) ?? ""))?.first { $0.lhs == scol }?.rhs |
| 1085 | 1096 | return .prompt(Prompt(key: "formula", message: "\(named ? "Field" : "Column") formula \(scol)=", initial: stored ?? "")) |
| 1086 | 1097 | } |
| 1087 | return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named) } | |
| 1098 | return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named, read: true) } | |
| 1088 | 1099 | } |
| 1089 | 1100 | } |
| 1090 | 1101 | |
| @@ -1162,7 +1173,7 @@ extension BlockEdit { | ||
| 1162 | 1173 | } |
| 1163 | 1174 | guard case .commit(let result) = step else { return step } |
| 1164 | 1175 | return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, |
| 1165 | effects: result.effects + [.message("New formulas installed - press C-u C-c C-c to apply.")])) | |
| 1176 | effects: result.effects + [.message("New formulas installed - Recalculate Table (C-c C-c on the #+TBLFM line) applies them.")])) | |
| 1166 | 1177 | } |
| 1167 | 1178 | } |
| 1168 | 1179 | |
Sources/OrgCore/Keymap/Presets.swift +1 −1
| @@ -998,7 +998,7 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 998 | 998 | command = "org.fill-paragraph" |
| 999 | 999 | [[bind]] |
| 1000 | 1000 | keys = "C-c C-c" |
| 1001 | command = "org.table.align" | |
| 1001 | command = "org.ctrl-c-ctrl-c" | |
| 1002 | 1002 | when = "table" |
| 1003 | 1003 | [[bind]] |
| 1004 | 1004 | keys = "C-c C-c" |
Sources/OrgCore/Links/LinkStore.swift +1 −1
| @@ -282,7 +282,7 @@ public struct InsertLink: OrgCommand { | ||
| 282 | 282 | public struct CreateID: OrgCommand { |
| 283 | 283 | let newID: @Sendable () -> String |
| 284 | 284 | |
| 285 | public init(newID: @escaping @Sendable () -> String = { UUID().uuidString }) { | |
| 285 | public init(newID: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }) { | |
| 286 | 286 | self.newID = newID |
| 287 | 287 | } |
| 288 | 288 | |
Sources/OrgCore/Links/Links.swift +68 −6
| @@ -9,19 +9,22 @@ public struct OrgLink: Sendable, Equatable { | ||
| 9 | 9 | public let searchOption: String? |
| 10 | 10 | /// The link as written, unescaped and with abbreviations expanded. |
| 11 | 11 | public let raw: String |
| 12 | /// Where an `attachment` link starts in its file, whose entry holds the attachments. | |
| 13 | public var position: Int? | |
| 12 | 14 | |
| 13 | public init(type: String, path: String, searchOption: String? = nil, raw: String) { | |
| 15 | public init(type: String, path: String, searchOption: String? = nil, raw: String, position: Int? = nil) { | |
| 14 | 16 | self.type = type |
| 15 | 17 | self.path = path |
| 16 | 18 | self.searchOption = searchOption |
| 17 | 19 | self.raw = raw |
| 20 | self.position = position | |
| 18 | 21 | } |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | 24 | public enum Links { |
| 22 | /// `org-link-parameters` in Emacs with Org's default modules. | |
| 25 | /// `org-link-parameters` in Emacs with Org's default modules, and org-attach's. | |
| 23 | 26 | static let types = [ |
| 24 | "id", "eww", "rmail", "mhe", "irc", "info", "gnus", "docview", "bibtex", "bbdb", "w3m", "doi", "file+sys", "file+emacs", | |
| 27 | "attachment", "id", "eww", "rmail", "mhe", "irc", "info", "gnus", "docview", "bibtex", "bbdb", "w3m", "doi", "file+sys", "file+emacs", | |
| 25 | 28 | "shell", "news", "mailto", "https", "http", "ftp", "shortdoc", "help", "file", "elisp", |
| 26 | 29 | ] |
| 27 | 30 | |
| @@ -132,11 +135,70 @@ public enum Links { | ||
| 132 | 135 | walk(tree.root) |
| 133 | 136 | guard let node = found else { return nil } |
| 134 | 137 | let whole = node.text |
| 138 | var link: OrgLink | |
| 135 | 139 | if whole.hasPrefix("[["), let path = node.tokens.first(where: { $0.kind == .linkPath }) { |
| 136 | return (parse(bracket: path.text, abbreviations: abbreviations(in: tree.settings.setupKeywords + text)), node.range) | |
| 140 | link = parse(bracket: path.text, abbreviations: abbreviations(in: tree.settings.setupKeywords + text)) | |
| 141 | } else if whole.hasPrefix("<") && whole.hasSuffix(">") { | |
| 142 | link = parse(plain: String(whole.dropFirst().dropLast())) | |
| 143 | } else if tree.settings.radioTargets.contains(where: { words($0) == words(whole) }) { | |
| 144 | link = OrgLink(type: "radio", path: whole, raw: whole) | |
| 145 | } else { | |
| 146 | link = parse(plain: whole) | |
| 147 | } | |
| 148 | if link.type == "attachment" { link.position = node.range.lowerBound } | |
| 149 | return (link, node.range) | |
| 150 | } | |
| 151 | ||
| 152 | /// Words without case, as radio links match their targets. | |
| 153 | static func words(_ s: String) -> [String] { s.lowercased().split(whereSeparator: \.isWhitespace).map(String.init) } | |
| 154 | ||
| 155 | /// `org-link--search-radio-target`: the `<<<target>>>` a radio link's text names. | |
| 156 | public static func searchRadio(_ target: String, tree: OrgTree) -> SearchResult { | |
| 157 | var found: Int? | |
| 158 | func walk(_ node: SyntaxNode) { | |
| 159 | for child in node.children where found == nil { | |
| 160 | if child.kind == .radioTarget, words(String(child.text.dropFirst(3).dropLast(3))) == words(target) { | |
| 161 | found = child.range.lowerBound | |
| 162 | } | |
| 163 | walk(child) | |
| 164 | } | |
| 165 | } | |
| 166 | walk(tree.root) | |
| 167 | return found.map { .found($0) } ?? .failed("No match for radio target: \(target)") | |
| 168 | } | |
| 169 | ||
| 170 | /// The file `org-attach-follow` opens for an `attachment` link, with org-attach's | |
| 171 | /// defaults: in the entry's DIR property (or the older ATTACH_DIR), else in the folder for | |
| 172 | /// its ID under `data/` that exists (`org-attach-id-to-path-function-list`), both relative | |
| 173 | /// to the file's folder. Without an existing folder the file is relative to the file's | |
| 174 | /// folder, as `org-attach-expand` has it. Nil without a folder to start from. | |
| 175 | public static func attachmentFile(_ link: OrgLink, tree: OrgTree, path: String?, isDirectory: (String) -> Bool) -> OrgLink? { | |
| 176 | let model = DocumentModel(tree: tree) | |
| 177 | let offset = link.position ?? 0 | |
| 178 | let heading = model.headings.lastIndex { $0.sectionRange.contains(offset) } | |
| 179 | let folder = path.map { ($0 as NSString).deletingLastPathComponent } | |
| 180 | func absolute(_ p: String, in folder: String?) -> String? { | |
| 181 | let expanded = (p as NSString).expandingTildeInPath | |
| 182 | if expanded.hasPrefix("/") { return (expanded as NSString).standardizingPath } | |
| 183 | return folder.map { (($0 as NSString).appendingPathComponent(expanded) as NSString).standardizingPath } | |
| 184 | } | |
| 185 | var candidates: [String] = [] | |
| 186 | if let dir = model.property("DIR", of: heading) ?? model.property("ATTACH_DIR", of: heading) { | |
| 187 | candidates = [dir.value] | |
| 188 | } else if let id = model.property("ID", of: heading)?.value, let first = id.first { | |
| 189 | let chars = Array(id) | |
| 190 | if chars.count > 2 { candidates.append("data/" + String(chars[..<2]) + "/" + String(chars[2...])) } | |
| 191 | if chars.count > 6 { candidates.append("data/" + String(chars[..<6]) + "/" + String(chars[6...])) } | |
| 192 | candidates.append("data/__/\(first)/\(id)") | |
| 193 | } | |
| 194 | let dir = candidates.lazy.compactMap { absolute($0, in: folder) }.first(where: isDirectory) ?? folder | |
| 195 | var file = link.path | |
| 196 | var search: String? | |
| 197 | if let range = file.range(of: "::") { | |
| 198 | search = String(file[range.upperBound...]) | |
| 199 | file = String(file[..<range.lowerBound]) | |
| 137 | 200 | } |
| 138 | let plain = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole | |
| 139 | return (parse(plain: plain), node.range) | |
| 201 | return absolute(file, in: dir).map { OrgLink(type: "file", path: $0, searchOption: search, raw: link.raw) } | |
| 140 | 202 | } |
| 141 | 203 | |
| 142 | 204 | // MARK: - Searching a file |
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 +28 −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 { |
| @@ -1019,4 +1027,21 @@ struct LinkFollowTests { | ||
| 1019 | 1027 | #expect(follow("doi:10.1000/182") == .openExternally(URL(string: "https://doi.org/10.1000/182")!)) |
| 1020 | 1028 | #expect(follow("shell:rm -rf x") == .message("Orgstar doesn't run shell links")) |
| 1021 | 1029 | } |
| 1030 | ||
| 1031 | @Test func followsAttachmentAndRadioLinks() throws { | |
| 1032 | let notes = try Folder() | |
| 1033 | let text = "* Task\n:PROPERTIES:\n:ID: ab12-cd\n:END:\nSee [[attachment:scan.pdf]] and <<<the plan>>>.\n* Later\nAs in The Plan.\n" | |
| 1034 | let a = try notes.write("a.org", text) | |
| 1035 | let scan = try notes.write("data/ab/12-cd/scan.pdf", "pdf") | |
| 1036 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | |
| 1037 | session.open(a.path) | |
| 1038 | let tree = try #require(session.document?.tree) | |
| 1039 | func follow(_ needle: String) throws -> LinkFollow { | |
| 1040 | let link = try #require(Links.link(at: (text as NSString).range(of: needle).location + 1, text: text, tree: tree)?.link) | |
| 1041 | return session.follow(link, workspace: nil, isText: { $0.hasSuffix(".org") }) | |
| 1042 | } | |
| 1043 | #expect(try follow("attachment:") == .openExternally(URL(fileURLWithPath: (scan.path as NSString).standardizingPath))) | |
| 1044 | #expect(try follow("The Plan") == .shown) | |
| 1045 | #expect(session.revealRequest?.offset == (text as NSString).range(of: "<<<the plan>>>").location) | |
| 1046 | } | |
| 1022 | 1047 | } |
Tests/OrgCoreTests/KeymapTests.swift +1 −1
| @@ -212,6 +212,6 @@ struct CtrlCCtrlCKeyTests { | ||
| 212 | 212 | } |
| 213 | 213 | #expect(command(at: 1) == "org.ctrl-c-ctrl-c") |
| 214 | 214 | #expect(command(at: 5) == "org.ctrl-c-ctrl-c") |
| 215 | #expect(command(at: 13) == "org.table.align") | |
| 215 | #expect(command(at: 13) == "org.ctrl-c-ctrl-c") | |
| 216 | 216 | } |
| 217 | 217 | } |
Tests/OrgCoreTests/LinkStoreTests.swift +10
| @@ -209,4 +209,14 @@ struct LinkStoreTests { | ||
| 209 | 209 | } |
| 210 | 210 | #expect(ours == emacs) |
| 211 | 211 | } |
| 212 | ||
| 213 | @Test(.enabled(if: oracle)) | |
| 214 | func createdIDsAreLowercaseUUIDsLikeOrgID() throws { | |
| 215 | let uuid = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" | |
| 216 | let emacs = try EmacsOracle.evaluate("* A\n", "(list (progn (require 'org-id) (org-id-uuid)))") | |
| 217 | #expect(emacs.first?.range(of: uuid, options: .regularExpression) != nil, "\(emacs)") | |
| 218 | let text = runCommand(CreateID(), "* A\n", caret: 0).text | |
| 219 | let id = try #require(text.firstMatch(of: /:ID: +(\S+)/)?.1) | |
| 220 | #expect(String(id).range(of: uuid, options: .regularExpression) != nil, "\(text)") | |
| 221 | } | |
| 212 | 222 | } |
Tests/OrgCoreTests/LinkTests.swift +76
| @@ -88,4 +88,80 @@ struct LinkTests { | ||
| 88 | 88 | #expect(ours == expected, "\(s)") |
| 89 | 89 | } |
| 90 | 90 | } |
| 91 | ||
| 92 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 93 | func followsRadioLinksLikeOrg() throws { | |
| 94 | let text = "* A\nSome <<<Radio Target>>> here.\n* B\nThen radio\ntarget again, and RADIO TARGET.\n" | |
| 95 | let tree = OrgParser.parse(text) | |
| 96 | let ns = text as NSString | |
| 97 | let offsets = [ns.range(of: "radio\ntarget").location + 1, ns.range(of: "RADIO TARGET").location + 2] | |
| 98 | let emacs = try EmacsOracle.run(offsets.map { EmacsOracle.Case(text: text, point: EmacsOracle.point($0, in: text), form: "(org-open-at-point)") }) | |
| 99 | for (offset, theirs) in zip(offsets, emacs) { | |
| 100 | let link = try #require(Links.link(at: offset, text: text, tree: tree)?.link) | |
| 101 | #expect(link.type == "radio") | |
| 102 | #expect(Links.searchRadio(link.path, tree: tree) == .found(EmacsOracle.offset(theirs.point, in: theirs.text)), "\(theirs)") | |
| 103 | } | |
| 104 | #expect(Links.searchRadio("elsewhere", tree: tree) == .failed("No match for radio target: elsewhere")) | |
| 105 | } | |
| 106 | ||
| 107 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 108 | func findsAttachmentsLikeOrgAttach() throws { | |
| 109 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-attach-\(UUID().uuidString)") | |
| 110 | defer { try? FileManager.default.removeItem(at: folder) } | |
| 111 | for dir in ["top", "files", "data/3f/2a77c0-1111", "data/202601/05T101010"] { | |
| 112 | try FileManager.default.createDirectory(at: folder.appendingPathComponent(dir), withIntermediateDirectories: true) | |
| 113 | } | |
| 114 | let text = """ | |
| 115 | :PROPERTIES: | |
| 116 | :DIR: top | |
| 117 | :END: | |
| 118 | [[attachment:t.txt]] | |
| 119 | * Dir | |
| 120 | :PROPERTIES: | |
| 121 | :DIR: files | |
| 122 | :END: | |
| 123 | [[attachment:a.txt::3]] | |
| 124 | * By ID | |
| 125 | :PROPERTIES: | |
| 126 | :ID: 3f2a77c0-1111 | |
| 127 | :END: | |
| 128 | [[attachment:b.txt]] | |
| 129 | ** Child without its own | |
| 130 | [[attachment:inherit.txt]] | |
| 131 | * Missing | |
| 132 | :PROPERTIES: | |
| 133 | :ID: zz99-none | |
| 134 | :END: | |
| 135 | [[attachment:c.txt]] | |
| 136 | * Timestamp ID | |
| 137 | :PROPERTIES: | |
| 138 | :ID: 20260105T101010 | |
| 139 | :END: | |
| 140 | [[attachment:sub/d.txt]] | |
| 141 | ||
| 142 | """ | |
| 143 | let file = folder.appendingPathComponent("notes.org").path | |
| 144 | try text.write(toFile: file, atomically: true, encoding: .utf8) | |
| 145 | let ns = text as NSString | |
| 146 | let offsets = ["t.txt", "a.txt", "b.txt", "inherit.txt", "c.txt", "d.txt"].map { ns.range(of: $0).location } | |
| 147 | let points = offsets.map { String(EmacsOracle.point($0, in: text)) }.joined(separator: " ") | |
| 148 | let output = try EmacsOracle.run(["-Q", "--batch", "--eval", """ | |
| 149 | (progn (require 'org) (require 'org-attach) | |
| 150 | (with-current-buffer (find-file-noselect "\(file)") | |
| 151 | (dolist (p '(\(points))) | |
| 152 | (goto-char p) | |
| 153 | (princ (format "%s\\n" (org-attach-expand (org-element-property :path (org-element-context)))))))) | |
| 154 | """]) | |
| 155 | let emacs = output.split(separator: "\n").map(String.init).filter { $0.hasPrefix("/") } | |
| 156 | let tree = OrgParser.parse(text) | |
| 157 | let ours = offsets.map { offset -> String in | |
| 158 | guard let link = Links.link(at: offset, text: text, tree: tree)?.link, | |
| 159 | let target = Links.attachmentFile(link, tree: tree, path: file, isDirectory: { path in | |
| 160 | var directory: ObjCBool = false | |
| 161 | return FileManager.default.fileExists(atPath: path, isDirectory: &directory) && directory.boolValue | |
| 162 | }) else { return "none" } | |
| 163 | return target.path + (target.searchOption.map { "::" + $0 } ?? "") | |
| 164 | } | |
| 165 | #expect(ours == emacs, "\(output)") | |
| 166 | } | |
| 91 | 167 | } |
Tests/OrgCoreTests/TableFormulaEntryTests.swift +36
| @@ -13,6 +13,9 @@ struct TableFormulaEntryTests { | ||
| 13 | 13 | let replaced = "| a | b |\n| 1 | =$1*5 |\n#+TBLFM: $2=$1*2::@1$2=x\n" |
| 14 | 14 | let reuse = "| a | b |\n| 2 | = |\n#+TBLFM: $2=$1*2\n\nText\n" |
| 15 | 15 | let last = "| 1 | =$1+1 |\n" |
| 16 | let marked = "| | x | y |\n|---+---+---|\n| # | 2 | |\n| | 3 | |\n#+TBLFM: $3=$2*10\n" | |
| 17 | let markedTyped = "| # | 2 | =$2+1 | |\n| # | 5 | | |\n#+TBLFM: $4=$2*2\n" | |
| 18 | let prompt = { (arg: String) in "(cl-letf (((symbol-function 'read-string) (lambda (&rest _) \"\"))) (org-table-eval-formula \(arg)))" } | |
| 16 | 19 | let cases: [(String, String, any OrgCommand, [String: String], String)] = [ |
| 17 | 20 | (plain, "*2", TableCommand(.nextField), [:], "(org-table-next-field)"), |
| 18 | 21 | (plain, "*2", TableCommand(.nextRow), [:], "(org-table-next-row)"), |
| @@ -25,6 +28,11 @@ struct TableFormulaEntryTests { | ||
| 25 | 28 | (stored, "| 3", TableEvalFormula(named: false), ["formula": "$1*3"], "(org-table-eval-formula nil \"$1*3\")"), |
| 26 | 29 | (stored, "| 3", TableEvalFormula(named: true), ["formula": "99"], "(org-table-eval-formula '(4) \"99\")"), |
| 27 | 30 | (stored, "| 1 | 2", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"), |
| 31 | (marked, "| 2", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"), | |
| 32 | (marked, "| 3", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"), | |
| 33 | (markedTyped, "+1", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"), | |
| 34 | (stored, "| 3 | 4", TableEvalFormula(named: false), ["formula": ""], prompt("nil")), | |
| 35 | (stored, "| 1", TableEvalFormula(named: true), ["formula": ""], prompt("'(4)")), | |
| 28 | 36 | ] |
| 29 | 37 | let emacs = try EmacsOracle.run(cases.map { c in |
| 30 | 38 | let caret = NSMaxRange((c.0 as NSString).range(of: c.1)) |
| @@ -67,3 +75,31 @@ extension TableFormulaEntryTests { | ||
| 67 | 75 | } |
| 68 | 76 | } |
| 69 | 77 | } |
| 78 | ||
| 79 | extension TableFormulaEntryTests { | |
| 80 | /// Runs a command that should commit: the new text, the caret and the message. | |
| 81 | func committed(_ command: any OrgCommand, _ text: String, after needle: String, answers: [String: String] = [:]) -> (text: String, caret: Int, message: String?)? { | |
| 82 | let caret = NSMaxRange((text as NSString).range(of: needle)) | |
| 83 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers) | |
| 84 | guard case .commit(let result) = command.run(in: context) else { return nil } | |
| 85 | var new = text | |
| 86 | for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) } | |
| 87 | let message = result.effects.compactMap { effect -> String? in if case .message(let m) = effect { m } else { nil } }.first | |
| 88 | return (new, result.selection?.first?.lowerBound ?? caret, message) | |
| 89 | } | |
| 90 | ||
| 91 | @Test func formulaEditsAreKeptWithTheirMessage() throws { | |
| 92 | let text = "| a | b |\n|---+---|\n| 1 | 2 |\n#+TBLFM: $2=$1*2\n" | |
| 93 | let removed = try #require(committed(TableEvalFormula(named: false), text, after: "| 1 | 2", answers: ["formula": ""])) | |
| 94 | #expect(removed.text == "| a | b |\n|---+---|\n| 1 | 2 |\n#+TBLFM: \n" && removed.message == "Formula removed", "\(removed)") | |
| 95 | // Text in a field is a Calc symbol, which needs Emacs: the formula is stored and TAB | |
| 96 | // still moves on. | |
| 97 | let symbolic = "| a | b |\n| x | =$1*2 |\n| y | |\n" | |
| 98 | let tab = try #require(committed(TableCommand(.nextField), symbolic, after: "*2")) | |
| 99 | #expect(tab.text == "| a | b |\n| x | =$1*2 |\n| y | |\n#+TBLFM: $2=$1*2\n", "\(tab.text.debugDescription)") | |
| 100 | #expect(tab.caret == (tab.text as NSString).range(of: "| y").location + 2) | |
| 101 | #expect(tab.message?.hasPrefix("The formula was stored; recalculating it needs Emacs") == true) | |
| 102 | let prompted = try #require(committed(TableEvalFormula(named: false), symbolic, after: "| x", answers: ["formula": "$1+1"])) | |
| 103 | #expect(prompted.text.hasSuffix("#+TBLFM: $1=$1+1\n") && prompted.message != nil, "\(prompted)") | |
| 104 | } | |
| 105 | } | |
Tests/OrgCoreTests/TableFormulaTests.swift +2
| @@ -56,6 +56,8 @@ struct TableFormulaTests { | ||
| 56 | 56 | "| a | b |\n|---+---|\n| 1 | |\n| 2 | |\n#+TBLFM: @-1$2=5\n", |
| 57 | 57 | "| a | b | f |\n|---+---+---|\n| 1 | 3 | |\n| 7 | 2 | |\n| -7 | 2 | |\n| 6 | 3 | |\n| 1 | 0.5 | |\n| 4 | 6 | |\n#+TBLFM: $3=$1/$2;F\n", |
| 58 | 58 | "| a |\n|---|\n| |\n| |\n| |\n| |\n| |\n| |\n| |\n| |\n| |\n#+TBLFM: @2$1=1/3+1/6;F::@3$1=(1/3)^2;F::@4$1=vsum([1/2,1/3]);F::@5$1=1/3*3;F::@6$1=sqrt(1/4);F::@7$1=vmean([1,2]);F::@8$1=2^-2;F::@9$1=abs(-1/3)+max(1/3,1/4);F::@10$1=round(7/2)+floor(7/2);F\n", |
| 59 | "| 10 | 20 | |\n| 1 | 2 | |\n| 3 | 4 | |\n#+TBLFM: $3=@0$1+@0$2*10\n", | |
| 60 | "#+NAME: t1\n| 5 | 6 |\n| 1 | 2 |\n\n#+NAME: t2\n| 7 | 8 |\n\n| name | val | sum |\n|------+-----+-----|\n| t1 | | |\n| t2 | | |\n#+TBLFM: $2=remote($1,@1$1)::$3=vsum(remote(t1,$1..$2))+vsum($1..$2)\n", | |
| 59 | 61 | ] |
| 60 | 62 | |
| 61 | 63 | @Test func remoteFindsTablesByIDInOtherFiles() { |
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]) |