krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

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}