Sources/OrgCore/Capture/Capture.swift
550 lines · 25422 bytes
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}