Capture into date trees, IDs and the clocked entry !71

merged merged by cmc on 2026-10-06 02:29 UTC · krz/orgstar:capture-targets into main

10 files changed, +585 −68

Layout: unified · split

Sources/OrgApp/CaptureModel.swift +50 −20
@@ -15,6 +15,11 @@ import OrgDocument
1515/// template = "* TODO %?\n%i\n%a"
1616/// prepend = false
1717/// ```
18///
19/// Other targets: `datetree = true` (under `olp` if given; `tree-type` "day", "week" or
20/// "month"), `id = "…"` without `file`, `clock = true` without `file`. Other properties, as
21/// in `org-capture-templates`: `immediate-finish`, `empty-lines`, `empty-lines-before`,
22/// `empty-lines-after`, `jump-to-captured`, `clock-in`, `clock-keep`, `clock-resume`.
1823@MainActor
1924public enum CaptureTemplates {
2025 public static var userFile: URL { UserConfig.file("capture.toml") }
@@ -40,7 +45,9 @@ public enum CaptureTemplates {
4045 problems.append("capture.toml line \(table.line): expected [[template]]")
4146 continue
4247 }
43 guard let key = table.values["key"]?.string, let file = table.values["file"]?.string, let body = table.values["template"]?.string else {
48 let values = table.values
49 let fileless = values["id"]?.string != nil || values["clock"]?.bool == true
50 guard let key = values["key"]?.string, let body = values["template"]?.string, let file = values["file"]?.string ?? (fileless ? "" : nil) else {
4451 problems.append("capture.toml line \(table.line): a template needs key, file and template")
4552 continue
4653 }
@@ -48,18 +55,33 @@ public enum CaptureTemplates {
4855 problems.append("capture.toml line \(table.line): unknown type")
4956 continue
5057 }
58 let olp = values["olp"]?.string.map { $0.split(separator: "/").map(String.init) }
5159 let target: CaptureTemplate.Target
52 if let headline = table.values["headline"]?.string {
60 if let id = values["id"]?.string {
61 target = .id(id)
62 } else if values["clock"]?.bool == true {
63 target = .clock
64 } else if values["datetree"]?.bool == true {
65 guard let tree = CaptureTemplate.TreeType(rawValue: values["tree-type"]?.string ?? "day") else {
66 problems.append("capture.toml line \(table.line): tree-type is day, week or month")
67 continue
68 }
69 target = .datetree(file: file, path: olp ?? [], tree: tree)
70 } else if let headline = values["headline"]?.string {
5371 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))
72 } else if let olp {
73 target = .outline(file: file, path: olp)
5674 } else {
5775 target = .file(file)
5876 }
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 ))
77 func flag(_ name: String) -> Bool { values[name]?.bool ?? false }
78 func lines(_ name: String) -> Int? { values[name]?.integer }
79 let options = CaptureTemplate.Options(
80 prepend: flag("prepend"), immediateFinish: flag("immediate-finish"), emptyLines: lines("empty-lines"),
81 emptyLinesBefore: lines("empty-lines-before"), emptyLinesAfter: lines("empty-lines-after"),
82 jumpToCaptured: flag("jump-to-captured"), clockIn: flag("clock-in"), clockKeep: flag("clock-keep"), clockResume: flag("clock-resume")
83 )
84 templates.append(CaptureTemplate(key: key, name: values["name"]?.string ?? key, kind: kind, target: target, template: body, options: options))
6385 }
6486 return (templates.isEmpty ? defaults : templates, problems)
6587 }
@@ -67,21 +89,23 @@ public enum CaptureTemplates {
6789
6890extension DocumentSession {
6991 /// 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)
92 /// otherwise in the file, created if missing, through the save path. `id` and `clock`
93 /// targets must be resolved first (`file` names the ID's file). Returns the file and where
94 /// the entry starts.
95 public func capture(_ text: String, template: CaptureTemplate, root: String?, file: String? = nil, now: Date = Date()) -> Result<(path: String, offset: Int), CaptureError> {
96 let path = file ?? Self.resolve(template.target.file, root: root)
7397 if !isOpen(path), !FileManager.default.fileExists(atPath: path) {
7498 do {
7599 try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true)
76100 try Data().write(to: URL(fileURLWithPath: path))
77101 } catch {
78 return "Can't create \(path): \(error.localizedDescription)"
102 return .failure(CaptureError(message: "Can't create \(path): \(error.localizedDescription)"))
79103 }
80104 }
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."
105 switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil, now: now) {
106 case .commit(let result): return .success((path, result.selection?.first?.lowerBound ?? 0))
107 case .failed(let message): return .failure(CaptureError(message: message))
108 default: return .failure(CaptureError(message: "Can't capture there."))
85109 }
86110 }
87111
@@ -101,7 +125,13 @@ extension DocumentSession {
101125 }
102126}
103127
104/// The edit `Capture.place` makes, as a command so the save path applies it.
128public struct CaptureError: Error, Equatable {
129 public let message: String
130 public init(message: String) { self.message = message }
131}
132
133/// The edit `Capture.place` makes, as a command so the save path applies it. The selection
134/// it leaves is the captured entry's start.
105135struct CapturePlacement: OrgCommand {
106136 let text: String
107137 let template: CaptureTemplate
@@ -109,11 +139,11 @@ struct CapturePlacement: OrgCommand {
109139 var title: String { "Capture" }
110140 func applies(in context: EditContext) -> Bool { true }
111141 func run(in context: EditContext) -> CommandStep {
112 switch Capture.place(text, template: template, in: context.text, settings: context.tree.settings) {
142 switch Capture.placement(text, template: template, in: context.text, settings: context.tree.settings, now: context.now, calendar: context.calendar) {
113143 case .failure(.message(let message)):
114144 return .failed(message)
115 case .success(let new):
116 return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, new)))
145 case .success(let placed):
146 return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, placed.text), selection: [placed.start..<placed.start]))
117147 }
118148 }
119149}
Sources/OrgApp/ClockModel.swift +21 −3
@@ -35,14 +35,21 @@ public final class ClockModel {
3535 /// `org-clock-in` at the heading under the caret, clocking out a running clock first.
3636 public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String {
3737 guard let path = session.path, session.document != nil else { return "No file is open." }
38 guard let entry = session.outline.last(where: { $0.start <= session.caret }) else { return "Before first headline" }
38 return clockIn(session, path: path, at: session.caret, now: now)
39 }
40
41 /// `org-clock-in` at the heading containing `offset` in `path`.
42 public func clockIn(_ session: DocumentSession, path: String, at offset: Int, now: Date = Date()) -> String {
43 guard let text = Self.text(path, session) else { return "Can't read \(path)" }
44 let tree = OrgParser.parse(text)
45 guard let entry = DocumentModel(tree: tree).headings.last(where: { $0.headingRange.lowerBound <= offset }) else { return "Before first headline" }
3946 if running != nil {
4047 let message = clockOut(session, now: now)
4148 if running != nil { return message }
4249 }
43 switch session.perform(ClockIn(), path: path, at: session.caret, expect: nil, now: now) {
50 switch session.perform(ClockIn(), path: path, at: offset, expect: nil, now: now) {
4451 case .commit:
45 guard let updated = session.document?.text, let start = ClockIn.runningStart(in: updated, near: entry.start) else {
52 guard let updated = Self.text(path, session), let start = ClockIn.runningStart(in: updated, near: entry.headingRange.lowerBound) else {
4653 return "The clock line wasn't found after clocking in."
4754 }
4855 running = Running(path: path, start: start, heading: entry.title, startedAt: now)
@@ -76,6 +83,17 @@ public final class ClockModel {
7683 }
7784 }
7885
86 static func text(_ path: String, _ session: DocumentSession) -> String? {
87 session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8))
88 }
89
90 /// The clocked entry's heading, for the `clock` capture target.
91 public func entry(_ session: DocumentSession) -> (path: String, offset: Int)? {
92 guard let line = location(session), let text = Self.text(line.path, session) else { return nil }
93 let heading = DocumentModel(tree: OrgParser.parse(text)).headings.last { $0.headingRange.lowerBound <= line.offset }
94 return heading.map { (line.path, $0.headingRange.lowerBound) }
95 }
96
7997 /// Where the running clock's line is, for `org-clock-goto`.
8098 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
8199 guard let clock = running else { return nil }
Sources/OrgApp/EmacsImport.swift +33 −3
@@ -526,13 +526,27 @@ public struct EmacsImport: Sendable {
526526 func quoted(_ s: String) -> String { UserConfig.format(.string(s)) }
527527 var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n"
528528 let strings = target.dropFirst().compactMap(\.string)
529 // Property list after the template: `:prepend t :empty-lines 1 …`.
530 var properties: [String: Sexp] = [:]
531 let rest = Array(parts.dropFirst(5))
532 for i in stride(from: 0, to: rest.count - 1, by: 2) { if let name = rest[i].symbol { properties[name] = rest[i + 1] } }
533 let allStrings = strings.count == target.count - 1
534 var treeType = properties[":tree-type"]?.symbol
529535 let place: String? = switch kind {
530536 case "file" where strings.count == 1 && target.count == 2:
531537 "file = \(quoted(strings[0]))\n"
532538 case "file+headline" where strings.count == 2 && target.count == 3:
533539 "file = \(quoted(strings[0]))\nheadline = \(quoted(strings[1]))\n"
534 case "file+olp" where strings.count >= 2 && strings.count == target.count - 1:
540 case "file+olp" where strings.count >= 2 && allStrings:
535541 "file = \(quoted(strings[0]))\nolp = \(quoted(strings.dropFirst().joined(separator: "/")))\n"
542 case "file+olp+datetree" where strings.count >= 1 && allStrings:
543 "file = \(quoted(strings[0]))\ndatetree = true\n" + (strings.count > 1 ? "olp = \(quoted(strings.dropFirst().joined(separator: "/")))\n" : "")
544 case let legacy where (legacy == "file+datetree" || legacy == "file+weektree") && strings.count == 1 && target.count == 2:
545 "file = \(quoted(strings[0]))\ndatetree = true\n"
546 case "id" where strings.count == 1 && target.count == 2:
547 "id = \(quoted(strings[0]))\n"
548 case "clock" where target.count == 1:
549 "clock = true\n"
536550 default:
537551 nil
538552 }
@@ -540,9 +554,25 @@ public struct EmacsImport: Sendable {
540554 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "target \(kind) isn't supported", source: source))
541555 return
542556 }
557 if kind == "file+weektree" { treeType = "week" }
543558 toml += place
544 let prepend = parts.dropFirst(5).enumerated().contains { $0.element.symbol == ":prepend" && parts.dropFirst(5).dropFirst($0.offset + 1).first?.symbol != "nil" }
545 if prepend { toml += "prepend = true\n" }
559 if let treeType {
560 guard ["day", "week", "month"].contains(treeType) else {
561 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: ":tree-type \(treeType) isn't supported", source: source))
562 return
563 }
564 if treeType != "day" { toml += "tree-type = \(quoted(treeType))\n" }
565 }
566 for flag in ["prepend", "immediate-finish", "jump-to-captured", "clock-in", "clock-keep", "clock-resume"] {
567 if let value = properties[":" + flag], value.symbol != "nil" { toml += "\(flag) = true\n" }
568 }
569 for count in ["empty-lines", "empty-lines-before", "empty-lines-after"] {
570 if let n = properties[":" + count]?.integer { toml += "\(count) = \(n)\n" }
571 }
572 let ignored = properties.keys.filter { ![":prepend", ":immediate-finish", ":jump-to-captured", ":clock-in", ":clock-keep", ":clock-resume", ":empty-lines", ":empty-lines-before", ":empty-lines-after", ":tree-type"].contains($0) }
573 if !ignored.isEmpty {
574 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "\(ignored.sorted().joined(separator: ", ")) left out", source: source))
575 }
546576 toml += "template = \(quoted(template))\n"
547577 items.append(Item(id: id(), name: "capture \"\(key)\" \(name)", summary: "capture template \"\(key)\"", source: source, change: .capture(key: key, toml: toml)))
548578 }
Sources/OrgCore/Capture/Capture.swift +135 −23
@@ -6,33 +6,89 @@ import Foundation
66public struct CaptureTemplate: Sendable, Equatable, Identifiable {
77 public enum Kind: String, Sendable, Equatable { case entry, item, checkitem, plain }
88
9 /// `:tree-type`: what a date tree groups entries by.
10 public enum TreeType: String, Sendable, Equatable { case day, week, month }
11
912 public enum Target: Sendable, Equatable {
1013 case file(String)
1114 case headline(file: String, headline: String)
1215 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)
1324
25 /// The target file, or "" for `id` and `clock` until the app resolves them.
1426 public var file: String {
1527 switch self {
16 case .file(let f), .headline(let f, _), .outline(let f, _): f
28 case .file(let f), .headline(let f, _), .outline(let f, _), .datetree(let f, _, _), .heading(let f, _): f
29 case .id, .clock: ""
1730 }
1831 }
1932 }
2033
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
50 public init(
51 prepend: Bool = false, immediateFinish: Bool = false, emptyLines: Int? = nil, emptyLinesBefore: Int? = nil,
52 emptyLinesAfter: Int? = nil, jumpToCaptured: Bool = false, clockIn: Bool = false, clockKeep: Bool = false, clockResume: Bool = false
53 ) {
54 self.prepend = prepend
55 self.immediateFinish = immediateFinish
56 self.emptyLines = emptyLines
57 self.emptyLinesBefore = emptyLinesBefore
58 self.emptyLinesAfter = emptyLinesAfter
59 self.jumpToCaptured = jumpToCaptured
60 self.clockIn = clockIn
61 self.clockKeep = clockKeep
62 self.clockResume = clockResume
63 }
64
65 var before: Int? { emptyLinesBefore ?? emptyLines }
66 var after: Int { emptyLinesAfter ?? emptyLines ?? 0 }
67 }
68
2169 public let key: String
2270 public let name: String
2371 public let kind: Kind
2472 public let target: Target
2573 public let template: String
26 public let prepend: Bool
74 public let options: Options
75 public var prepend: Bool { options.prepend }
2776 public var id: String { key }
2877
29 public init(key: String, name: String, kind: Kind = .entry, target: Target, template: String, prepend: Bool = false) {
78 public init(key: String, name: String, kind: Kind = .entry, target: Target, template: String, prepend: Bool = false, options: Options = Options()) {
3079 self.key = key
3180 self.name = name
3281 self.kind = kind
3382 self.target = target
3483 self.template = template
35 self.prepend = prepend
84 var options = options
85 if prepend { options.prepend = true }
86 self.options = options
87 }
88
89 /// The same template aimed at `target`.
90 public func retargeted(_ target: Target) -> CaptureTemplate {
91 CaptureTemplate(key: key, name: name, kind: kind, target: target, template: template, options: options)
3692 }
3793}
3894
@@ -229,14 +285,28 @@ public enum Capture {
229285 // MARK: - Placing
230286
231287 /// 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> {
288 /// `now` and `calendar` pick a date tree's day.
289 public static func place(
290 _ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default,
291 now: Date = Date(), calendar: Calendar = .current
292 ) -> Result<String, Failure> {
293 placement(text, template: template, in: fileText, settings: settings, now: now, calendar: calendar).map(\.text)
294 }
295
296 /// The new text, and where the captured entry starts in it.
297 public static func placement(
298 _ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default,
299 now: Date = Date(), calendar: Calendar = .current
300 ) -> Result<(text: String, start: Int), Failure> {
233301 let buffer = EmacsBuffer(fileText, point: 0, settings: settings)
302 let start: Int
234303 do {
235 let entryHeading = try locate(template.target, buffer)
304 let entryHeading = try locate(template.target, buffer, date: calendar.dateComponents([.year, .month, .day], from: now))
305 let options = template.options
236306 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)
307 case .entry: start = try placeEntry(text, options: options, heading: entryHeading, buffer)
308 case .plain: start = placePlain(text, options: options, heading: entryHeading, buffer)
309 case .item, .checkitem: start = try placeItem(text, checkbox: template.kind == .checkitem, options: options, heading: entryHeading, buffer)
240310 }
241311 } catch let error as Failure {
242312 return .failure(error)
@@ -248,14 +318,32 @@ public enum Capture {
248318 // Saving the target adds a final newline, as `require-final-newline` does in Org.
249319 var result = buffer.text as String
250320 if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" }
251 return .success(result)
321 return .success((result, start))
252322 }
253323
254324 /// `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? {
325 static func locate(_ target: CaptureTemplate.Target, _ buffer: EmacsBuffer, date: DateComponents) throws -> Int? {
256326 switch target {
257327 case .file:
258328 return nil
329 case .id(let id):
330 buffer.point = 0
331 guard let match = buffer.searchForward("^[ \\t]*:ID:[ \\t]+" + NSRegularExpression.escapedPattern(for: id) + "[ \\t]*$", caseInsensitive: true) else {
332 throw Failure.message("Cannot find target ID \"\(id)\"")
333 }
334 buffer.point = match[0]!.lowerBound
335 buffer.backToHeadingOrPointMin()
336 return buffer.beforeFirstHeading ? nil : buffer.point
337 case .clock:
338 throw Failure.message("No running clock")
339 case .heading(_, let offset):
340 guard offset <= buffer.length else { throw Failure.message("The clocked entry moved; try again") }
341 buffer.point = buffer.lineStart(offset)
342 guard atHeading(buffer) else { throw Failure.message("The clocked entry moved; try again") }
343 return buffer.point
344 case .datetree(_, let path, let tree):
345 let parent = path.isEmpty ? nil : try locate(.outline(file: "", path: path), buffer, date: date)
346 return Datetree.findCreate(buffer, under: parent, tree: tree, date: date)
259347 case .headline(_, let headline):
260348 buffer.point = 0
261349 let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$"
@@ -394,7 +482,8 @@ public enum Capture {
394482 }
395483
396484 /// `org-capture-place-entry`.
397 static func placeEntry(_ entry: String, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws {
485 static func placeEntry(_ entry: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
486 let prepend = options.prepend
398487 var template = entry
399488 if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template }
400489 // `org-capture-verify-tree`: the first heading has the lowest level.
@@ -424,7 +513,8 @@ public enum Capture {
424513 if !buffer.bolp { buffer.insert("\n") }
425514 let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer)
426515 deleteEmptyLinesBefore(buffer)
427 if blank { buffer.insert("\n") }
516 buffer.insert(String(repeating: "\n", count: options.before ?? (blank ? 1 : 0)))
517 let start = buffer.point
428518 // `org-paste-subtree` at `level`.
429519 let oldLevel = levels.first ?? 1
430520 let shift = level - oldLevel
@@ -438,11 +528,19 @@ public enum Capture {
438528 }
439529 if !shifted.hasSuffix("\n") { shifted += "\n" }
440530 buffer.insert(shifted)
531 emptyLinesAfter(buffer, options.after)
532 return start
533 }
534
535 /// `org-capture-empty-lines-after`: exactly `n` blank lines after point, point kept.
536 static func emptyLinesAfter(_ buffer: EmacsBuffer, _ n: Int) {
441537 deleteEmptyLinesAfter(buffer)
538 buffer.saveExcursion { buffer.insert(String(repeating: "\n", count: n)) }
442539 }
443540
444541 /// `org-capture-place-plain-text`.
445 static func placePlain(_ text: String, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) {
542 static func placePlain(_ text: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) -> Int {
543 let prepend = options.prepend
446544 if let heading {
447545 buffer.point = heading
448546 if prepend {
@@ -458,8 +556,11 @@ public enum Capture {
458556 }
459557 if !buffer.bolp { buffer.insert("\n") }
460558 deleteEmptyLinesBefore(buffer)
559 buffer.insert(String(repeating: "\n", count: options.before ?? 0))
560 let start = buffer.point
461561 buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n")
462 deleteEmptyLinesAfter(buffer)
562 emptyLinesAfter(buffer, options.after)
563 return start
463564 }
464565
465566 /// `org-end-of-meta-data t`: past planning, drawers and the property drawer.
@@ -482,9 +583,10 @@ public enum Capture {
482583 }
483584 }
484585
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 {
586 /// `org-capture-place-item`: after the last item of the first list in the entry (the file
587 /// for a file target), or at the end of the entry's text.
588 static func placeItem(_ text: String, checkbox: Bool, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
589 let prepend = options.prepend
488590 var item = text.trimmingCharacters(in: .newlines)
489591 if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil {
490592 let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil
@@ -501,16 +603,15 @@ public enum Capture {
501603 end = buffer.point
502604 } else {
503605 start = 0
504 buffer.point = 0
505 if !atHeading(buffer) { nextHeading(buffer) }
506 end = buffer.point
606 end = buffer.length
507607 }
508608 // The first list's items in the range.
509609 buffer.point = start
510610 var items: [(start: Int, end: Int, indent: String)] = []
511611 while buffer.point < end {
512612 let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point))
513 if let m = line.firstMatch(of: /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]/) {
613 // A `*` bullet needs indentation; at the margin it starts a heading.
614 if let m = line.firstMatch(of: /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]/), m.2 != "*" || !m.1.isEmpty {
514615 if let first = items.first, String(m.1) != first.indent {
515616 // A nested item belongs to the current one.
516617 } else {
@@ -538,13 +639,24 @@ public enum Capture {
538639 }
539640 }
540641 if !buffer.bolp { buffer.insert("\n") }
642 // In an existing list, at most one blank line between items.
643 if !prepend {
644 deleteEmptyLinesBefore(buffer)
645 buffer.insert(String(repeating: "\n", count: min(1, options.before ?? 0)))
646 }
647 let at = buffer.point
541648 buffer.insert(indented + "\n")
649 if prepend { emptyLinesAfter(buffer, min(1, options.after)) }
650 return at
542651 } else {
543652 buffer.point = prepend ? start : end
544653 if !buffer.bolp { buffer.insert("\n") }
545654 deleteEmptyLinesBefore(buffer)
655 buffer.insert(String(repeating: "\n", count: options.before ?? 0))
656 let at = buffer.point
546657 buffer.insert(item + "\n")
547 deleteEmptyLinesAfter(buffer)
658 emptyLinesAfter(buffer, options.after)
659 return at
548660 }
549661 }
550662}
Sources/OrgCore/Capture/Datetree.swift added +155
@@ -0,0 +1,155 @@
1import Foundation
2
3// Date trees (org-datetree.el, Org 9.8.7): `org-datetree-find-create-entry` with the groupings
4// capture uses, and `org-datetree-add-timestamp` nil. Each level is found or created inside
5// the one above it, as org does while narrowed to it.
6enum Datetree {
7 static let weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
8 static let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
9
10 /// Each level's heading title, and the pattern whose first group orders siblings.
11 static func levels(_ tree: CaptureTemplate.TreeType, date: DateComponents) -> [(title: String, pattern: String)] {
12 var gregorian = Calendar(identifier: .gregorian)
13 gregorian.timeZone = TimeZone(identifier: "UTC")!
14 let day = gregorian.date(from: DateComponents(year: date.year, month: date.month, day: date.day))!
15 let weekday = weekdays[gregorian.component(.weekday, from: day) - 1]
16 var iso = Calendar(identifier: .iso8601)
17 iso.timeZone = gregorian.timeZone
18 let isoYear = iso.component(.yearForWeekOfYear, from: day)
19 let week = iso.component(.weekOfYear, from: day)
20 let year = (title: String(date.year!), pattern: "([12][0-9]{3})")
21 let dayLevel = (title: String(format: "%04d-%02d-%02d ", date.year!, date.month!, date.day!) + weekday,
22 pattern: "([12][0-9]{3}-[01][0-9]-[0123][0-9]) \\w+")
23 switch tree {
24 case .day, .month:
25 let month = (title: String(format: "%04d-%02d ", date.year!, date.month!) + months[date.month! - 1],
26 pattern: "([12][0-9]{3}-[01][0-9]) \\w+")
27 return tree == .day ? [year, month, dayLevel] : [year, month]
28 case .week:
29 return [(String(isoYear), year.pattern), (String(format: "%04d-W%02d", isoYear, week), "([12][0-9]{3}-W[0-5][0-9])"), dayLevel]
30 }
31 }
32
33 /// Point at the date's entry, created where missing, under the heading at `parent` or at
34 /// the top of the file. Returns the entry's start.
35 static func findCreate(_ buffer: EmacsBuffer, under parent: Int?, tree: CaptureTemplate.TreeType, date: DateComponents) -> Int {
36 let low = buffer.marker(parent ?? 0)
37 let high = buffer.marker(parent.map { narrowEnd(buffer, $0, limit: buffer.length) } ?? buffer.length, advances: true)
38 defer {
39 buffer.release(low)
40 buffer.release(high)
41 }
42 var level = parent.map { Capture.headingLevel(buffer, at: $0) + 1 } ?? 1
43 for (title, pattern) in levels(tree, date: date) {
44 subheading(buffer, title: title, pattern: pattern, level: level, low: low, high: high)
45 level += 1
46 }
47 return low.position
48 }
49
50 /// `org-datetree--find-create-subheading`: leaves the region narrowed to the subheading.
51 static func subheading(_ buffer: EmacsBuffer, title: String, pattern: String, level: Int, low: EmacsBuffer.Marker, high: EmacsBuffer.Marker) {
52 func compare(_ sibling: String) -> Int? {
53 guard let a = firstGroup(pattern, sibling), let b = firstGroup(pattern, title) else { return nil }
54 return a < b ? -1 : a > b ? 1 : 0
55 }
56 var sibling: (start: Int, order: Int)?
57 buffer.point = low.position
58 while let match = buffer.searchForward("^(\\*+) ", bound: high.position, caseInsensitive: false) {
59 let start = match[0]!.lowerBound
60 buffer.point = buffer.lineEnd(start)
61 guard match[1]!.count == level else { continue }
62 if let order = compare(rawTitle(buffer, start)), order >= 0 {
63 sibling = (start, order)
64 break
65 }
66 }
67 if let sibling {
68 buffer.point = sibling.start
69 } else {
70 buffer.point = high.position
71 if !buffer.bolp { buffer.insert("\n") }
72 }
73 if let sibling, sibling.order == 0 {
74 narrow(buffer, to: sibling.start, low: low, high: high)
75 return
76 }
77 let end = buffer.point
78 while buffer.point > low.position, let c = buffer.character(at: buffer.point - 1), c == 32 || c == 9 || c == 10 { buffer.point -= 1 }
79 buffer.deleteRegion(buffer.point, end)
80 if blankBeforeHeading(buffer, low: low.position, high: high.position) { buffer.insert("\n") }
81 buffer.insert("\n" + String(repeating: "*", count: level) + " " + title + "\n")
82 buffer.forwardLine(-1)
83 narrow(buffer, to: buffer.point, low: low, high: high)
84 }
85
86 static func firstGroup(_ pattern: String, _ s: String) -> String? {
87 let regex = try! NSRegularExpression(pattern: pattern)
88 let ns = s as NSString
89 guard let m = regex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil }
90 return ns.substring(with: m.range(at: 1))
91 }
92
93 /// A heading's `:raw-value`: its title without keyword, priority or tags.
94 static func rawTitle(_ buffer: EmacsBuffer, _ start: Int) -> String {
95 buffer.saveExcursion {
96 buffer.point = start
97 guard let match = buffer.lookingAt(buffer.complexHeadingPattern), let title = match[4] else { return "" }
98 return buffer.substring(title.lowerBound, title.upperBound)
99 }
100 }
101
102 /// `org-narrow-to-subtree` inside the current region.
103 static func narrow(_ buffer: EmacsBuffer, to heading: Int, low: EmacsBuffer.Marker, high: EmacsBuffer.Marker) {
104 low.position = heading
105 high.position = narrowEnd(buffer, heading, limit: high.position)
106 buffer.point = heading
107 }
108
109 /// The subtree's end, before the newline that precedes the next heading, within `limit`.
110 static func narrowEnd(_ buffer: EmacsBuffer, _ heading: Int, limit: Int) -> Int {
111 let end = buffer.saveExcursion { () -> Int in
112 Capture.endOfSubtree(buffer, heading: heading)
113 return buffer.point
114 }
115 return min(limit, end == buffer.length ? end : end - 1)
116 }
117
118 /// `org--blank-before-heading-p` with `org-blank-before-new-entry` `(heading . auto)`,
119 /// in the region `low..<high`.
120 static func blankBeforeHeading(_ buffer: EmacsBuffer, low: Int, high: Int) -> Bool {
121 buffer.saveExcursion {
122 func headingStart(before limit: Int) -> Int? {
123 buffer.saveExcursion {
124 buffer.point = buffer.lineEnd(limit)
125 return buffer.searchBackward("^\\*+ ", bound: low, caseInsensitive: false)?[0]?.lowerBound
126 }
127 }
128 /// `outline-next-heading` bounded by `high`.
129 func nextHeading() -> Bool {
130 if let match = buffer.searchForward("\\n\\*+ ", bound: high, caseInsensitive: false) {
131 buffer.point = match[0]!.lowerBound + 1
132 return true
133 }
134 buffer.point = high
135 return false
136 }
137 func previousLineEmpty() -> Bool {
138 guard buffer.point > low else { return false }
139 let start = buffer.lineStart(buffer.point)
140 guard start > low else { return false }
141 let previous = max(low, buffer.lineStart(start - 1))
142 return buffer.substring(previous, start - 1).allSatisfy { $0 == " " || $0 == "\t" }
143 }
144 if headingStart(before: buffer.point) == nil {
145 guard nextHeading() else { return false }
146 }
147 guard let heading = headingStart(before: buffer.point) else { return false }
148 buffer.point = heading
149 if buffer.point != low { return previousLineEmpty() }
150 if nextHeading() { return previousLineEmpty() }
151 while buffer.point > low, let c = buffer.character(at: buffer.point - 1), c == 32 || c == 9 { buffer.point -= 1 }
152 return buffer.bolp ? previousLineEmpty() : false
153 }
154 }
155}
Sources/Orgstar/CaptureView.swift +49 −4
@@ -10,6 +10,7 @@ struct CaptureView: View {
1010 let workspace: WorkspaceModel
1111 let session: DocumentSession
1212 let commands: AppCommands
13 let clock: ClockModel
1314 @State private var templates = CaptureTemplates.defaults
1415 @State private var problem: String?
1516 @State private var template: CaptureTemplate?
@@ -82,9 +83,12 @@ struct CaptureView: View {
8283 private func target(_ template: CaptureTemplate) -> String {
8384 let file = (template.target.file as NSString).lastPathComponent
8485 switch template.target {
85 case .file: return file
86 case .file, .heading: return file
8687 case .headline(_, let headline): return "\(file) / \(headline)"
8788 case .outline(_, let path): return ([file] + path).joined(separator: " / ")
89 case .datetree(_, let path, _): return ([file] + path + ["date tree"]).joined(separator: " / ")
90 case .id(let id): return "ID \(id)"
91 case .clock: return "clocked entry"
8892 }
8993 }
9094
@@ -119,6 +123,10 @@ struct CaptureView: View {
119123 self.template = nil
120124 case .success(let filled):
121125 text = filled.text
126 if template.options.immediateFinish {
127 finish()
128 return
129 }
122130 let index = String.Index(utf16Offset: filled.cursor, in: text)
123131 selection = TextSelection(insertionPoint: index)
124132 editing = true
@@ -126,13 +134,50 @@ struct CaptureView: View {
126134 }
127135 }
128136
137 /// `id` and `clock` targets as a file and a heading.
138 private func resolved(_ template: CaptureTemplate) -> Result<(template: CaptureTemplate, file: String?), CaptureError> {
139 switch template.target {
140 case .id(let id):
141 guard let location = try? workspace.index.headings(withID: id).first else {
142 return .failure(CaptureError(message: "Cannot find target ID \"\(id)\""))
143 }
144 return .success((template, location.path))
145 case .clock:
146 guard let entry = clock.entry(session) else { return .failure(CaptureError(message: "No running clock")) }
147 return .success((template.retargeted(.heading(file: entry.path, offset: entry.offset)), entry.path))
148 default:
149 return .success((template, nil))
150 }
151 }
152
129153 private func finish() {
130154 guard let template else { return }
131 if let message = session.capture(text, template: template, root: workspace.rootPaths.first) {
132 problem = message
155 let opened = context.now
156 let now = Date()
157 let placed = resolved(template).flatMap { resolved in
158 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
159 }
160 switch placed {
161 case .failure(let error):
162 problem = error.message
163 if template.options.immediateFinish { editing = true }
133164 return
165 case .success(let entry):
166 commands.show("Captured to \(target(template))")
167 let options = template.options
168 if options.clockIn {
169 // Clocked from when capture began; the clock it interrupted resumes after.
170 let previous = clock.entry(session)
171 commands.show(clock.clockIn(session, path: entry.path, at: entry.offset, now: opened))
172 if !options.clockKeep {
173 commands.show(clock.clockOut(session, now: now))
174 if options.clockResume, let previous {
175 commands.show(clock.clockIn(session, path: previous.path, at: previous.offset, now: now))
176 }
177 }
178 }
179 if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) }
134180 }
135 commands.show("Captured to \(target(template))")
136181 close()
137182 }
138183
Sources/Orgstar/OrgstarApp.swift +1 −1
@@ -270,7 +270,7 @@ struct OrgstarApp: App {
270270 .defaultSize(width: 860, height: 560)
271271 Window("Capture", id: "capture") {
272272 if case .success(let model) = workspace {
273 CaptureView(workspace: model, session: session, commands: commands)
273 CaptureView(workspace: model, session: session, commands: commands, clock: clock)
274274 }
275275 }
276276 .windowResizability(.contentSize)
Tests/OrgAppTests/AppTests.swift +39 −3
@@ -626,6 +626,35 @@ struct CaptureAppTests {
626626 #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"]))
627627 #expect(loaded.templates[0].template == "* TODO %?\n%a")
628628 #expect(loaded.templates[1].kind == .plain && loaded.templates[1].prepend)
629 try """
630 [[template]]
631 key = "d"
632 file = "journal.org"
633 datetree = true
634 olp = "Journal"
635 tree-type = "month"
636 template = "* %?"
637 empty-lines-after = 1
638 jump-to-captured = true
639 [[template]]
640 key = "i"
641 id = "abc"
642 template = "* %?"
643 clock-in = true
644 clock-resume = true
645 [[template]]
646 key = "k"
647 clock = true
648 type = "item"
649 template = "%?"
650 immediate-finish = true
651 """.write(to: file, atomically: true, encoding: .utf8)
652 let targets = CaptureTemplates.load(userFile: file)
653 #expect(targets.problems.isEmpty)
654 #expect(targets.templates.map(\.target) == [.datetree(file: "journal.org", path: ["Journal"], tree: .month), .id("abc"), .clock])
655 #expect(targets.templates[0].options == .init(emptyLinesAfter: 1, jumpToCaptured: true))
656 #expect(targets.templates[1].options == .init(clockIn: true, clockResume: true))
657 #expect(targets.templates[2].options.immediateFinish)
629658 #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults)
630659 }
631660
@@ -634,15 +663,22 @@ struct CaptureAppTests {
634663 let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n")
635664 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
636665 let template = CaptureTemplates.defaults[0]
637 #expect(session.capture("* TODO New\n", template: template, root: notes.url.path) == nil)
666 let placed = try session.capture("* TODO New\n", template: template, root: notes.url.path).get()
638667 #expect(try String(contentsOf: todo, encoding: .utf8) == "* Inbox\n** TODO Old\n** TODO New\n* Later\n")
668 #expect(placed.path == todo.path && placed.offset == ("* Inbox\n** TODO Old\n" as NSString).length)
639669
640 #expect(session.capture("* Note\n", template: CaptureTemplates.defaults[1], root: notes.url.path) == nil)
670 _ = try session.capture("* Note\n", template: CaptureTemplates.defaults[1], root: notes.url.path).get()
641671 #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n")
642672
643673 session.open(todo.path)
644 #expect(session.capture("* TODO Third\n", template: template, root: notes.url.path) == nil)
674 _ = try session.capture("* TODO Third\n", template: template, root: notes.url.path).get()
645675 #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n")
676
677 // An ID's file is resolved by the caller.
678 let id = CaptureTemplate(key: "i", name: "", target: .id("x1"), template: "")
679 let ided = try notes.write("ided.org", "* Target\n:PROPERTIES:\n:ID: x1\n:END:\n")
680 _ = try session.capture("* Child\n", template: id, root: nil, file: ided.path).get()
681 #expect(try String(contentsOf: ided, encoding: .utf8) == "* Target\n:PROPERTIES:\n:ID: x1\n:END:\n** Child\n")
646682 }
647683}
648684
Tests/OrgAppTests/EmacsImportTests.swift +11 −7
@@ -22,7 +22,9 @@ struct EmacsImportTests {
2222 (setq fill-column (+ 70 2))
2323 (setq org-capture-templates
2424 '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a")
25 ("j" "Journal" entry (file+olp+datetree "~/notes/journal.org") "* %?")
25 ("j" "Journal" entry (file+olp+datetree "~/notes/journal.org") "* %?" :tree-type week :empty-lines 1 :time-prompt t)
26 ("c" "Clocked" item (clock) "%?" :immediate-finish t)
27 ("x" "Function" entry (function ignore) "* %?")
2628 ("w" "Work")
2729 ("wn" "Work note" plain (file "~/work/notes.org") "%?" :prepend t)))
2830 (map! :leader :desc "Report" "z t" #'tr/report
@@ -81,7 +83,7 @@ struct EmacsImportTests {
8183 #expect(folders == ["~/notes/", "~/work"])
8284 #expect(result.unreadable.map(\.name) == ["fill-column"])
8385 #expect(Set(result.unsupported.map(\.name)) == ["org-support-shift-select", "doom-theme", "org-roam-directory",
84 "capture \"j\"", "SPC z t → tr/report", "C-c l"])
86 "capture \"j\"", "capture \"x\"", "SPC z t → tr/report", "C-c l"])
8587 #expect(result.skipped == 1)
8688 #expect(result.doomDefaultsLeftOut == 1)
8789 }
@@ -89,10 +91,12 @@ struct EmacsImportTests {
8991 @Test func readsCaptureTemplatesAndBindings() throws {
9092 let result = try scan()
9193 let captures = result.items.compactMap { if case .capture(let key, let toml) = $0.change { (key, toml) } else { nil } }
92 #expect(captures.map(\.0) == ["t", "wn"])
94 #expect(captures.map(\.0) == ["t", "j", "c", "wn"])
95 #expect(captures[1].1 == "[[template]]\nkey = \"j\"\nname = \"Journal\"\ntype = \"entry\"\nfile = \"~/notes/journal.org\"\ndatetree = true\ntree-type = \"week\"\nempty-lines = 1\ntemplate = \"* %?\"\n")
96 #expect(captures[2].1.contains("clock = true\n") && captures[2].1.contains("immediate-finish = true\n"))
97 #expect(result.unsupported.contains { $0.name == "capture \"j\"" && $0.reason == ":time-prompt left out" })
9398 #expect(captures[0].1 == "[[template]]\nkey = \"t\"\nname = \"Todo\"\ntype = \"entry\"\nfile = \"~/notes/todo.org\"\nheadline = \"Inbox\"\ntemplate = \"* TODO %?\\n%a\"\n")
94 #expect(captures[1].1.contains("prepend = true\n") && captures[1].1.contains("type = \"plain\""))
95 #expect(result.unsupported.contains { $0.name == "capture \"j\"" })
99 #expect(captures[3].1.contains("prepend = true\n") && captures[3].1.contains("type = \"plain\""))
96100
97101 let bindings = result.items.compactMap { if case .binding(let keys, let command, let mode) = $0.change { "\(keys) \(command) \(mode ?? "-")" } else { nil } }
98102 #expect(bindings == ["SPC m x org.checkbox.toggle normal", "g s app.save normal", "C-c a app.agenda normal"])
@@ -131,12 +135,12 @@ struct EmacsImportTests {
131135 #expect(defaults.integer(forKey: "agendaStartOffset") == -3)
132136 #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)\nBUG | FIXED")
133137 let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8)
134 #expect(capture.components(separatedBy: "[[template]]").count == 3)
138 #expect(capture.components(separatedBy: "[[template]]").count == 5)
135139 #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty)
136140 let keymap = folder.appendingPathComponent("keymap.toml")
137141 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
138142 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a"))
139 #expect(summary.hasPrefix("Imported 14 settings, 1 capture template, 3 key bindings."), "\(summary)")
143 #expect(summary.hasPrefix("Imported 14 settings, 3 capture templates, 3 key bindings."), "\(summary)")
140144 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
141145 #expect(defaults.integer(forKey: "themeFontSize") == 14)
142146 }
Tests/OrgCoreTests/CaptureTests.swift +91 −4
@@ -23,14 +23,33 @@ struct CaptureTests {
2323 let path = folder.appendingPathComponent("target\(i).org").path
2424 try c.file.write(toFile: path, atomically: true, encoding: .utf8)
2525 let target: String
26 var properties = c.template.prepend ? " :prepend t" : ""
27 let o = c.template.options
28 if let n = o.emptyLines { properties += " :empty-lines \(n)" }
29 if let n = o.emptyLinesBefore { properties += " :empty-lines-before \(n)" }
30 if let n = o.emptyLinesAfter { properties += " :empty-lines-after \(n)" }
2631 switch c.template.target {
2732 case .file: target = "(file \(lisp(path)))"
2833 case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))"
2934 case .outline(_, let p): target = "(file+olp \(lisp(path)) \(p.map(lisp).joined(separator: " ")))"
35 case .datetree(_, let p, let tree):
36 target = "(file+olp+datetree \(lisp(path)) \(p.map(lisp).joined(separator: " ")))"
37 properties += " :tree-type \(tree.rawValue)"
38 case .id(let id): target = "(id \(lisp(id)))"
39 case .clock, .heading: target = "(clock)"
3040 }
3141 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")
42 (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(properties))))
43 (path \(lisp(path))))
44 (cl-letf (((symbol-function 'org-id-find)
45 (lambda (id &rest _)
46 (with-current-buffer (find-file-noselect path)
47 (save-excursion
48 (goto-char (point-min))
49 (when (re-search-forward (concat "^[ \t]*:ID:[ \t]+" (regexp-quote id) "[ \t]*$") nil t)
50 (org-back-to-heading t)
51 (cons path (point))))))))
52 (org-capture nil "x"))
3453 (insert \(lisp(c.typed)))
3554 (org-capture-finalize)
3655 (with-current-buffer (find-buffer-visiting \(lisp(path)))
@@ -43,24 +62,28 @@ struct CaptureTests {
4362 ;;; -*- lexical-binding: t -*-
4463 (require 'org)
4564 (require 'org-capture)
65 (require 'org-id)
4666 (require 'json)
4767 (setq org-adapt-indentation nil)
4868 (defvar results nil)
4969 \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")"))
5070 (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results)))))
5171 """.write(to: script, atomically: true, encoding: .utf8)
52 _ = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"])
72 let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"])
73 guard FileManager.default.fileExists(atPath: output.path) else { throw EmacsOracle.EvaluateError(log: log) }
5374 return try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
5475 }
5576
5677 static func ours(_ c: Case, now: Date) -> String {
78 var utcCalendar = Calendar(identifier: .gregorian)
79 utcCalendar.timeZone = TimeZone(identifier: "UTC")!
5780 var utc = Calendar(identifier: .gregorian)
5881 utc.timeZone = TimeZone(identifier: "UTC")!
5982 guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" }
6083 var text = filled.text
6184 let index = String.Index(utf16Offset: filled.cursor, in: text)
6285 text.insert(contentsOf: c.typed, at: index)
63 switch Capture.place(text, template: c.template, in: c.file) {
86 switch Capture.place(text, template: c.template, in: c.file, now: now, calendar: utcCalendar) {
6487 case .success(let result): return result
6588 case .failure(.message(let message)): return "failed: \(message)"
6689 }
@@ -112,6 +135,70 @@ struct CaptureTests {
112135 }
113136 }
114137
138 static let datetreeFiles = [
139 "",
140 "#+TITLE: Journal\n",
141 "* 2026\n** 2026-09 September\n*** 2026-09-01 Tuesday\nOld\n*** 2026-09-30 Wednesday\n** 2026-11 November\n* 2027\n",
142 "* 2025\n\n** 2025-01 January\n\n*** 2025-01-01 Wednesday\n\nNew year\n\n* Other\n",
143 "Notes before.\n\n* Journal\n** 2026\n*** 2026-09 September\n* Tasks\n** TODO Thing\n",
144 "* Journal\n:PROPERTIES:\n:ID: journal-id\n:END:\n* Tasks\n",
145 "* 2026\n** 2026-W36\n*** 2026-09-01 Tuesday\n** 2026-W38\n",
146 ]
147
148 static let datetreeTemplates: [CaptureTemplate] = [
149 CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .day), template: "* %?"),
150 CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .week), template: "* %U %?"),
151 CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .month), template: "* %?"),
152 CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: ["Journal"], tree: .day), template: "* %?"),
153 CaptureTemplate(key: "x", name: "", kind: .plain, target: .datetree(file: "", path: [], tree: .day), template: "%U %?"),
154 CaptureTemplate(key: "x", name: "", kind: .item, target: .datetree(file: "", path: [], tree: .day), template: "%?"),
155 CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .day), template: "* %?", options: .init(prepend: true)),
156 ]
157
158 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil), arguments: [1_789_000_000, 1_798_804_800])
159 func datetreesMatchEmacs(seconds: Int) throws {
160 var cases: [Case] = []
161 for template in Self.datetreeTemplates {
162 for file in Self.datetreeFiles {
163 if case .datetree(_, let path, _) = template.target, !path.isEmpty, !file.contains("* Journal") { continue }
164 cases.append(Case(file: file, template: template, typed: "Entry"))
165 }
166 }
167 let emacs = try Self.emacs(cases, seconds: seconds)
168 let now = Date(timeIntervalSince1970: Double(seconds))
169 for (c, expected) in zip(cases, emacs) {
170 let mine = Self.ours(c, now: now)
171 #expect(mine == expected, "\(c.template.target) \(c.template.kind) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)")
172 }
173 }
174
175 static let optionTemplates: [CaptureTemplate] = [
176 CaptureTemplate(key: "x", name: "", target: .id("journal-id"), template: "* %?"),
177 CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %?", options: .init(emptyLines: 1)),
178 CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %?", options: .init(emptyLinesBefore: 2, emptyLinesAfter: 1)),
179 CaptureTemplate(key: "x", name: "", target: .file(""), template: "* %?", options: .init(prepend: true, emptyLinesAfter: 1)),
180 CaptureTemplate(key: "x", name: "", kind: .plain, target: .headline(file: "", headline: "Inbox"), template: "%?", options: .init(emptyLines: 1)),
181 CaptureTemplate(key: "x", name: "", kind: .item, target: .file(""), template: "%?", options: .init(emptyLines: 1)),
182 ]
183
184 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
185 func targetsAndEmptyLinesMatchEmacs() throws {
186 var cases: [Case] = []
187 let files = ["* Journal\n:PROPERTIES:\n:ID: journal-id\n:END:\nText\n** Old\n* Inbox\n", "* Inbox\n\n** Old\n\n* Later\n", "Preamble\n* Inbox\nBody\n"]
188 for template in Self.optionTemplates {
189 for file in files {
190 if case .id = template.target, !file.contains("journal-id") { continue }
191 cases.append(Case(file: file, template: template, typed: "Entry"))
192 }
193 }
194 let seconds = 1_789_000_000
195 let emacs = try Self.emacs(cases, seconds: seconds)
196 for (c, expected) in zip(cases, emacs) {
197 let mine = Self.ours(c, now: Date(timeIntervalSince1970: Double(seconds)))
198 #expect(mine == expected, "\(c.template.target) \(c.template.kind) \(c.template.options) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)")
199 }
200 }
201
115202 @Test func fillsPrompts() {
116203 let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again"
117204 #expect(Capture.questions(template) == [