Date prompt calendar, reschedule logging, ranges, C-c C-y !82

merged merged by cmc on 2026-10-06 03:41 UTC · krz/orgstar:date-picker into main

14 files changed, +384 −25

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +1 −1
@@ -469,7 +469,7 @@ public struct EmacsImport: Sendable {
469 if ["agenda", "todo", "tags"].contains(view) { add("org-agenda-prefix-format.\(view)", .string(s)) } 469 if ["agenda", "todo", "tags"].contains(view) { add("org-agenda-prefix-format.\(view)", .string(s)) }
470 } 470 }
471 } 471 }
472 case "org-log-done": 472 case "org-log-done", "org-log-reschedule", "org-log-redeadline":
473 switch value.symbol { 473 switch value.symbol {
474 case "nil": add(name, .string("nil")) 474 case "nil": add(name, .string("nil"))
475 case "time", "t": add(name, .string("time")) 475 case "time", "t": add(name, .string("time"))
Sources/OrgApp/UserConfig.swift +4
@@ -76,6 +76,10 @@ public enum UserConfig {
76 comment: "for files without #+TODO, in #+TODO syntax, one sequence per line (\\n); keys in parentheses turn on fast selection; read at launch", legacy: "editor.todo-keywords"), 76 comment: "for files without #+TODO, in #+TODO syntax, one sequence per line (\\n); keys in parentheses turn on fast selection; read at launch", legacy: "editor.todo-keywords"),
77 Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]), 77 Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
78 fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""), 78 fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""),
79 Setting(section: "", key: "org-log-reschedule", defaultsKey: "logReschedule", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
80 fallback: .string("nil"), comment: "nil, time or note: log changing or removing a SCHEDULED date", legacy: ""),
81 Setting(section: "", key: "org-log-redeadline", defaultsKey: "logRedeadline", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
82 fallback: .string("nil"), comment: "nil, time or note: log changing or removing a DEADLINE", legacy: ""),
79 Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""), 83 Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""),
80 comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""), 84 comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""),
81 Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true), 85 Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true),
Sources/OrgCore/Commands/Command.swift +20 −4
@@ -17,6 +17,9 @@ public struct EditingOptions: Sendable, Equatable, Codable {
17 public var logDone: LogHow? 17 public var logDone: LogHow?
18 /// `org-log-into-drawer`: the drawer state notes go in, or nil for none. 18 /// `org-log-into-drawer`: the drawer state notes go in, or nil for none.
19 public var logIntoDrawer: String? 19 public var logIntoDrawer: String?
20 /// `org-log-reschedule` and `org-log-redeadline`: a note when a date changes or goes.
21 public var logReschedule: LogHow?
22 public var logRedeadline: LogHow?
20 /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one. 23 /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one.
21 public var fastTodoSelection: Bool 24 public var fastTodoSelection: Bool
22 /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. 25 /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens.
@@ -37,8 +40,10 @@ public struct EditingOptions: Sendable, Equatable, Codable {
37 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, 40 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
38 hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false, 41 hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false,
39 logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true, 42 logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true,
40 hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false 43 hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil
41 ) { 44 ) {
45 self.logReschedule = logReschedule
46 self.logRedeadline = logRedeadline
42 self.hideDrawerStartup = hideDrawerStartup 47 self.hideDrawerStartup = hideDrawerStartup
43 self.hideBlockStartup = hideBlockStartup 48 self.hideBlockStartup = hideBlockStartup
44 self.fastTodoSelection = fastTodoSelection 49 self.fastTodoSelection = fastTodoSelection
@@ -78,12 +83,15 @@ public struct EditContext: Sendable {
78 public let options: EditingOptions 83 public let options: EditingOptions
79 /// Tags used across the workspace, for completion; read when a command needs them. 84 /// Tags used across the workspace, for completion; read when a command needs them.
80 public let knownTags: @Sendable () -> [String] 85 public let knownTags: @Sendable () -> [String]
86 /// The command run just before, with nothing typed since (`last-command`).
87 public let lastCommand: String?
81 88
82 public init( 89 public init(
83 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], 90 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
84 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [], 91 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [],
85 options: EditingOptions = .org, knownTags: @escaping @Sendable () -> [String] = { [] } 92 options: EditingOptions = .org, knownTags: @escaping @Sendable () -> [String] = { [] }, lastCommand: String? = nil
86 ) { 93 ) {
94 self.lastCommand = lastCommand
87 self.knownTags = knownTags 95 self.knownTags = knownTags
88 self.revision = revision 96 self.revision = revision
89 self.text = text 97 self.text = text
@@ -112,10 +120,17 @@ public struct Prompt: Sendable, Equatable {
112 public let completionSeparator: Character? 120 public let completionSeparator: Character?
113 /// Single keys answer instead of text. 121 /// Single keys answer instead of text.
114 public let fast: FastSelection? 122 public let fast: FastSelection?
123 /// A date prompt, read by `org-read-date` against this default (nil: today).
124 public let date: DateRead?
125
126 public struct DateRead: Sendable, Equatable {
127 public let defaultDate: Date?
128 public init(defaultDate: Date?) { self.defaultDate = defaultDate }
129 }
115 130
116 public init( 131 public init(
117 key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true, 132 key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true,
118 completionSeparator: Character? = nil, fast: FastSelection? = nil 133 completionSeparator: Character? = nil, fast: FastSelection? = nil, date: DateRead? = nil
119 ) { 134 ) {
120 self.key = key 135 self.key = key
121 self.message = message 136 self.message = message
@@ -124,6 +139,7 @@ public struct Prompt: Sendable, Equatable {
124 self.requireMatch = requireMatch 139 self.requireMatch = requireMatch
125 self.completionSeparator = completionSeparator 140 self.completionSeparator = completionSeparator
126 self.fast = fast 141 self.fast = fast
142 self.date = date
127 } 143 }
128} 144}
129 145
@@ -216,7 +232,7 @@ public enum Commands {
216 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 232 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
217 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), 233 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
218 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), 234 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
219 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), 235 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(),
220 ] 236 ]
221 237
222 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } 238 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/TimestampCommands.swift +74 −12
@@ -4,15 +4,12 @@ extension EmacsBuffer {
4 /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the 4 /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the
5 /// default date. 5 /// default date.
6 func readDate(_ answer: String, default defaultDate: Date?) -> ReadDate.Answer { 6 func readDate(_ answer: String, default defaultDate: Date?) -> ReadDate.Answer {
7 let def = defaultDate ?? now 7 ReadDate.read(answer, default: defaultDate, now: now, calendar: calendar)
8 var input = answer
9 if input.trimmingCharacters(in: .whitespaces).isEmpty {
10 let c = calendar.dateComponents([.year, .month, .day], from: def)
11 input = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
12 }
13 return ReadDate.analyze(input, default: def, now: now, calendar: calendar)
14 } 8 }
15 9
10 /// The date an Org time string stands for, as a prompt default.
11 func dateDefault(_ stamp: String?) -> Date? { stamp.flatMap(Self.parseTimeString).map(date) }
12
16 func date(_ components: DateComponents) -> Date { calendar.date(from: components)! } 13 func date(_ components: DateComponents) -> Date { calendar.date(from: components)! }
17 14
18 /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one. 15 /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one.
@@ -45,7 +42,7 @@ extension EmacsBuffer {
45 } 42 }
46 43
47 /// `org--deadline-or-schedule` without a prefix argument. 44 /// `org--deadline-or-schedule` without a prefix argument.
48 func schedule(_ kind: PlanningKind, answer: String) throws { 45 func schedule(_ kind: PlanningKind, answer: String, answers: [String: String] = [:]) throws {
49 let old = planningValue(kind) 46 let old = planningValue(kind)
50 let repeatPattern = "([.+-]+[0-9]+[hdwmy](?:[/ ][-+]?[0-9]+[hdwmy])?)" 47 let repeatPattern = "([.+-]+[0-9]+[hdwmy](?:[/ ][-+]?[0-9]+[hdwmy])?)"
51 let repeater = old.flatMap { $0.firstMatch(of: try! Regex(repeatPattern)).map { String($0.output[1].substring!) } } 48 let repeater = old.flatMap { $0.firstMatch(of: try! Regex(repeatPattern)).map { String($0.output[1].substring!) } }
@@ -62,6 +59,9 @@ extension EmacsBuffer {
62 } 59 }
63 } 60 }
64 } 61 }
62 if let old, let inserted = lastInsertedTimestamp {
63 try logPlanningChange(kind, old: "<" + old + ">", new: inserted, answers: answers)
64 }
65 } 65 }
66 66
67 /// `org--deadline-or-schedule` with C-u: removes the planning timestamp. 67 /// `org--deadline-or-schedule` with C-u: removes the planning timestamp.
@@ -108,7 +108,21 @@ public struct InsertTimestamp: OrgCommand {
108 public func applies(in context: EditContext) -> Bool { true } 108 public func applies(in context: EditContext) -> Bool { true }
109 public func run(in context: EditContext) -> CommandStep { 109 public func run(in context: EditContext) -> CommandStep {
110 guard let answer = context.answers["date"] else { 110 guard let answer = context.answers["date"] else {
111 return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context))) 111 let buffer = EmacsBuffer(context.text, point: context.caret)
112 buffer.calendar = context.calendar
113 let stamp = buffer.atTimestamp().map { buffer.substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
114 let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive"
115 return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context),
116 date: .init(defaultDate: repeated ? nil : buffer.dateDefault(stamp))))
117 }
118 // Twice in a row on a timestamp: a range to a second one.
119 let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive"
120 if repeated, EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil {
121 return timestampCommand(context) { buffer in
122 let result = buffer.readDate(answer, default: nil)
123 buffer.insert("--")
124 buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive)
125 }
112 } 126 }
113 return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) } 127 return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) }
114 } 128 }
@@ -126,10 +140,12 @@ public struct SetPlanning: OrgCommand {
126 let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled 140 let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled
127 guard let answer = context.answers["date"] else { 141 guard let answer = context.answers["date"] else {
128 let buffer = EmacsBuffer(context.text, point: context.caret) 142 let buffer = EmacsBuffer(context.text, point: context.caret)
143 buffer.calendar = context.calendar
129 let initial = buffer.planningDefault(kind).flatMap(EmacsBuffer.compactTimeOfDay) ?? "" 144 let initial = buffer.planningDefault(kind).flatMap(EmacsBuffer.compactTimeOfDay) ?? ""
130 return .prompt(Prompt(key: "date", message: deadline ? "DEADLINE:" : "SCHEDULED:", initial: initial)) 145 return .prompt(Prompt(key: "date", message: deadline ? "DEADLINE:" : "SCHEDULED:", initial: initial,
146 date: .init(defaultDate: buffer.dateDefault(buffer.planningDefault(kind)))))
131 } 147 }
132 return timestampCommand(context) { try $0.schedule(kind, answer: answer) } 148 return timestampCommand(context) { try $0.schedule(kind, answer: answer, answers: context.answers) }
133 } 149 }
134} 150}
135 151
@@ -141,7 +157,12 @@ public struct RemovePlanning: OrgCommand {
141 public var title: String { deadline ? "Remove Deadline" : "Remove Schedule" } 157 public var title: String { deadline ? "Remove Deadline" : "Remove Schedule" }
142 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } 158 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
143 public func run(in context: EditContext) -> CommandStep { 159 public func run(in context: EditContext) -> CommandStep {
144 timestampCommand(context) { $0.removePlanning(deadline ? .deadline : .scheduled) } 160 timestampCommand(context) { buffer in
161 let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled
162 let old = buffer.planningValue(kind)
163 if let old { try buffer.logPlanningChange(kind, old: "<" + old + ">", new: nil, answers: context.answers) }
164 buffer.removePlanning(kind)
165 }
145 } 166 }
146} 167}
147 168
@@ -168,3 +189,44 @@ public struct ShiftTimestampDay: OrgCommand {
168 timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) } 189 timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) }
169 } 190 }
170} 191}
192
193/// C-c C-y: `org-evaluate-time-range`. On a clock line, updates it; otherwise says how long the
194/// range at the caret, or the first on its line, lasts.
195public struct EvaluateTimeRange: OrgCommand {
196 public init() {}
197 public var id: String { "org.timestamp.evaluate-range" }
198 public var title: String { "Evaluate Time Range" }
199 public func applies(in context: EditContext) -> Bool { true }
200 public func run(in context: EditContext) -> CommandStep {
201 let buffer = EmacsBuffer(context.text, point: context.caret)
202 buffer.calendar = context.calendar
203 if buffer.lookingAtLine("^[ \\t]*CLOCK:") { return CtrlCCtrlC().run(in: context) }
204 let ns = context.text as NSString
205 let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0))
206 let regex = try! NSRegularExpression(pattern: "[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*?)[\\]>]--?-?[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*?)[\\]>]")
207 let matches = regex.matches(in: context.text, range: line)
208 guard let m = matches.first(where: { $0.range.location <= context.caret && context.caret <= NSMaxRange($0.range) }) ?? matches.first else {
209 return .failed("Not at a timestamp range, and none found in current line")
210 }
211 let ts1 = ns.substring(with: m.range(at: 1))
212 let ts2 = ns.substring(with: m.range(at: 2))
213 let haveTime = ts1.count > 15 || ts2.count > 15
214 let t1 = TagsMatcher.seconds(ts1, now: context.now, calendar: context.calendar)
215 let t2 = TagsMatcher.seconds(ts2, now: context.now, calendar: context.calendar)
216 var diff = abs(t2 - t1)
217 var d = 0, h = 0, minutes = 0
218 if haveTime {
219 d = Int(floor(diff / 86400)); diff = diff.truncatingRemainder(dividingBy: 86400)
220 h = Int(floor(diff / 3600)); diff = diff.truncatingRemainder(dividingBy: 3600)
221 minutes = Int(floor(diff / 60))
222 } else {
223 d = Int((diff / 86400).rounded())
224 }
225 var parts: [String] = []
226 if d > 0 { parts.append("\(d) day" + (d > 1 ? "s" : "")) }
227 if h > 0 { parts.append("\(h) hour" + (h > 1 ? "s" : "")) }
228 if minutes > 0 { parts.append("\(minutes) minute" + (minutes > 1 ? "s" : "")) }
229 let text = parts.isEmpty ? "" : parts.joined(separator: " ") + " "
230 return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.message(text)]))
231 }
232}
Sources/OrgCore/Commands/TodoLogging.swift +50 −2
@@ -14,6 +14,9 @@ public enum LogHow: String, Sendable, Equatable, Codable {
14struct LogSettings { 14struct LogSettings {
15 var done: LogHow? 15 var done: LogHow?
16 var repeatHow: LogHow? 16 var repeatHow: LogHow?
17 /// `org-log-reschedule` and `org-log-redeadline`.
18 var reschedule: LogHow?
19 var redeadline: LogHow?
17 var drawer: String? 20 var drawer: String?
18 /// `org-todo-log-states`: keyword → (on entering, on leaving). 21 /// `org-todo-log-states`: keyword → (on entering, on leaving).
19 var states: [String: (enter: LogHow?, leave: LogHow?)] = [:] 22 var states: [String: (enter: LogHow?, leave: LogHow?)] = [:]
@@ -35,6 +38,12 @@ struct LogSettings {
35 case "logrepeat": repeatHow = .time 38 case "logrepeat": repeatHow = .time
36 case "lognoterepeat": repeatHow = .note 39 case "lognoterepeat": repeatHow = .note
37 case "nologrepeat": repeatHow = nil 40 case "nologrepeat": repeatHow = nil
41 case "logreschedule": reschedule = .time
42 case "lognotereschedule": reschedule = .note
43 case "nologreschedule": reschedule = nil
44 case "logredeadline": redeadline = .time
45 case "lognoteredeadline": redeadline = .note
46 case "nologredeadline": redeadline = nil
38 case "logdrawer": drawer = "LOGBOOK" 47 case "logdrawer": drawer = "LOGBOOK"
39 case "nologdrawer": drawer = nil 48 case "nologdrawer": drawer = nil
40 default: break 49 default: break
@@ -53,7 +62,7 @@ struct LogSettings {
53 62
54/// A note `org-add-log-setup` asked for, stored when the command ends. 63/// A note `org-add-log-setup` asked for, stored when the command ends.
55struct LogSetup { 64struct LogSetup {
56 enum Purpose { case done, state } 65 enum Purpose { case done, state, reschedule, delschedule, redeadline, deldeadline }
57 var purpose: Purpose 66 var purpose: Purpose
58 var state: String? 67 var state: String?
59 var previous: String? 68 var previous: String?
@@ -64,7 +73,8 @@ struct LogSetup {
64extension EmacsBuffer { 73extension EmacsBuffer {
65 /// The logging settings for the entry at point. 74 /// The logging settings for the entry at point.
66 func logSettings() -> LogSettings { 75 func logSettings() -> LogSettings {
67 var log = LogSettings(done: options.logDone, repeatHow: options.logRepeat ? .time : nil, drawer: options.logIntoDrawer) 76 var log = LogSettings(done: options.logDone, repeatHow: options.logRepeat ? .time : nil,
77 reschedule: options.logReschedule, redeadline: options.logRedeadline, drawer: options.logIntoDrawer)
68 for sequence in settings.todoSequences { 78 for sequence in settings.todoSequences {
69 for keyword in sequence.active + sequence.done { 79 for keyword in sequence.active + sequence.done {
70 let enter = LogSettings.how(keyword.logOnEnter) 80 let enter = LogSettings.how(keyword.logOnEnter)
@@ -123,6 +133,10 @@ extension EmacsBuffer {
123 let about = switch setup.purpose { 133 let about = switch setup.purpose {
124 case .done: "closed todo item" 134 case .done: "closed todo item"
125 case .state: "state change from \"\(setup.previous ?? "")\" to \"\(setup.state ?? "")\"" 135 case .state: "state change from \"\(setup.previous ?? "")\" to \"\(setup.state ?? "")\""
136 case .reschedule: "rescheduling"
137 case .delschedule: "no longer scheduled"
138 case .redeadline: "changing deadline"
139 case .deldeadline: "removing deadline"
126 } 140 }
127 return Prompt(key: "note", message: "Note for \(about):") 141 return Prompt(key: "note", message: "Note for \(about):")
128 } 142 }
@@ -137,6 +151,10 @@ extension EmacsBuffer {
137 "State " + quoted(setup.state).padding(toLength: max(12, quoted(setup.state).count), withPad: " ", startingAt: 0) 151 "State " + quoted(setup.state).padding(toLength: max(12, quoted(setup.state).count), withPad: " ", startingAt: 0)
138 + " from " + quoted(setup.previous).padding(toLength: max(12, quoted(setup.previous).count), withPad: " ", startingAt: 0) 152 + " from " + quoted(setup.previous).padding(toLength: max(12, quoted(setup.previous).count), withPad: " ", startingAt: 0)
139 + " " + stamp 153 + " " + stamp
154 case .reschedule: "Rescheduled from \(Self.quotedState(setup.previous)) on \(stamp)"
155 case .delschedule: "Not scheduled, was \(Self.quotedState(setup.previous)) on \(stamp)"
156 case .redeadline: "New deadline from \(Self.quotedState(setup.previous)) on \(stamp)"
157 case .deldeadline: "Removed deadline, was \(Self.quotedState(setup.previous)) on \(stamp)"
140 } 158 }
141 var trimmed = text 159 var trimmed = text
142 while let range = trimmed.range(of: "^# .*\\n[ \\t\\n]*", options: .regularExpression) { trimmed.removeSubrange(range) } 160 while let range = trimmed.range(of: "^# .*\\n[ \\t\\n]*", options: .regularExpression) { trimmed.removeSubrange(range) }
@@ -187,4 +205,34 @@ extension EmacsBuffer {
187 storeLog(setup, text: "") 205 storeLog(setup, text: "")
188 } 206 }
189 } 207 }
208
209 /// `%S` in `org-log-note-headings`: a timestamp as an inactive one, in quotes.
210 static func quotedState(_ s: String?) -> String {
211 guard let s else { return "" }
212 if s.range(of: "^[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}", options: .regularExpression) != nil {
213 return "\"[" + s.dropFirst().dropLast() + "]\""
214 }
215 return "\"" + s + "\""
216 }
217
218 /// The note `org--deadline-or-schedule` logs when a planning date changes or goes, with
219 /// `org-log-reschedule` and `org-log-redeadline`; asks for its text with `answers`.
220 func logPlanningChange(_ kind: PlanningKind, old: String, new: String?, answers: [String: String]) throws {
221 let log = logSettings()
222 guard let how = kind == .deadline ? log.redeadline : log.reschedule else { return }
223 if let new, new == old { return }
224 let purpose: LogSetup.Purpose = switch (kind == .deadline, new == nil) {
225 case (true, true): .deldeadline
226 case (true, false): .redeadline
227 case (false, true): .delschedule
228 case (false, false): .reschedule
229 }
230 let setup = LogSetup(purpose: purpose, state: new, previous: old, how: how, drawer: log.drawer)
231 if how == .note {
232 guard let note = answers["note"] else { throw Ask(prompt: Self.notePrompt(setup)) }
233 storeLog(setup, text: note)
234 } else {
235 storeLog(setup, text: "")
236 }
237 }
190} 238}
Sources/OrgCore/Keymap/Presets.swift +3
@@ -638,6 +638,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
638 keys = "C-c C-c" 638 keys = "C-c C-c"
639 command = "org.ctrl-c-ctrl-c" 639 command = "org.ctrl-c-ctrl-c"
640 [[bind]] 640 [[bind]]
641 keys = "C-c C-y"
642 command = "org.timestamp.evaluate-range"
643 [[bind]]
641 keys = "C-c C-x C-r" 644 keys = "C-c C-x C-r"
642 command = "org.clock.report" 645 command = "org.clock.report"
643 [[bind]] 646 [[bind]]
Sources/OrgCore/Time/ReadDate.swift +11
@@ -165,6 +165,17 @@ public enum ReadDate {
165 return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match) 165 return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match)
166 } 166 }
167 167
168 /// `org-read-date`'s reading of a prompt answer: an empty one takes the default date.
169 public static func read(_ answer: String, default defaultDate: Date?, now: Date, calendar: Calendar) -> Answer {
170 let def = defaultDate ?? now
171 var input = answer
172 if input.trimmingCharacters(in: .whitespaces).isEmpty {
173 let c = calendar.dateComponents([.year, .month, .day], from: def)
174 input = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
175 }
176 return analyze(input, default: def, now: now, calendar: calendar)
177 }
178
168 /// `org-read-date-analyze` with `org-read-date-prefer-future` t. 179 /// `org-read-date-analyze` with `org-read-date-prefer-future` t.
169 public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer { 180 public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer {
170 let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday] 181 let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday]
Sources/OrgEditorAppKit/OrgEditor.swift +6 −1
@@ -325,7 +325,8 @@ public final class OrgEditor: NSObject {
325 return EditContext( 325 return EditContext(
326 revision: document.revision, text: document.text, tree: document.tree, 326 revision: document.revision, text: document.text, tree: document.tree,
327 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers, 327 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers,
328 hidden: hidden.all, options: options, knownTags: knownTags 328 hidden: hidden.all, options: options, knownTags: knownTags,
329 lastCommand: lastCommand.flatMap { $0.revision == document.revision ? $0.id : nil }
329 ) 330 )
330 } 331 }
331 332
@@ -375,8 +376,12 @@ public final class OrgEditor: NSObject {
375 } 376 }
376 } 377 }
377 378
379 /// The last command run and the revision it left, for `last-command`.
380 private var lastCommand: (id: String, revision: Int)?
381
378 /// Runs a command by id, as a key, a menu item or the palette would. 382 /// Runs a command by id, as a key, a menu item or the palette would.
379 public func run(_ id: String) { 383 public func run(_ id: String) {
384 defer { lastCommand = (id, document.revision) }
380 guard let command = EditorCommands.command(id) else { 385 guard let command = EditorCommands.command(id) else {
381 onMessage?("Unknown command \(id)") 386 onMessage?("Unknown command \(id)")
382 return 387 return
Sources/Orgstar/ContentView.swift +3 −1
@@ -36,6 +36,8 @@ struct ContentView: View {
36 @AppStorage("prettyEntities") private var prettyEntities = true 36 @AppStorage("prettyEntities") private var prettyEntities = true
37 @AppStorage("logDone") private var logDone = "nil" 37 @AppStorage("logDone") private var logDone = "nil"
38 @AppStorage("logIntoDrawer") private var logIntoDrawer = "" 38 @AppStorage("logIntoDrawer") private var logIntoDrawer = ""
39 @AppStorage("logReschedule") private var logReschedule = "nil"
40 @AppStorage("logRedeadline") private var logRedeadline = "nil"
39 @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true 41 @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true
40 @AppStorage("hideBlockStartup") private var hideBlockStartup = false 42 @AppStorage("hideBlockStartup") private var hideBlockStartup = false
41 43
@@ -43,7 +45,7 @@ struct ContentView: View {
43 Preferences.options( 45 Preferences.options(
44 tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists, 46 tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists,
45 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer, 47 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer,
46 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup 48 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline
47 ) 49 )
48 } 50 }
49 /// A block being edited apart (`org-edit-special`). 51 /// A block being edited apart (`org-edit-special`).
Sources/Orgstar/DatePickerView.swift added +112
@@ -0,0 +1,112 @@
1import OrgCore
2import SwiftUI
3
4/// What `org-read-date` shows beside its prompt: the date the answer reads as, and a month
5/// calendar to pick one by clicking.
6struct DatePickerView: View {
7 let answer: String
8 let read: Prompt.DateRead
9 let pick: (String) -> Void
10 @State private var month: Date?
11 private let calendar = Calendar.current
12
13 var body: some View {
14 let result = Self.result(answer, read)
15 let selected = calendar.date(from: result.components(calendar))!
16 let shown = month ?? selected
17 VStack(alignment: .leading, spacing: 6) {
18 HStack {
19 Button { month = calendar.date(byAdding: .month, value: -1, to: shown) } label: { Image(systemName: "chevron.left") }
20 .accessibilityLabel("Previous month")
21 Text(shown.formatted(.dateTime.month(.wide).year())).frame(minWidth: 140)
22 Button { month = calendar.date(byAdding: .month, value: 1, to: shown) } label: { Image(systemName: "chevron.right") }
23 .accessibilityLabel("Next month")
24 Spacer()
25 Text("=> " + Self.stamp(result, calendar)).font(.system(.callout, design: .monospaced))
26 }
27 .buttonStyle(.plain)
28 Grid(horizontalSpacing: 4, verticalSpacing: 2) {
29 GridRow {
30 ForEach(weekdaySymbols.indices, id: \.self) { Text(weekdaySymbols[$0]).foregroundStyle(.secondary) }
31 }
32 ForEach(weeks(of: shown), id: \.self) { week in
33 GridRow {
34 ForEach(week.indices, id: \.self) { i in
35 if let day = week[i] {
36 dayButton(day, selected: calendar.isDate(day, inSameDayAs: selected), result: result)
37 } else {
38 Color.clear.frame(width: 1, height: 1)
39 }
40 }
41 }
42 }
43 }
44 .font(.system(.callout, design: .monospaced))
45 }
46 .padding(10)
47 .onChange(of: selected) { month = nil }
48 }
49
50 private func dayButton(_ day: Date, selected: Bool, result: ReadDate.Answer) -> some View {
51 Button {
52 pick(Self.answer(for: day, keeping: result, calendar))
53 } label: {
54 Text("\(calendar.component(.day, from: day))")
55 .frame(width: 26, height: 20)
56 .background(selected ? Color.accentColor.opacity(0.35) : .clear, in: RoundedRectangle(cornerRadius: 4))
57 .overlay {
58 if calendar.isDateInToday(day) { RoundedRectangle(cornerRadius: 4).stroke(Color.accentColor) }
59 }
60 }
61 .buttonStyle(.plain)
62 .accessibilityLabel(day.formatted(date: .complete, time: .omitted))
63 }
64
65 private var weekdaySymbols: [String] {
66 let symbols = calendar.veryShortWeekdaySymbols
67 let first = calendar.firstWeekday - 1
68 return Array(symbols[first...] + symbols[..<first])
69 }
70
71 /// The month's days in weeks, nil before the first and after the last.
72 private func weeks(of date: Date) -> [[Date?]] {
73 let start = calendar.date(from: calendar.dateComponents([.year, .month], from: date))!
74 let count = calendar.range(of: .day, in: .month, for: start)!.count
75 let lead = (calendar.component(.weekday, from: start) - calendar.firstWeekday + 7) % 7
76 var days: [Date?] = Array(repeating: nil, count: lead)
77 days += (0..<count).map { calendar.date(byAdding: .day, value: $0, to: start) }
78 days += Array(repeating: nil, count: (7 - days.count % 7) % 7)
79 return stride(from: 0, to: days.count, by: 7).map { Array(days[$0..<$0 + 7]) }
80 }
81
82 static func result(_ answer: String, _ read: Prompt.DateRead) -> ReadDate.Answer {
83 ReadDate.read(answer, default: read.defaultDate, now: Date(), calendar: .current)
84 }
85
86 /// The answer for `day`, with the time `result` had.
87 static func answer(for day: Date, keeping result: ReadDate.Answer, _ calendar: Calendar) -> String {
88 let c = calendar.dateComponents([.year, .month, .day], from: day)
89 var text = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
90 if result.timeGiven {
91 text += String(format: " %02d:%02d", result.hour, result.minute)
92 if let end = result.endTime { text += "-" + end }
93 }
94 return text
95 }
96
97 /// The answer moved by `days`, as S-arrows move `org-read-date`'s calendar.
98 static func shifted(_ answer: String, _ read: Prompt.DateRead, by days: Int) -> String {
99 let calendar = Calendar.current
100 let result = result(answer, read)
101 let day = calendar.date(byAdding: .day, value: days, to: calendar.date(from: result.components(calendar))!)!
102 return Self.answer(for: day, keeping: result, calendar)
103 }
104
105 static func stamp(_ result: ReadDate.Answer, _ calendar: Calendar) -> String {
106 let formatter = DateFormatter()
107 formatter.locale = Locale(identifier: "en_US_POSIX")
108 formatter.dateFormat = result.timeGiven ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
109 let end = result.timeGiven ? result.endTime.map { "-" + $0 } ?? "" : ""
110 return "<" + formatter.string(from: calendar.date(from: result.components(calendar))!) + end + ">"
111 }
112}
Sources/Orgstar/Keys.swift +10
@@ -55,6 +55,10 @@ struct EchoArea: View {
55 } 55 }
56 .frame(maxHeight: 200) 56 .frame(maxHeight: 200)
57 } 57 }
58 if let read = request.prompt.date {
59 Divider()
60 DatePickerView(answer: answer, read: read) { commands.answer($0) }
61 }
58 Divider() 62 Divider()
59 HStack(spacing: 6) { 63 HStack(spacing: 6) {
60 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) 64 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
@@ -77,6 +81,12 @@ struct EchoArea: View {
77 complete(first, request.prompt) 81 complete(first, request.prompt)
78 return .handled 82 return .handled
79 } 83 }
84 .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow]) { press in
85 guard let read = request.prompt.date, press.modifiers == .shift else { return .ignored }
86 let days = [KeyEquivalent.leftArrow: -1, .rightArrow: 1, .upArrow: -7, .downArrow: 7][press.key]!
87 answer = DatePickerView.shifted(answer, read, by: days)
88 return .handled
89 }
80 .onExitCommand { commands.answer(nil) } 90 .onExitCommand { commands.answer(nil) }
81 } 91 }
82 .padding(.horizontal, 10) 92 .padding(.horizontal, 10)
Sources/Orgstar/Preferences.swift +3 −2
@@ -18,14 +18,15 @@ enum Preferences {
18 static func options( 18 static func options(
19 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, 19 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int,
20 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String, 20 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String,
21 hideDrawerStartup: Bool, hideBlockStartup: Bool 21 hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String
22 ) -> EditingOptions { 22 ) -> EditingOptions {
23 EditingOptions( 23 EditingOptions(
24 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, 24 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree,
25 metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, 25 metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn,
26 hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true, 26 hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true,
27 logDone: LogHow(rawValue: logDone), logIntoDrawer: logIntoDrawer.isEmpty ? nil : logIntoDrawer, 27 logDone: LogHow(rawValue: logDone), logIntoDrawer: logIntoDrawer.isEmpty ? nil : logIntoDrawer,
28 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup 28 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup,
29 logReschedule: LogHow(rawValue: logReschedule), logRedeadline: LogHow(rawValue: logRedeadline)
29 ) 30 )
30 } 31 }
31} 32}
Tests/OrgAppTests/EmacsImportTests.swift +3 −2
@@ -18,7 +18,7 @@ struct EmacsImportTests {
18 org-agenda-files '("~/notes/" "~/work/plan.org") 18 org-agenda-files '("~/notes/" "~/work/plan.org")
19 org-tags-column -80)) 19 org-tags-column -80))
20 (setq org-tags-column 4) 20 (setq org-tags-column 4)
21 (setq org-log-done 'note org-log-into-drawer t) 21 (setq org-log-done 'note org-log-into-drawer t org-log-reschedule 'time)
22 (setq org-agenda-prefix-format '((agenda . " %i %-12:c%?-12t% s%b") (todo . " %-8:c"))) 22 (setq org-agenda-prefix-format '((agenda . " %i %-12:c%?-12t% s%b") (todo . " %-8:c")))
23 (setq fill-column (+ 70 2)) 23 (setq fill-column (+ 70 2))
24 (setq org-capture-templates 24 (setq org-capture-templates
@@ -71,6 +71,7 @@ struct EmacsImportTests {
71 #expect(s["org-hide-emphasis-markers"] == .bool(true)) 71 #expect(s["org-hide-emphasis-markers"] == .bool(true))
72 #expect(s["org-pretty-entities"] == .bool(true)) 72 #expect(s["org-pretty-entities"] == .bool(true))
73 #expect(s["org-log-done"] == .string("note")) 73 #expect(s["org-log-done"] == .string("note"))
74 #expect(s["org-log-reschedule"] == .string("time"))
74 #expect(s["org-log-into-drawer"] == .string("LOGBOOK")) 75 #expect(s["org-log-into-drawer"] == .string("LOGBOOK"))
75 #expect(s["org-agenda-prefix-format.agenda"] == .string(" %i %-12:c%?-12t% s%b")) 76 #expect(s["org-agenda-prefix-format.agenda"] == .string(" %i %-12:c%?-12t% s%b"))
76 #expect(s["org-agenda-prefix-format.todo"] == .string(" %-8:c")) 77 #expect(s["org-agenda-prefix-format.todo"] == .string(" %-8:c"))
@@ -143,7 +144,7 @@ struct EmacsImportTests {
143 let keymap = folder.appendingPathComponent("keymap.toml") 144 let keymap = folder.appendingPathComponent("keymap.toml")
144 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) 145 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
145 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) 146 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a"))
146 #expect(summary.hasPrefix("Imported 16 settings, 3 capture templates, 3 key bindings."), "\(summary)") 147 #expect(summary.hasPrefix("Imported 17 settings, 3 capture templates, 3 key bindings."), "\(summary)")
147 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") 148 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
148 #expect(defaults.integer(forKey: "themeFontSize") == 14) 149 #expect(defaults.integer(forKey: "themeFontSize") == 14)
149 } 150 }
Tests/OrgCoreTests/DateCommandTests.swift added +84
@@ -0,0 +1,84 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct DateCommandTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7 static let seconds = 1_789_000_000
8
9 static func run(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String], options: EditingOptions = .org, lastCommand: String? = nil) -> (text: String, message: String?) {
10 var utc = Calendar(identifier: .gregorian)
11 utc.timeZone = TimeZone(identifier: "UTC")!
12 var answers = answers
13 while true {
14 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Date(timeIntervalSince1970: Double(seconds)),
15 calendar: utc, answers: answers, options: options, lastCommand: lastCommand)
16 switch command.run(in: context) {
17 case .commit(let result):
18 var new = text
19 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
20 let message = result.effects.compactMap { if case .message(let m) = $0 { m } else { nil } }.first
21 return (new, message)
22 case .prompt(let prompt) where prompt.key == "note" && answers["note"] == nil:
23 answers["note"] = "moved"
24 case .failed(let message): return (text, "failed: \(message)")
25 default: return (text, "other")
26 }
27 }
28 }
29
30 @Test(.enabled(if: oracle))
31 func logsReschedulesLikeOrg() throws {
32 let texts = ["* Task\nSCHEDULED: <2026-10-05 Mon>\n", "* Task\nDEADLINE: <2026-10-05 Mon> SCHEDULED: <2026-10-01 Thu>\nbody\n", "* Task\n"]
33 let actions: [(any OrgCommand, String, [String: String])] = [
34 (SetPlanning(deadline: false), "(org-schedule nil \"2026-10-10\")", ["date": "2026-10-10"]),
35 (SetPlanning(deadline: true), "(org-deadline nil \"2026-10-12\")", ["date": "2026-10-12"]),
36 (RemovePlanning(deadline: false), "(org-schedule '(4))", [:]),
37 (RemovePlanning(deadline: true), "(org-deadline '(4))", [:]),
38 ]
39 for (how, drawer) in [(LogHow.time, nil as String?), (.note, "LOGBOOK")] {
40 var cases: [EmacsOracle.Case] = []
41 var ours: [String] = []
42 for text in texts {
43 for (command, form, answers) in actions {
44 let lisp = """
45 (let ((org-log-reschedule '\(how.rawValue)) (org-log-redeadline '\(how.rawValue)) (org-log-into-drawer \(drawer.map { "\"\($0)\"" } ?? "nil")) (b (current-buffer)))
46 \(form)
47 (run-hooks 'post-command-hook)
48 (when (get-buffer "*Org Note*")
49 (with-current-buffer "*Org Note*" (goto-char (point-max)) (insert "moved") (org-store-log-note)))
50 (set-buffer b))
51 """
52 cases.append(EmacsOracle.Case(text: text, point: 3, form: frozenClock(Self.seconds, lisp)))
53 let options = EditingOptions(logIntoDrawer: drawer, logReschedule: how, logRedeadline: how)
54 ours.append(Self.run(command, text, caret: 2, answers: answers, options: options).text)
55 }
56 }
57 for (i, (mine, theirs)) in zip(ours, try EmacsOracle.run(cases)).enumerated() {
58 #expect(mine == theirs.text, "\(how) \(cases[i].form.split(separator: "\n")[2]) on \(cases[i].text.debugDescription)\nours: \(mine.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
59 }
60 }
61 }
62
63 @Test(.enabled(if: oracle))
64 func insertsRangesLikeOrgTimestamp() throws {
65 let text = "Meet <2026-10-05 Mon> then\n"
66 let caret = (text as NSString).range(of: " then").location
67 let emacs = try EmacsOracle.run([EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(Self.seconds, """
68 (let ((last-command 'org-timestamp) (this-command 'org-timestamp) (ord (symbol-function 'org-read-date)))
69 (cl-letf (((symbol-function 'org-read-date) (lambda (&optional with-time to-time _from prompt &rest _) (funcall ord with-time to-time "+2d" prompt))))
70 (org-timestamp nil)))
71 """))])
72 let ours = Self.run(InsertTimestamp(), text, caret: caret, answers: ["date": "+2d"], lastCommand: "org.timestamp.active")
73 #expect(ours.text == emacs[0].text)
74 }
75
76 @Test(.enabled(if: oracle))
77 func evaluatesRangesLikeOrg() throws {
78 let lines = ["<2026-10-05 Mon>--<2026-10-08 Thu>", "[2026-10-05 Mon 10:00]--[2026-10-07 Wed 12:30]", "x <2026-10-05 Mon 10:00>--<2026-10-05 Mon 10:45> y", "<2026-10-05 Mon>--<2026-10-05 Mon>"]
79 let forms = lines.map { "(progn (erase-buffer) (insert \(lispString($0))) (goto-char 1) (org-evaluate-time-range))" }
80 let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))")
81 let ours = lines.map { Self.run(EvaluateTimeRange(), $0, caret: 0, answers: [:]).message ?? "nil" }
82 #expect(ours == emacs)
83 }
84}