Sources/OrgApp/CaptureModel.swift
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}