Capture !27

merged merged by cmc on 2026-10-05 07:21 UTC · krz/orgstar:phase5-capture into main

14 files changed, +1095 −17

Layout: unified · split

Sources/OrgApp/CaptureModel.swift added +119
@@ -0,0 +1,119 @@
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}
Sources/OrgApp/DocumentSession.swift +2
@@ -87,6 +87,8 @@ public final class DocumentSession {
87 } 87 }
88 88
89 private var url: URL? { path.map { URL(fileURLWithPath: $0) } } 89 private var url: URL? { path.map { URL(fileURLWithPath: $0) } }
90 /// The caret in the open file.
91 public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 }
90 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } 92 private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" }
91 93
92 public func open(_ path: String) { 94 public func open(_ path: String) {
Sources/OrgCore/Capture/Capture.swift added +550
@@ -0,0 +1,550 @@
1import Foundation
2
3// Capture (org-capture.el, Org 9.8.7): filling a template and placing the result in its
4// target, as `org-capture` followed by `org-capture-finalize` leaves the target file.
5
6public struct CaptureTemplate: Sendable, Equatable, Identifiable {
7 public enum Kind: String, Sendable, Equatable { case entry, item, checkitem, plain }
8
9 public enum Target: Sendable, Equatable {
10 case file(String)
11 case headline(file: String, headline: String)
12 case outline(file: String, path: [String])
13
14 public var file: String {
15 switch self {
16 case .file(let f), .headline(let f, _), .outline(let f, _): f
17 }
18 }
19 }
20
21 public let key: String
22 public let name: String
23 public let kind: Kind
24 public let target: Target
25 public let template: String
26 public let prepend: Bool
27 public var id: String { key }
28
29 public init(key: String, name: String, kind: Kind = .entry, target: Target, template: String, prepend: Bool = false) {
30 self.key = key
31 self.name = name
32 self.kind = kind
33 self.target = target
34 self.template = template
35 self.prepend = prepend
36 }
37}
38
39/// What the escapes in a template stand for.
40public struct CaptureContext: Sendable {
41 public var now: Date
42 public var calendar: Calendar
43 /// `%i`: the selected text.
44 public var initial: String
45 /// `%a`: a link to where capture started, `[[target][description]]`, or empty.
46 public var annotation: String
47 /// `%c`: the clipboard.
48 public var clipboard: String
49 /// `%f` and `%F`: the file capture started from.
50 public var file: String
51 /// `%n`.
52 public var userName: String
53
54 public init(now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "", file: String = "", userName: String = "") {
55 self.now = now
56 self.calendar = calendar
57 self.initial = initial
58 self.annotation = annotation
59 self.clipboard = clipboard
60 self.file = file
61 self.userName = userName
62 }
63}
64
65public enum Capture {
66 public enum Failure: Error, Equatable {
67 case message(String)
68 }
69
70 /// A `%^{prompt|default|choice…}` the template asks before it can be filled.
71 public struct Question: Sendable, Equatable {
72 public let prompt: String
73 public let defaultValue: String
74 public let choices: [String]
75 }
76
77 /// The prompts in `template`, in order.
78 public static func questions(_ template: String) -> [Question] {
79 let regex = try! NSRegularExpression(pattern: "(?<!%)%\\^\\{([^}]*)\\}")
80 let ns = template as NSString
81 return regex.matches(in: template, range: NSRange(location: 0, length: ns.length)).map { m in
82 let items = ns.substring(with: m.range(at: 1)).components(separatedBy: "|")
83 return Question(prompt: items[0], defaultValue: items.count > 1 ? items[1] : "", choices: Array(items.dropFirst(2)))
84 }
85 }
86
87 /// `org-capture-fill-template`: the text and where `%?` puts the cursor. `answers` holds
88 /// the replies to `questions(template)` in order.
89 public static func fill(_ template: String, context: CaptureContext, answers: [String] = []) -> Result<(text: String, cursor: Int), Failure> {
90 func stamp(_ active: Bool, _ withTime: Bool) -> String {
91 let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute], from: context.now)
92 let day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.weekday ?? 1) - 1]
93 let body = String(format: "%04d-%02d-%02d %@", c.year!, c.month!, c.day!, day) + (withTime ? String(format: " %02d:%02d", c.hour!, c.minute!) : "")
94 return active ? "<\(body)>" : "[\(body)]"
95 }
96 let link = try! NSRegularExpression(pattern: "\\[\\[(.*?)\\](\\[.*?\\])?\\]")
97 func linkForm(_ template: String) -> String {
98 let ns = context.annotation as NSString
99 guard let m = link.firstMatch(in: context.annotation, range: NSRange(location: 0, length: ns.length)) else { return context.annotation }
100 return template.replacingOccurrences(of: "$1", with: ns.substring(with: m.range(at: 1)))
101 }
102 var text = ""
103 var answerIndex = 0
104 var answered: [String] = []
105 var cursor: Int?
106 let chars = Array(template)
107 var i = 0
108 while i < chars.count {
109 let c = chars[i]
110 guard c == "%", i + 1 < chars.count else {
111 text.append(c)
112 i += 1
113 continue
114 }
115 let next = chars[i + 1]
116 if next == "%" {
117 text.append("%")
118 i += 2
119 continue
120 }
121 var consumed = 2
122 switch next {
123 case "?": cursor = text.utf16.count
124 case "i":
125 // Continuation lines keep the indentation of the line `%i` is on.
126 let lead = String(text.split(separator: "\n", omittingEmptySubsequences: false).last ?? "").prefix { $0 == " " || $0 == "\t" }
127 text += context.initial.replacingOccurrences(of: "\n", with: "\n" + lead)
128 case "a": text += context.annotation == "[[]]" ? "" : context.annotation
129 case "l": text += linkForm("[[$1]]")
130 case "L": text += linkForm("$1")
131 case "c": text += context.clipboard
132 case "f": text += (context.file as NSString).lastPathComponent
133 case "F": text += context.file
134 case "n": text += context.userName
135 case "t": text += stamp(true, false)
136 case "T": text += stamp(true, true)
137 case "u": text += stamp(false, false)
138 case "U": text += stamp(false, true)
139 case "<":
140 guard let close = chars[(i + 2)...].firstIndex(of: ">") else { return .failure(.message("Unclosed %< in the template")) }
141 text += strftime(String(chars[(i + 2)..<close]), context)
142 consumed = close - i + 1
143 case "^":
144 guard i + 2 < chars.count, chars[i + 2] == "{", let close = chars[(i + 3)...].firstIndex(of: "}") else {
145 return .failure(.message("Template escapes like %^\(i + 2 < chars.count ? String(chars[i + 2]) : "") aren't supported yet."))
146 }
147 let after = close + 1 < chars.count ? chars[close + 1] : " "
148 if "CgGLptTuU".contains(after) { return .failure(.message("Template escapes like %^{…}\(after) aren't supported yet.")) }
149 let items = String(chars[(i + 3)..<close]).components(separatedBy: "|")
150 var answer = answerIndex < answers.count ? answers[answerIndex] : ""
151 if answer.isEmpty, items.count > 1 { answer = items[1] }
152 answerIndex += 1
153 answered.append(answer)
154 text += answer
155 consumed = close - i + 1
156 case "\\":
157 var j = i + 2
158 var digits = ""
159 while j < chars.count, chars[j].isNumber {
160 digits.append(chars[j])
161 j += 1
162 }
163 if let n = Int(digits), n >= 1, n <= answered.count {
164 text += answered[n - 1]
165 consumed = j - i
166 } else {
167 text.append(c)
168 consumed = 1
169 }
170 case "(", "[", "k", "K", "x", "A", ":":
171 return .failure(.message("Template escapes like %\(next) aren't supported yet."))
172 default:
173 text.append(c)
174 consumed = 1
175 }
176 i += consumed
177 }
178 // No blank lines before the text; it ends with one newline, or is empty.
179 var marked = text
180 let marker = "\u{0}CURSOR\u{0}"
181 if let cursor {
182 let index = String.Index(utf16Offset: cursor, in: marked)
183 marked.insert(contentsOf: marker, at: index)
184 }
185 var lines = marked.components(separatedBy: "\n")
186 while let first = lines.first, first.trimmingCharacters(in: .whitespaces).isEmpty, lines.count > 1 { lines.removeFirst() }
187 marked = lines.joined(separator: "\n")
188 let trimmed = marked.replacingOccurrences(of: "[ \\t\\n]+$", with: "", options: .regularExpression)
189 marked = trimmed.isEmpty ? "" : trimmed + "\n"
190 marked = marked.replacingOccurrences(of: "\t", with: " ")
191 guard let range = marked.range(of: marker) else { return .success((marked, marked.utf16.count > 0 ? marked.utf16.count - 1 : 0)) }
192 let offset = marked[..<range.lowerBound].utf16.count
193 marked.removeSubrange(range)
194 return .success((marked, offset))
195 }
196
197 static func strftime(_ format: String, _ context: CaptureContext) -> String {
198 let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute, .second], from: context.now)
199 let days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
200 let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
201 var out = ""
202 var chars = format.makeIterator()
203 while let ch = chars.next() {
204 guard ch == "%", let f = chars.next() else {
205 out.append(ch)
206 continue
207 }
208 switch f {
209 case "Y": out += String(format: "%04d", c.year!)
210 case "m": out += String(format: "%02d", c.month!)
211 case "d": out += String(format: "%02d", c.day!)
212 case "e": out += String(format: "%2d", c.day!)
213 case "H": out += String(format: "%02d", c.hour!)
214 case "M": out += String(format: "%02d", c.minute!)
215 case "S": out += String(format: "%02d", c.second!)
216 case "a": out += String(days[c.weekday! - 1].prefix(3))
217 case "A": out += days[c.weekday! - 1]
218 case "b": out += String(months[c.month! - 1].prefix(3))
219 case "B": out += months[c.month! - 1]
220 case "F": out += String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
221 case "R": out += String(format: "%02d:%02d", c.hour!, c.minute!)
222 case "%": out += "%"
223 default: out += "%" + String(f)
224 }
225 }
226 return out
227 }
228
229 // MARK: - Placing
230
231 /// The target file's text after capturing `text`, as `org-capture-finalize` leaves it.
232 public static func place(_ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default) -> Result<String, Failure> {
233 let buffer = EmacsBuffer(fileText, point: 0, settings: settings)
234 do {
235 let entryHeading = try locate(template.target, buffer)
236 switch template.kind {
237 case .entry: try placeEntry(text, prepend: template.prepend, heading: entryHeading, buffer)
238 case .plain: placePlain(text, prepend: template.prepend, heading: entryHeading, buffer)
239 case .item, .checkitem: try placeItem(text, checkbox: template.kind == .checkitem, prepend: template.prepend, heading: entryHeading, buffer)
240 }
241 } catch let error as Failure {
242 return .failure(error)
243 } catch let error as EmacsBuffer.UserError {
244 return .failure(.message(error.message))
245 } catch {
246 return .failure(.message("\(error)"))
247 }
248 // Saving the target adds a final newline, as `require-final-newline` does in Org.
249 var result = buffer.text as String
250 if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" }
251 return .success(result)
252 }
253
254 /// `org-capture-set-target-location`: point at the target heading, or nil for a file.
255 static func locate(_ target: CaptureTemplate.Target, _ buffer: EmacsBuffer) throws -> Int? {
256 switch target {
257 case .file:
258 return nil
259 case .headline(_, let headline):
260 buffer.point = 0
261 let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$"
262 if let match = buffer.searchForward(pattern, caseInsensitive: false) {
263 return buffer.lineStart(match[0]!.lowerBound)
264 }
265 buffer.point = buffer.length
266 if !buffer.bolp { buffer.insert("\n") }
267 let start = buffer.point
268 buffer.insert("* " + headline + "\n")
269 return start
270 case .outline(_, let path):
271 var start = 0
272 var end = buffer.length
273 var level = 0
274 var found: Int?
275 for title in path {
276 buffer.point = start
277 let pattern = "^(\\*+)[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: title) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$"
278 var hit: Int?
279 while let match = buffer.searchForward(pattern, bound: end, caseInsensitive: false) {
280 let stars = buffer.substring(match[1]!.lowerBound, match[1]!.upperBound).count
281 if stars > level, level == 0 || stars == level + 1 || found != nil {
282 hit = buffer.lineStart(match[0]!.lowerBound)
283 level = stars
284 break
285 }
286 }
287 guard let hit else { throw Failure.message("Heading not found on outline path \(path.joined(separator: "/"))") }
288 found = hit
289 buffer.point = hit
290 start = hit
291 end = buffer.saveExcursion { () -> Int in
292 endOfSubtree(buffer, heading: hit)
293 return buffer.point
294 }
295 }
296 return found
297 }
298 }
299
300 static func headingLevel(_ buffer: EmacsBuffer, at line: Int) -> Int {
301 buffer.substring(line, buffer.lineEnd(line)).prefix { $0 == "*" }.count
302 }
303
304 static func atHeading(_ buffer: EmacsBuffer) -> Bool {
305 buffer.lookingAtLine("^\\*+ ")
306 }
307
308 /// `outline-next-heading`.
309 static func nextHeading(_ buffer: EmacsBuffer) {
310 if let match = buffer.searchForward("\\n\\*+ ", caseInsensitive: false) {
311 buffer.point = match[0]!.lowerBound + 1
312 } else {
313 buffer.point = buffer.length
314 }
315 }
316
317 /// `org-end-of-subtree t t`: the start of the next heading at the same level or higher.
318 static func endOfSubtree(_ buffer: EmacsBuffer, heading: Int) {
319 let level = headingLevel(buffer, at: heading)
320 buffer.point = heading
321 while true {
322 guard let match = buffer.searchForward("\\n(\\*+) ", caseInsensitive: false) else {
323 buffer.point = buffer.length
324 return
325 }
326 let stars = match[1]!.count
327 if stars <= level {
328 buffer.point = match[0]!.lowerBound + 1
329 return
330 }
331 buffer.point = match[0]!.upperBound
332 }
333 }
334
335 static func previousLineEmpty(_ buffer: EmacsBuffer) -> Bool {
336 let start = buffer.lineStart(buffer.point)
337 guard start > 0 else { return false }
338 let previous = buffer.lineStart(start - 1)
339 return buffer.substring(previous, start - 1).trimmingCharacters(in: .whitespaces).isEmpty
340 }
341
342 /// `org--blank-before-heading-p` with `org-blank-before-new-entry` `(heading . auto)`.
343 static func blankBeforeHeading(_ buffer: EmacsBuffer) -> Bool {
344 buffer.saveExcursion {
345 let before = buffer.searchBackward("^\\*+ ", caseInsensitive: false) == nil && !atHeading(buffer)
346 if before {
347 nextHeading(buffer)
348 if buffer.point >= buffer.length, !atHeading(buffer) { return false }
349 }
350 // `org-back-to-heading`.
351 if !atHeading(buffer) {
352 buffer.point = buffer.lineStart(buffer.point)
353 if !atHeading(buffer), let match = buffer.searchBackward("^\\*+ ", caseInsensitive: false) {
354 buffer.point = match[0]!.lowerBound
355 }
356 } else {
357 buffer.point = buffer.lineStart(buffer.point)
358 }
359 if buffer.point > 0 { return previousLineEmpty(buffer) }
360 let here = buffer.point
361 nextHeading(buffer)
362 if buffer.point < buffer.length || atHeading(buffer) { return previousLineEmpty(buffer) }
363 buffer.point = here
364 return false
365 }
366 }
367
368 /// `org-back-over-empty-lines`, deleting them.
369 static func deleteEmptyLinesBefore(_ buffer: EmacsBuffer) {
370 let pos = buffer.point
371 var start = pos
372 while start > 0 {
373 let lineStart = buffer.lineStart(start - 1)
374 guard buffer.substring(lineStart, start - 1).trimmingCharacters(in: .whitespaces).isEmpty, start - 1 >= lineStart else { break }
375 if lineStart == start - 1 || buffer.substring(lineStart, start).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
376 // A blank line precedes; but keep the newline that ends the previous text line.
377 if lineStart == 0 { start = 0; break }
378 start = lineStart
379 } else {
380 break
381 }
382 }
383 if start < pos { buffer.deleteRegion(start, pos) }
384 buffer.point = start
385 }
386
387 /// `org-capture-empty-lines-after` with no lines: blank lines after point go.
388 static func deleteEmptyLinesAfter(_ buffer: EmacsBuffer) {
389 deleteEmptyLinesBefore(buffer)
390 while buffer.looking("[ \\t]*\\n", caseInsensitive: false, setsMatch: false) != nil {
391 let end = buffer.lineEnd(buffer.point) + 1
392 buffer.deleteRegion(buffer.point, end)
393 }
394 }
395
396 /// `org-capture-place-entry`.
397 static func placeEntry(_ entry: String, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws {
398 var template = entry
399 if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template }
400 // `org-capture-verify-tree`: the first heading has the lowest level.
401 let levels = template.components(separatedBy: "\n").compactMap { line -> Int? in
402 let stars = line.prefix { $0 == "*" }.count
403 return stars > 0 && line.dropFirst(stars).first == " " ? stars : nil
404 }
405 if let first = levels.first, levels.contains(where: { $0 < first }) || !template.hasPrefix("*") {
406 throw Failure.message("Template is not a valid Org entry or tree")
407 }
408 var level = 1
409 if let heading {
410 level = headingLevel(buffer, at: heading) + 1
411 buffer.point = heading
412 if prepend {
413 buffer.point = buffer.lineEnd(heading)
414 nextHeading(buffer)
415 } else {
416 endOfSubtree(buffer, heading: heading)
417 }
418 } else if prepend {
419 buffer.point = 0
420 if !atHeading(buffer) { nextHeading(buffer) }
421 } else {
422 buffer.point = buffer.length
423 }
424 if !buffer.bolp { buffer.insert("\n") }
425 let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer)
426 deleteEmptyLinesBefore(buffer)
427 if blank { buffer.insert("\n") }
428 // `org-paste-subtree` at `level`.
429 let oldLevel = levels.first ?? 1
430 let shift = level - oldLevel
431 var shifted = template
432 if shift != 0 {
433 shifted = template.components(separatedBy: "\n").map { line in
434 let stars = line.prefix { $0 == "*" }.count
435 guard stars > 0, line.dropFirst(stars).first == " " else { return line }
436 return String(repeating: "*", count: max(1, stars + shift)) + line.dropFirst(stars)
437 }.joined(separator: "\n")
438 }
439 if !shifted.hasSuffix("\n") { shifted += "\n" }
440 buffer.insert(shifted)
441 deleteEmptyLinesAfter(buffer)
442 }
443
444 /// `org-capture-place-plain-text`.
445 static func placePlain(_ text: String, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) {
446 if let heading {
447 buffer.point = heading
448 if prepend {
449 buffer.point = buffer.lineEnd(heading)
450 if buffer.point < buffer.length { buffer.point += 1 }
451 skipMetaData(buffer)
452 } else {
453 buffer.point = buffer.lineEnd(heading)
454 nextHeading(buffer)
455 }
456 } else {
457 buffer.point = prepend ? 0 : buffer.length
458 }
459 if !buffer.bolp { buffer.insert("\n") }
460 deleteEmptyLinesBefore(buffer)
461 buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n")
462 deleteEmptyLinesAfter(buffer)
463 }
464
465 /// `org-end-of-meta-data t`: past planning, drawers and the property drawer.
466 static func skipMetaData(_ buffer: EmacsBuffer) {
467 while buffer.point < buffer.length {
468 let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point))
469 let trimmed = line.trimmingCharacters(in: .whitespaces)
470 if trimmed.range(of: "^(SCHEDULED|DEADLINE|CLOSED):", options: .regularExpression) != nil {
471 buffer.forwardLine(1)
472 } else if trimmed.range(of: "^:[\\w-]+:$", options: .regularExpression) != nil {
473 while buffer.forwardLine(1) == 0 {
474 if buffer.substring(buffer.point, buffer.lineEnd(buffer.point)).trimmingCharacters(in: .whitespaces).uppercased() == ":END:" {
475 buffer.forwardLine(1)
476 break
477 }
478 }
479 } else {
480 break
481 }
482 }
483 }
484
485 /// `org-capture-place-item`: after the last item of the first list in the entry, or at the
486 /// end of the entry's text.
487 static func placeItem(_ text: String, checkbox: Bool, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws {
488 var item = text.trimmingCharacters(in: .newlines)
489 if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil {
490 let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil
491 item = "- " + (checkbox && !boxed ? "[ ] " : "") + item
492 } else if checkbox, item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]+\\[[ X-]\\]", options: .regularExpression) == nil {
493 item = item.replacingOccurrences(of: "^([ \\t]*([-+*]|[0-9]+[.)])[ \\t]+)", with: "$1[ ] ", options: .regularExpression)
494 }
495 let start: Int
496 let end: Int
497 if let heading {
498 start = min(buffer.length, buffer.lineEnd(heading) + 1)
499 buffer.point = buffer.lineEnd(heading)
500 nextHeading(buffer)
501 end = buffer.point
502 } else {
503 start = 0
504 buffer.point = 0
505 if !atHeading(buffer) { nextHeading(buffer) }
506 end = buffer.point
507 }
508 // The first list's items in the range.
509 buffer.point = start
510 var items: [(start: Int, end: Int, indent: String)] = []
511 while buffer.point < end {
512 let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point))
513 if let m = line.firstMatch(of: /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]/) {
514 if let first = items.first, String(m.1) != first.indent {
515 // A nested item belongs to the current one.
516 } else {
517 items.append((buffer.point, buffer.point, String(m.1)))
518 }
519 } else if !items.isEmpty, !line.trimmingCharacters(in: .whitespaces).isEmpty,
520 line.prefix(while: { $0 == " " || $0 == "\t" }).count <= (items.first?.indent.count ?? 0) {
521 break
522 }
523 if buffer.forwardLine(1) != 0 { break }
524 if !items.isEmpty { items[items.count - 1].end = buffer.point }
525 }
526 if let first = items.first {
527 let indent = first.indent
528 let indented = item.components(separatedBy: "\n").enumerated().map { $0.offset == 0 ? indent + $0.element : indent + $0.element }.joined(separator: "\n")
529 if prepend {
530 buffer.point = first.start
531 } else {
532 buffer.point = items.last!.end
533 // Before blank lines that end the list.
534 while buffer.point > first.start {
535 let previous = buffer.lineStart(buffer.point - 1)
536 guard buffer.substring(previous, buffer.point - 1).trimmingCharacters(in: .whitespaces).isEmpty else { break }
537 buffer.point = previous
538 }
539 }
540 if !buffer.bolp { buffer.insert("\n") }
541 buffer.insert(indented + "\n")
542 } else {
543 buffer.point = prepend ? start : end
544 if !buffer.bolp { buffer.insert("\n") }
545 deleteEmptyLinesBefore(buffer)
546 buffer.insert(item + "\n")
547 deleteEmptyLinesAfter(buffer)
548 }
549 }
550}
Sources/OrgCore/Compute/Babel.swift +1 −17
@@ -530,23 +530,7 @@ public enum Babel {
530 if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) } 530 if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) }
531 let buffer = EmacsBuffer(text, point: 0, settings: settings) 531 let buffer = EmacsBuffer(text, point: 0, settings: settings)
532 insertResult(value, job.finish, block: blockRange, buffer: buffer) 532 insertResult(value, job.finish, block: blockRange, buffer: buffer)
533 return .success(singleEdit(from: text, to: buffer.text as String)) 533 return .success(TextEdit.between(text, buffer.text as String))
534 }
535
536 /// The one edit between two texts: everything between their common prefix and suffix.
537 static func singleEdit(from old: String, to new: String) -> [TextEdit] {
538 let a = Array(old.utf16)
539 let b = Array(new.utf16)
540 var prefix = 0
541 while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 }
542 var suffix = 0
543 while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] { suffix += 1 }
544 // Keep the edit on scalar boundaries.
545 while prefix > 0, (prefix < a.count && UTF16.isTrailSurrogate(a[prefix])) || (prefix < b.count && UTF16.isTrailSurrogate(b[prefix])) { prefix -= 1 }
546 while suffix > 0, suffix < a.count, UTF16.isLeadSurrogate(a[a.count - suffix - 1]) { suffix -= 1 }
547 if prefix == a.count, prefix == b.count { return [] }
548 let replacement = String(utf16CodeUnits: Array(b[prefix..<(b.count - suffix)]), count: b.count - suffix - prefix)
549 return [TextEdit(range: prefix..<(a.count - suffix), replacement: replacement)]
550 } 534 }
551 535
552 /// The language's result handling: `org-babel-result-cond` and the table import. 536 /// The language's result handling: `org-babel-result-cond` and the table import.
Sources/OrgCore/Keymap/Presets.swift +3
@@ -154,6 +154,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
154 keys = "C-c a" 154 keys = "C-c a"
155 command = "app.agenda" 155 command = "app.agenda"
156 [[bind]] 156 [[bind]]
157 keys = "C-c c"
158 command = "app.capture"
159 [[bind]]
157 keys = "C-c C-e h h" 160 keys = "C-c C-e h h"
158 command = "app.export.html" 161 command = "app.export.html"
159 [[bind]] 162 [[bind]]
Sources/OrgCore/Parser/Incremental.swift +19
@@ -392,3 +392,22 @@ extension Parser {
392 return green.kind == kind ? green : nil 392 return green.kind == kind ? green : nil
393 } 393 }
394} 394}
395
396extension TextEdit {
397 /// The one edit between two texts: everything between their common prefix and suffix.
398 public static func between(_ old: String, _ new: String) -> [TextEdit] {
399 let a = Array(old.utf16)
400 let b = Array(new.utf16)
401 var prefix = 0
402 while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 }
403 var suffix = 0
404 while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] { suffix += 1 }
405 // Keep the edit on scalar boundaries.
406 while prefix > 0, (prefix < a.count && UTF16.isTrailSurrogate(a[prefix])) || (prefix < b.count && UTF16.isTrailSurrogate(b[prefix])) { prefix -= 1 }
407 while suffix > 0, suffix < a.count, UTF16.isLeadSurrogate(a[a.count - suffix - 1]) { suffix -= 1 }
408 if prefix == a.count, prefix == b.count { return [] }
409 let replacement = String(utf16CodeUnits: Array(b[prefix..<(b.count - suffix)]), count: b.count - suffix - prefix)
410 return [TextEdit(range: prefix..<(a.count - suffix), replacement: replacement)]
411 }
412
413}
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -43,6 +43,7 @@ public enum EditorCommands {
43 EditorCommand(id: "app.refile", title: "Refile…", action: .app), 43 EditorCommand(id: "app.refile", title: "Refile…", action: .app),
44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), 44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app),
45 EditorCommand(id: "app.agenda", title: "Agenda", action: .app), 45 EditorCommand(id: "app.agenda", title: "Agenda", action: .app),
46 EditorCommand(id: "app.capture", title: "Capture…", action: .app),
46 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), 47 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app),
47 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), 48 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app),
48 EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), 49 EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app),
Sources/OrgEditorAppKit/OrgEditor.swift +7
@@ -598,6 +598,13 @@ public final class OrgEditor: NSObject {
598} 598}
599 599
600extension OrgEditor: DocumentBuffer { 600extension OrgEditor: DocumentBuffer {
601 /// The selected text, for capture's `%i`.
602 public var selectedText: String {
603 let range = textView.selectedRange()
604 guard range.length > 0 else { return "" }
605 return (textView.string as NSString).substring(with: range)
606 }
607
601 public var selection: [Range<Int>] { 608 public var selection: [Range<Int>] {
602 let selected = textView.selectedRange() 609 let selected = textView.selectedRange()
603 return [selected.location..<NSMaxRange(selected)] 610 return [selected.location..<NSMaxRange(selected)]
Sources/Orgstar/CaptureView.swift added +173
@@ -0,0 +1,173 @@
1import AppKit
2import Carbon.HIToolbox
3import OrgApp
4import OrgCore
5import SwiftUI
6
7/// The capture window: pick a template by its key, answer its prompts, edit the entry, and
8/// ⌘Return files it where the template says, as `org-capture` does.
9struct CaptureView: View {
10 let workspace: WorkspaceModel
11 let session: DocumentSession
12 let commands: AppCommands
13 @State private var templates = CaptureTemplates.defaults
14 @State private var problem: String?
15 @State private var template: CaptureTemplate?
16 @State private var questions: [Capture.Question] = []
17 @State private var answers: [String] = []
18 @State private var text = ""
19 @State private var selection: TextSelection?
20 @State private var context = CaptureContext()
21 @State private var editing = false
22 @FocusState private var focused: Bool
23 @Environment(\.dismissWindow) private var dismissWindow
24
25 var body: some View {
26 VStack(alignment: .leading, spacing: 12) {
27 if let template, editing {
28 Text("\(template.name) → \(target(template))").font(.headline)
29 TextEditor(text: $text, selection: $selection)
30 .font(.body.monospaced())
31 .focused($focused)
32 .frame(minHeight: 140)
33 HStack {
34 Spacer()
35 Button("Cancel", role: .cancel) { close() }
36 .keyboardShortcut(.cancelAction)
37 Button("File It") { finish() }
38 .keyboardShortcut(.return, modifiers: .command)
39 }
40 } else if let template, !questions.isEmpty {
41 Text(template.name).font(.headline)
42 Form {
43 ForEach(questions.indices, id: \.self) { index in
44 TextField(questions[index].prompt, text: $answers[index], prompt: Text(questions[index].defaultValue))
45 }
46 }
47 HStack {
48 Spacer()
49 Button("Cancel", role: .cancel) { close() }
50 .keyboardShortcut(.cancelAction)
51 Button("Continue") { expand() }
52 .keyboardShortcut(.defaultAction)
53 }
54 } else {
55 Text("Capture").font(.headline)
56 ForEach(templates) { template in
57 Button {
58 choose(template)
59 } label: {
60 HStack {
61 Text("[\(template.key)]").monospaced().foregroundStyle(.secondary)
62 Text(template.name)
63 Spacer()
64 Text(target(template)).foregroundStyle(.secondary).font(.caption)
65 }
66 }
67 .buttonStyle(.plain)
68 }
69 if let problem { Text(problem).font(.caption).foregroundStyle(.red) }
70 }
71 }
72 .padding(16)
73 .frame(width: 520)
74 .onKeyPress(phases: .down) { press in
75 guard template == nil, let match = templates.first(where: { $0.key == press.characters }) else { return .ignored }
76 choose(match)
77 return .handled
78 }
79 .onAppear(perform: reset)
80 }
81
82 private func target(_ template: CaptureTemplate) -> String {
83 let file = (template.target.file as NSString).lastPathComponent
84 switch template.target {
85 case .file: return file
86 case .headline(_, let headline): return "\(file) / \(headline)"
87 case .outline(_, let path): return ([file] + path).joined(separator: " / ")
88 }
89 }
90
91 private func reset() {
92 let loaded = CaptureTemplates.load()
93 templates = loaded.templates
94 problem = loaded.problems.first
95 template = nil
96 editing = false
97 context = CaptureContext(
98 initial: commands.editor?.selectedText ?? "",
99 annotation: session.captureAnnotation(),
100 clipboard: NSPasteboard.general.string(forType: .string) ?? "",
101 file: session.path ?? "",
102 userName: NSFullUserName()
103 )
104 }
105
106 private func choose(_ chosen: CaptureTemplate) {
107 template = chosen
108 questions = Capture.questions(chosen.template)
109 answers = questions.map { _ in "" }
110 if questions.isEmpty { expand() }
111 }
112
113 private func expand() {
114 guard let template else { return }
115 context.now = Date()
116 switch Capture.fill(template.template, context: context, answers: answers) {
117 case .failure(.message(let message)):
118 problem = message
119 self.template = nil
120 case .success(let filled):
121 text = filled.text
122 let index = String.Index(utf16Offset: filled.cursor, in: text)
123 selection = TextSelection(insertionPoint: index)
124 editing = true
125 focused = true
126 }
127 }
128
129 private func finish() {
130 guard let template else { return }
131 if let message = session.capture(text, template: template, root: workspace.rootPaths.first) {
132 problem = message
133 return
134 }
135 commands.show("Captured to \(target(template))")
136 close()
137 }
138
139 private func close() {
140 reset()
141 dismissWindow(id: "capture")
142 }
143}
144
145/// A system-wide shortcut, registered with Carbon so it needs no accessibility permission.
146@MainActor
147final class GlobalHotKey {
148 private var reference: EventHotKeyRef?
149 private var handler: EventHandlerRef?
150 private let action: () -> Void
151
152 /// ⌃⌥Space.
153 init(action: @escaping () -> Void) {
154 self.action = action
155 var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
156 let me = Unmanaged.passUnretained(self).toOpaque()
157 InstallEventHandler(GetApplicationEventTarget(), { _, _, data in
158 guard let data else { return noErr }
159 let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(data).takeUnretainedValue()
160 MainActor.assumeIsolated { hotKey.action() }
161 return noErr
162 }, 1, &spec, me, &handler)
163 let id = EventHotKeyID(signature: OSType(0x4F52_4753), id: 1)
164 RegisterEventHotKey(UInt32(kVK_Space), UInt32(controlKey | optionKey), id, GetApplicationEventTarget(), 0, &reference)
165 }
166
167 func unregister() {
168 if let reference { UnregisterEventHotKey(reference) }
169 if let handler { RemoveEventHandler(handler) }
170 reference = nil
171 handler = nil
172 }
173}
Sources/Orgstar/ContentView.swift +9
@@ -15,6 +15,8 @@ struct ContentView: View {
15 @AppStorage("agendaSubfolders") private var agendaSubfolders = false 15 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
16 @State private var reminderAgenda: AgendaModel? 16 @State private var reminderAgenda: AgendaModel?
17 @State private var trust = TrustStore() 17 @State private var trust = TrustStore()
18 @State private var hotKey: GlobalHotKey?
19 @AppStorage("globalCapture") private var globalCapture = true
18 @State private var hour = 0 20 @State private var hour = 0
19 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 21 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
20 @AppStorage("keymap") private var preset = KeymapPreset.emacs 22 @AppStorage("keymap") private var preset = KeymapPreset.emacs
@@ -114,6 +116,10 @@ struct ContentView: View {
114 116
115 var body: some View { 117 var body: some View {
116 main 118 main
119 .onChange(of: globalCapture, initial: true) { _, enabled in
120 hotKey?.unregister()
121 hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil
122 }
117 .onChange(of: commands.runRequest?.serial) { 123 .onChange(of: commands.runRequest?.serial) {
118 if let id = commands.runRequest?.id { perform(id) } 124 if let id = commands.runRequest?.id { perform(id) }
119 } 125 }
@@ -309,6 +315,9 @@ struct ContentView: View {
309 if let message = session.archive() { commands.show(message) } 315 if let message = session.archive() { commands.show(message) }
310 case "app.agenda": 316 case "app.agenda":
311 openWindow(id: "agenda") 317 openWindow(id: "agenda")
318 case "app.capture":
319 NSApp.activate()
320 openWindow(id: "capture")
312 case let id where id.hasPrefix("app.export."): 321 case let id where id.hasPrefix("app.export."):
313 export(String(id.dropFirst("app.export.".count))) 322 export(String(id.dropFirst("app.export.".count)))
314 default: 323 default:
Sources/Orgstar/OrgstarApp.swift +17
@@ -161,6 +161,13 @@ struct OrgstarApp: App {
161 } 161 }
162 } 162 }
163 .defaultSize(width: 760, height: 720) 163 .defaultSize(width: 760, height: 720)
164 Window("Capture", id: "capture") {
165 if case .success(let model) = workspace {
166 CaptureView(workspace: model, session: session, commands: commands)
167 }
168 }
169 .windowResizability(.contentSize)
170 .windowLevel(.floating)
164 .commands { 171 .commands {
165 CommandGroup(after: .newItem) { 172 CommandGroup(after: .newItem) {
166 Button("Add Folder…") { commands.showingFolderPicker = true } 173 Button("Add Folder…") { commands.showingFolderPicker = true }
@@ -192,6 +199,10 @@ struct OrgstarApp: App {
192 CommandGroup(before: .windowList) { 199 CommandGroup(before: .windowList) {
193 AgendaMenuItem() 200 AgendaMenuItem()
194 } 201 }
202 CommandGroup(after: .newItem) {
203 Button("Capture…") { commands.run("app.capture") }
204 .keyboardShortcut("n", modifiers: [.command, .shift])
205 }
195 } 206 }
196 .commands { 207 .commands {
197 CommandMenu("Org") { 208 CommandMenu("Org") {
@@ -234,6 +245,7 @@ struct SettingsView: View {
234 @AppStorage("agendaSubfolders") private var agendaSubfolders = false 245 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
235 @AppStorage("reminders") private var reminders = true 246 @AppStorage("reminders") private var reminders = true
236 @AppStorage("reminderLead") private var reminderLead = 12 247 @AppStorage("reminderLead") private var reminderLead = 12
248 @AppStorage("globalCapture") private var globalCapture = true
237 249
238 var body: some View { 250 var body: some View {
239 Form { 251 Form {
@@ -280,6 +292,11 @@ struct SettingsView: View {
280 Toggle("Notify before timed agenda entries", isOn: $reminders) 292 Toggle("Notify before timed agenda entries", isOn: $reminders)
281 Stepper("\(reminderLead) minutes before (APPT_WARNTIME overrides)", value: $reminderLead, in: 0...120) 293 Stepper("\(reminderLead) minutes before (APPT_WARNTIME overrides)", value: $reminderLead, in: 0...120)
282 .disabled(!reminders) 294 .disabled(!reminders)
295 Toggle("⌃⌥Space opens Capture from any app", isOn: $globalCapture)
296 Text("Templates go in \(CaptureTemplates.userFile.path).")
297 .font(.caption)
298 .foregroundStyle(.secondary)
299 .textSelection(.enabled)
283 } 300 }
284 .padding(20) 301 .padding(20)
285 .frame(width: 480) 302 .frame(width: 480)
Tests/OrgAppTests/AppTests.swift +44
@@ -473,3 +473,47 @@ struct EmacsExportTests {
473 #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n") 473 #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n")
474 } 474 }
475} 475}
476
477@MainActor
478struct CaptureAppTests {
479 @Test func loadsTemplates() throws {
480 let folder = try Folder()
481 let file = try folder.write("capture.toml", """
482 [[template]]
483 key = "w"
484 name = "Work"
485 file = "work.org"
486 olp = "Projects/Work"
487 template = "* TODO %?\\n%a"
488 [[template]]
489 key = "j"
490 type = "plain"
491 file = "~/journal.org"
492 template = "%U %?"
493 prepend = true
494 """)
495 let loaded = CaptureTemplates.load(userFile: file)
496 #expect(loaded.problems.isEmpty)
497 #expect(loaded.templates.map(\.key) == ["w", "j"])
498 #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"]))
499 #expect(loaded.templates[0].template == "* TODO %?\n%a")
500 #expect(loaded.templates[1].kind == .plain && loaded.templates[1].prepend)
501 #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults)
502 }
503
504 @Test func capturesIntoClosedMissingAndOpenFiles() throws {
505 let notes = try Folder()
506 let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n")
507 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
508 let template = CaptureTemplates.defaults[0]
509 #expect(session.capture("* TODO New\n", template: template, root: notes.url.path) == nil)
510 #expect(try String(contentsOf: todo, encoding: .utf8) == "* Inbox\n** TODO Old\n** TODO New\n* Later\n")
511
512 #expect(session.capture("* Note\n", template: CaptureTemplates.defaults[1], root: notes.url.path) == nil)
513 #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n")
514
515 session.open(todo.path)
516 #expect(session.capture("* TODO Third\n", template: template, root: notes.url.path) == nil)
517 #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n")
518 }
519}
Tests/OrgCoreTests/CaptureTests.swift added +127
@@ -0,0 +1,127 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct CaptureTests {
6 struct Case {
7 var file: String
8 var template: CaptureTemplate
9 var typed: String
10 }
11
12 static func lisp(_ s: String) -> String {
13 "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
14 }
15
16 /// Captures each case in `emacs --batch`: the target file afterwards.
17 static func emacs(_ cases: [Case], seconds: Int) throws -> [String] {
18 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-capture-\(UUID().uuidString)")
19 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
20 defer { try? FileManager.default.removeItem(at: folder) }
21 var forms: [String] = []
22 for (i, c) in cases.enumerated() {
23 let path = folder.appendingPathComponent("target\(i).org").path
24 try c.file.write(toFile: path, atomically: true, encoding: .utf8)
25 let target: String
26 switch c.template.target {
27 case .file: target = "(file \(lisp(path)))"
28 case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))"
29 case .outline(_, let p): target = "(file+olp \(lisp(path)) \(p.map(lisp).joined(separator: " ")))"
30 }
31 forms.append("""
32 (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(c.template.prepend ? " :prepend t" : "")))))
33 (org-capture nil "x")
34 (insert \(lisp(c.typed)))
35 (org-capture-finalize)
36 (with-current-buffer (find-buffer-visiting \(lisp(path)))
37 (push (buffer-substring-no-properties (point-min) (point-max)) results)))
38 """)
39 }
40 let output = folder.appendingPathComponent("out.json")
41 let script = folder.appendingPathComponent("capture.el")
42 try """
43 ;;; -*- lexical-binding: t -*-
44 (require 'org)
45 (require 'org-capture)
46 (require 'json)
47 (setq org-adapt-indentation nil)
48 (defvar results nil)
49 \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")"))
50 (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results)))))
51 """.write(to: script, atomically: true, encoding: .utf8)
52 _ = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"])
53 return try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
54 }
55
56 static func ours(_ c: Case, now: Date) -> String {
57 var utc = Calendar(identifier: .gregorian)
58 utc.timeZone = TimeZone(identifier: "UTC")!
59 guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" }
60 var text = filled.text
61 let index = String.Index(utf16Offset: filled.cursor, in: text)
62 text.insert(contentsOf: c.typed, at: index)
63 switch Capture.place(text, template: c.template, in: c.file) {
64 case .success(let result): return result
65 case .failure(.message(let message)): return "failed: \(message)"
66 }
67 }
68
69 static let files = [
70 "#+TITLE: Todo\n* Inbox\n** TODO Old\n* Later\nText\n",
71 "* Inbox\n\n** TODO Old\n\n* Later\n",
72 "* Inbox\n",
73 "* Inbox",
74 "",
75 "Preamble\n* Other\n",
76 "* Inbox\nSome notes.\n- one\n- two\n\n** Child\n",
77 "* Projects\n** Work\n*** Active\n** Home\n",
78 "* Inbox\n:PROPERTIES:\n:ID: x\n:END:\nBody\n",
79 ]
80
81 static let templates: [CaptureTemplate] = [
82 CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* TODO %?\n%i\n%a"),
83 CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %u %?\n%i\n%a"),
84 CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* TODO %?", prepend: true),
85 CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Missing"), template: "* %?"),
86 CaptureTemplate(key: "x", name: "", target: .file(""), template: "* Note %U\n%?"),
87 CaptureTemplate(key: "x", name: "", target: .file(""), template: "** Deep %?", prepend: true),
88 CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "Plain %? text", ),
89 CaptureTemplate(key: "x", name: "", kind: .plain, target: .headline(file: "", headline: "Inbox"), template: "Plain line %?"),
90 CaptureTemplate(key: "x", name: "", kind: .plain, target: .file(""), template: "%<%Y-%m-%d>: %?"),
91 CaptureTemplate(key: "x", name: "", kind: .item, target: .headline(file: "", headline: "Inbox"), template: "%?"),
92 CaptureTemplate(key: "x", name: "", kind: .checkitem, target: .headline(file: "", headline: "Inbox"), template: "[ ] %?"),
93 CaptureTemplate(key: "x", name: "", target: .outline(file: "", path: ["Projects", "Work"]), template: "* TODO %?\n Added %T"),
94 ]
95
96 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
97 func matchesEmacs() throws {
98 let seconds = 1_789_000_000
99 var cases: [Case] = []
100 for template in Self.templates {
101 for file in Self.files {
102 if case .outline = template.target, !file.contains("Projects") { continue }
103 if template.kind == .item || template.kind == .checkitem, !file.contains("Inbox") { continue }
104 cases.append(Case(file: file, template: template, typed: "Buy milk"))
105 }
106 }
107 let emacs = try Self.emacs(cases, seconds: seconds)
108 let now = Date(timeIntervalSince1970: Double(seconds))
109 for (c, expected) in zip(cases, emacs) {
110 let mine = Self.ours(c, now: now)
111 #expect(mine == expected, "\(c.template.template.debugDescription) \(c.template.kind) prepend \(c.template.prepend) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)")
112 }
113 }
114
115 @Test func fillsPrompts() {
116 let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again"
117 #expect(Capture.questions(template) == [
118 Capture.Question(prompt: "Title", defaultValue: "untitled", choices: []),
119 Capture.Question(prompt: "Who", defaultValue: "", choices: []),
120 ])
121 guard case .success(let filled) = Capture.fill(template, context: CaptureContext(), answers: ["", "me"]) else {
122 Issue.record("fill failed")
123 return
124 }
125 #expect(filled.text == "* untitled for me\nuntitled again\n")
126 }
127}
docs/plans/2026-10-05-capture.md added +23
@@ -0,0 +1,23 @@
1# Capture Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** `org-capture`: templates, filling them, filing the result where Emacs would, from inside the app and from a system-wide shortcut.
6
7**Architecture:** `OrgCore/Capture/Capture.swift` ports `org-capture-fill-template` (`%?`, `%i`, `%a`, `%l`, `%L`, `%c`, `%f`, `%F`, `%n`, `%t`, `%T`, `%u`, `%U`, `%<…>`, `%^{prompt|default}`, `%\N`; the text trimmed and ending in one newline) and placement for `entry`, `plain`, `item` and `checkitem` into `file`, `file+headline` (created when missing) and `file+olp` targets, with `org-capture-place-entry`'s blank-line handling (`org--blank-before-heading-p` with `auto`, empty lines after removed) and level shifting, and a final newline as saving adds. Templates come from `capture.toml` beside `keymap.toml`, defaulting to the two in your Doom config, files relative to the first folder. The capture window picks a template by key, asks the prompts, and files the edited text through the open buffer or the save path. ⌃⌥Space opens it from any app (Carbon hot key, no accessibility permission), as do `C-c c` and ⇧⌘N.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle (`org-capture` + `org-capture-finalize` in batch).
10
11**Spec:** `docs/design.md`, "Phase 5".
12
13## Global Constraints
14
15- Oracle: 12 templates × target files (existing and missing headlines, blank lines, prepend, nested outline paths, property drawers, lists, files without a final newline).
16- Not yet: datetree targets, `%^g`/`%^t`/`%^C` and other typed prompts, `%(sexp)`, `%[file]`, `%k`, `%K`, `%x`; such templates show a message instead of filing something different.
17
18---
19
20- [x] Fill, questions, placement; oracle.
21- [x] `CaptureTemplates`, `DocumentSession.capture`, `captureAnnotation`; tests.
22- [x] Capture window, global shortcut, menu, keys, settings.
23- [x] Commit "Capture".