krz/orgstar

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

Sources/OrgApp/CaptureModel.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgApp/CaptureModel.swift history · blame · raw

119 lines · 5743 bytes

  1import Foundation
  2import Observation
  3import OrgCore
  4import OrgDocument
  5
  6/// Capture templates from `capture.toml`, and capturing into their targets:
  7///
  8/// ```toml
  9/// [[template]]
 10/// key = "t"
 11/// name = "Personal todo"
 12/// type = "entry"            # entry, item, checkitem or plain
 13/// file = "todo.org"         # relative to the first folder, or ~/… or /…
 14/// headline = "Inbox"        # or olp = "Projects/Work"; neither for the end of the file
 15/// template = "* TODO %?\n%i\n%a"
 16/// prepend = false
 17/// ```
 18@MainActor
 19public enum CaptureTemplates {
 20    public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("capture.toml") }
 21
 22    /// The templates in your Doom configuration.
 23    public static let defaults = [
 24        CaptureTemplate(key: "t", name: "Personal todo", target: .headline(file: "todo.org", headline: "Inbox"), template: "* TODO %?\n%i\n%a"),
 25        CaptureTemplate(key: "n", name: "Personal notes", target: .headline(file: "notes.org", headline: "Inbox"), template: "* %u %?\n%i\n%a"),
 26    ]
 27
 28    public static func load(userFile: URL = userFile) -> (templates: [CaptureTemplate], problems: [String]) {
 29        guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (defaults, []) }
 30        let tables: [TOML.Table]
 31        do {
 32            tables = try TOML.parse(text)
 33        } catch {
 34            return (defaults, ["capture.toml: \(error)"])
 35        }
 36        var templates: [CaptureTemplate] = []
 37        var problems: [String] = []
 38        for table in tables where !(table.name.isEmpty && table.values.isEmpty) {
 39            guard table.name == "template", table.isArrayElement else {
 40                problems.append("capture.toml line \(table.line): expected [[template]]")
 41                continue
 42            }
 43            guard let key = table.values["key"]?.string, let file = table.values["file"]?.string, let body = table.values["template"]?.string else {
 44                problems.append("capture.toml line \(table.line): a template needs key, file and template")
 45                continue
 46            }
 47            guard let kind = CaptureTemplate.Kind(rawValue: table.values["type"]?.string ?? "entry") else {
 48                problems.append("capture.toml line \(table.line): unknown type")
 49                continue
 50            }
 51            let target: CaptureTemplate.Target
 52            if let headline = table.values["headline"]?.string {
 53                target = .headline(file: file, headline: headline)
 54            } else if let olp = table.values["olp"]?.string {
 55                target = .outline(file: file, path: olp.split(separator: "/").map(String.init))
 56            } else {
 57                target = .file(file)
 58            }
 59            templates.append(CaptureTemplate(
 60                key: key, name: table.values["name"]?.string ?? key, kind: kind, target: target, template: body,
 61                prepend: table.values["prepend"]?.bool ?? false
 62            ))
 63        }
 64        return (templates.isEmpty ? defaults : templates, problems)
 65    }
 66}
 67
 68extension DocumentSession {
 69    /// Places captured `text` with `template`: through the buffer when the target is open,
 70    /// otherwise in the file, created if missing, through the save path.
 71    public func capture(_ text: String, template: CaptureTemplate, root: String?) -> String? {
 72        let path = Self.resolve(template.target.file, root: root)
 73        if path != self.path, !FileManager.default.fileExists(atPath: path) {
 74            do {
 75                try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true)
 76                try Data().write(to: URL(fileURLWithPath: path))
 77            } catch {
 78                return "Can't create \(path): \(error.localizedDescription)"
 79            }
 80        }
 81        switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil) {
 82        case .commit: return nil
 83        case .failed(let message): return message
 84        default: return "Can't capture there."
 85        }
 86    }
 87
 88    /// A template's file: absolute, `~/…`, or relative to `root`.
 89    public static func resolve(_ file: String, root: String?) -> String {
 90        if file.hasPrefix("/") { return file }
 91        if file.hasPrefix("~") { return (file as NSString).expandingTildeInPath }
 92        return ((root ?? FileManager.default.homeDirectoryForCurrentUser.path) as NSString).appendingPathComponent(file)
 93    }
 94
 95    /// `%a`: a link to the heading at the caret, as `org-store-link` makes one.
 96    public func captureAnnotation() -> String {
 97        guard let path, self.document != nil else { return "" }
 98        let abbreviated = path.hasPrefix(NSHomeDirectory()) ? "~" + path.dropFirst(NSHomeDirectory().count) : Substring(path)
 99        guard let entry = outline.last(where: { $0.start <= caret }) else { return "[[file:\(abbreviated)]]" }
100        return "[[file:\(abbreviated)::*\(entry.title)][\(entry.title)]]"
101    }
102}
103
104/// The edit `Capture.place` makes, as a command so the save path applies it.
105struct CapturePlacement: OrgCommand {
106    let text: String
107    let template: CaptureTemplate
108    var id: String { "internal.capture" }
109    var title: String { "Capture" }
110    func applies(in context: EditContext) -> Bool { true }
111    func run(in context: EditContext) -> CommandStep {
112        switch Capture.place(text, template: template, in: context.text, settings: context.tree.settings) {
113        case .failure(.message(let message)):
114            return .failed(message)
115        case .success(let new):
116            return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, new)))
117        }
118    }
119}