Commit 30f6716c5b
Verified · cmc
Layout: unified · split
README.md +5
| @@ -25,6 +25,11 @@ searches, ⇧⌘A shows the agenda. In the editor, TAB on a heading cycles its | |||
| 25 | folding and Shift-TAB cycles the whole file. Orgstar opens `.org` files from | 25 | folding and Shift-TAB cycles the whole file. Orgstar opens `.org` files from |
| 26 | Finder once the app has been launched. | 26 | Finder 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 | ||
| 30 | bookmarklets written for org-protocol work unchanged. Shortcuts has a | ||
| 31 | "Capture to Orgstar" action that files text with a capture template. | ||
| 32 | |||
| 28 | Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File), | 33 | Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File), |
| 29 | with keys in `keymap.toml` and capture templates in `capture.toml` beside it. | 34 | with keys in `keymap.toml` and capture templates in `capture.toml` beside it. |
| 30 | Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom | 35 | Settings ▸ 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 @@ | |||
| 1 | import 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 | |||
| 6 | public 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 @@ | |||
| 1 | import AppIntents | ||
| 2 | import AppKit | ||
| 3 | import OrgApp | ||
| 4 | import 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. | ||
| 8 | struct 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 | |||
| 58 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct 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> |
| 92 | EOF | 101 | EOF |
| 93 | 102 | ||
| 103 | # App Intents metadata for Shortcuts, from the constant values Swift Build extracted; SwiftPM | ||
| 104 | # doesn't run this step itself. | ||
| 105 | toolchain="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain" | ||
| 106 | objects=".build/out/Intermediates.noindex/Orgstar.build/Release/Orgstar-p.build/Objects-normal/$(uname -m)" | ||
| 107 | work="$(mktemp -d)" | ||
| 108 | echo "$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 | ||
| 114 | rm -rf "$work" | ||
| 115 | |||
| 94 | codesign --force --sign - "$app" | 116 | codesign --force --sign - "$app" |
| 95 | echo "$app" | 117 | echo "$app" |