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
15/// template = "* TODO %?\n%i\n%a" 15/// template = "* TODO %?\n%i\n%a"
16/// prepend = false 16/// prepend = false
17/// ``` 17/// ```
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`.
18@MainActor 23@MainActor
19public enum CaptureTemplates { 24public enum CaptureTemplates {
20 public static var userFile: URL { UserConfig.file("capture.toml") } 25 public static var userFile: URL { UserConfig.file("capture.toml") }
@@ -40,7 +45,9 @@ public enum CaptureTemplates {
40 problems.append("capture.toml line \(table.line): expected [[template]]") 45 problems.append("capture.toml line \(table.line): expected [[template]]")
41 continue 46 continue
42 } 47 }
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 {
44 problems.append("capture.toml line \(table.line): a template needs key, file and template") 51 problems.append("capture.toml line \(table.line): a template needs key, file and template")
45 continue 52 continue
46 } 53 }
@@ -48,18 +55,33 @@ public enum CaptureTemplates {
48 problems.append("capture.toml line \(table.line): unknown type") 55 problems.append("capture.toml line \(table.line): unknown type")
49 continue 56 continue
50 } 57 }
58 let olp = values["olp"]?.string.map { $0.split(separator: "/").map(String.init) }
51 let target: CaptureTemplate.Target 59 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 {
53 target = .headline(file: file, headline: headline) 71 target = .headline(file: file, headline: headline)
54 } else if let olp = table.values["olp"]?.string { 72 } else if let olp {
55 target = .outline(file: file, path: olp.split(separator: "/").map(String.init)) 73 target = .outline(file: file, path: olp)
56 } else { 74 } else {
57 target = .file(file) 75 target = .file(file)
58 } 76 }
59 templates.append(CaptureTemplate( 77 func flag(_ name: String) -> Bool { values[name]?.bool ?? false }
60 key: key, name: table.values["name"]?.string ?? key, kind: kind, target: target, template: body, 78 func lines(_ name: String) -> Int? { values[name]?.integer }
61 prepend: table.values["prepend"]?.bool ?? false 79 let options = CaptureTemplate.Options(
62 )) 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))
63 } 85 }
64 return (templates.isEmpty ? defaults : templates, problems) 86 return (templates.isEmpty ? defaults : templates, problems)
65 } 87 }
@@ -67,21 +89,23 @@ public enum CaptureTemplates {
67 89
68extension DocumentSession { 90extension DocumentSession {
69 /// Places captured `text` with `template`: through the buffer when the target is open, 91 /// 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. 92 /// otherwise in the file, created if missing, through the save path. `id` and `clock`
71 public func capture(_ text: String, template: CaptureTemplate, root: String?) -> String? { 93 /// targets must be resolved first (`file` names the ID's file). Returns the file and where
72 let path = Self.resolve(template.target.file, root: root) 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)
73 if !isOpen(path), !FileManager.default.fileExists(atPath: path) { 97 if !isOpen(path), !FileManager.default.fileExists(atPath: path) {
74 do { 98 do {
75 try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true) 99 try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true)
76 try Data().write(to: URL(fileURLWithPath: path)) 100 try Data().write(to: URL(fileURLWithPath: path))
77 } catch { 101 } catch {
78 return "Can't create \(path): \(error.localizedDescription)" 102 return .failure(CaptureError(message: "Can't create \(path): \(error.localizedDescription)"))
79 } 103 }
80 } 104 }
81 switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil) { 105 switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil, now: now) {
82 case .commit: return nil 106 case .commit(let result): return .success((path, result.selection?.first?.lowerBound ?? 0))
83 case .failed(let message): return message 107 case .failed(let message): return .failure(CaptureError(message: message))
84 default: return "Can't capture there." 108 default: return .failure(CaptureError(message: "Can't capture there."))
85 } 109 }
86 } 110 }
87 111
@@ -101,7 +125,13 @@ extension DocumentSession {
101 } 125 }
102} 126}
103 127
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.
105struct CapturePlacement: OrgCommand { 135struct CapturePlacement: OrgCommand {
106 let text: String 136 let text: String
107 let template: CaptureTemplate 137 let template: CaptureTemplate
@@ -109,11 +139,11 @@ struct CapturePlacement: OrgCommand {
109 var title: String { "Capture" } 139 var title: String { "Capture" }
110 func applies(in context: EditContext) -> Bool { true } 140 func applies(in context: EditContext) -> Bool { true }
111 func run(in context: EditContext) -> CommandStep { 141 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) {
113 case .failure(.message(let message)): 143 case .failure(.message(let message)):
114 return .failed(message) 144 return .failed(message)
115 case .success(let new): 145 case .success(let placed):
116 return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, new))) 146 return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, placed.text), selection: [placed.start..<placed.start]))
117 } 147 }
118 } 148 }
119} 149}
Sources/OrgApp/ClockModel.swift +21 −3
@@ -35,14 +35,21 @@ public final class ClockModel {
35 /// `org-clock-in` at the heading under the caret, clocking out a running clock first. 35 /// `org-clock-in` at the heading under the caret, clocking out a running clock first.
36 public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String { 36 public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String {
37 guard let path = session.path, session.document != nil else { return "No file is open." } 37 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" }
39 if running != nil { 46 if running != nil {
40 let message = clockOut(session, now: now) 47 let message = clockOut(session, now: now)
41 if running != nil { return message } 48 if running != nil { return message }
42 } 49 }
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) {
44 case .commit: 51 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 {
46 return "The clock line wasn't found after clocking in." 53 return "The clock line wasn't found after clocking in."
47 } 54 }
48 running = Running(path: path, start: start, heading: entry.title, startedAt: now) 55 running = Running(path: path, start: start, heading: entry.title, startedAt: now)
@@ -76,6 +83,17 @@ public final class ClockModel {
76 } 83 }
77 } 84 }
78 85
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
79 /// Where the running clock's line is, for `org-clock-goto`. 97 /// Where the running clock's line is, for `org-clock-goto`.
80 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? { 98 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
81 guard let clock = running else { return nil } 99 guard let clock = running else { return nil }
Sources/OrgApp/EmacsImport.swift +33 −3
@@ -526,13 +526,27 @@ public struct EmacsImport: Sendable {
526 func quoted(_ s: String) -> String { UserConfig.format(.string(s)) } 526 func quoted(_ s: String) -> String { UserConfig.format(.string(s)) }
527 var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n" 527 var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n"
528 let strings = target.dropFirst().compactMap(\.string) 528 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
529 let place: String? = switch kind { 535 let place: String? = switch kind {
530 case "file" where strings.count == 1 && target.count == 2: 536 case "file" where strings.count == 1 && target.count == 2:
531 "file = \(quoted(strings[0]))\n" 537 "file = \(quoted(strings[0]))\n"
532 case "file+headline" where strings.count == 2 && target.count == 3: 538 case "file+headline" where strings.count == 2 && target.count == 3:
533 "file = \(quoted(strings[0]))\nheadline = \(quoted(strings[1]))\n" 539 "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:
535 "file = \(quoted(strings[0]))\nolp = \(quoted(strings.dropFirst().joined(separator: "/")))\n" 541 "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"
536 default: 550 default:
537 nil 551 nil
538 } 552 }
@@ -540,9 +554,25 @@ public struct EmacsImport: Sendable {
540 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "target \(kind) isn't supported", source: source)) 554 unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "target \(kind) isn't supported", source: source))
541 return 555 return
542 } 556 }
557 if kind == "file+weektree" { treeType = "week" }
543 toml += place 558 toml += place
544 let prepend = parts.dropFirst(5).enumerated().contains { $0.element.symbol == ":prepend" && parts.dropFirst(5).dropFirst($0.offset + 1).first?.symbol != "nil" } 559 if let treeType {
545 if prepend { toml += "prepend = true\n" } 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 }
546 toml += "template = \(quoted(template))\n" 576 toml += "template = \(quoted(template))\n"
547 items.append(Item(id: id(), name: "capture \"\(key)\" \(name)", summary: "capture template \"\(key)\"", source: source, change: .capture(key: key, toml: toml))) 577 items.append(Item(id: id(), name: "capture \"\(key)\" \(name)", summary: "capture template \"\(key)\"", source: source, change: .capture(key: key, toml: toml)))
548 } 578 }
Sources/OrgCore/Capture/Capture.swift +135 −23
@@ -6,33 +6,89 @@ import Foundation
6public struct CaptureTemplate: Sendable, Equatable, Identifiable { 6public struct CaptureTemplate: Sendable, Equatable, Identifiable {
7 public enum Kind: String, Sendable, Equatable { case entry, item, checkitem, plain } 7 public enum Kind: String, Sendable, Equatable { case entry, item, checkitem, plain }
8 8
9 /// `:tree-type`: what a date tree groups entries by.
10 public enum TreeType: String, Sendable, Equatable { case day, week, month }
11
9 public enum Target: Sendable, Equatable { 12 public enum Target: Sendable, Equatable {
10 case file(String) 13 case file(String)
11 case headline(file: String, headline: String) 14 case headline(file: String, headline: String)
12 case outline(file: String, path: [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)
13 24
25 /// The target file, or "" for `id` and `clock` until the app resolves them.
14 public var file: String { 26 public var file: String {
15 switch self { 27 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: ""
17 } 30 }
18 } 31 }
19 } 32 }
20 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
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
21 public let key: String 69 public let key: String
22 public let name: String 70 public let name: String
23 public let kind: Kind 71 public let kind: Kind
24 public let target: Target 72 public let target: Target
25 public let template: String 73 public let template: String
26 public let prepend: Bool 74 public let options: Options
75 public var prepend: Bool { options.prepend }
27 public var id: String { key } 76 public var id: String { key }
28 77
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()) {
30 self.key = key 79 self.key = key
31 self.name = name 80 self.name = name
32 self.kind = kind 81 self.kind = kind
33 self.target = target 82 self.target = target
34 self.template = template 83 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)
36 } 92 }
37} 93}
38 94
@@ -229,14 +285,28 @@ public enum Capture {
229 // MARK: - Placing 285 // MARK: - Placing
230 286
231 /// The target file's text after capturing `text`, as `org-capture-finalize` leaves it. 287 /// 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> {
233 let buffer = EmacsBuffer(fileText, point: 0, settings: settings) 301 let buffer = EmacsBuffer(fileText, point: 0, settings: settings)
302 let start: Int
234 do { 303 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
236 switch template.kind { 306 switch template.kind {
237 case .entry: try placeEntry(text, prepend: template.prepend, heading: entryHeading, buffer) 307 case .entry: start = try placeEntry(text, options: options, heading: entryHeading, buffer)
238 case .plain: placePlain(text, prepend: template.prepend, heading: entryHeading, buffer) 308 case .plain: start = placePlain(text, options: options, heading: entryHeading, buffer)
239 case .item, .checkitem: try placeItem(text, checkbox: template.kind == .checkitem, prepend: template.prepend, heading: entryHeading, buffer) 309 case .item, .checkitem: start = try placeItem(text, checkbox: template.kind == .checkitem, options: options, heading: entryHeading, buffer)
240 } 310 }
241 } catch let error as Failure { 311 } catch let error as Failure {
242 return .failure(error) 312 return .failure(error)
@@ -248,14 +318,32 @@ public enum Capture {
248 // Saving the target adds a final newline, as `require-final-newline` does in Org. 318 // Saving the target adds a final newline, as `require-final-newline` does in Org.
249 var result = buffer.text as String 319 var result = buffer.text as String
250 if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" } 320 if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" }
251 return .success(result) 321 return .success((result, start))
252 } 322 }
253 323
254 /// `org-capture-set-target-location`: point at the target heading, or nil for a file. 324 /// `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? {
256 switch target { 326 switch target {
257 case .file: 327 case .file:
258 return nil 328 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)
259 case .headline(_, let headline): 347 case .headline(_, let headline):
260 buffer.point = 0 348 buffer.point = 0
261 let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$" 349 let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$"
@@ -394,7 +482,8 @@ public enum Capture {
394 } 482 }
395 483
396 /// `org-capture-place-entry`. 484 /// `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
398 var template = entry 487 var template = entry
399 if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template } 488 if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template }
400 // `org-capture-verify-tree`: the first heading has the lowest level. 489 // `org-capture-verify-tree`: the first heading has the lowest level.
@@ -424,7 +513,8 @@ public enum Capture {
424 if !buffer.bolp { buffer.insert("\n") } 513 if !buffer.bolp { buffer.insert("\n") }
425 let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer) 514 let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer)
426 deleteEmptyLinesBefore(buffer) 515 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
428 // `org-paste-subtree` at `level`. 518 // `org-paste-subtree` at `level`.
429 let oldLevel = levels.first ?? 1 519 let oldLevel = levels.first ?? 1
430 let shift = level - oldLevel 520 let shift = level - oldLevel
@@ -438,11 +528,19 @@ public enum Capture {
438 } 528 }
439 if !shifted.hasSuffix("\n") { shifted += "\n" } 529 if !shifted.hasSuffix("\n") { shifted += "\n" }
440 buffer.insert(shifted) 530 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) {
441 deleteEmptyLinesAfter(buffer) 537 deleteEmptyLinesAfter(buffer)
538 buffer.saveExcursion { buffer.insert(String(repeating: "\n", count: n)) }
442 } 539 }
443 540
444 /// `org-capture-place-plain-text`. 541 /// `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
446 if let heading { 544 if let heading {
447 buffer.point = heading 545 buffer.point = heading
448 if prepend { 546 if prepend {
@@ -458,8 +556,11 @@ public enum Capture {
458 } 556 }
459 if !buffer.bolp { buffer.insert("\n") } 557 if !buffer.bolp { buffer.insert("\n") }
460 deleteEmptyLinesBefore(buffer) 558 deleteEmptyLinesBefore(buffer)
559 buffer.insert(String(repeating: "\n", count: options.before ?? 0))
560 let start = buffer.point
461 buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n") 561 buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n")
462 deleteEmptyLinesAfter(buffer) 562 emptyLinesAfter(buffer, options.after)
563 return start
463 } 564 }
464 565
465 /// `org-end-of-meta-data t`: past planning, drawers and the property drawer. 566 /// `org-end-of-meta-data t`: past planning, drawers and the property drawer.
@@ -482,9 +583,10 @@ public enum Capture {
482 } 583 }
483 } 584 }
484 585
485 /// `org-capture-place-item`: after the last item of the first list in the entry, or at the 586 /// `org-capture-place-item`: after the last item of the first list in the entry (the file
486 /// end of the entry's text. 587 /// for a file target), or at the end of the entry's text.
487 static func placeItem(_ text: String, checkbox: Bool, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws { 588 static func placeItem(_ text: String, checkbox: Bool, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int {
589 let prepend = options.prepend
488 var item = text.trimmingCharacters(in: .newlines) 590 var item = text.trimmingCharacters(in: .newlines)
489 if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil { 591 if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil {
490 let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil 592 let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil
@@ -501,16 +603,15 @@ public enum Capture {
501 end = buffer.point 603 end = buffer.point
502 } else { 604 } else {
503 start = 0 605 start = 0
504 buffer.point = 0 606 end = buffer.length
505 if !atHeading(buffer) { nextHeading(buffer) }
506 end = buffer.point
507 } 607 }
508 // The first list's items in the range. 608 // The first list's items in the range.
509 buffer.point = start 609 buffer.point = start
510 var items: [(start: Int, end: Int, indent: String)] = [] 610 var items: [(start: Int, end: Int, indent: String)] = []
511 while buffer.point < end { 611 while buffer.point < end {
512 let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point)) 612 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 {
514 if let first = items.first, String(m.1) != first.indent { 615 if let first = items.first, String(m.1) != first.indent {
515 // A nested item belongs to the current one. 616 // A nested item belongs to the current one.
516 } else { 617 } else {
@@ -538,13 +639,24 @@ public enum Capture {
538 } 639 }
539 } 640 }
540 if !buffer.bolp { buffer.insert("\n") } 641 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
541 buffer.insert(indented + "\n") 648 buffer.insert(indented + "\n")
649 if prepend { emptyLinesAfter(buffer, min(1, options.after)) }
650 return at
542 } else { 651 } else {
543 buffer.point = prepend ? start : end 652 buffer.point = prepend ? start : end
544 if !buffer.bolp { buffer.insert("\n") } 653 if !buffer.bolp { buffer.insert("\n") }
545 deleteEmptyLinesBefore(buffer) 654 deleteEmptyLinesBefore(buffer)
655 buffer.insert(String(repeating: "\n", count: options.before ?? 0))
656 let at = buffer.point
546 buffer.insert(item + "\n") 657 buffer.insert(item + "\n")
547 deleteEmptyLinesAfter(buffer) 658 emptyLinesAfter(buffer, options.after)
659 return at
548 } 660 }
549 } 661 }
550} 662}
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 {
10 let workspace: WorkspaceModel 10 let workspace: WorkspaceModel
11 let session: DocumentSession 11 let session: DocumentSession
12 let commands: AppCommands 12 let commands: AppCommands
13 let clock: ClockModel
13 @State private var templates = CaptureTemplates.defaults 14 @State private var templates = CaptureTemplates.defaults
14 @State private var problem: String? 15 @State private var problem: String?
15 @State private var template: CaptureTemplate? 16 @State private var template: CaptureTemplate?
@@ -82,9 +83,12 @@ struct CaptureView: View {
82 private func target(_ template: CaptureTemplate) -> String { 83 private func target(_ template: CaptureTemplate) -> String {
83 let file = (template.target.file as NSString).lastPathComponent 84 let file = (template.target.file as NSString).lastPathComponent
84 switch template.target { 85 switch template.target {
85 case .file: return file 86 case .file, .heading: return file
86 case .headline(_, let headline): return "\(file) / \(headline)" 87 case .headline(_, let headline): return "\(file) / \(headline)"
87 case .outline(_, let path): return ([file] + path).joined(separator: " / ") 88 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"
88 } 92 }
89 } 93 }
90 94
@@ -119,6 +123,10 @@ struct CaptureView: View {
119 self.template = nil 123 self.template = nil
120 case .success(let filled): 124 case .success(let filled):
121 text = filled.text 125 text = filled.text
126 if template.options.immediateFinish {
127 finish()
128 return
129 }
122 let index = String.Index(utf16Offset: filled.cursor, in: text) 130 let index = String.Index(utf16Offset: filled.cursor, in: text)
123 selection = TextSelection(insertionPoint: index) 131 selection = TextSelection(insertionPoint: index)
124 editing = true 132 editing = true
@@ -126,13 +134,50 @@ struct CaptureView: View {
126 } 134 }
127 } 135 }
128 136
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
129 private func finish() { 153 private func finish() {
130 guard let template else { return } 154 guard let template else { return }
131 if let message = session.capture(text, template: template, root: workspace.rootPaths.first) { 155 let opened = context.now
132 problem = message 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 }
133 return 164 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) }
134 } 180 }
135 commands.show("Captured to \(target(template))")
136 close() 181 close()
137 } 182 }
138 183
Sources/Orgstar/OrgstarApp.swift +1 −1
@@ -270,7 +270,7 @@ struct OrgstarApp: App {
270 .defaultSize(width: 860, height: 560) 270 .defaultSize(width: 860, height: 560)
271 Window("Capture", id: "capture") { 271 Window("Capture", id: "capture") {
272 if case .success(let model) = workspace { 272 if case .success(let model) = workspace {
273 CaptureView(workspace: model, session: session, commands: commands) 273 CaptureView(workspace: model, session: session, commands: commands, clock: clock)
274 } 274 }
275 } 275 }
276 .windowResizability(.contentSize) 276 .windowResizability(.contentSize)
Tests/OrgAppTests/AppTests.swift +39 −3
@@ -626,6 +626,35 @@ struct CaptureAppTests {
626 #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"])) 626 #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"]))
627 #expect(loaded.templates[0].template == "* TODO %?\n%a") 627 #expect(loaded.templates[0].template == "* TODO %?\n%a")
628 #expect(loaded.templates[1].kind == .plain && loaded.templates[1].prepend) 628 #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)
629 #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults) 658 #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults)
630 } 659 }
631 660
@@ -634,15 +663,22 @@ struct CaptureAppTests {
634 let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n") 663 let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n")
635 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) 664 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
636 let template = CaptureTemplates.defaults[0] 665 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()
638 #expect(try String(contentsOf: todo, encoding: .utf8) == "* Inbox\n** TODO Old\n** TODO New\n* Later\n") 667 #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)
639 669
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()
641 #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n") 671 #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n")
642 672
643 session.open(todo.path) 673 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()
645 #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n") 675 #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")
646 } 682 }
647} 683}
648 684
Tests/OrgAppTests/EmacsImportTests.swift +11 −7
@@ -22,7 +22,9 @@ struct EmacsImportTests {
22 (setq fill-column (+ 70 2)) 22 (setq fill-column (+ 70 2))
23 (setq org-capture-templates 23 (setq org-capture-templates
24 '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a") 24 '(("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) "* %?")
26 ("w" "Work") 28 ("w" "Work")
27 ("wn" "Work note" plain (file "~/work/notes.org") "%?" :prepend t))) 29 ("wn" "Work note" plain (file "~/work/notes.org") "%?" :prepend t)))
28 (map! :leader :desc "Report" "z t" #'tr/report 30 (map! :leader :desc "Report" "z t" #'tr/report
@@ -81,7 +83,7 @@ struct EmacsImportTests {
81 #expect(folders == ["~/notes/", "~/work"]) 83 #expect(folders == ["~/notes/", "~/work"])
82 #expect(result.unreadable.map(\.name) == ["fill-column"]) 84 #expect(result.unreadable.map(\.name) == ["fill-column"])
83 #expect(Set(result.unsupported.map(\.name)) == ["org-support-shift-select", "doom-theme", "org-roam-directory", 85 #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"])
85 #expect(result.skipped == 1) 87 #expect(result.skipped == 1)
86 #expect(result.doomDefaultsLeftOut == 1) 88 #expect(result.doomDefaultsLeftOut == 1)
87 } 89 }
@@ -89,10 +91,12 @@ struct EmacsImportTests {
89 @Test func readsCaptureTemplatesAndBindings() throws { 91 @Test func readsCaptureTemplatesAndBindings() throws {
90 let result = try scan() 92 let result = try scan()
91 let captures = result.items.compactMap { if case .capture(let key, let toml) = $0.change { (key, toml) } else { nil } } 93 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" })
93 #expect(captures[0].1 == "[[template]]\nkey = \"t\"\nname = \"Todo\"\ntype = \"entry\"\nfile = \"~/notes/todo.org\"\nheadline = \"Inbox\"\ntemplate = \"* TODO %?\\n%a\"\n") 98 #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\"")) 99 #expect(captures[3].1.contains("prepend = true\n") && captures[3].1.contains("type = \"plain\""))
95 #expect(result.unsupported.contains { $0.name == "capture \"j\"" })
96 100
97 let bindings = result.items.compactMap { if case .binding(let keys, let command, let mode) = $0.change { "\(keys) \(command) \(mode ?? "-")" } else { nil } } 101 let bindings = result.items.compactMap { if case .binding(let keys, let command, let mode) = $0.change { "\(keys) \(command) \(mode ?? "-")" } else { nil } }
98 #expect(bindings == ["SPC m x org.checkbox.toggle normal", "g s app.save normal", "C-c a app.agenda normal"]) 102 #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 {
131 #expect(defaults.integer(forKey: "agendaStartOffset") == -3) 135 #expect(defaults.integer(forKey: "agendaStartOffset") == -3)
132 #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)\nBUG | FIXED") 136 #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)\nBUG | FIXED")
133 let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8) 137 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)
135 #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty) 139 #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty)
136 let keymap = folder.appendingPathComponent("keymap.toml") 140 let keymap = folder.appendingPathComponent("keymap.toml")
137 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) 141 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
138 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) 142 #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)")
140 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") 144 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
141 #expect(defaults.integer(forKey: "themeFontSize") == 14) 145 #expect(defaults.integer(forKey: "themeFontSize") == 14)
142 } 146 }
Tests/OrgCoreTests/CaptureTests.swift +91 −4
@@ -23,14 +23,33 @@ struct CaptureTests {
23 let path = folder.appendingPathComponent("target\(i).org").path 23 let path = folder.appendingPathComponent("target\(i).org").path
24 try c.file.write(toFile: path, atomically: true, encoding: .utf8) 24 try c.file.write(toFile: path, atomically: true, encoding: .utf8)
25 let target: String 25 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)" }
26 switch c.template.target { 31 switch c.template.target {
27 case .file: target = "(file \(lisp(path)))" 32 case .file: target = "(file \(lisp(path)))"
28 case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))" 33 case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))"
29 case .outline(_, let p): target = "(file+olp \(lisp(path)) \(p.map(lisp).joined(separator: " ")))" 34 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)"
30 } 40 }
31 forms.append(""" 41 forms.append("""
32 (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(c.template.prepend ? " :prepend t" : ""))))) 42 (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(properties))))
33 (org-capture nil "x") 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"))
34 (insert \(lisp(c.typed))) 53 (insert \(lisp(c.typed)))
35 (org-capture-finalize) 54 (org-capture-finalize)
36 (with-current-buffer (find-buffer-visiting \(lisp(path))) 55 (with-current-buffer (find-buffer-visiting \(lisp(path)))
@@ -43,24 +62,28 @@ struct CaptureTests {
43 ;;; -*- lexical-binding: t -*- 62 ;;; -*- lexical-binding: t -*-
44 (require 'org) 63 (require 'org)
45 (require 'org-capture) 64 (require 'org-capture)
65 (require 'org-id)
46 (require 'json) 66 (require 'json)
47 (setq org-adapt-indentation nil) 67 (setq org-adapt-indentation nil)
48 (defvar results nil) 68 (defvar results nil)
49 \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")")) 69 \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")"))
50 (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results))))) 70 (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results)))))
51 """.write(to: script, atomically: true, encoding: .utf8) 71 """.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) }
53 return try JSONDecoder().decode([String].self, from: Data(contentsOf: output)) 74 return try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
54 } 75 }
55 76
56 static func ours(_ c: Case, now: Date) -> String { 77 static func ours(_ c: Case, now: Date) -> String {
78 var utcCalendar = Calendar(identifier: .gregorian)
79 utcCalendar.timeZone = TimeZone(identifier: "UTC")!
57 var utc = Calendar(identifier: .gregorian) 80 var utc = Calendar(identifier: .gregorian)
58 utc.timeZone = TimeZone(identifier: "UTC")! 81 utc.timeZone = TimeZone(identifier: "UTC")!
59 guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" } 82 guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" }
60 var text = filled.text 83 var text = filled.text
61 let index = String.Index(utf16Offset: filled.cursor, in: text) 84 let index = String.Index(utf16Offset: filled.cursor, in: text)
62 text.insert(contentsOf: c.typed, at: index) 85 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) {
64 case .success(let result): return result 87 case .success(let result): return result
65 case .failure(.message(let message)): return "failed: \(message)" 88 case .failure(.message(let message)): return "failed: \(message)"
66 } 89 }
@@ -112,6 +135,70 @@ struct CaptureTests {
112 } 135 }
113 } 136 }
114 137
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
115 @Test func fillsPrompts() { 202 @Test func fillsPrompts() {
116 let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again" 203 let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again"
117 #expect(Capture.questions(template) == [ 204 #expect(Capture.questions(template) == [