Sources/OrgCore/Links/OrgProtocol.swift
95 lines · 4995 bytes
10 symbols in this file
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 /// An `org-protocol://capture?…` link, as bookmarklets build them, with every value
53 /// percent-encoded so `+`, `&` and `=` survive `parse`.
54 public static func captureURL(template: String?, url: String?, title: String, body: String) -> String {
55 let fields: [(String, String?)] = [("template", template), ("url", url), ("title", title), ("body", body)]
56 return "org-protocol://capture?" + fields.compactMap { key, value in
57 value.map { key + "=" + ($0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0) }
58 }.joined(separator: "&")
59 }
60
61 /// `org-protocol-convert-query-to-plist` and `org-link-decode`: `+` is a space; a value
62 /// ends at its second `=`.
63 static func query(_ info: String) -> [(String, String?)] {
64 info.replacingOccurrences(of: "+", with: " ").components(separatedBy: "&").map { pair in
65 let c = pair.components(separatedBy: "=")
66 return (c[0], c.count > 1 ? decode(c[1]) : nil)
67 }
68 }
69
70 /// `org-protocol-split-data` with `org-protocol-data-separator`, each part decoded.
71 static func split(_ info: String) -> [String] {
72 info.split(separator: /\/+|\?/, omittingEmptySubsequences: false).map { decode(String($0)) }
73 }
74
75 /// `org-protocol-assign-parameters`: values in order, then the rest as key, value.
76 static func assign(_ data: [String], _ order: [String]) -> [String: String?] {
77 var result: [String: String?] = [:]
78 var data = data[...]
79 for key in order { result[key] = data.popFirst() }
80 while let key = data.popFirst() { result[key] = data.popFirst() }
81 return result
82 }
83
84 /// `org-link-decode`: `%XX` sequences as UTF-8.
85 static func decode(_ s: String) -> String {
86 s.removingPercentEncoding ?? s
87 }
88
89 /// `org-protocol-sanitize-uri`: `scheme:/a/b` back to `scheme://a/b`.
90 static func sanitize(_ uri: String) -> String {
91 guard uri.firstMatch(of: /^[a-z]+:\//) != nil else { return uri }
92 let parts = uri.split(separator: /\/+/, omittingEmptySubsequences: false)
93 return parts[0] + "//" + parts.dropFirst().joined(separator: "/")
94 }
95}