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
25folding and Shift-TAB cycles the whole file. Orgstar opens `.org` files from 25folding and Shift-TAB cycles the whole file. Orgstar opens `.org` files from
26Finder once the app has been launched. 26Finder once the app has been launched.
27 27
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
28Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File), 33Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File),
29with keys in `keymap.toml` and capture templates in `capture.toml` beside it. 34with keys in `keymap.toml` and capture templates in `capture.toml` beside it.
30Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom 35Settings ▸ 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 {
114 public var allTags: [String] 114 public var allTags: [String]
115 /// `org-tags-column`, for aligning tags `%^g` adds to a heading. 115 /// `org-tags-column`, for aligning tags `%^g` adds to a heading.
116 public var tagsColumn: Int 116 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] = [:]
117 119
118 public init( 120 public init(
119 now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "", 121 now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "",
@@ -386,7 +388,7 @@ public enum Capture {
386 text += strftime(String(chars[(i + 2)..<close]), context) 388 text += strftime(String(chars[(i + 2)..<close]), context)
387 consumed = close - i + 1 389 consumed = close - i + 1
388 case ":": 390 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.
390 var j = i + 2 392 var j = i + 2
391 while j < chars.count, chars[j].isLetter || chars[j] == "-" { j += 1 } 393 while j < chars.count, chars[j].isLetter || chars[j] == "-" { j += 1 }
392 guard j > i + 2 else { 394 guard j > i + 2 else {
@@ -394,7 +396,9 @@ public enum Capture {
394 consumed = 1 396 consumed = 1
395 break 397 break
396 } 398 }
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]!
398 case "annotation": text += context.annotation 402 case "annotation": text += context.annotation
399 case "initial": text += context.initial 403 case "initial": text += context.initial
400 default: break 404 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 {
26 var body: some View { 26 var body: some View {
27 VStack(alignment: .leading, spacing: 12) { 27 VStack(alignment: .leading, spacing: 12) {
28 if let template, editing { 28 if let template, editing {
29 Text("\(template.name) → \(target(template))").font(.headline) 29 Text("\(template.name) → \(Self.target(template))").font(.headline)
30 TextEditor(text: $text, selection: $selection) 30 TextEditor(text: $text, selection: $selection)
31 .font(.body.monospaced()) 31 .font(.body.monospaced())
32 .focused($focused) 32 .focused($focused)
@@ -66,7 +66,7 @@ struct CaptureView: View {
66 Text("[\(template.key)]").monospaced().foregroundStyle(.secondary) 66 Text("[\(template.key)]").monospaced().foregroundStyle(.secondary)
67 Text(template.name) 67 Text(template.name)
68 Spacer() 68 Spacer()
69 Text(target(template)).foregroundStyle(.secondary).font(.caption) 69 Text(Self.target(template)).foregroundStyle(.secondary).font(.caption)
70 } 70 }
71 } 71 }
72 .buttonStyle(.plain) 72 .buttonStyle(.plain)
@@ -81,7 +81,15 @@ struct CaptureView: View {
81 choose(match) 81 choose(match)
82 return .handled 82 return .handled
83 } 83 }
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 }
85 } 93 }
86 94
87 /// What an empty answer means. 95 /// What an empty answer means.
@@ -93,7 +101,7 @@ struct CaptureView: View {
93 } 101 }
94 } 102 }
95 103
96 private func target(_ template: CaptureTemplate) -> String { 104 static func target(_ template: CaptureTemplate) -> String {
97 let file = (template.target.file as NSString).lastPathComponent 105 let file = (template.target.file as NSString).lastPathComponent
98 switch template.target { 106 switch template.target {
99 case .file, .heading: return file 107 case .file, .heading: return file
@@ -126,6 +134,19 @@ struct CaptureView: View {
126 ) 134 )
127 } 135 }
128 136
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
129 private func choose(_ chosen: CaptureTemplate) { 150 private func choose(_ chosen: CaptureTemplate) {
130 template = chosen 151 template = chosen
131 // `%^g` offers the target file's tags. 152 // `%^g` offers the target file's tags.
@@ -161,7 +182,9 @@ struct CaptureView: View {
161 } 182 }
162 183
163 /// `id` and `clock` targets as a file and a heading. 184 /// `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> {
165 switch template.target { 188 switch template.target {
166 case .id(let id): 189 case .id(let id):
167 guard let location = try? workspace.index.headings(withID: id).first else { 190 guard let location = try? workspace.index.headings(withID: id).first else {
@@ -180,7 +203,7 @@ struct CaptureView: View {
180 guard let template else { return } 203 guard let template else { return }
181 let opened = context.now 204 let opened = context.now
182 let now = Date() 205 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
184 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now) 207 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
185 } 208 }
186 switch placed { 209 switch placed {
@@ -189,7 +212,7 @@ struct CaptureView: View {
189 if template.options.immediateFinish { editing = true } 212 if template.options.immediateFinish { editing = true }
190 return 213 return
191 case .success(let entry): 214 case .success(let entry):
192 commands.show("Captured to \(target(template))") 215 commands.show("Captured to \(Self.target(template))")
193 let options = template.options 216 let options = template.options
194 if options.clockIn { 217 if options.clockIn {
195 // Clocked from when capture began; the clock it interrupted resumes after. 218 // Clocked from when capture began; the clock it interrupted resumes after.
Sources/Orgstar/ContentView.swift +2
@@ -335,6 +335,8 @@ struct ContentView: View {
335 // Files handed over at launch open after the restored buffers, and show. 335 // Files handed over at launch open after the restored buffers, and show.
336 for path in AppDelegate.pendingOpens { session.open(path) } 336 for path in AppDelegate.pendingOpens { session.open(path) }
337 AppDelegate.pendingOpens = [] 337 AppDelegate.pendingOpens = []
338 for link in AppDelegate.pendingLinks + [DevelopmentHooks.urlToOpen].compactMap({ $0 }) { commands.handle(protocolURL: link) }
339 AppDelegate.pendingLinks = []
338 if let path = DevelopmentHooks.fileToOpen { selectedFile = path } 340 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
339 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } 341 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
340 if let commandList = DevelopmentHooks.commandToRun { 342 if let commandList = DevelopmentHooks.commandToRun {
Sources/Orgstar/DevelopmentHooks.swift +2
@@ -8,6 +8,7 @@ import AppKit
8/// - `ORGSTAR_COMMAND`: commands to run at launch, separated by commas (`app.import-emacs`); a 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. 9/// sheet or prompt they open is what the snapshot renders.
10/// - `ORGSTAR_ANSWERS`: answers to the prompts those commands ask, in order, separated by commas. 10/// - `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.
11/// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's. 12/// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's.
12/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main 13/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
13/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording 14/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
@@ -17,6 +18,7 @@ enum DevelopmentHooks {
17 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] } 18 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] }
18 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] } 19 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] }
19 static var commandToRun: String? { ProcessInfo.processInfo.environment["ORGSTAR_COMMAND"] } 20 static var commandToRun: String? { ProcessInfo.processInfo.environment["ORGSTAR_COMMAND"] }
21 static var urlToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_URL"] }
20 static var answers: [String] { ProcessInfo.processInfo.environment["ORGSTAR_ANSWERS"]?.split(separator: ",").map(String.init) ?? [] } 22 static var answers: [String] { ProcessInfo.processInfo.environment["ORGSTAR_ANSWERS"]?.split(separator: ",").map(String.init) ?? [] }
21 23
22 @MainActor 24 @MainActor
Sources/Orgstar/OrgstarApp.swift +29 −1
@@ -77,6 +77,27 @@ final class AppCommands {
77 runRequest = (id, runSerial) 77 runRequest = (id, runSerial)
78 } 78 }
79 79
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
80 func jump(path: String, offset: Int) { 101 func jump(path: String, offset: Int) {
81 jumpID += 1 102 jumpID += 1
82 jumpRequest = (path, offset, jumpID) 103 jumpRequest = (path, offset, jumpID)
@@ -136,14 +157,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
136 157
137 func application(_ application: NSApplication, open urls: [URL]) { 158 func application(_ application: NSApplication, open urls: [URL]) {
138 let paths = urls.filter(\.isFileURL).map(\.path) 159 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 {
140 Self.pendingOpens += paths 162 Self.pendingOpens += paths
163 Self.pendingLinks += links
141 return 164 return
142 } 165 }
143 NSApp.activate() 166 NSApp.activate()
144 for path in paths { session.open(path) } 167 for path in paths { session.open(path) }
168 for link in links { commands.handle(protocolURL: link) }
145 } 169 }
146 170
171 /// org-protocol links that arrive before the window is ready.
172 static var pendingLinks: [String] = []
173
147 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } 174 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
148 175
149 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { 176 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
@@ -247,6 +274,7 @@ struct OrgstarApp: App {
247 .onAppear { 274 .onAppear {
248 delegate.session = session 275 delegate.session = session
249 delegate.commands = commands 276 delegate.commands = commands
277 CaptureIntent.environment = (model, session, clock, commands)
250 } 278 }
251 case .failure(let error): 279 case .failure(let error):
252 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) 280 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
75 <array><string>public.plain-text</string></array> 75 <array><string>public.plain-text</string></array>
76 </dict> 76 </dict>
77 </array> 77 </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>
78 <key>CFBundleShortVersionString</key> 87 <key>CFBundleShortVersionString</key>
79 <string>${version}</string> 88 <string>${version}</string>
80 <key>CFBundleVersion</key> 89 <key>CFBundleVersion</key>
@@ -91,5 +100,18 @@ cat > "$app/Contents/Info.plist" <<EOF
91</plist> 100</plist>
92EOF 101EOF
93 102
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
94codesign --force --sign - "$app" 116codesign --force --sign - "$app"
95echo "$app" 117echo "$app"