org-protocol capture and store-link; Shortcuts capture action !103

merged merged by cmc on 2026-10-06 06:53 UTC · krz/orgstar:org-protocol into main

10 files changed, +307 −10

Layout: unified · split

README.md +5
@@ -25,6 +25,11 @@ searches, ⇧⌘A shows the agenda. In the editor, TAB on a heading cycles its
2525folding and Shift-TAB cycles the whole file. Orgstar opens `.org` files from
2626Finder once the app has been launched.
2727
28`org-protocol://capture?template=…&url=…&title=…&body=…` and
29`org-protocol://store-link?url=…&title=…` links work as in Emacs, so browser
30bookmarklets written for org-protocol work unchanged. Shortcuts has a
31"Capture to Orgstar" action that files text with a capture template.
32
2833Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File),
2934with keys in `keymap.toml` and capture templates in `capture.toml` beside it.
3035Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom
Sources/OrgCore/Capture/Capture.swift +6 −2
@@ -114,6 +114,8 @@ public struct CaptureContext: Sendable {
114114 public var allTags: [String]
115115 /// `org-tags-column`, for aligning tags `%^g` adds to a heading.
116116 public var tagsColumn: Int
117 /// `org-store-link-plist` as org-protocol sets it, for `%:link`, `%:description` and others.
118 public var linkProperties: [String: String] = [:]
117119
118120 public init(
119121 now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "",
@@ -386,7 +388,7 @@ public enum Capture {
386388 text += strftime(String(chars[(i + 2)..<close]), context)
387389 consumed = close - i + 1
388390 case ":":
389 // A link property (`%:annotation`); only the capture's own are known.
391 // A link property (`%:annotation`), from org-protocol or the capture's own.
390392 var j = i + 2
391393 while j < chars.count, chars[j].isLetter || chars[j] == "-" { j += 1 }
392394 guard j > i + 2 else {
@@ -394,7 +396,9 @@ public enum Capture {
394396 consumed = 1
395397 break
396398 }
397 switch String(chars[(i + 2)..<j]) {
399 let name = String(chars[(i + 2)..<j])
400 switch name {
401 case _ where context.linkProperties[name] != nil: text += context.linkProperties[name]!
398402 case "annotation": text += context.annotation
399403 case "initial": text += context.initial
400404 default: break
Sources/OrgCore/Links/OrgProtocol.swift added +86
@@ -0,0 +1,86 @@
1import Foundation
2
3// `org-protocol://` URLs (Org 9.8.7's org-protocol.el): `capture` and `store-link`, in the
4// new `?key=value` form and the old `:/a/b/c` form.
5
6public enum OrgProtocol {
7 public enum Request: Sendable, Equatable {
8 /// `org-protocol-capture`: the template key (nil for the template list) and the link
9 /// properties `org-link-store-props` sets (`type`, `link`, `description`,
10 /// `annotation`, `initial`), absent when nil.
11 case capture(template: String?, properties: [String: String])
12 /// `org-protocol-store-link`: the link and its title.
13 case storeLink(url: String, title: String?)
14 }
15
16 public static func parse(_ url: String) -> Request? {
17 guard let protocolMatch = url.firstMatch(of: /^org-protocol:\/+/) else { return nil }
18 let rest = url[protocolMatch.range.upperBound...]
19 for name in ["store-link", "capture"] {
20 guard rest.hasPrefix(name), let separator = rest.dropFirst(name.count).firstMatch(of: /^(:\/+|\/*\?)/) else { continue }
21 let info = String(rest.dropFirst(name.count)[separator.range.upperBound...])
22 let newStyle = !separator.output.1.hasPrefix(":")
23 let parts: [(String, String?)] = newStyle ? query(info) : []
24 let old = newStyle ? [] : split(info)
25 func value(_ key: String) -> String? { parts.first { $0.0 == key }?.1 ?? nil }
26 if name == "store-link" {
27 let order = assign(old, ["url", "title"])
28 let link = newStyle ? value("url") : order["url"] ?? nil
29 return .storeLink(url: sanitize(link ?? ""), title: newStyle ? value("title") : order["title"] ?? nil)
30 }
31 var fields: [String: String?] = [:]
32 if newStyle {
33 for key in ["template", "url", "title", "body"] { fields[key] = value(key) }
34 } else {
35 fields = assign(old, old.first?.count == 1 ? ["template", "url", "title", "body"] : ["url", "title", "body"])
36 }
37 let link = (fields["url"] ?? nil).map(sanitize)
38 let title = (fields["title"] ?? nil) ?? ""
39 var properties: [String: String] = ["description": title, "initial": (fields["body"] ?? nil) ?? ""]
40 if let link {
41 properties["link"] = link
42 properties["annotation"] = Links.makeString(link, title.contains { !$0.isWhitespace } ? title : link)
43 if let type = link.firstMatch(of: /^([a-z]+):/) { properties["type"] = String(type.1) }
44 } else {
45 properties["annotation"] = title
46 }
47 return .capture(template: (fields["template"] ?? nil).flatMap { $0.isEmpty ? nil : $0 }, properties: properties)
48 }
49 return nil
50 }
51
52 /// `org-protocol-convert-query-to-plist` and `org-link-decode`: `+` is a space; a value
53 /// ends at its second `=`.
54 static func query(_ info: String) -> [(String, String?)] {
55 info.replacingOccurrences(of: "+", with: " ").components(separatedBy: "&").map { pair in
56 let c = pair.components(separatedBy: "=")
57 return (c[0], c.count > 1 ? decode(c[1]) : nil)
58 }
59 }
60
61 /// `org-protocol-split-data` with `org-protocol-data-separator`, each part decoded.
62 static func split(_ info: String) -> [String] {
63 info.split(separator: /\/+|\?/, omittingEmptySubsequences: false).map { decode(String($0)) }
64 }
65
66 /// `org-protocol-assign-parameters`: values in order, then the rest as key, value.
67 static func assign(_ data: [String], _ order: [String]) -> [String: String?] {
68 var result: [String: String?] = [:]
69 var data = data[...]
70 for key in order { result[key] = data.popFirst() }
71 while let key = data.popFirst() { result[key] = data.popFirst() }
72 return result
73 }
74
75 /// `org-link-decode`: `%XX` sequences as UTF-8.
76 static func decode(_ s: String) -> String {
77 s.removingPercentEncoding ?? s
78 }
79
80 /// `org-protocol-sanitize-uri`: `scheme:/a/b` back to `scheme://a/b`.
81 static func sanitize(_ uri: String) -> String {
82 guard uri.firstMatch(of: /^[a-z]+:\//) != nil else { return uri }
83 let parts = uri.split(separator: /\/+/, omittingEmptySubsequences: false)
84 return parts[0] + "//" + parts.dropFirst().joined(separator: "/")
85 }
86}
Sources/Orgstar/CaptureIntent.swift added +62
@@ -0,0 +1,62 @@
1import AppIntents
2import AppKit
3import OrgApp
4import OrgCore
5
6/// Shortcuts' "Capture to Orgstar": files text with a capture template without the capture
7/// window. Prompts in the template take their defaults.
8struct CaptureIntent: AppIntent {
9 static let title: LocalizedStringResource = "Capture to Orgstar"
10 static let description = IntentDescription("Files text with one of your capture templates.")
11
12 @Parameter(title: "Text")
13 var text: String
14
15 @Parameter(title: "Template Key", description: "A template's key in capture.toml; the first template when empty.")
16 var template: String?
17
18 /// The app's models, once its window is up.
19 @MainActor static var environment: (workspace: WorkspaceModel, session: DocumentSession, clock: ClockModel, commands: AppCommands)?
20
21 struct Failure: Error, CustomLocalizedStringResourceConvertible {
22 let message: String
23 var localizedStringResource: LocalizedStringResource { "\(message)" }
24 }
25
26 @MainActor
27 func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
28 // Launched for the intent, the window sets the models up shortly.
29 for _ in 0..<50 where Self.environment == nil { try? await Task.sleep(for: .milliseconds(100)) }
30 guard let (workspace, session, clock, commands) = Self.environment else { throw Failure(message: "Orgstar isn't ready") }
31 let templates = CaptureTemplates.load().templates
32 let key = template.flatMap { $0.isEmpty ? nil : $0 }
33 guard let chosen = key.map({ key in templates.first { $0.key == key } }) ?? templates.first else {
34 throw Failure(message: "No capture template \"\(key ?? "")\"")
35 }
36 let context = CaptureContext(
37 initial: text, clipboard: NSPasteboard.general.string(forType: .string) ?? "", userName: NSFullUserName(),
38 allTags: (try? workspace.index.tags()) ?? []
39 )
40 let answers = Capture.questions(chosen.template, context: context).map { _ in "" }
41 guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else {
42 throw Failure(message: "Can't fill template \"\(chosen.key)\"")
43 }
44 let placed = CaptureView.resolved(chosen, workspace: workspace, session: session, clock: clock).flatMap { resolved in
45 session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file)
46 }
47 switch placed {
48 case .failure(let error):
49 throw Failure(message: error.message)
50 case .success:
51 let message = "Captured to \(CaptureView.target(chosen))"
52 commands.show(message)
53 return .result(value: message, dialog: "\(message)")
54 }
55 }
56}
57
58struct OrgstarShortcuts: AppShortcutsProvider {
59 static var appShortcuts: [AppShortcut] {
60 AppShortcut(intent: CaptureIntent(), phrases: ["Capture to \(.applicationName)"], shortTitle: "Capture", systemImageName: "tray.and.arrow.down")
61 }
62}
Sources/Orgstar/CaptureView.swift +30 −7
@@ -26,7 +26,7 @@ struct CaptureView: View {
2626 var body: some View {
2727 VStack(alignment: .leading, spacing: 12) {
2828 if let template, editing {
29 Text("\(template.name) → \(target(template))").font(.headline)
29 Text("\(template.name) → \(Self.target(template))").font(.headline)
3030 TextEditor(text: $text, selection: $selection)
3131 .font(.body.monospaced())
3232 .focused($focused)
@@ -66,7 +66,7 @@ struct CaptureView: View {
6666 Text("[\(template.key)]").monospaced().foregroundStyle(.secondary)
6767 Text(template.name)
6868 Spacer()
69 Text(target(template)).foregroundStyle(.secondary).font(.caption)
69 Text(Self.target(template)).foregroundStyle(.secondary).font(.caption)
7070 }
7171 }
7272 .buttonStyle(.plain)
@@ -81,7 +81,15 @@ struct CaptureView: View {
8181 choose(match)
8282 return .handled
8383 }
84 .onAppear(perform: reset)
84 .onAppear {
85 reset()
86 takeRequest()
87 }
88 .onChange(of: commands.captureRequest?.serial) {
89 guard commands.captureRequest != nil else { return }
90 reset()
91 takeRequest()
92 }
8593 }
8694
8795 /// What an empty answer means.
@@ -93,7 +101,7 @@ struct CaptureView: View {
93101 }
94102 }
95103
96 private func target(_ template: CaptureTemplate) -> String {
104 static func target(_ template: CaptureTemplate) -> String {
97105 let file = (template.target.file as NSString).lastPathComponent
98106 switch template.target {
99107 case .file, .heading: return file
@@ -126,6 +134,19 @@ struct CaptureView: View {
126134 )
127135 }
128136
137 /// An org-protocol capture: its link properties fill `%a`, `%i` and `%:…`, and its
138 /// template, when it names one, is chosen.
139 private func takeRequest() {
140 guard let request = commands.captureRequest else { return }
141 commands.captureRequest = nil
142 context.initial = request.properties["initial"] ?? ""
143 context.annotation = request.properties["annotation"] ?? ""
144 context.linkProperties = request.properties
145 if let key = request.template {
146 if let match = templates.first(where: { $0.key == key }) { choose(match) } else { problem = "No capture template \"\(key)\"" }
147 }
148 }
149
129150 private func choose(_ chosen: CaptureTemplate) {
130151 template = chosen
131152 // `%^g` offers the target file's tags.
@@ -161,7 +182,9 @@ struct CaptureView: View {
161182 }
162183
163184 /// `id` and `clock` targets as a file and a heading.
164 private func resolved(_ template: CaptureTemplate) -> Result<(template: CaptureTemplate, file: String?), CaptureError> {
185 static func resolved(
186 _ template: CaptureTemplate, workspace: WorkspaceModel, session: DocumentSession, clock: ClockModel
187 ) -> Result<(template: CaptureTemplate, file: String?), CaptureError> {
165188 switch template.target {
166189 case .id(let id):
167190 guard let location = try? workspace.index.headings(withID: id).first else {
@@ -180,7 +203,7 @@ struct CaptureView: View {
180203 guard let template else { return }
181204 let opened = context.now
182205 let now = Date()
183 let placed = resolved(template).flatMap { resolved in
206 let placed = Self.resolved(template, workspace: workspace, session: session, clock: clock).flatMap { resolved in
184207 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
185208 }
186209 switch placed {
@@ -189,7 +212,7 @@ struct CaptureView: View {
189212 if template.options.immediateFinish { editing = true }
190213 return
191214 case .success(let entry):
192 commands.show("Captured to \(target(template))")
215 commands.show("Captured to \(Self.target(template))")
193216 let options = template.options
194217 if options.clockIn {
195218 // Clocked from when capture began; the clock it interrupted resumes after.
Sources/Orgstar/ContentView.swift +2
@@ -335,6 +335,8 @@ struct ContentView: View {
335335 // Files handed over at launch open after the restored buffers, and show.
336336 for path in AppDelegate.pendingOpens { session.open(path) }
337337 AppDelegate.pendingOpens = []
338 for link in AppDelegate.pendingLinks + [DevelopmentHooks.urlToOpen].compactMap({ $0 }) { commands.handle(protocolURL: link) }
339 AppDelegate.pendingLinks = []
338340 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
339341 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
340342 if let commandList = DevelopmentHooks.commandToRun {
Sources/Orgstar/DevelopmentHooks.swift +2
@@ -8,6 +8,7 @@ import AppKit
88/// - `ORGSTAR_COMMAND`: commands to run at launch, separated by commas (`app.import-emacs`); a
99/// sheet or prompt they open is what the snapshot renders.
1010/// - `ORGSTAR_ANSWERS`: answers to the prompts those commands ask, in order, separated by commas.
11/// - `ORGSTAR_URL`: an `org-protocol://` link to handle at launch, as if opened from a browser.
1112/// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's.
1213/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
1314/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
@@ -17,6 +18,7 @@ enum DevelopmentHooks {
1718 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] }
1819 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] }
1920 static var commandToRun: String? { ProcessInfo.processInfo.environment["ORGSTAR_COMMAND"] }
21 static var urlToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_URL"] }
2022 static var answers: [String] { ProcessInfo.processInfo.environment["ORGSTAR_ANSWERS"]?.split(separator: ",").map(String.init) ?? [] }
2123
2224 @MainActor
Sources/Orgstar/OrgstarApp.swift +29 −1
@@ -77,6 +77,27 @@ final class AppCommands {
7777 runRequest = (id, runSerial)
7878 }
7979
80 /// An org-protocol capture waiting for the capture window: the template key and the link
81 /// properties for its escapes.
82 var captureRequest: (template: String?, properties: [String: String], serial: Int)?
83 private var captureSerial = 0
84
85 /// `org-protocol://capture` and `store-link`.
86 func handle(protocolURL url: String) {
87 switch OrgProtocol.parse(url) {
88 case .capture(let template, let properties):
89 captureSerial += 1
90 captureRequest = (template, properties, captureSerial)
91 run("app.capture")
92 case .storeLink(let link, let title):
93 storeLink(StoredLink(link: link, description: title))
94 NSPasteboard.general.clearContents()
95 NSPasteboard.general.setString(link, forType: .string)
96 case nil:
97 show("Orgstar handles org-protocol capture and store-link")
98 }
99 }
100
80101 func jump(path: String, offset: Int) {
81102 jumpID += 1
82103 jumpRequest = (path, offset, jumpID)
@@ -136,14 +157,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
136157
137158 func application(_ application: NSApplication, open urls: [URL]) {
138159 let paths = urls.filter(\.isFileURL).map(\.path)
139 guard commands != nil, let session else {
160 let links = urls.filter { $0.scheme == "org-protocol" }.map(\.absoluteString)
161 guard let commands, let session else {
140162 Self.pendingOpens += paths
163 Self.pendingLinks += links
141164 return
142165 }
143166 NSApp.activate()
144167 for path in paths { session.open(path) }
168 for link in links { commands.handle(protocolURL: link) }
145169 }
146170
171 /// org-protocol links that arrive before the window is ready.
172 static var pendingLinks: [String] = []
173
147174 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
148175
149176 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
@@ -247,6 +274,7 @@ struct OrgstarApp: App {
247274 .onAppear {
248275 delegate.session = session
249276 delegate.commands = commands
277 CaptureIntent.environment = (model, session, clock, commands)
250278 }
251279 case .failure(let error):
252280 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
Tests/OrgCoreTests/OrgProtocolTests.swift added +63
@@ -0,0 +1,63 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct OrgProtocolTests {
6 static let urls = [
7 "org-protocol://capture?template=b&url=https%3A%2F%2Fexample.com%2Fa%3Fq%3D1&title=A+Page&body=some%20text",
8 "org-protocol://capture?url=https%3A%2F%2Fx.org&title=&body=",
9 "org-protocol:/capture?template=t&title=Just%20a%20title",
10 "org-protocol://capture?template=t&url=file%3A%2F%2F%2Ftmp%2Fa.org&title=%5Bweird%5D%20%C3%A9",
11 "org-protocol://capture:/b/https%3A%2F%2Fexample.com/Title/Body",
12 "org-protocol://capture:/https%3A%2F%2Fexample.com/Title",
13 "org-protocol://capture://t/https:/example.com/x/Title",
14 "org-protocol://store-link?url=https%3A%2F%2Fexample.com&title=Example",
15 "org-protocol://store-link?url=https%3A%2F%2Fexample.com",
16 "org-protocol://store-link:/https%3A%2F%2Fexample.com/Old%20Title",
17 "https://example.com",
18 ]
19
20 func describe(_ request: OrgProtocol.Request?) -> String {
21 switch request {
22 case .capture(let template, let properties)?:
23 return (["capture template=\(template ?? "nil")"] + ["type", "link", "description", "annotation", "initial"].compactMap { key in
24 properties[key].map { "\(key)=\($0)" }
25 }).joined(separator: "\n")
26 case .storeLink(let url, let title)?:
27 return "store url=\(url)\ntitle=\(title ?? "nil")"
28 case nil:
29 return "none"
30 }
31 }
32
33 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
34 func matchesOrgProtocol() throws {
35 let form = """
36 (progn
37 (require 'org-protocol)
38 (let (out (org-stored-links nil))
39 (cl-letf (((symbol-function 'raise-frame) #'ignore)
40 ((symbol-function 'org-capture)
41 (lambda (_ template)
42 (setq out (mapconcat #'identity
43 (cons (format "capture template=%s" (or template "nil"))
44 (delq nil (mapcar (lambda (k)
45 (let ((v (plist-get org-store-link-plist (intern (concat ":" k)))))
46 (and v (format "%s=%s" k v))))
47 '("type" "link" "description" "annotation" "initial"))))
48 "\\n")))))
49 (let ((result (org-protocol-check-filename-for-protocol (buffer-string) nil nil)))
50 (cond (out nil)
51 ((null result)
52 (setq out (format "store url=%s\\ntitle=%s" (car (car org-stored-links)) (or (cadr (car org-stored-links)) "nil"))))
53 (t (setq out "none")))))
54 (erase-buffer)
55 (insert out)))
56 """
57 let results = try EmacsOracle.run(Self.urls.map { EmacsOracle.Case(text: $0, point: 1, form: form) })
58 for (url, result) in zip(Self.urls, results) {
59 let ours = describe(OrgProtocol.parse(url))
60 #expect(ours == result.text, "\(url)\nours: \(ours.debugDescription)\nemacs: \(result.text.debugDescription) \(result.error)")
61 }
62 }
63}
scripts/build-app.sh +22
@@ -75,6 +75,15 @@ cat > "$app/Contents/Info.plist" <<EOF
7575 <array><string>public.plain-text</string></array>
7676 </dict>
7777 </array>
78 <key>CFBundleURLTypes</key>
79 <array>
80 <dict>
81 <key>CFBundleURLName</key>
82 <string>org-protocol</string>
83 <key>CFBundleURLSchemes</key>
84 <array><string>org-protocol</string></array>
85 </dict>
86 </array>
7887 <key>CFBundleShortVersionString</key>
7988 <string>${version}</string>
8089 <key>CFBundleVersion</key>
@@ -91,5 +100,18 @@ cat > "$app/Contents/Info.plist" <<EOF
91100</plist>
92101EOF
93102
103# App Intents metadata for Shortcuts, from the constant values Swift Build extracted; SwiftPM
104# doesn't run this step itself.
105toolchain="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain"
106objects=".build/out/Intermediates.noindex/Orgstar.build/Release/Orgstar-p.build/Objects-normal/$(uname -m)"
107work="$(mktemp -d)"
108echo "$PWD/$objects/Orgstar-primary.swiftconstvalues" > "$work/constvalues.txt"
109"$toolchain/usr/bin/appintentsmetadataprocessor" --toolchain-dir "$toolchain" --module-name Orgstar \
110 --sdk-root "$(xcrun --sdk macosx --show-sdk-path)" --xcode-version "$(xcodebuild -version | awk '/Build version/ {print $3}')" \
111 --platform-family macOS --deployment-target 26.0 --bundle-identifier sh.krz.orgstar \
112 --target-triple "$(uname -m)-apple-macos26.0" --binary-file "$binary" --source-file-list "$objects/Orgstar.SwiftFileList" \
113 --swift-const-vals-list "$work/constvalues.txt" --output "$app/Contents/Resources" --force > /dev/null 2>&1
114rm -rf "$work"
115
94116codesign --force --sign - "$app"
95117echo "$app"