Commit 198b949ff1
Verified · cmc
Layout: unified · split
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/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 |
Tests/OrgAppTests/AppTests.swift +17
| @@ -1019,4 +1019,21 @@ struct LinkFollowTests { | ||
| 1019 | 1019 | #expect(follow("doi:10.1000/182") == .openExternally(URL(string: "https://doi.org/10.1000/182")!)) |
| 1020 | 1020 | #expect(follow("shell:rm -rf x") == .message("Orgstar doesn't run shell links")) |
| 1021 | 1021 | } |
| 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 | } | |
| 1022 | 1039 | } |
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 | } |