krz/orgstar

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

Sources/OrgApp/CaptureModel.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgApp/CaptureModel.swift history · blame · raw

195 lines · 10426 bytes

13 symbols in this file
  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, plain or table-line
 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///
 19/// Other targets: `datetree = true` (under `olp` if given; `tree-type` "day", "week" or
 20/// "month"), `id = "…"` without `file`, `clock = true` without `file`. Other properties, as
 21/// in `org-capture-templates`: `immediate-finish`, `empty-lines`, `empty-lines-before`,
 22/// `empty-lines-after`, `jump-to-captured`, `clock-in`, `clock-keep`, `clock-resume`,
 23/// `table-line-pos`.
 24@MainActor
 25public enum CaptureTemplates {
 26    public static var userFile: URL { UserConfig.file("capture.toml") }
 27
 28    /// The templates in your Doom configuration.
 29    public static let defaults = [
 30        CaptureTemplate(key: "t", name: "Personal todo", target: .headline(file: "todo.org", headline: "Inbox"), template: "* TODO %?\n%i\n%a"),
 31        CaptureTemplate(key: "n", name: "Personal notes", target: .headline(file: "notes.org", headline: "Inbox"), template: "* %u %?\n%i\n%a"),
 32    ]
 33
 34    public static func load(userFile: URL = userFile) -> (templates: [CaptureTemplate], problems: [String]) {
 35        guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (defaults, []) }
 36        let tables: [TOML.Table]
 37        do {
 38            tables = try TOML.parse(text)
 39        } catch {
 40            return (defaults, ["capture.toml: \(error)"])
 41        }
 42        var templates: [CaptureTemplate] = []
 43        var problems: [String] = []
 44        for table in tables where !(table.name.isEmpty && table.values.isEmpty) {
 45            guard table.name == "template", table.isArrayElement else {
 46                problems.append("capture.toml line \(table.line): expected [[template]]")
 47                continue
 48            }
 49            let values = table.values
 50            let fileless = values["id"]?.string != nil || values["clock"]?.bool == true
 51            guard let key = values["key"]?.string, let body = values["template"]?.string, let file = values["file"]?.string ?? (fileless ? "" : nil) else {
 52                problems.append("capture.toml line \(table.line): a template needs key, file and template")
 53                continue
 54            }
 55            guard let kind = CaptureTemplate.Kind(rawValue: table.values["type"]?.string ?? "entry") else {
 56                problems.append("capture.toml line \(table.line): unknown type")
 57                continue
 58            }
 59            let olp = values["olp"]?.string.map { $0.split(separator: "/").map(String.init) }
 60            let target: CaptureTemplate.Target
 61            if let id = values["id"]?.string {
 62                target = .id(id)
 63            } else if values["clock"]?.bool == true {
 64                target = .clock
 65            } else if values["datetree"]?.bool == true {
 66                guard let tree = CaptureTemplate.TreeType(rawValue: values["tree-type"]?.string ?? "day") else {
 67                    problems.append("capture.toml line \(table.line): tree-type is day, week or month")
 68                    continue
 69                }
 70                target = .datetree(file: file, path: olp ?? [], tree: tree)
 71            } else if let headline = values["headline"]?.string {
 72                target = .headline(file: file, headline: headline)
 73            } else if let olp {
 74                target = .outline(file: file, path: olp)
 75            } else {
 76                target = .file(file)
 77            }
 78            func flag(_ name: String) -> Bool { values[name]?.bool ?? false }
 79            func lines(_ name: String) -> Int? { values[name]?.integer }
 80            let options = CaptureTemplate.Options(
 81                prepend: flag("prepend"), immediateFinish: flag("immediate-finish"), emptyLines: lines("empty-lines"),
 82                emptyLinesBefore: lines("empty-lines-before"), emptyLinesAfter: lines("empty-lines-after"),
 83                jumpToCaptured: flag("jump-to-captured"), clockIn: flag("clock-in"), clockKeep: flag("clock-keep"), clockResume: flag("clock-resume"),
 84                tableLinePos: values["table-line-pos"]?.string
 85            )
 86            templates.append(CaptureTemplate(key: key, name: values["name"]?.string ?? key, kind: kind, target: target, template: body, options: options))
 87        }
 88        return (templates.isEmpty ? defaults : templates, problems)
 89    }
 90}
 91
 92extension DocumentSession {
 93    /// Places captured `text` with `template`: through the buffer when the target is open,
 94    /// otherwise in the file, created if missing, through the save path. `id` and `clock`
 95    /// targets must be resolved first (`file` names the ID's file). Returns the file and where
 96    /// the entry starts.
 97    public func capture(_ text: String, template: CaptureTemplate, root: String?, file: String? = nil, now: Date = Date()) -> Result<(path: String, offset: Int), CaptureError> {
 98        let path = file ?? Self.resolve(template.target.file, root: root)
 99        if !isOpen(path), !FileManager.default.fileExists(atPath: path) {
100            do {
101                try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true)
102                try Data().write(to: URL(fileURLWithPath: path))
103            } catch {
104                return .failure(CaptureError(message: "Can't create \(path): \(error.localizedDescription)"))
105            }
106        }
107        switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil, now: now) {
108        case .commit(let result): return .success((path, result.selection?.first?.lowerBound ?? 0))
109        case .failed(let message): return .failure(CaptureError(message: message))
110        default: return .failure(CaptureError(message: "Can't capture there."))
111        }
112    }
113
114    /// A template's file: absolute, `~/…`, or relative to `root`.
115    public static func resolve(_ file: String, root: String?) -> String {
116        if file.hasPrefix("/") { return file }
117        if file.hasPrefix("~") { return (file as NSString).expandingTildeInPath }
118        return ((root ?? NSHomeDirectory()) as NSString).appendingPathComponent(file)
119    }
120
121    /// `%a`: a link to the heading at the caret, as `org-store-link` makes one.
122    public func captureAnnotation() -> String {
123        guard let path, self.document != nil else { return "" }
124        let abbreviated = path.hasPrefix(NSHomeDirectory()) ? "~" + path.dropFirst(NSHomeDirectory().count) : Substring(path)
125        guard let entry = outline.last(where: { $0.start <= caret }) else { return "[[file:\(abbreviated)]]" }
126        return "[[file:\(abbreviated)::*\(entry.title)][\(entry.title)]]"
127    }
128}
129
130public struct CaptureError: Error, Equatable {
131    public let message: String
132    public init(message: String) { self.message = message }
133}
134
135/// The edit `Capture.place` makes, as a command so the save path applies it. The selection
136/// it leaves is the captured entry's start.
137struct CapturePlacement: OrgCommand {
138    let text: String
139    let template: CaptureTemplate
140    var id: String { "internal.capture" }
141    var title: String { "Capture" }
142    func applies(in context: EditContext) -> Bool { true }
143    func run(in context: EditContext) -> CommandStep {
144        switch Capture.placement(text, template: template, in: context.text, settings: context.tree.settings, now: context.now, calendar: context.calendar) {
145        case .failure(.message(let message)):
146            return .failed(message)
147        case .success(let placed):
148            return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, placed.text), selection: [placed.start..<placed.start]))
149        }
150    }
151}
152
153extension DocumentSession {
154    /// Shortcuts' "Capture to Orgstar": `text` as `%i` in the template keyed `key` (the first
155    /// when nil or empty), its prompts taking their defaults, filed without the capture window.
156    /// The template used.
157    public func capture(
158        _ text: String, templateKey key: String?, templates: [CaptureTemplate], workspace: WorkspaceModel, clock: ClockModel,
159        clipboard: String, userName: String
160    ) -> Result<CaptureTemplate, CaptureError> {
161        let key = key.flatMap { $0.isEmpty ? nil : $0 }
162        guard let chosen = key.map({ key in templates.first { $0.key == key } }) ?? templates.first else {
163            return .failure(CaptureError(message: "No capture template \"\(key ?? "")\""))
164        }
165        let context = CaptureContext(initial: text, clipboard: clipboard, userName: userName, allTags: (try? workspace.index.tags()) ?? [])
166        let answers = Capture.questions(chosen.template, context: context).map { _ in "" }
167        guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else {
168            return .failure(CaptureError(message: "Can't fill template \"\(chosen.key)\""))
169        }
170        return chosen.resolved(workspace: workspace, session: self, clock: clock).flatMap { resolved in
171            capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file)
172        }.map { _ in chosen }
173    }
174}
175
176extension CaptureTemplate {
177    /// `id` and `clock` targets as a file and a heading.
178    @MainActor
179    public func resolved(
180        workspace: WorkspaceModel, session: DocumentSession, clock: ClockModel
181    ) -> Result<(template: CaptureTemplate, file: String?), CaptureError> {
182        switch target {
183        case .id(let id):
184            guard let location = try? workspace.index.headings(withID: id).first else {
185                return .failure(CaptureError(message: "Cannot find target ID \"\(id)\""))
186            }
187            return .success((self, location.path))
188        case .clock:
189            guard let entry = clock.entry(session) else { return .failure(CaptureError(message: "No running clock")) }
190            return .success((retargeted(.heading(file: entry.path, offset: entry.offset)), entry.path))
191        default:
192            return .success((self, nil))
193        }
194    }
195}