Sources/OrgCore/Capture/Capture.swift
914 lines · 45044 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, tableLine = "table-line" }
8
9 /// `:tree-type`: what a date tree groups entries by.
10 public enum TreeType: String, Sendable, Equatable { case day, week, month }
11
12 public enum Target: Sendable, Equatable {
13 case file(String)
14 case headline(file: String, headline: String)
15 case outline(file: String, path: [String])
16 /// `file+olp+datetree`: today's entry in a date tree, under `path` or at the top.
17 case datetree(file: String, path: [String], tree: TreeType)
18 /// `(id "…")`: the heading with that ID; the app finds its file.
19 case id(String)
20 /// `(clock)`: the clocked-in entry; the app turns it into `heading`.
21 case clock
22 /// The heading starting at `offset` in `file`.
23 case heading(file: String, offset: Int)
24
25 /// The target file, or "" for `id` and `clock` until the app resolves them.
26 public var file: String {
27 switch self {
28 case .file(let f), .headline(let f, _), .outline(let f, _), .datetree(let f, _, _), .heading(let f, _): f
29 case .id, .clock: ""
30 }
31 }
32 }
33
34 /// Template properties beyond the target (`org-capture-templates`).
35 public struct Options: Sendable, Equatable {
36 public var prepend = false
37 /// `:immediate-finish`: file without showing the text.
38 public var immediateFinish = false
39 /// `:empty-lines`, `:empty-lines-before`, `:empty-lines-after`.
40 public var emptyLines: Int?
41 public var emptyLinesBefore: Int?
42 public var emptyLinesAfter: Int?
43 /// `:jump-to-captured`: show the entry after filing it.
44 public var jumpToCaptured = false
45 /// `:clock-in`, `:clock-keep`, `:clock-resume`.
46 public var clockIn = false
47 public var clockKeep = false
48 public var clockResume = false
49 /// `:table-line-pos`: `II-3` is the third line above the second rule.
50 public var tableLinePos: String?
51
52 public init(
53 prepend: Bool = false, immediateFinish: Bool = false, emptyLines: Int? = nil, emptyLinesBefore: Int? = nil,
54 emptyLinesAfter: Int? = nil, jumpToCaptured: Bool = false, clockIn: Bool = false, clockKeep: Bool = false, clockResume: Bool = false,
55 tableLinePos: String? = nil
56 ) {
57 self.tableLinePos = tableLinePos
58 self.prepend = prepend
59 self.immediateFinish = immediateFinish
60 self.emptyLines = emptyLines
61 self.emptyLinesBefore = emptyLinesBefore
62 self.emptyLinesAfter = emptyLinesAfter
63 self.jumpToCaptured = jumpToCaptured
64 self.clockIn = clockIn
65 self.clockKeep = clockKeep
66 self.clockResume = clockResume
67 }
68
69 var before: Int? { emptyLinesBefore ?? emptyLines }
70 var after: Int { emptyLinesAfter ?? emptyLines ?? 0 }
71 }
72
73 public let key: String
74 public let name: String
75 public let kind: Kind
76 public let target: Target
77 public let template: String
78 public let options: Options
79 public var prepend: Bool { options.prepend }
80 public var id: String { key }
81
82 public init(key: String, name: String, kind: Kind = .entry, target: Target, template: String, prepend: Bool = false, options: Options = Options()) {
83 self.key = key
84 self.name = name
85 self.kind = kind
86 self.target = target
87 self.template = template
88 var options = options
89 if prepend { options.prepend = true }
90 self.options = options
91 }
92
93 /// The same template aimed at `target`.
94 public func retargeted(_ target: Target) -> CaptureTemplate {
95 CaptureTemplate(key: key, name: name, kind: kind, target: target, template: template, options: options)
96 }
97}
98
99/// What the escapes in a template stand for.
100public struct CaptureContext: Sendable {
101 public var now: Date
102 public var calendar: Calendar
103 /// `%i`: the selected text.
104 public var initial: String
105 /// `%a`: a link to where capture started, `[[target][description]]`, or empty.
106 public var annotation: String
107 /// `%c` and `%x`: the clipboard.
108 public var clipboard: String
109 /// `%f` and `%F`: the file capture started from.
110 public var file: String
111 /// `%n`.
112 public var userName: String
113 /// `%k` and `%K`: the clocked-in entry's title and a link to it, or empty.
114 public var clockHeading: String
115 public var clockLink: String
116 /// `%^g` and `%^G` offer these: the target file's tags, and every file's.
117 public var fileTags: [String]
118 public var allTags: [String]
119 /// `org-tags-column`, for aligning tags `%^g` adds to a heading.
120 public var tagsColumn: Int
121 /// `org-store-link-plist` as org-protocol sets it, for `%:link`, `%:description` and others.
122 public var linkProperties: [String: String] = [:]
123
124 public init(
125 now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "",
126 file: String = "", userName: String = "", clockHeading: String = "", clockLink: String = "",
127 fileTags: [String] = [], allTags: [String] = [], tagsColumn: Int = -77
128 ) {
129 self.now = now
130 self.calendar = calendar
131 self.initial = initial
132 self.annotation = annotation
133 self.clipboard = clipboard
134 self.file = file
135 self.userName = userName
136 self.clockHeading = clockHeading
137 self.clockLink = clockLink
138 self.fileTags = fileTags
139 self.allTags = allTags
140 self.tagsColumn = tagsColumn
141 }
142}
143
144public enum Capture {
145 public enum Failure: Error, Equatable {
146 case message(String)
147 }
148
149 /// A question the template asks before it can be filled: `%^{prompt|default|choice…}`,
150 /// or one of `%^g`, `%^G`, `%^t`, `%^T`, `%^u`, `%^U`, `%^C`, `%^L`, `%^{prop}p`.
151 public struct Question: Sendable, Equatable {
152 public enum Kind: Sendable, Equatable {
153 case text
154 /// Tags, separated by colons.
155 case tags
156 /// A date as `org-read-date` reads it (`+2d`, `fri 14:00`); empty is today.
157 case date(withTime: Bool, inactive: Bool)
158 /// `%^C` and `%^L`: text from the clipboard, inserted as a link with `link`.
159 case clipboard(link: Bool)
160 /// `%^{NAME}p`: the value of property `NAME`.
161 case property
162 }
163
164 public let prompt: String
165 public let defaultValue: String
166 public let choices: [String]
167 public let kind: Kind
168
169 public init(prompt: String, defaultValue: String, choices: [String], kind: Kind = .text) {
170 self.prompt = prompt
171 self.defaultValue = defaultValue
172 self.choices = choices
173 self.kind = kind
174 }
175 }
176
177 static let promptPattern = "%\\^(?:\\{([^}]*)\\})?([CgGLptTuU])?"
178
179 /// The questions `template` asks, in order.
180 public static func questions(_ template: String, context: CaptureContext = CaptureContext()) -> [Question] {
181 guard case .success(let expanded) = expand(template, context) else { return [] }
182 return prompts(in: expanded).map { question($0.items, $0.key, context) }
183 }
184
185 static func question(_ items: [String]?, _ key: String?, _ context: CaptureContext) -> Question {
186 let prompt = items?.first
187 let defaultValue = items.map { $0.count > 1 ? $0[1] : "" } ?? ""
188 switch key {
189 case "g", "G": return Question(prompt: prompt ?? "Tags", defaultValue: "", choices: key == "g" ? context.fileTags : context.allTags, kind: .tags)
190 case "t", "T", "u", "U":
191 let upper = key == key?.uppercased()
192 return Question(prompt: prompt ?? "Date", defaultValue: "", choices: [], kind: .date(withTime: upper, inactive: key == "u" || key == "U"))
193 case "C", "L":
194 let values = [context.initial, context.clipboard].filter { !$0.isEmpty }
195 return Question(prompt: "Clipboard/kill value", defaultValue: context.initial, choices: values, kind: .clipboard(link: key == "L"))
196 case "p": return Question(prompt: prompt ?? "", defaultValue: defaultValue, choices: [], kind: .property)
197 default: return Question(prompt: prompt ?? "Enter string", defaultValue: defaultValue, choices: items.map { Array($0.dropFirst(2)) } ?? [])
198 }
199 }
200
201 /// The unescaped `%^` placeholders in `text`.
202 static func prompts(in text: String) -> [(range: NSRange, items: [String]?, key: String?)] {
203 let regex = try! NSRegularExpression(pattern: promptPattern)
204 let ns = text as NSString
205 return regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).compactMap { m in
206 guard !escaped(ns, at: m.range.location) else { return nil }
207 let items = m.range(at: 1).location == NSNotFound ? nil : ns.substring(with: m.range(at: 1)).components(separatedBy: "|")
208 let key = m.range(at: 2).location == NSNotFound ? nil : ns.substring(with: m.range(at: 2))
209 return (m.range, items, key)
210 }
211 }
212
213 /// `org-capture-escaped-%` without its unescaping: an odd run of backslashes before `%`.
214 static func escaped(_ ns: NSString, at index: Int) -> Bool {
215 var i = index
216 while i > 0, ns.character(at: i - 1) == 92 { i -= 1 }
217 return (index - i) % 2 == 1
218 }
219
220 /// Where `%?` puts the cursor while the template is filled.
221 static let cursorMark = "\u{E000}"
222
223 /// `org-capture-fill-template`: the text and where `%?` puts the cursor. `answers` holds
224 /// the replies to `questions(template)` in order.
225 public static func fill(_ template: String, context: CaptureContext, answers: [String] = []) -> Result<(text: String, cursor: Int), Failure> {
226 let expanded: String
227 switch expand(template, context) {
228 case .failure(let failure): return .failure(failure)
229 case .success(let text): expanded = text
230 }
231 // Prompts, in order, answered in the text as it stands then, as org does.
232 var options = EditingOptions.org
233 options.tagsColumn = context.tagsColumn
234 let buffer = EmacsBuffer(expanded, point: 0, settings: .default, options: options)
235 buffer.now = context.now
236 buffer.calendar = context.calendar
237 var strings: [String] = []
238 var all: [String] = []
239 var index = 0
240 while let match = buffer.searchForward(promptPattern, caseInsensitive: false) {
241 let whole = match[0]!
242 if unescape(buffer, at: whole.lowerBound) { continue }
243 let start = buffer.point - whole.count
244 let items = match[1].map { buffer.substring($0.lowerBound, $0.upperBound).components(separatedBy: "|") }
245 let key = match[2].map { buffer.substring($0.lowerBound, $0.upperBound) }
246 let question = question(items, key, context)
247 let answer = index < answers.count ? answers[index] : ""
248 index += 1
249 buffer.deleteRegion(start, buffer.point)
250 buffer.point = start
251 switch question.kind {
252 case .text:
253 let value = answer.isEmpty ? question.defaultValue : answer
254 strings.append(value)
255 all.append(value)
256 buffer.insert(value)
257 case .tags:
258 let tags = answer.split(separator: ":").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
259 guard !tags.isEmpty else { continue }
260 all.append(":" + tags.joined(separator: ":") + ":")
261 if buffer.point == 0 || buffer.character(at: buffer.point - 1) != 58 { buffer.insert(":") }
262 buffer.insert(tags.joined(separator: ":"))
263 if buffer.point >= buffer.length || buffer.character(at: buffer.point) != 58 { buffer.insert(":") }
264 if buffer.lookingAtLine("^\\*+ ") { buffer.alignTags(onLineAt: buffer.point) }
265 case .date(let withTime, let inactive):
266 let result: ReadDate.Answer
267 if answer.trimmingCharacters(in: .whitespaces).isEmpty {
268 let c = context.calendar.dateComponents([.year, .month, .day, .hour, .minute], from: context.now)
269 result = ReadDate.Answer(year: c.year!, month: c.month!, day: c.day!, hour: c.hour!, minute: c.minute!, timeGiven: false)
270 } else {
271 result = buffer.readDate(answer, default: context.now)
272 }
273 let stamp = buffer.insertTimestamp(result.components(context.calendar), withTime: result.timeGiven || withTime, inactive: inactive, endTime: result.endTime)
274 all.append(stamp)
275 case .clipboard(let link):
276 let value = answer.isEmpty ? question.defaultValue : answer
277 all.append(value)
278 buffer.insert(link ? Links.makeString(value, nil) : value)
279 case .property:
280 let value = answer.isEmpty ? question.defaultValue : answer
281 buffer.saveExcursion { buffer.entryPut(question.prompt, value) }
282 all.append(value)
283 }
284 }
285 // `%\N`: the Nth `%^{…}` answer; `%\*N`: the Nth answer of any prompt.
286 var text = buffer.string
287 for (pattern, values) in [("%\\\\\\*([1-9][0-9]*)", all), ("%\\\\([1-9][0-9]*)", strings)] {
288 let regex = try! NSRegularExpression(pattern: pattern)
289 let ns = text as NSString
290 var out = text
291 for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).reversed() where !escaped(ns, at: m.range.location) {
292 let n = Int(ns.substring(with: m.range(at: 1))) ?? Int.max
293 out = (out as NSString).replacingCharacters(in: m.range, with: n <= values.count ? values[n - 1] : "")
294 }
295 text = out
296 }
297 // No blank lines before the text; it ends with one newline, or is empty.
298 var lines = text.components(separatedBy: "\n")
299 while let first = lines.first, first.trimmingCharacters(in: .whitespaces).isEmpty, lines.count > 1 { lines.removeFirst() }
300 var marked = lines.joined(separator: "\n")
301 let trimmed = marked.replacingOccurrences(of: "[ \\t\\n]+$", with: "", options: .regularExpression)
302 marked = trimmed.isEmpty ? "" : trimmed + "\n"
303 marked = marked.replacingOccurrences(of: "\t", with: " ")
304 guard let range = marked.range(of: cursorMark) else { return .success((marked, marked.utf16.count > 0 ? marked.utf16.count - 1 : 0)) }
305 let offset = marked[..<range.lowerBound].utf16.count
306 marked.removeSubrange(range)
307 return .success((marked, offset))
308 }
309
310 /// `org-capture-escaped-%` at the `%` at `index`: drops half the backslashes before it;
311 /// true when it was escaped. Point follows the deletion.
312 static func unescape(_ buffer: EmacsBuffer, at index: Int) -> Bool {
313 var i = index
314 while i > 0, buffer.character(at: i - 1) == 92 { i -= 1 }
315 let n = index - i
316 guard n > 0 else { return false }
317 buffer.deleteRegion(i, i + (n + 1) / 2)
318 return n % 2 == 1
319 }
320
321 /// The escapes that need no answer (`%a`, `%t`, `%<…>`, `%?`…), with `%^` prompts and `%\N`
322 /// left in place.
323 static func expand(_ template: String, _ context: CaptureContext) -> Result<String, Failure> {
324 func stamp(_ active: Bool, _ withTime: Bool) -> String {
325 let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute], from: context.now)
326 let day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.weekday ?? 1) - 1]
327 let body = String(format: "%04d-%02d-%02d %@", c.year!, c.month!, c.day!, day) + (withTime ? String(format: " %02d:%02d", c.hour!, c.minute!) : "")
328 return active ? "<\(body)>" : "[\(body)]"
329 }
330 let link = try! NSRegularExpression(pattern: "\\[\\[(.*?)\\](\\[.*?\\])?\\]")
331 func linkForm(_ template: String) -> String {
332 let ns = context.annotation as NSString
333 guard let m = link.firstMatch(in: context.annotation, range: NSRange(location: 0, length: ns.length)) else { return context.annotation }
334 return template.replacingOccurrences(of: "$1", with: ns.substring(with: m.range(at: 1)))
335 }
336 var text = ""
337 let chars = Array(template)
338 var i = 0
339 while i < chars.count {
340 let c = chars[i]
341 guard c == "%", i + 1 < chars.count else {
342 text.append(c)
343 i += 1
344 continue
345 }
346 // A `%` after an odd run of backslashes stays, with half of them.
347 var slashes = 0
348 while slashes < text.count, text.dropLast(slashes).last == "\\" { slashes += 1 }
349 let next = chars[i + 1]
350 if slashes > 0, "aAcfFikKlLntTuUx:<?".contains(next) {
351 text.removeLast((slashes + 1) / 2)
352 if slashes % 2 == 1 {
353 text.append(c)
354 i += 1
355 continue
356 }
357 }
358 var consumed = 2
359 switch next {
360 case "?": text += cursorMark
361 case "i":
362 // Continuation lines keep the indentation of the line `%i` is on.
363 let lead = String(text.split(separator: "\n", omittingEmptySubsequences: false).last ?? "").prefix { $0 == " " || $0 == "\t" }
364 text += context.initial.replacingOccurrences(of: "\n", with: "\n" + lead)
365 case "a": text += context.annotation == "[[]]" ? "" : context.annotation
366 case "A":
367 // The link, with its description asked for.
368 let ns = context.annotation as NSString
369 if let m = link.firstMatch(in: context.annotation, range: NSRange(location: 0, length: ns.length)) {
370 text += ns.replacingCharacters(in: m.range, with: "[[" + ns.substring(with: m.range(at: 1)) + "][%^{Link description}]]")
371 } else {
372 text += context.annotation
373 }
374 case "l": text += linkForm("[[$1]]")
375 case "L": text += linkForm("$1")
376 case "c", "x": text += context.clipboard
377 case "f": text += (context.file as NSString).lastPathComponent
378 case "F": text += context.file
379 case "n": text += context.userName
380 case "k": text += context.clockHeading
381 case "K": text += context.clockLink
382 case "t": text += stamp(true, false)
383 case "T": text += stamp(true, true)
384 case "u": text += stamp(false, false)
385 case "U": text += stamp(false, true)
386 case "<":
387 guard let close = chars[(i + 2)...].firstIndex(of: ">"), !chars[(i + 2)..<close].contains("\n") else {
388 text.append(c)
389 consumed = 1
390 break
391 }
392 text += strftime(String(chars[(i + 2)..<close]), context)
393 consumed = close - i + 1
394 case ":":
395 // A link property (`%:annotation`), from org-protocol or the capture's own.
396 var j = i + 2
397 while j < chars.count, chars[j].isLetter || chars[j] == "-" { j += 1 }
398 guard j > i + 2 else {
399 text.append(c)
400 consumed = 1
401 break
402 }
403 let name = String(chars[(i + 2)..<j])
404 switch name {
405 case _ where context.linkProperties[name] != nil: text += context.linkProperties[name]!
406 case "annotation": text += context.annotation
407 case "initial": text += context.initial
408 default: break
409 }
410 consumed = j - i
411 case "(":
412 return .failure(.message("Template escapes like %( run Emacs Lisp, which Orgstar doesn't."))
413 case "[":
414 return .failure(.message("Template escapes like %[file] aren't supported yet."))
415 default:
416 text.append(c)
417 consumed = 1
418 }
419 i += consumed
420 }
421 return .success(text)
422 }
423
424 static func strftime(_ format: String, _ context: CaptureContext) -> String {
425 let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute, .second], from: context.now)
426 let days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
427 let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
428 var out = ""
429 var chars = format.makeIterator()
430 while let ch = chars.next() {
431 guard ch == "%", let f = chars.next() else {
432 out.append(ch)
433 continue
434 }
435 switch f {
436 case "Y": out += String(format: "%04d", c.year!)
437 case "m": out += String(format: "%02d", c.month!)
438 case "d": out += String(format: "%02d", c.day!)
439 case "e": out += String(format: "%2d", c.day!)
440 case "H": out += String(format: "%02d", c.hour!)
441 case "M": out += String(format: "%02d", c.minute!)
442 case "S": out += String(format: "%02d", c.second!)
443 case "a": out += String(days[c.weekday! - 1].prefix(3))
444 case "A": out += days[c.weekday! - 1]
445 case "b": out += String(months[c.month! - 1].prefix(3))
446 case "B": out += months[c.month! - 1]
447 case "F": out += String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
448 case "R": out += String(format: "%02d:%02d", c.hour!, c.minute!)
449 case "%": out += "%"
450 default: out += "%" + String(f)
451 }
452 }
453 return out
454 }
455
456 // MARK: - Placing
457
458 /// The target file's text after capturing `text`, as `org-capture-finalize` leaves it.
459 /// `now` and `calendar` pick a date tree's day.
460 public static func place(
461 _ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default,
462 now: Date = Date(), calendar: Calendar = .current
463 ) -> Result<String, Failure> {
464 placement(text, template: template, in: fileText, settings: settings, now: now, calendar: calendar).map(\.text)
465 }
466
467 /// The new text, and where the captured entry starts in it.
468 public static func placement(
469 _ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default,
470 now: Date = Date(), calendar: Calendar = .current
471 ) -> Result<(text: String, start: Int), Failure> {
472 let buffer = EmacsBuffer(fileText, point: 0, settings: settings)
473 let start: Int
474 do {
475 let entryHeading = try locate(template.target, buffer, date: calendar.dateComponents([.year, .month, .day], from: now))
476 let options = template.options
477 switch template.kind {
478 case .entry: start = try placeEntry(text, options: options, heading: entryHeading, buffer)
479 case .plain: start = placePlain(text, options: options, heading: entryHeading, buffer)
480 case .item, .checkitem: start = try placeItem(text, checkbox: template.kind == .checkitem, options: options, heading: entryHeading, buffer)
481 case .tableLine: start = try placeTableLine(text, options: options, heading: entryHeading, buffer)
482 }
483 } catch let error as Failure {
484 return .failure(error)
485 } catch let error as EmacsBuffer.UserError {
486 return .failure(.message(error.message))
487 } catch {
488 return .failure(.message("\(error)"))
489 }
490 // Saving the target adds a final newline, as `require-final-newline` does in Org.
491 var result = buffer.text as String
492 if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" }
493 return .success((result, start))
494 }
495
496 /// `org-capture-set-target-location`: point at the target heading, or nil for a file.
497 static func locate(_ target: CaptureTemplate.Target, _ buffer: EmacsBuffer, date: DateComponents) throws -> Int? {
498 switch target {
499 case .file:
500 return nil
501 case .id(let id):
502 buffer.point = 0
503 guard let match = buffer.searchForward("^[ \\t]*:ID:[ \\t]+" + NSRegularExpression.escapedPattern(for: id) + "[ \\t]*$", caseInsensitive: true) else {
504 throw Failure.message("Cannot find target ID \"\(id)\"")
505 }
506 buffer.point = match[0]!.lowerBound
507 buffer.backToHeadingOrPointMin()
508 return buffer.beforeFirstHeading ? nil : buffer.point
509 case .clock:
510 throw Failure.message("No running clock")
511 case .heading(_, let offset):
512 guard offset <= buffer.length else { throw Failure.message("The clocked entry moved; try again") }
513 buffer.point = buffer.lineStart(offset)
514 guard atHeading(buffer) else { throw Failure.message("The clocked entry moved; try again") }
515 return buffer.point
516 case .datetree(_, let path, let tree):
517 let parent = path.isEmpty ? nil : try locate(.outline(file: "", path: path), buffer, date: date)
518 return Datetree.findCreate(buffer, under: parent, tree: tree, date: date)
519 case .headline(_, let headline):
520 buffer.point = 0
521 let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$"
522 if let match = buffer.searchForward(pattern, caseInsensitive: false) {
523 return buffer.lineStart(match[0]!.lowerBound)
524 }
525 buffer.point = buffer.length
526 if !buffer.bolp { buffer.insert("\n") }
527 let start = buffer.point
528 buffer.insert("* " + headline + "\n")
529 return start
530 case .outline(_, let path):
531 var start = 0
532 var end = buffer.length
533 var level = 0
534 var found: Int?
535 for title in path {
536 buffer.point = start
537 let pattern = "^(\\*+)[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: title) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$"
538 var hit: Int?
539 while let match = buffer.searchForward(pattern, bound: end, caseInsensitive: false) {
540 let stars = buffer.substring(match[1]!.lowerBound, match[1]!.upperBound).count
541 if stars > level, level == 0 || stars == level + 1 || found != nil {
542 hit = buffer.lineStart(match[0]!.lowerBound)
543 level = stars
544 break
545 }
546 }
547 guard let hit else { throw Failure.message("Heading not found on outline path \(path.joined(separator: "/"))") }
548 found = hit
549 buffer.point = hit
550 start = hit
551 end = buffer.saveExcursion { () -> Int in
552 endOfSubtree(buffer, heading: hit)
553 return buffer.point
554 }
555 }
556 return found
557 }
558 }
559
560 static func headingLevel(_ buffer: EmacsBuffer, at line: Int) -> Int {
561 buffer.substring(line, buffer.lineEnd(line)).prefix { $0 == "*" }.count
562 }
563
564 static func atHeading(_ buffer: EmacsBuffer) -> Bool {
565 buffer.lookingAtLine("^\\*+ ")
566 }
567
568 /// `outline-next-heading`.
569 static func nextHeading(_ buffer: EmacsBuffer) {
570 if let match = buffer.searchForward("\\n\\*+ ", caseInsensitive: false) {
571 buffer.point = match[0]!.lowerBound + 1
572 } else {
573 buffer.point = buffer.length
574 }
575 }
576
577 /// `org-end-of-subtree t t`: the start of the next heading at the same level or higher.
578 static func endOfSubtree(_ buffer: EmacsBuffer, heading: Int) {
579 let level = headingLevel(buffer, at: heading)
580 buffer.point = heading
581 while true {
582 guard let match = buffer.searchForward("\\n(\\*+) ", caseInsensitive: false) else {
583 buffer.point = buffer.length
584 return
585 }
586 let stars = match[1]!.count
587 if stars <= level {
588 buffer.point = match[0]!.lowerBound + 1
589 return
590 }
591 buffer.point = match[0]!.upperBound
592 }
593 }
594
595 static func previousLineEmpty(_ buffer: EmacsBuffer) -> Bool {
596 let start = buffer.lineStart(buffer.point)
597 guard start > 0 else { return false }
598 let previous = buffer.lineStart(start - 1)
599 return buffer.substring(previous, start - 1).trimmingCharacters(in: .whitespaces).isEmpty
600 }
601
602 /// `org--blank-before-heading-p` with `org-blank-before-new-entry` `(heading . auto)`.
603 static func blankBeforeHeading(_ buffer: EmacsBuffer) -> Bool {
604 buffer.saveExcursion {
605 let before = buffer.searchBackward("^\\*+ ", caseInsensitive: false) == nil && !atHeading(buffer)
606 if before {
607 nextHeading(buffer)
608 if buffer.point >= buffer.length, !atHeading(buffer) { return false }
609 }
610 // `org-back-to-heading`.
611 if !atHeading(buffer) {
612 buffer.point = buffer.lineStart(buffer.point)
613 if !atHeading(buffer), let match = buffer.searchBackward("^\\*+ ", caseInsensitive: false) {
614 buffer.point = match[0]!.lowerBound
615 }
616 } else {
617 buffer.point = buffer.lineStart(buffer.point)
618 }
619 if buffer.point > 0 { return previousLineEmpty(buffer) }
620 let here = buffer.point
621 nextHeading(buffer)
622 if buffer.point < buffer.length || atHeading(buffer) { return previousLineEmpty(buffer) }
623 buffer.point = here
624 return false
625 }
626 }
627
628 /// `org-back-over-empty-lines`, deleting them.
629 static func deleteEmptyLinesBefore(_ buffer: EmacsBuffer) {
630 let pos = buffer.point
631 var start = pos
632 while start > 0 {
633 let lineStart = buffer.lineStart(start - 1)
634 guard buffer.substring(lineStart, start - 1).trimmingCharacters(in: .whitespaces).isEmpty, start - 1 >= lineStart else { break }
635 if lineStart == start - 1 || buffer.substring(lineStart, start).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
636 // A blank line precedes; but keep the newline that ends the previous text line.
637 if lineStart == 0 { start = 0; break }
638 start = lineStart
639 } else {
640 break
641 }
642 }
643 if start < pos { buffer.deleteRegion(start, pos) }
644 buffer.point = start
645 }
646
647 /// `org-capture-empty-lines-after` with no lines: blank lines after point go.
648 static func deleteEmptyLinesAfter(_ buffer: EmacsBuffer) {
649 deleteEmptyLinesBefore(buffer)
650 while buffer.looking("[ \\t]*\\n", caseInsensitive: false, setsMatch: false) != nil {
651 let end = buffer.lineEnd(buffer.point) + 1
652 buffer.deleteRegion(buffer.point, end)
653 }
654 }
655
656 /// `org-capture-place-entry`.
657 static func placeEntry(_ entry: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
658 let prepend = options.prepend
659 var template = entry
660 if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template }
661 // `org-capture-verify-tree`: the first heading has the lowest level.
662 let levels = template.components(separatedBy: "\n").compactMap { line -> Int? in
663 let stars = line.prefix { $0 == "*" }.count
664 return stars > 0 && line.dropFirst(stars).first == " " ? stars : nil
665 }
666 if let first = levels.first, levels.contains(where: { $0 < first }) || !template.hasPrefix("*") {
667 throw Failure.message("Template is not a valid Org entry or tree")
668 }
669 var level = 1
670 if let heading {
671 level = headingLevel(buffer, at: heading) + 1
672 buffer.point = heading
673 if prepend {
674 buffer.point = buffer.lineEnd(heading)
675 nextHeading(buffer)
676 } else {
677 endOfSubtree(buffer, heading: heading)
678 }
679 } else if prepend {
680 buffer.point = 0
681 if !atHeading(buffer) { nextHeading(buffer) }
682 } else {
683 buffer.point = buffer.length
684 }
685 if !buffer.bolp { buffer.insert("\n") }
686 let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer)
687 deleteEmptyLinesBefore(buffer)
688 buffer.insert(String(repeating: "\n", count: options.before ?? (blank ? 1 : 0)))
689 let start = buffer.point
690 // `org-paste-subtree` at `level`.
691 let oldLevel = levels.first ?? 1
692 let shift = level - oldLevel
693 var shifted = template
694 if shift != 0 {
695 shifted = template.components(separatedBy: "\n").map { line in
696 let stars = line.prefix { $0 == "*" }.count
697 guard stars > 0, line.dropFirst(stars).first == " " else { return line }
698 return String(repeating: "*", count: max(1, stars + shift)) + line.dropFirst(stars)
699 }.joined(separator: "\n")
700 }
701 if !shifted.hasSuffix("\n") { shifted += "\n" }
702 buffer.insert(shifted)
703 emptyLinesAfter(buffer, options.after)
704 return start
705 }
706
707 /// `org-capture-empty-lines-after`: exactly `n` blank lines after point, point kept.
708 static func emptyLinesAfter(_ buffer: EmacsBuffer, _ n: Int) {
709 deleteEmptyLinesAfter(buffer)
710 buffer.saveExcursion { buffer.insert(String(repeating: "\n", count: n)) }
711 }
712
713 /// `org-capture-place-plain-text`.
714 static func placePlain(_ text: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) -> Int {
715 let prepend = options.prepend
716 if let heading {
717 buffer.point = heading
718 if prepend {
719 buffer.point = buffer.lineEnd(heading)
720 if buffer.point < buffer.length { buffer.point += 1 }
721 skipMetaData(buffer)
722 } else {
723 buffer.point = buffer.lineEnd(heading)
724 nextHeading(buffer)
725 }
726 } else {
727 buffer.point = prepend ? 0 : buffer.length
728 }
729 if !buffer.bolp { buffer.insert("\n") }
730 deleteEmptyLinesBefore(buffer)
731 buffer.insert(String(repeating: "\n", count: options.before ?? 0))
732 let start = buffer.point
733 buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n")
734 emptyLinesAfter(buffer, options.after)
735 return start
736 }
737
738 /// `org-capture-place-table-line`, then what `org-capture-finalize` does to the table: the
739 /// first table in the entry, or the file, gets the line, at its end, after its first rule
740 /// with `prepend`, or where `table-line-pos` says; a table is made when there's none.
741 static func placeTableLine(_ filled: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
742 let template = filled.trimmingCharacters(in: .whitespacesAndNewlines)
743 // `org-table-border-regexp`: a template that isn't a table line becomes one.
744 let text = template.range(of: "^[ \\t]*[^| \\t]", options: .regularExpression) != nil ? "| " + template : template + "\n"
745 var begin = 0
746 var end = buffer.length
747 if let heading {
748 begin = min(buffer.length, buffer.lineEnd(heading) + 1)
749 buffer.point = buffer.lineEnd(heading)
750 nextHeading(buffer)
751 end = buffer.point
752 }
753 var table: Range<Int>?
754 buffer.point = begin
755 while table == nil, let match = buffer.searchForward("^[ \\t]*\\|[^-]", bound: end, caseInsensitive: false) {
756 buffer.point = match[0]!.lowerBound
757 if buffer.atTable { table = buffer.tableBegin()..<buffer.tableEnd() } else { buffer.point = match[0]!.upperBound }
758 }
759 if table == nil {
760 buffer.point = end
761 if !buffer.bolp { buffer.insert("\n") }
762 let origin = buffer.point
763 buffer.insert("| |\n|---|\n")
764 table = origin..<buffer.point
765 }
766 let rows = buffer.substring(table!.lowerBound, table!.upperBound).components(separatedBy: "\n")
767 /// The start of the table's line `index`, within the table, as Org narrows to it.
768 func lineStart(_ index: Int) -> Int {
769 guard index > 0 else { return table!.lowerBound }
770 return buffer.saveExcursion {
771 buffer.point = table!.lowerBound
772 return buffer.forwardLine(index) == 0 ? min(buffer.point, table!.upperBound) : table!.upperBound
773 }
774 }
775 if let pos = options.tableLinePos {
776 guard let m = pos.firstMatch(of: /(I+)([-+][0-9]+)/) else { throw Failure.message("Invalid table line specification \"\(pos)\"") }
777 let hlines = rows.indices.filter { rows[$0].range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil }
778 guard m.1.count <= hlines.count else { throw Failure.message("Invalid table line specification \"\(pos)\"") }
779 guard let delta = Int(m.2.replacingOccurrences(of: "+", with: "")), (-999_999...999_999).contains(delta) else {
780 throw Failure.message("Invalid table line specification \"\(pos)\"")
781 }
782 // `forward-line` stops at the table's start, then one more line down.
783 buffer.point = lineStart(max(0, hlines[m.1.count - 1] + delta + (delta < 0 ? 0 : -1)) + 1)
784 } else if options.prepend {
785 if let rule = rows.firstIndex(where: { $0.range(of: "^[ \\t]*\\|-", options: .regularExpression) != nil }) {
786 let data = rows.indices.first { $0 > rule && rows[$0].range(of: "^[ \\t]*\\|[^-]", options: .regularExpression) != nil }
787 buffer.point = data.map(lineStart) ?? table!.upperBound
788 } else {
789 buffer.point = table!.lowerBound
790 }
791 } else {
792 buffer.point = table!.upperBound
793 }
794 if !buffer.bolp { buffer.insert("\n") }
795 let start = buffer.point
796 let lines = text.filter { $0 == "\n" }.count
797 buffer.insert(text)
798 // `org-capture-finalize`: align, or fix the formulas' row references and recalculate.
799 buffer.point = start
800 let row = buffer.substring(0, start).filter { $0 == "\n" }.count
801 if buffer.atTable {
802 if buffer.firstTblfm(after: buffer.tableEnd()) == nil {
803 buffer.tableAlign()
804 } else {
805 buffer.fixFormulas("@", replace: [:], limit: buffer.tableCurrentDataLine() - 1, delta: lines)
806 do {
807 try buffer.tableRecalculate(all: true)
808 } catch let unsupported as Calc.Unsupported {
809 throw Failure.message("The table's formulas need Emacs: \(unsupported.reason)")
810 }
811 }
812 }
813 buffer.point = 0
814 buffer.forwardLine(row)
815 return buffer.point
816 }
817
818 /// `org-end-of-meta-data t`: past planning, drawers and the property drawer.
819 static func skipMetaData(_ buffer: EmacsBuffer) {
820 while buffer.point < buffer.length {
821 let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point))
822 let trimmed = line.trimmingCharacters(in: .whitespaces)
823 if trimmed.range(of: "^(SCHEDULED|DEADLINE|CLOSED):", options: .regularExpression) != nil {
824 buffer.forwardLine(1)
825 } else if trimmed.range(of: "^:[\\w-]+:$", options: .regularExpression) != nil {
826 while buffer.forwardLine(1) == 0 {
827 if buffer.substring(buffer.point, buffer.lineEnd(buffer.point)).trimmingCharacters(in: .whitespaces).uppercased() == ":END:" {
828 buffer.forwardLine(1)
829 break
830 }
831 }
832 } else {
833 break
834 }
835 }
836 }
837
838 /// `org-capture-place-item`: after the last item of the first list in the entry (the file
839 /// for a file target), or at the end of the entry's text.
840 static func placeItem(_ text: String, checkbox: Bool, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
841 let prepend = options.prepend
842 var item = text.trimmingCharacters(in: .newlines)
843 if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil {
844 let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil
845 item = "- " + (checkbox && !boxed ? "[ ] " : "") + item
846 } else if checkbox, item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]+\\[[ X-]\\]", options: .regularExpression) == nil {
847 item = item.replacingOccurrences(of: "^([ \\t]*([-+*]|[0-9]+[.)])[ \\t]+)", with: "$1[ ] ", options: .regularExpression)
848 }
849 let start: Int
850 let end: Int
851 if let heading {
852 start = min(buffer.length, buffer.lineEnd(heading) + 1)
853 buffer.point = buffer.lineEnd(heading)
854 nextHeading(buffer)
855 end = buffer.point
856 } else {
857 start = 0
858 end = buffer.length
859 }
860 // The first list's items in the range.
861 buffer.point = start
862 var items: [(start: Int, end: Int, indent: String)] = []
863 while buffer.point < end {
864 let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point))
865 // A `*` bullet needs indentation; at the margin it starts a heading.
866 if let m = line.firstMatch(of: /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]/), m.2 != "*" || !m.1.isEmpty {
867 if let first = items.first, String(m.1) != first.indent {
868 // A nested item belongs to the current one.
869 } else {
870 items.append((buffer.point, buffer.point, String(m.1)))
871 }
872 } else if !items.isEmpty, !line.trimmingCharacters(in: .whitespaces).isEmpty,
873 line.prefix(while: { $0 == " " || $0 == "\t" }).count <= (items.first?.indent.count ?? 0) {
874 break
875 }
876 if buffer.forwardLine(1) != 0 { break }
877 if !items.isEmpty { items[items.count - 1].end = buffer.point }
878 }
879 if let first = items.first {
880 let indent = first.indent
881 let indented = item.components(separatedBy: "\n").enumerated().map { $0.offset == 0 ? indent + $0.element : indent + $0.element }.joined(separator: "\n")
882 if prepend {
883 buffer.point = first.start
884 } else {
885 buffer.point = items.last!.end
886 // Before blank lines that end the list.
887 while buffer.point > first.start {
888 let previous = buffer.lineStart(buffer.point - 1)
889 guard buffer.substring(previous, buffer.point - 1).trimmingCharacters(in: .whitespaces).isEmpty else { break }
890 buffer.point = previous
891 }
892 }
893 if !buffer.bolp { buffer.insert("\n") }
894 // In an existing list, at most one blank line between items.
895 if !prepend {
896 deleteEmptyLinesBefore(buffer)
897 buffer.insert(String(repeating: "\n", count: min(1, options.before ?? 0)))
898 }
899 let at = buffer.point
900 buffer.insert(indented + "\n")
901 if prepend { emptyLinesAfter(buffer, min(1, options.after)) }
902 return at
903 } else {
904 buffer.point = prepend ? start : end
905 if !buffer.bolp { buffer.insert("\n") }
906 deleteEmptyLinesBefore(buffer)
907 buffer.insert(String(repeating: "\n", count: options.before ?? 0))
908 let at = buffer.point
909 buffer.insert(item + "\n")
910 emptyLinesAfter(buffer, options.after)
911 return at
912 }
913 }
914}