krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 198b949ff1

198b949ff11296a1c24cc73525e7b384fa46bfca

parent: 5765a3a586

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:49 UTC

Follow attachment: and radio links

attachment is a link type, as with org-attach loaded. Its file is in the entry's DIR (or ATTACH_DIR) folder, else the existing ID folder under data/ (uuid, timestamp, then __/ fallback formats), else the file's folder, as org-attach-expand does. Radio link text is a radio link that jumps to its <<<target>>>, as org-link--search-radio-target does.

Layout: unified · split

Sources/OrgApp/LinkFollow.swift +22 −3
@@ -12,8 +12,9 @@ public enum LinkFollow: Equatable, Sendable {
1212
1313extension DocumentSession {
1414 /// 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.
1718 public func follow(_ link: OrgLink, workspace: WorkspaceModel?, isText: (String) -> Bool) -> LinkFollow {
1819 switch link.type {
1920 case "http", "https", "ftp", "mailto", "news":
@@ -32,6 +33,15 @@ extension DocumentSession {
3233 return searchCurrent("#" + link.path)
3334 case "fuzzy":
3435 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)
3545 case "coderef":
3646 return .message("Coderef links aren't supported")
3747 case "shell", "elisp":
@@ -73,9 +83,18 @@ extension DocumentSession {
7383 return searchCurrent(option)
7484 }
7585
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
7691 private func searchCurrent(_ s: String) -> LinkFollow {
7792 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 {
7998 case .found(let offset):
8099 reveal(offset)
81100 return .shown
Sources/OrgCore/Links/Links.swift +68 −6
@@ -9,19 +9,22 @@ public struct OrgLink: Sendable, Equatable {
99 public let searchOption: String?
1010 /// The link as written, unescaped and with abbreviations expanded.
1111 public let raw: String
12 /// Where an `attachment` link starts in its file, whose entry holds the attachments.
13 public var position: Int?
1214
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) {
1416 self.type = type
1517 self.path = path
1618 self.searchOption = searchOption
1719 self.raw = raw
20 self.position = position
1821 }
1922}
2023
2124public 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.
2326 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",
2528 "shell", "news", "mailto", "https", "http", "ftp", "shortdoc", "help", "file", "elisp",
2629 ]
2730
@@ -132,11 +135,70 @@ public enum Links {
132135 walk(tree.root)
133136 guard let node = found else { return nil }
134137 let whole = node.text
138 var link: OrgLink
135139 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])
137200 }
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) }
140202 }
141203
142204 // MARK: - Searching a file
Tests/OrgAppTests/AppTests.swift +17
@@ -1019,4 +1019,21 @@ struct LinkFollowTests {
10191019 #expect(follow("doi:10.1000/182") == .openExternally(URL(string: "https://doi.org/10.1000/182")!))
10201020 #expect(follow("shell:rm -rf x") == .message("Orgstar doesn't run shell links"))
10211021 }
1022
1023 @Test func followsAttachmentAndRadioLinks() throws {
1024 let notes = try Folder()
1025 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"
1026 let a = try notes.write("a.org", text)
1027 let scan = try notes.write("data/ab/12-cd/scan.pdf", "pdf")
1028 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
1029 session.open(a.path)
1030 let tree = try #require(session.document?.tree)
1031 func follow(_ needle: String) throws -> LinkFollow {
1032 let link = try #require(Links.link(at: (text as NSString).range(of: needle).location + 1, text: text, tree: tree)?.link)
1033 return session.follow(link, workspace: nil, isText: { $0.hasSuffix(".org") })
1034 }
1035 #expect(try follow("attachment:") == .openExternally(URL(fileURLWithPath: (scan.path as NSString).standardizingPath)))
1036 #expect(try follow("The Plan") == .shown)
1037 #expect(session.revealRequest?.offset == (text as NSString).range(of: "<<<the plan>>>").location)
1038 }
10221039}
Tests/OrgCoreTests/LinkTests.swift +76
@@ -88,4 +88,80 @@ struct LinkTests {
8888 #expect(ours == expected, "\(s)")
8989 }
9090 }
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 }
91167}