Insert and store links, create IDs !66
11 files changed, +614 −10
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +7 −3
| @@ -85,14 +85,16 @@ public struct Prompt: Sendable, Equatable { | ||
| 85 | 85 | public let message: String |
| 86 | 86 | /// Text the answer field starts with. |
| 87 | 87 | public let initial: String |
| 88 | /// When set, the answer is one of these. | |
| 88 | /// When set, the answer is one of these, or with `requireMatch` false, these are offered. | |
| 89 | 89 | public let choices: [String] |
| 90 | public let requireMatch: Bool | |
| 90 | 91 | |
| 91 | public init(key: String, message: String, initial: String = "", choices: [String] = []) { | |
| 92 | public init(key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true) { | |
| 92 | 93 | self.key = key |
| 93 | 94 | self.message = message |
| 94 | 95 | self.initial = initial |
| 95 | 96 | self.choices = choices |
| 97 | self.requireMatch = requireMatch | |
| 96 | 98 | } |
| 97 | 99 | } |
| 98 | 100 | |
| @@ -112,6 +114,8 @@ public struct PrecomputedEdit: OrgCommand { | ||
| 112 | 114 | /// Things a command asks for besides text changes; the platform layer carries them out. |
| 113 | 115 | public enum Effect: Sendable, Equatable { |
| 114 | 116 | case message(String) |
| 117 | /// An inserted link was a stored one; it leaves `org-stored-links`. | |
| 118 | case storedLinkUsed(String) | |
| 115 | 119 | } |
| 116 | 120 | |
| 117 | 121 | public struct EditResult: Sendable, Equatable { |
| @@ -177,7 +181,7 @@ public enum Commands { | ||
| 177 | 181 | RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(), |
| 178 | 182 | TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), |
| 179 | 183 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 180 | OpenLinkAtPoint(), | |
| 184 | OpenLinkAtPoint(), CreateID(), | |
| 181 | 185 | ] |
| 182 | 186 | |
| 183 | 187 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } |
Sources/OrgCore/Keymap/Presets.swift +22
| @@ -190,6 +190,22 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 190 | 190 | |
| 191 | 191 | # Localleader (SPC m) |
| 192 | 192 | [[bind]] |
| 193 | keys = "SPC m l l" | |
| 194 | command = "org.link.insert" | |
| 195 | mode = "normal" | |
| 196 | [[bind]] | |
| 197 | keys = "SPC m l s" | |
| 198 | command = "org.link.store" | |
| 199 | mode = "normal" | |
| 200 | [[bind]] | |
| 201 | keys = "SPC m l i" | |
| 202 | command = "org.id.store-link" | |
| 203 | mode = "normal" | |
| 204 | [[bind]] | |
| 205 | keys = "SPC n l" | |
| 206 | command = "org.link.store" | |
| 207 | mode = "normal" | |
| 208 | [[bind]] | |
| 193 | 209 | keys = "SPC m t" |
| 194 | 210 | command = "org.todo.cycle" |
| 195 | 211 | mode = "normal" |
| @@ -554,6 +570,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 554 | 570 | keys = "C-c C-o" |
| 555 | 571 | command = "org.link.open" |
| 556 | 572 | [[bind]] |
| 573 | keys = "C-c C-l" | |
| 574 | command = "org.link.insert" | |
| 575 | [[bind]] | |
| 576 | keys = "C-c l" | |
| 577 | command = "org.link.store" | |
| 578 | [[bind]] | |
| 557 | 579 | keys = "C-x b" |
| 558 | 580 | command = "app.buffer.switch" |
| 559 | 581 | [[bind]] |
Sources/OrgCore/Links/LinkStore.swift added +319
| @@ -0,0 +1,319 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// An entry of `org-stored-links`. | |
| 4 | public struct StoredLink: Sendable, Equatable, Codable { | |
| 5 | public let link: String | |
| 6 | public let description: String? | |
| 7 | ||
| 8 | public init(link: String, description: String?) { | |
| 9 | self.link = link | |
| 10 | self.description = description | |
| 11 | } | |
| 12 | } | |
| 13 | ||
| 14 | extension Links { | |
| 15 | /// `org-store-link` in an org file, with `org-link-context-for-files` t: a file link to | |
| 16 | /// `path` with a search string for the target, named element, heading or line at the | |
| 17 | /// caret (the selection when there is one). `path` is as `abbreviate-file-name` gives it. | |
| 18 | public static func store(in context: EditContext, path: String) -> StoredLink { | |
| 19 | let link = "file:" + path | |
| 20 | guard let target = targetAtCaret(context) else { | |
| 21 | guard let (search, description, _) = preciseTarget(context) else { return StoredLink(link: link, description: nil) } | |
| 22 | return StoredLink(link: link + "::" + search, description: description.map(displayFormat)) | |
| 23 | } | |
| 24 | return StoredLink(link: link + "::" + target, description: nil) | |
| 25 | } | |
| 26 | ||
| 27 | /// `org-id-store-link` after `org-id-get-create`: an `id:` link to the entry at the caret, | |
| 28 | /// described by its title, or before the first heading by `#+TITLE` or the file name. | |
| 29 | public static func storeID(in context: EditContext, fileName: String) -> StoredLink? { | |
| 30 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) | |
| 31 | buffer.backToHeadingOrPointMin() | |
| 32 | guard let id = buffer.entryID() else { return nil } | |
| 33 | let link = "id:" + id | |
| 34 | // With `org-id-link-use-context`, a target after the entry's start narrows the link. | |
| 35 | if let (search, description, position) = preciseTarget(context), position > buffer.point { | |
| 36 | return StoredLink(link: link + "::" + search, description: description) | |
| 37 | } | |
| 38 | if buffer.beforeFirstHeading { return StoredLink(link: link, description: buffer.keyword("TITLE") ?? fileName) } | |
| 39 | guard let match = buffer.lookingAt(buffer.complexHeadingPattern), let all = match[0] else { return StoredLink(link: link, description: link) } | |
| 40 | let title = match[4] ?? all | |
| 41 | return StoredLink(link: link, description: buffer.substring(title.lowerBound, title.upperBound)) | |
| 42 | } | |
| 43 | ||
| 44 | /// A `<<target>>` on the caret's line that the caret touches, as `org-in-regexp` finds it. | |
| 45 | static func targetAtCaret(_ context: EditContext) -> String? { | |
| 46 | let ns = context.text as NSString | |
| 47 | let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0)) | |
| 48 | var end = NSMaxRange(line) | |
| 49 | if end > line.location, ns.character(at: end - 1) == 10 { end -= 1 } | |
| 50 | let regex = try! NSRegularExpression(pattern: "[^<]<<([^<>]+)>>[^>]") | |
| 51 | var from = line.location | |
| 52 | while from < end, let m = regex.firstMatch(in: context.text, range: NSRange(from..<end)), m.range.location <= context.caret { | |
| 53 | if NSMaxRange(m.range) >= context.caret { return ns.substring(with: m.range(at: 1)) } | |
| 54 | from = m.range.location + 1 | |
| 55 | } | |
| 56 | return nil | |
| 57 | } | |
| 58 | ||
| 59 | /// `org-link-precise-link-target`: the search string and description for the caret, and | |
| 60 | /// where the search matches. | |
| 61 | static func preciseTarget(_ context: EditContext) -> (String, String?, Int)? { | |
| 62 | let ns = context.text as NSString | |
| 63 | let result: (String, String?, Int) | |
| 64 | if let range = context.selection.first, !range.isEmpty { | |
| 65 | result = (normalize(ns.substring(with: NSRange(range)), context: true), nil, range.lowerBound) | |
| 66 | } else if let (name, start) = namedElement(at: context.caret, in: context.tree) { | |
| 67 | result = (name, name, start) | |
| 68 | } else if let heading = DocumentModel(tree: context.tree).headings.last(where: { $0.headingRange.lowerBound <= context.caret }) { | |
| 69 | let title = normalizedTitle(heading.title) | |
| 70 | let customID = heading.properties.last { $0.key.uppercased() == "CUSTOM_ID" }?.value | |
| 71 | result = (customID.map { "#" + $0 } ?? "*" + title, title, heading.headingRange.lowerBound) | |
| 72 | } else { | |
| 73 | let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0)) | |
| 74 | result = (normalize(ns.substring(with: line).trimmingCharacters(in: .newlines), context: true), nil, line.location) | |
| 75 | } | |
| 76 | return result.0.contains(where: { !$0.isWhitespace }) ? result : nil | |
| 77 | } | |
| 78 | ||
| 79 | /// The `#+NAME` of the element at `offset`, and where the element starts (its first | |
| 80 | /// affiliated keyword). | |
| 81 | static func namedElement(at offset: Int, in tree: OrgTree) -> (String, Int)? { | |
| 82 | let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer, .footnoteDefinition] | |
| 83 | var node = tree.root | |
| 84 | while true { | |
| 85 | let children = node.children | |
| 86 | guard let index = children.firstIndex(where: { $0.range.contains(offset) || ($0.range.upperBound == offset && offset == tree.root.range.upperBound) }) else { return nil } | |
| 87 | var element = index | |
| 88 | while element < children.count, children[element].kind == .affiliatedKeyword { element += 1 } | |
| 89 | if element > index, element == children.count || children[element - 1].range.upperBound != children[element].range.lowerBound { return nil } | |
| 90 | if element == index, containers.contains(children[index].kind) { | |
| 91 | node = children[index] | |
| 92 | continue | |
| 93 | } | |
| 94 | var first = element | |
| 95 | while first > 0, children[first - 1].kind == .affiliatedKeyword, children[first - 1].range.upperBound == children[first].range.lowerBound { first -= 1 } | |
| 96 | for keyword in children[first..<element].reversed() { | |
| 97 | if let range = keyword.text.range(of: "^[ \\t]*#\\+NAME:[ \\t]*(.*?)[ \\t]*\\n?$", options: [.regularExpression, .caseInsensitive]) { | |
| 98 | let value = keyword.text[range].replacingOccurrences(of: "^[ \\t]*#\\+[Nn][Aa][Mm][Ee]:[ \\t]*|[ \\t]*\\n?$", with: "", options: .regularExpression) | |
| 99 | if !value.isEmpty { return (value, children[first].range.lowerBound) } | |
| 100 | } | |
| 101 | } | |
| 102 | return nil | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | /// `org-link--normalize-string`: statistics cookies and runs of blanks become one space; | |
| 107 | /// with `context`, enclosing parentheses and leading `#` and `*` go too. | |
| 108 | static func normalize(_ s: String, context: Bool) -> String { | |
| 109 | var t = s.replacingOccurrences(of: "\\[[0-9]*(%|/[0-9]*)\\]", with: " ", options: .regularExpression) | |
| 110 | t = t.replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression) | |
| 111 | t = t.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 112 | guard context else { return t } | |
| 113 | while true { | |
| 114 | if t.hasPrefix("("), t.hasSuffix(")"), t.count >= 2 { | |
| 115 | t = String(t.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) | |
| 116 | } else if let range = t.range(of: "^[#*]+[ \\t]*", options: .regularExpression) { | |
| 117 | t.removeSubrange(range) | |
| 118 | } else { | |
| 119 | return t | |
| 120 | } | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | /// `org-link-display-format`: bracket links become their descriptions. | |
| 125 | static func displayFormat(_ s: String) -> String { | |
| 126 | let regex = try! NSRegularExpression(pattern: "\\[\\[((?:[^\\]\\[\\\\]|\\\\(?:\\\\\\\\)*[\\]\\[]|\\\\+[^\\]\\[])+)\\](?:\\[((?:.|\\n)+?)\\])?\\]") | |
| 127 | let ns = s as NSString | |
| 128 | var out = s | |
| 129 | for m in regex.matches(in: s, range: NSRange(location: 0, length: ns.length)).reversed() { | |
| 130 | let group = m.range(at: 2).location != NSNotFound ? m.range(at: 2) : m.range(at: 1) | |
| 131 | out = (out as NSString).replacingCharacters(in: m.range, with: ns.substring(with: group)) | |
| 132 | } | |
| 133 | return out | |
| 134 | } | |
| 135 | ||
| 136 | /// `org-link-escape`. | |
| 137 | static func escape(_ link: String) -> String { | |
| 138 | let regex = try! NSRegularExpression(pattern: "(\\\\*)([\\[\\]]|$)") | |
| 139 | let ns = link as NSString | |
| 140 | var out = link | |
| 141 | for m in regex.matches(in: link, range: NSRange(location: 0, length: ns.length)).reversed() { | |
| 142 | let run = ns.substring(with: m.range(at: 1)) | |
| 143 | out = (out as NSString).replacingCharacters(in: m.range(at: 1), with: run + run + (m.range(at: 2).length > 0 ? "\\" : "")) | |
| 144 | } | |
| 145 | return out | |
| 146 | } | |
| 147 | ||
| 148 | /// `org-link-make-string`. | |
| 149 | static func makeString(_ link: String, _ description: String?) -> String { | |
| 150 | let zeroWidthSpace = "\u{200B}" | |
| 151 | let description = description.flatMap { d -> String? in | |
| 152 | guard d.contains(where: { !$0.isWhitespace }) else { return nil } | |
| 153 | return d.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 154 | .replacingOccurrences(of: "\\]$", with: "]" + zeroWidthSpace, options: .regularExpression) | |
| 155 | .replacingOccurrences(of: "\\]\\]", with: "]" + zeroWidthSpace + "]", options: .regularExpression) | |
| 156 | } | |
| 157 | return "[[" + escape(link) + "]" + (description.map { "[" + $0 + "]" } ?? "") + "]" | |
| 158 | } | |
| 159 | ||
| 160 | /// `expand-file-name` of `path` in `directory`, with `~` for `home`. | |
| 161 | static func expand(_ path: String, directory: String?, home: String) -> String { | |
| 162 | var p = path | |
| 163 | if p == "~" || p.hasPrefix("~/") { p = home + p.dropFirst() } | |
| 164 | if !p.hasPrefix("/"), let directory { p = (directory as NSString).appendingPathComponent(p) } | |
| 165 | return (p as NSString).standardizingPath | |
| 166 | } | |
| 167 | ||
| 168 | /// `abbreviate-file-name`: `~` for the home folder. | |
| 169 | public static func abbreviate(_ path: String, home: String) -> String { | |
| 170 | if path == home { return "~" } | |
| 171 | return path.hasPrefix(home + "/") ? "~" + path.dropFirst(home.count) : path | |
| 172 | } | |
| 173 | ||
| 174 | /// `org-link-make-string-for-buffer` before its description prompt: the link a buffer | |
| 175 | /// visiting `file` gets, and the description to offer. | |
| 176 | static func forBuffer(_ link: String, description: String?, file: String?, home: String) -> (link: String, description: String?) { | |
| 177 | var link = link | |
| 178 | var description = description | |
| 179 | if link.hasPrefix("<"), link.hasSuffix(">"), typesPattern.firstMatch(in: String(link.dropFirst()), range: NSRange(location: 0, length: (link as NSString).length - 1)) != nil { | |
| 180 | link = String(link.dropFirst().dropLast()) | |
| 181 | } | |
| 182 | let directory = file.map { ($0 as NSString).deletingLastPathComponent } | |
| 183 | if let file, let range = link.range(of: "^file:(.+?)::", options: .regularExpression) { | |
| 184 | let path = String(link[range].dropFirst(5).dropLast(2)) | |
| 185 | if expand(path, directory: directory, home: home) == expand(file, directory: nil, home: home) { | |
| 186 | link = String(link[range.upperBound...]) | |
| 187 | } | |
| 188 | } | |
| 189 | if let prefix = ["file:", "docview:"].first(where: link.hasPrefix) { | |
| 190 | var rest = String(link.dropFirst(prefix.count)) | |
| 191 | var search: String? | |
| 192 | if let range = rest.range(of: "::(.*)$", options: .regularExpression) { | |
| 193 | search = String(rest[range].dropFirst(2)) | |
| 194 | rest.removeSubrange(range) | |
| 195 | } | |
| 196 | let original = rest.contains(where: { !$0.isWhitespace }) ? rest : file ?? "" | |
| 197 | let expanded = expand(original, directory: directory, home: home) | |
| 198 | let base = directory.map { expand($0, directory: nil, home: home) + "/" } | |
| 199 | let path = base.map(expanded.hasPrefix) == true ? String(expanded.dropFirst(base!.count)) : abbreviate(expanded, home: home) | |
| 200 | link = prefix + path + (search.map { "::" + $0 } ?? "") | |
| 201 | if description == original { description = path } | |
| 202 | } | |
| 203 | return (link, description) | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | /// `org-insert-link` (C-c C-l): asks for a link, offering stored links and link types, and a | |
| 208 | /// description; on a link, edits it. | |
| 209 | public struct InsertLink: OrgCommand { | |
| 210 | /// `org-stored-links`, most recent first. | |
| 211 | public let stored: [StoredLink] | |
| 212 | /// The file the buffer visits. | |
| 213 | public let file: String? | |
| 214 | public let home: String | |
| 215 | ||
| 216 | public init(stored: [StoredLink] = [], file: String? = nil, home: String = NSHomeDirectory()) { | |
| 217 | self.stored = stored | |
| 218 | self.file = file | |
| 219 | self.home = home | |
| 220 | } | |
| 221 | ||
| 222 | public var id: String { "org.link.insert" } | |
| 223 | public var title: String { "Insert Link" } | |
| 224 | public func applies(in context: EditContext) -> Bool { true } | |
| 225 | ||
| 226 | public func run(in context: EditContext) -> CommandStep { | |
| 227 | let ns = context.text as NSString | |
| 228 | var remove: Range<Int>? | |
| 229 | var description: String? | |
| 230 | if let range = context.selection.first, !range.isEmpty { | |
| 231 | remove = range | |
| 232 | description = ns.substring(with: NSRange(range)) | |
| 233 | } | |
| 234 | var link: String | |
| 235 | if let found = Links.link(at: context.caret, text: context.text, tree: context.tree) { | |
| 236 | let whole = ns.substring(with: NSRange(found.range)) | |
| 237 | remove = found.range | |
| 238 | let initial: String | |
| 239 | if whole.hasPrefix("[[") { | |
| 240 | let inside = whole.dropFirst(2).dropLast(1) | |
| 241 | let split = inside.range(of: "][") | |
| 242 | initial = Links.unescape(String(split.map { inside[..<$0.lowerBound] } ?? inside.dropLast())) | |
| 243 | description = split.map { String(inside[$0.upperBound...].dropLast()) } | |
| 244 | } else { | |
| 245 | initial = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole | |
| 246 | } | |
| 247 | guard let answer = context.answers["link"] else { return .prompt(Prompt(key: "link", message: "Link:", initial: initial)) } | |
| 248 | link = answer | |
| 249 | } else { | |
| 250 | let prefixes = Array(Links.abbreviations(in: context.text).keys).sorted() + Links.types | |
| 251 | guard let answer = context.answers["link"] else { | |
| 252 | let message = stored.first.map { "Insert link (default \($0.link)):" } ?? "Insert link:" | |
| 253 | let choices = stored.map(\.link) + stored.compactMap(\.description) + prefixes.map { $0 + ":" } | |
| 254 | return .prompt(Prompt(key: "link", message: message, choices: choices, requireMatch: false)) | |
| 255 | } | |
| 256 | link = answer.isEmpty ? stored.first?.link ?? "" : answer | |
| 257 | guard link.contains(where: { !$0.isWhitespace }) else { return .failed("No link selected") } | |
| 258 | if let byDescription = stored.last(where: { $0.description == link }) { link = byDescription.link } | |
| 259 | let type = link.hasSuffix(":") ? String(link.dropLast()) : link | |
| 260 | if prefixes.contains(type) { | |
| 261 | guard let special = context.answers["special"] else { | |
| 262 | return .prompt(Prompt(key: "special", message: "Link (no completion support):", initial: type + ":")) | |
| 263 | } | |
| 264 | link = special | |
| 265 | } | |
| 266 | description = description ?? stored.first { $0.link == link }?.description | |
| 267 | } | |
| 268 | let used = link | |
| 269 | let prepared = Links.forBuffer(link, description: description, file: file, home: home) | |
| 270 | guard let typed = context.answers["description"] else { | |
| 271 | return .prompt(Prompt(key: "description", message: "Description:", initial: prepared.description ?? "")) | |
| 272 | } | |
| 273 | let text = Links.makeString(prepared.link, typed) | |
| 274 | let at = remove ?? context.caret..<context.caret | |
| 275 | let end = at.lowerBound + (text as NSString).length | |
| 276 | let effects: [Effect] = stored.contains { $0.link == used } ? [.storedLinkUsed(used)] : [] | |
| 277 | return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: at, replacement: text)], selection: [end..<end], effects: effects)) | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | /// `org-id-get-create`: gives the entry an `ID` property unless it has one. | |
| 282 | public struct CreateID: OrgCommand { | |
| 283 | let newID: @Sendable () -> String | |
| 284 | ||
| 285 | public init(newID: @escaping @Sendable () -> String = { UUID().uuidString }) { | |
| 286 | self.newID = newID | |
| 287 | } | |
| 288 | ||
| 289 | public var id: String { "org.id.create" } | |
| 290 | public var title: String { "Create ID" } | |
| 291 | public func applies(in context: EditContext) -> Bool { true } | |
| 292 | public func run(in context: EditContext) -> CommandStep { | |
| 293 | commitBuffer(context) { buffer in | |
| 294 | buffer.saveExcursion { | |
| 295 | buffer.backToHeadingOrPointMin() | |
| 296 | if buffer.entryID() == nil { buffer.entryPut("ID", newID()) } | |
| 297 | } | |
| 298 | } | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | extension EmacsBuffer { | |
| 303 | /// The entry's own `ID` when it has non-blank one, from point at its heading (or the | |
| 304 | /// start of the file). | |
| 305 | func entryID() -> String? { | |
| 306 | saveExcursion { | |
| 307 | if beforeFirstHeading { | |
| 308 | while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } } | |
| 309 | } else { | |
| 310 | forwardLine(1) | |
| 311 | if lookingAt(planningPattern) != nil { forwardLine(1) } | |
| 312 | } | |
| 313 | guard let drawer = looking(Self.propertyDrawerPattern, caseInsensitive: true) else { return nil } | |
| 314 | let body = substring(drawer[0]!.lowerBound, drawer[0]!.upperBound) | |
| 315 | guard let range = body.range(of: "(?m)^[ \\t]*:ID:[ \\t]+(\\S.*?)[ \\t]*$", options: [.regularExpression, .caseInsensitive]) else { return nil } | |
| 316 | return body[range].replacingOccurrences(of: "^[ \\t]*:[Ii][Dd]:[ \\t]+|[ \\t]+$", with: "", options: .regularExpression) | |
| 317 | } | |
| 318 | } | |
| 319 | } | |
Sources/OrgEditorAppKit/EditorCommands.swift +3
| @@ -37,6 +37,9 @@ public enum EditorCommands { | ||
| 37 | 37 | public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [ |
| 38 | 38 | EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view), |
| 39 | 39 | EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view), |
| 40 | EditorCommand(id: "org.link.insert", title: "Insert Link…", action: .app), | |
| 41 | EditorCommand(id: "org.link.store", title: "Store Link", action: .app), | |
| 42 | EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app), | |
| 40 | 43 | |
| 41 | 44 | EditorCommand(id: "app.save", title: "Save", action: .app), |
| 42 | 45 | EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), |
Sources/OrgEditorAppKit/OrgEditor.swift +6 −2
| @@ -260,6 +260,8 @@ public final class OrgEditor: NSObject { | ||
| 260 | 260 | public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? |
| 261 | 261 | /// Runs work outside the text, such as a table recalculation in Emacs. |
| 262 | 262 | public var onExternal: ((ExternalRequest) -> Void)? |
| 263 | /// Effects other than messages, for the app to carry out. | |
| 264 | public var onEffect: ((Effect) -> Void)? | |
| 263 | 265 | |
| 264 | 266 | public var keymap: Keymap { |
| 265 | 267 | get { dispatcher.keymap } |
| @@ -308,7 +310,7 @@ public final class OrgEditor: NSObject { | ||
| 308 | 310 | /// A region is active: movement extends the selection, as after C-SPC in Emacs. |
| 309 | 311 | private(set) var markActive = false |
| 310 | 312 | |
| 311 | private func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext { | |
| 313 | public func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext { | |
| 312 | 314 | let selected = textView.selectedRange() |
| 313 | 315 | return EditContext( |
| 314 | 316 | revision: document.revision, text: document.text, tree: document.tree, |
| @@ -423,7 +425,9 @@ public final class OrgEditor: NSObject { | ||
| 423 | 425 | setCaret(selection.lowerBound) |
| 424 | 426 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } |
| 425 | 427 | } |
| 426 | for case .message(let text) in result.effects { onMessage?(text) } | |
| 428 | for effect in result.effects { | |
| 429 | if case .message(let text) = effect { onMessage?(text) } else { onEffect?(effect) } | |
| 430 | } | |
| 427 | 431 | case .failed(let message): |
| 428 | 432 | onMessage?(message) |
| 429 | 433 | case .external(let request): |
Sources/Orgstar/ContentView.swift +17 −2
| @@ -293,7 +293,11 @@ struct ContentView: View { | ||
| 293 | 293 | AppDelegate.pendingOpens = [] |
| 294 | 294 | if let path = DevelopmentHooks.fileToOpen { selectedFile = path } |
| 295 | 295 | if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } |
| 296 | if let command = DevelopmentHooks.commandToRun { perform(command) } | |
| 296 | if let commandList = DevelopmentHooks.commandToRun { | |
| 297 | // The file opened above needs its editor first. | |
| 298 | for _ in 0..<20 where DevelopmentHooks.fileToOpen != nil && commands.editor == nil { try? await Task.sleep(for: .milliseconds(100)) } | |
| 299 | for command in commandList.split(separator: ",") { perform(String(command)) } | |
| 300 | } | |
| 297 | 301 | if let id = DevelopmentHooks.windowToOpen { |
| 298 | 302 | if id == "settings" { openSettings() } else { openWindow(id: id) } |
| 299 | 303 | } |
| @@ -438,7 +442,7 @@ struct ContentView: View { | ||
| 438 | 442 | |
| 439 | 443 | /// Runs a command from a key, the palette or the Org menu. |
| 440 | 444 | private func perform(_ id: String) { |
| 441 | let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.") | |
| 445 | let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.") | |
| 442 | 446 | if orgOnly, !session.isOrg { |
| 443 | 447 | commands.show("Not an org file") |
| 444 | 448 | return |
| @@ -487,6 +491,17 @@ struct ContentView: View { | ||
| 487 | 491 | } |
| 488 | 492 | case "app.archive": |
| 489 | 493 | if let message = session.archive() { commands.show(message) } |
| 494 | case "org.link.insert": | |
| 495 | commands.editor?.perform(InsertLink(stored: commands.storedLinks, file: session.path)) | |
| 496 | case "org.link.store": | |
| 497 | guard let editor = commands.editor, let path = session.path else { return commands.show("No method for storing a link from this buffer") } | |
| 498 | commands.storeLink(Links.store(in: editor.context(), path: Links.abbreviate(path, home: NSHomeDirectory()))) | |
| 499 | case "org.id.store-link": | |
| 500 | guard let editor = commands.editor, let path = session.path else { return } | |
| 501 | if case .commit = editor.perform(CreateID()), | |
| 502 | let link = Links.storeID(in: editor.context(), fileName: (path as NSString).lastPathComponent) { | |
| 503 | commands.storeLink(link) | |
| 504 | } | |
| 490 | 505 | case "app.agenda": |
| 491 | 506 | openWindow(id: "agenda") |
| 492 | 507 | case "app.clock.in": |
Sources/Orgstar/DevelopmentHooks.swift +2 −2
| @@ -5,8 +5,8 @@ import AppKit | ||
| 5 | 5 | /// - `ORGSTAR_OPEN`: a file to select at launch. |
| 6 | 6 | /// - `ORGSTAR_ROOT`: a folder to add at launch. |
| 7 | 7 | /// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it. |
| 8 | /// - `ORGSTAR_COMMAND`: a command to run at launch (`app.import-emacs`); a sheet it opens is | |
| 9 | /// what the snapshot renders. | |
| 8 | /// - `ORGSTAR_COMMAND`: commands to run at launch, separated by commas (`app.import-emacs`); a | |
| 9 | /// sheet or prompt they open is what the snapshot renders. | |
| 10 | 10 | /// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's. |
| 11 | 11 | /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main |
| 12 | 12 | /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording |
Sources/Orgstar/EditorView.swift +3
| @@ -95,6 +95,9 @@ struct EditorView: NSViewRepresentable { | ||
| 95 | 95 | editor.onAppCommand = onAppCommand |
| 96 | 96 | editor.onExternal = onExternal |
| 97 | 97 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
| 98 | editor.onEffect = { [weak commands] effect in | |
| 99 | if case .storedLinkUsed(let link) = effect { commands?.storedLinks.removeAll { $0.link == link } } | |
| 100 | } | |
| 98 | 101 | let scroll = editor.makeScrollView() |
| 99 | 102 | scroll.autoresizingMask = [.width, .height] |
| 100 | 103 | return (editor, scroll) |
Sources/Orgstar/Keys.swift +9 −1
| @@ -52,17 +52,25 @@ struct EchoArea: View { | ||
| 52 | 52 | Divider() |
| 53 | 53 | HStack(spacing: 6) { |
| 54 | 54 | Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) |
| 55 | .lineLimit(1) | |
| 56 | .truncationMode(.middle) | |
| 55 | 57 | TextField("", text: $answer) |
| 58 | .frame(minWidth: 240) | |
| 56 | 59 | .textFieldStyle(.plain) |
| 57 | 60 | .font(.system(.callout, design: .monospaced)) |
| 58 | 61 | .focused($answerFocused) |
| 59 | 62 | .onSubmit { |
| 60 | if request.prompt.choices.isEmpty { | |
| 63 | if request.prompt.choices.isEmpty || !request.prompt.requireMatch { | |
| 61 | 64 | commands.answer(answer) |
| 62 | 65 | } else if let first = filteredChoices(request.prompt.choices).first { |
| 63 | 66 | commands.answer(first) |
| 64 | 67 | } |
| 65 | 68 | } |
| 69 | .onKeyPress(.tab) { | |
| 70 | guard let first = filteredChoices(request.prompt.choices).first else { return .ignored } | |
| 71 | answer = first | |
| 72 | return .handled | |
| 73 | } | |
| 66 | 74 | .onExitCommand { commands.answer(nil) } |
| 67 | 75 | } |
| 68 | 76 | .padding(.horizontal, 10) |
Sources/Orgstar/OrgstarApp.swift +14
| @@ -26,6 +26,20 @@ final class AppCommands { | ||
| 26 | 26 | /// The keymap in use, for showing keys in menus and the palette. |
| 27 | 27 | var keymap = KeymapPreset.emacs.keymap |
| 28 | 28 | var modal = false |
| 29 | /// `org-stored-links`, most recent first; kept for the session, as in Emacs. | |
| 30 | var storedLinks: [StoredLink] = [] | |
| 31 | ||
| 32 | /// `org-link--add-to-stored-links`: puts `link` first and says what happened. | |
| 33 | func storeLink(_ link: StoredLink) { | |
| 34 | if storedLinks.first == link { | |
| 35 | show("This link has already been stored") | |
| 36 | } else { | |
| 37 | let moved = storedLinks.contains(link) | |
| 38 | storedLinks.removeAll { $0 == link } | |
| 39 | storedLinks.insert(link, at: 0) | |
| 40 | show((moved ? "Link moved to front: " : "Stored: ") + (link.description ?? link.link)) | |
| 41 | } | |
| 42 | } | |
| 29 | 43 | |
| 30 | 44 | /// The keys that run `command`, as menus and the palette show them. |
| 31 | 45 | func keyLabel(for command: String) -> String? { |
Tests/OrgCoreTests/LinkStoreTests.swift added +212
| @@ -0,0 +1,212 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct LinkStoreTests { | |
| 6 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | |
| 7 | ||
| 8 | static let text = """ | |
| 9 | #+TITLE: Store | |
| 10 | intro (line) here | |
| 11 | * TODO [#A] First [1/2] heading :tag: | |
| 12 | body <<my target>> here | |
| 13 | text with [[https://x.org][a link]] in it | |
| 14 | * Second | |
| 15 | :PROPERTIES: | |
| 16 | :CUSTOM_ID: sec | |
| 17 | :END: | |
| 18 | #+NAME: tbl | |
| 19 | | a | | |
| 20 | ||
| 21 | #+name: code | |
| 22 | #+begin_src sh | |
| 23 | echo hi | |
| 24 | #+end_src | |
| 25 | * COMMENT Third [[https://y.org][linked]] title | |
| 26 | *\u{20} | |
| 27 | ||
| 28 | """ | |
| 29 | ||
| 30 | /// Carets as text before them, and selections as `start…end` pairs of such text. | |
| 31 | static let cases: [(String, String?)] = [ | |
| 32 | ("#+TITLE", nil), ("intro", nil), ("* TODO", nil), ("First", nil), ("<<my", nil), ("y target", nil), ("here\ntext", nil), | |
| 33 | ("body", nil), ("text with", nil), (":CUSTOM_ID", nil), ("#+NAME", nil), ("| a", nil), ("#+name", nil), ("echo", nil), | |
| 34 | ("#+end_src", nil), ("Third", nil), ("* \n", nil), ("intro", "First"), ("(line)", "here"), | |
| 35 | ] | |
| 36 | ||
| 37 | static func offset(_ s: String) -> Int { (text as NSString).range(of: s).location } | |
| 38 | ||
| 39 | @Test(.enabled(if: oracle)) | |
| 40 | func storesLikeOrgStoreLink() throws { | |
| 41 | let points = Self.cases.map { c -> String in | |
| 42 | let start = EmacsOracle.point(Self.offset(c.0), in: Self.text) | |
| 43 | let end = c.1.map { EmacsOracle.point(Self.offset($0), in: Self.text) } | |
| 44 | return "(\(start) \(end.map(String.init) ?? "nil"))" | |
| 45 | } | |
| 46 | let emacs = try EmacsOracle.evaluate(Self.text, """ | |
| 47 | (let ((dir (file-name-directory buffer-file-name)) out) | |
| 48 | (dolist (c '(\(points.joined(separator: " ")))) | |
| 49 | (deactivate-mark) | |
| 50 | (goto-char (car c)) | |
| 51 | (when (cadr c) (set-mark (car c)) (goto-char (cadr c)) (activate-mark)) | |
| 52 | (setq org-stored-links nil) | |
| 53 | (call-interactively #'org-store-link) | |
| 54 | (push (format "%S" (mapcar (lambda (l) (list (replace-regexp-in-string (regexp-quote dir) "/d/" (car l) t t) (cadr l))) org-stored-links)) out)) | |
| 55 | (nreverse out)) | |
| 56 | """, prelude: "(transient-mark-mode 1)") | |
| 57 | var ours: [String] = [] | |
| 58 | let tree = OrgParser.parse(Self.text) | |
| 59 | for c in Self.cases { | |
| 60 | let start = Self.offset(c.0) | |
| 61 | let selection = c.1.map { start..<Self.offset($0) } ?? start..<start | |
| 62 | let context = EditContext(revision: 0, text: Self.text, tree: tree, selection: [selection]) | |
| 63 | let stored = Links.store(in: context, path: "/d/oracle.org") | |
| 64 | ours.append("((\(lisp(stored.link)) \(stored.description.map(lisp) ?? "nil")))") | |
| 65 | } | |
| 66 | #expect(ours == emacs) | |
| 67 | } | |
| 68 | ||
| 69 | func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" } | |
| 70 | ||
| 71 | struct InsertCase { | |
| 72 | var text: String | |
| 73 | /// Where the caret goes: before this text, or with `|` marking it. | |
| 74 | var caret: Int | |
| 75 | var region: Range<Int>? | |
| 76 | var stored: [StoredLink] | |
| 77 | var link: String | |
| 78 | /// nil accepts the offered description. | |
| 79 | var description: String? | |
| 80 | var special: String? = nil | |
| 81 | } | |
| 82 | ||
| 83 | static func insertCase(_ marked: String, region: String? = nil, stored: [StoredLink] = [], link: String, description: String? = nil, special: String? = nil) -> InsertCase { | |
| 84 | let caret = (marked as NSString).range(of: "|").location | |
| 85 | let text = marked.replacingOccurrences(of: "|", with: "") | |
| 86 | let range = region.map { r -> Range<Int> in | |
| 87 | let start = (text as NSString).range(of: r).location | |
| 88 | return start..<(start + (r as NSString).length) | |
| 89 | } | |
| 90 | return InsertCase(text: text, caret: range?.upperBound ?? caret, region: range, stored: stored, link: link, description: description, special: special) | |
| 91 | } | |
| 92 | ||
| 93 | static let insertCases: [InsertCase] = [ | |
| 94 | insertCase("* A\n|\n* Heading\n", stored: [StoredLink(link: "file:/d/oracle.org::*Heading", description: "Heading")], link: ""), | |
| 95 | insertCase("* A\n|\n", stored: [StoredLink(link: "file:/d/sub/b.org::*X", description: "X"), StoredLink(link: "file:/d/oracle.org::#c", description: "C")], link: "file:/d/sub/b.org::*X"), | |
| 96 | insertCase("* A\n|\n", stored: [StoredLink(link: "file:/elsewhere/c.org", description: nil)], link: "", description: "Elsewhere"), | |
| 97 | insertCase("* A\n|\n", stored: [StoredLink(link: "file:~/notes/x.org::42", description: nil)], link: ""), | |
| 98 | insertCase("Some |text\n", link: "https://orgmode.org", description: "Org"), | |
| 99 | insertCase("Some text here\n", region: "text", link: "https://orgmode.org"), | |
| 100 | insertCase("See [[https://a.org][the [a] site]] |\n", link: "https://b.org"), | |
| 101 | insertCase("See [[https://a.org][the site]]\n", region: nil, link: "https://b.org", description: "B"), | |
| 102 | insertCase("Visit https://a.org/x| now\n", link: "https://c.org"), | |
| 103 | insertCase("x |\n", link: "a [b] c\\", description: "ends]] with]"), | |
| 104 | insertCase("x |\n", link: " "), | |
| 105 | insertCase("x |\n", stored: [StoredLink(link: "file:/d/oracle.org::*H", description: "Heading H")], link: "Heading H"), | |
| 106 | insertCase("x |\n", link: "https:", special: "https://typed.org"), | |
| 107 | insertCase("x |\n", link: "<https://angle.org>", description: ""), | |
| 108 | insertCase("x |\n", stored: [StoredLink(link: "file:/d/sub/b.org", description: "/d/sub/b.org")], link: ""), | |
| 109 | ] | |
| 110 | ||
| 111 | @Test(.enabled(if: oracle)) | |
| 112 | func insertsLikeOrgInsertLink() throws { | |
| 113 | var cases = Self.insertCases | |
| 114 | // The edit-link case puts its caret inside the link. | |
| 115 | cases[7].caret = (cases[7].text as NSString).range(of: "the site").location | |
| 116 | var forms: [String] = [] | |
| 117 | for c in cases { | |
| 118 | let stored = c.stored.map { "(list (funcall fix \(lisp($0.link))) \($0.description.map { "(funcall fix \(lisp($0)))" } ?? "nil"))" }.joined(separator: " ") | |
| 119 | let region = c.region.map { "(set-mark \(EmacsOracle.point($0.lowerBound, in: c.text))) (goto-char \(EmacsOracle.point($0.upperBound, in: c.text))) (activate-mark)" } ?? "" | |
| 120 | forms.append(""" | |
| 121 | (progn (erase-buffer) (insert \(lisp(c.text))) (deactivate-mark) (goto-char \(EmacsOracle.point(c.caret, in: c.text))) \(region) | |
| 122 | (setq org-stored-links (list \(stored))) | |
| 123 | (let ((answers (list (cons "link" (funcall fix \(lisp(c.link)))) (cons "description" \(c.description.map(lisp) ?? "nil")) (cons "special" \(c.special.map(lisp) ?? "nil"))))) | |
| 124 | (let ((err (condition-case e | |
| 125 | (cl-letf (((symbol-function 'org-completing-read) | |
| 126 | (lambda (_prompt _coll &optional _p _r _i _h def &rest _) | |
| 127 | (let ((a (cdr (assoc "link" answers)))) (if (equal a "") (or def "") a)))) | |
| 128 | ((symbol-function 'read-string) | |
| 129 | (lambda (prompt &optional initial &rest _) | |
| 130 | (cond ((string-prefix-p "Description" prompt) (or (cdr (assoc "description" answers)) initial "")) | |
| 131 | ((string-prefix-p "Link (no" prompt) (cdr (assoc "special" answers))) | |
| 132 | (t (cdr (assoc "link" answers))))))) | |
| 133 | (call-interactively #'org-insert-link) "") | |
| 134 | (error (error-message-string e))))) | |
| 135 | (format "%s|%d|%d|%s" (replace-regexp-in-string (regexp-quote dir) "/d/" (buffer-string) t t) (1- (point)) (length org-stored-links) err)))) | |
| 136 | """) | |
| 137 | } | |
| 138 | let emacs = try EmacsOracle.evaluate("", """ | |
| 139 | (let* ((dir (file-name-directory buffer-file-name)) | |
| 140 | (fix (lambda (s) (replace-regexp-in-string "/d/" dir s t t)))) | |
| 141 | (list \(forms.joined(separator: "\n")))) | |
| 142 | """, prelude: "(require 'cl-lib) (transient-mark-mode 1)") | |
| 143 | var ours: [String] = [] | |
| 144 | for c in cases { | |
| 145 | let tree = OrgParser.parse(c.text) | |
| 146 | var answers: [String: String] = [:] | |
| 147 | let command = InsertLink(stored: c.stored, file: "/d/oracle.org", home: NSHomeDirectory()) | |
| 148 | var line = "" | |
| 149 | loop: while true { | |
| 150 | let context = EditContext(revision: 0, text: c.text, tree: tree, selection: [c.region ?? c.caret..<c.caret], answers: answers) | |
| 151 | switch command.run(in: context) { | |
| 152 | case .prompt(let prompt): | |
| 153 | switch prompt.key { | |
| 154 | case "link": answers["link"] = c.link | |
| 155 | case "special": answers["special"] = c.special ?? "" | |
| 156 | default: answers["description"] = c.description ?? prompt.initial | |
| 157 | } | |
| 158 | case .commit(let result): | |
| 159 | var new = c.text | |
| 160 | for edit in result.edits { new = edit.apply(to: new) } | |
| 161 | let used = result.effects.contains { if case .storedLinkUsed = $0 { true } else { false } } | |
| 162 | let point = EmacsOracle.point(result.selection!.first!.lowerBound, in: new) - 1 | |
| 163 | line = "\(new)|\(point)|\(c.stored.count - (used ? 1 : 0))|" | |
| 164 | break loop | |
| 165 | case .failed(let message): | |
| 166 | line = "\(c.text)|\(EmacsOracle.point(c.caret, in: c.text) - 1)|\(c.stored.count)|\(message)" | |
| 167 | break loop | |
| 168 | case .external: | |
| 169 | break loop | |
| 170 | } | |
| 171 | } | |
| 172 | ours.append(line) | |
| 173 | } | |
| 174 | for (o, e) in zip(ours, emacs) { #expect(o == e) } | |
| 175 | #expect(ours.count == emacs.count) | |
| 176 | } | |
| 177 | ||
| 178 | @Test(.enabled(if: oracle)) | |
| 179 | func createsIDsLikeOrgIDGetCreate() throws { | |
| 180 | let cases = [ | |
| 181 | ("* A\nbody\n", "body"), ("* A\nSCHEDULED: <2026-10-05 Mon>\n", "A"), | |
| 182 | ("* A\n:PROPERTIES:\n:ID: old-id\n:END:\n", "A"), ("* A\n:PROPERTIES:\n:CUSTOM_ID: x\n:END:\n", "A"), | |
| 183 | ("#+TITLE: T\ntext\n* A\n", "text"), ("* A\n:PROPERTIES:\n:ID:\n:END:\n", "A"), | |
| 184 | ] | |
| 185 | let forms = cases.map { c in | |
| 186 | "(progn (erase-buffer) (insert \(lisp(c.0))) (goto-char \(EmacsOracle.point((c.0 as NSString).range(of: c.1).location, in: c.0))) (cl-letf (((symbol-function 'org-id-new) (lambda (&rest _) \"NEW-ID\")) ((symbol-function 'org-id-add-location) #'ignore)) (call-interactively #'org-id-get-create)) (format \"%s|%d\" (buffer-string) (1- (point))))" | |
| 187 | } | |
| 188 | let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))", prelude: "(require 'org-id) (require 'cl-lib)") | |
| 189 | let ours = cases.map { c -> String in | |
| 190 | let result = runCommand(CreateID { "NEW-ID" }, c.0, caret: (c.0 as NSString).range(of: c.1).location) | |
| 191 | return "\(result.text)|\(EmacsOracle.point(result.caret, in: result.text) - 1)" | |
| 192 | } | |
| 193 | #expect(ours == emacs) | |
| 194 | } | |
| 195 | ||
| 196 | @Test(.enabled(if: oracle)) | |
| 197 | func storesIDLinksLikeOrgIDStoreLink() throws { | |
| 198 | let cases = [("* TODO [#A] First [1/2] :tag:\nbody\n", "body"), ("#+TITLE: The Title\ntext\n* A\n", "text"), ("text\n* A\n", "text"), ("* A\n:PROPERTIES:\n:ID: kept\n:END:\n", "A")] | |
| 199 | let forms = cases.map { c in | |
| 200 | "(progn (erase-buffer) (insert \(lisp(c.0))) (goto-char \(EmacsOracle.point((c.0 as NSString).range(of: c.1).location, in: c.0))) (cl-letf (((symbol-function 'org-id-new) (lambda (&rest _) \"NEW-ID\")) ((symbol-function 'org-id-add-location) #'ignore)) (call-interactively #'org-id-store-link)) (format \"%s|%s\" (plist-get org-store-link-plist :link) (plist-get org-store-link-plist :description)))" | |
| 201 | } | |
| 202 | let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))", prelude: "(require 'org-id) (require 'cl-lib)") | |
| 203 | let ours = cases.map { c -> String in | |
| 204 | let caret = (c.0 as NSString).range(of: c.1).location | |
| 205 | let created = runCommand(CreateID { "NEW-ID" }, c.0, caret: caret) | |
| 206 | let context = EditContext(revision: 0, text: created.text, tree: OrgParser.parse(created.text), selection: [created.caret..<created.caret]) | |
| 207 | let stored = Links.storeID(in: context, fileName: "oracle.org") | |
| 208 | return "\(stored?.link ?? "nil")|\(stored?.description ?? "nil")" | |
| 209 | } | |
| 210 | #expect(ours == emacs) | |
| 211 | } | |
| 212 | } | |