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 {
469469 if ["agenda", "todo", "tags"].contains(view) { add("org-agenda-prefix-format.\(view)", .string(s)) }
470470 }
471471 }
472 case "org-log-done":
472 case "org-log-done", "org-log-reschedule", "org-log-redeadline":
473473 switch value.symbol {
474474 case "nil": add(name, .string("nil"))
475475 case "time", "t": add(name, .string("time"))
Sources/OrgApp/UserConfig.swift +4
@@ -76,6 +76,10 @@ public enum UserConfig {
7676 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"),
7777 Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
7878 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: ""),
7983 Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""),
8084 comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""),
8185 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 {
1717 public var logDone: LogHow?
1818 /// `org-log-into-drawer`: the drawer state notes go in, or nil for none.
1919 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?
2023 /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one.
2124 public var fastTodoSelection: Bool
2225 /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens.
@@ -37,8 +40,10 @@ public struct EditingOptions: Sendable, Equatable, Codable {
3740 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
3841 hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false,
3942 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
4144 ) {
45 self.logReschedule = logReschedule
46 self.logRedeadline = logRedeadline
4247 self.hideDrawerStartup = hideDrawerStartup
4348 self.hideBlockStartup = hideBlockStartup
4449 self.fastTodoSelection = fastTodoSelection
@@ -78,12 +83,15 @@ public struct EditContext: Sendable {
7883 public let options: EditingOptions
7984 /// Tags used across the workspace, for completion; read when a command needs them.
8085 public let knownTags: @Sendable () -> [String]
86 /// The command run just before, with nothing typed since (`last-command`).
87 public let lastCommand: String?
8188
8289 public init(
8390 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
8491 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
8693 ) {
94 self.lastCommand = lastCommand
8795 self.knownTags = knownTags
8896 self.revision = revision
8997 self.text = text
@@ -112,10 +120,17 @@ public struct Prompt: Sendable, Equatable {
112120 public let completionSeparator: Character?
113121 /// Single keys answer instead of text.
114122 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 }
115130
116131 public init(
117132 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
119134 ) {
120135 self.key = key
121136 self.message = message
@@ -124,6 +139,7 @@ public struct Prompt: Sendable, Equatable {
124139 self.requireMatch = requireMatch
125140 self.completionSeparator = completionSeparator
126141 self.fast = fast
142 self.date = date
127143 }
128144}
129145
@@ -216,7 +232,7 @@ public enum Commands {
216232 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
217233 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
218234 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
219 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(),
235 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(),
220236 ]
221237
222238 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 {
44 /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the
55 /// default date.
66 func readDate(_ answer: String, default defaultDate: Date?) -> ReadDate.Answer {
7 let def = defaultDate ?? now
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)
7 ReadDate.read(answer, default: defaultDate, now: now, calendar: calendar)
148 }
159
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
1613 func date(_ components: DateComponents) -> Date { calendar.date(from: components)! }
1714
1815 /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one.
@@ -45,7 +42,7 @@ extension EmacsBuffer {
4542 }
4643
4744 /// `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 {
4946 let old = planningValue(kind)
5047 let repeatPattern = "([.+-]+[0-9]+[hdwmy](?:[/ ][-+]?[0-9]+[hdwmy])?)"
5148 let repeater = old.flatMap { $0.firstMatch(of: try! Regex(repeatPattern)).map { String($0.output[1].substring!) } }
@@ -62,6 +59,9 @@ extension EmacsBuffer {
6259 }
6360 }
6461 }
62 if let old, let inserted = lastInsertedTimestamp {
63 try logPlanningChange(kind, old: "<" + old + ">", new: inserted, answers: answers)
64 }
6565 }
6666
6767 /// `org--deadline-or-schedule` with C-u: removes the planning timestamp.
@@ -108,7 +108,21 @@ public struct InsertTimestamp: OrgCommand {
108108 public func applies(in context: EditContext) -> Bool { true }
109109 public func run(in context: EditContext) -> CommandStep {
110110 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 }
112126 }
113127 return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) }
114128 }
@@ -126,10 +140,12 @@ public struct SetPlanning: OrgCommand {
126140 let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled
127141 guard let answer = context.answers["date"] else {
128142 let buffer = EmacsBuffer(context.text, point: context.caret)
143 buffer.calendar = context.calendar
129144 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)))))
131147 }
132 return timestampCommand(context) { try $0.schedule(kind, answer: answer) }
148 return timestampCommand(context) { try $0.schedule(kind, answer: answer, answers: context.answers) }
133149 }
134150}
135151
@@ -141,7 +157,12 @@ public struct RemovePlanning: OrgCommand {
141157 public var title: String { deadline ? "Remove Deadline" : "Remove Schedule" }
142158 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
143159 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 }
145166 }
146167}
147168
@@ -168,3 +189,44 @@ public struct ShiftTimestampDay: OrgCommand {
168189 timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) }
169190 }
170191}
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 {
1414struct LogSettings {
1515 var done: LogHow?
1616 var repeatHow: LogHow?
17 /// `org-log-reschedule` and `org-log-redeadline`.
18 var reschedule: LogHow?
19 var redeadline: LogHow?
1720 var drawer: String?
1821 /// `org-todo-log-states`: keyword → (on entering, on leaving).
1922 var states: [String: (enter: LogHow?, leave: LogHow?)] = [:]
@@ -35,6 +38,12 @@ struct LogSettings {
3538 case "logrepeat": repeatHow = .time
3639 case "lognoterepeat": repeatHow = .note
3740 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
3847 case "logdrawer": drawer = "LOGBOOK"
3948 case "nologdrawer": drawer = nil
4049 default: break
@@ -53,7 +62,7 @@ struct LogSettings {
5362
5463/// A note `org-add-log-setup` asked for, stored when the command ends.
5564struct LogSetup {
56 enum Purpose { case done, state }
65 enum Purpose { case done, state, reschedule, delschedule, redeadline, deldeadline }
5766 var purpose: Purpose
5867 var state: String?
5968 var previous: String?
@@ -64,7 +73,8 @@ struct LogSetup {
6473extension EmacsBuffer {
6574 /// The logging settings for the entry at point.
6675 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)
6878 for sequence in settings.todoSequences {
6979 for keyword in sequence.active + sequence.done {
7080 let enter = LogSettings.how(keyword.logOnEnter)
@@ -123,6 +133,10 @@ extension EmacsBuffer {
123133 let about = switch setup.purpose {
124134 case .done: "closed todo item"
125135 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"
126140 }
127141 return Prompt(key: "note", message: "Note for \(about):")
128142 }
@@ -137,6 +151,10 @@ extension EmacsBuffer {
137151 "State " + quoted(setup.state).padding(toLength: max(12, quoted(setup.state).count), withPad: " ", startingAt: 0)
138152 + " from " + quoted(setup.previous).padding(toLength: max(12, quoted(setup.previous).count), withPad: " ", startingAt: 0)
139153 + " " + 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)"
140158 }
141159 var trimmed = text
142160 while let range = trimmed.range(of: "^# .*\\n[ \\t\\n]*", options: .regularExpression) { trimmed.removeSubrange(range) }
@@ -187,4 +205,34 @@ extension EmacsBuffer {
187205 storeLog(setup, text: "")
188206 }
189207 }
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 }
190238}
Sources/OrgCore/Keymap/Presets.swift +3
@@ -638,6 +638,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
638638 keys = "C-c C-c"
639639 command = "org.ctrl-c-ctrl-c"
640640 [[bind]]
641 keys = "C-c C-y"
642 command = "org.timestamp.evaluate-range"
643 [[bind]]
641644 keys = "C-c C-x C-r"
642645 command = "org.clock.report"
643646 [[bind]]
Sources/OrgCore/Time/ReadDate.swift +11
@@ -165,6 +165,17 @@ public enum ReadDate {
165165 return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match)
166166 }
167167
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
168179 /// `org-read-date-analyze` with `org-read-date-prefer-future` t.
169180 public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer {
170181 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 {
325325 return EditContext(
326326 revision: document.revision, text: document.text, tree: document.tree,
327327 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 }
329330 )
330331 }
331332
@@ -375,8 +376,12 @@ public final class OrgEditor: NSObject {
375376 }
376377 }
377378
379 /// The last command run and the revision it left, for `last-command`.
380 private var lastCommand: (id: String, revision: Int)?
381
378382 /// Runs a command by id, as a key, a menu item or the palette would.
379383 public func run(_ id: String) {
384 defer { lastCommand = (id, document.revision) }
380385 guard let command = EditorCommands.command(id) else {
381386 onMessage?("Unknown command \(id)")
382387 return
Sources/Orgstar/ContentView.swift +3 −1
@@ -36,6 +36,8 @@ struct ContentView: View {
3636 @AppStorage("prettyEntities") private var prettyEntities = true
3737 @AppStorage("logDone") private var logDone = "nil"
3838 @AppStorage("logIntoDrawer") private var logIntoDrawer = ""
39 @AppStorage("logReschedule") private var logReschedule = "nil"
40 @AppStorage("logRedeadline") private var logRedeadline = "nil"
3941 @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true
4042 @AppStorage("hideBlockStartup") private var hideBlockStartup = false
4143
@@ -43,7 +45,7 @@ struct ContentView: View {
4345 Preferences.options(
4446 tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists,
4547 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer,
46 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup
48 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline
4749 )
4850 }
4951 /// 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 {
5555 }
5656 .frame(maxHeight: 200)
5757 }
58 if let read = request.prompt.date {
59 Divider()
60 DatePickerView(answer: answer, read: read) { commands.answer($0) }
61 }
5862 Divider()
5963 HStack(spacing: 6) {
6064 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
@@ -77,6 +81,12 @@ struct EchoArea: View {
7781 complete(first, request.prompt)
7882 return .handled
7983 }
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 }
8090 .onExitCommand { commands.answer(nil) }
8191 }
8292 .padding(.horizontal, 10)
Sources/Orgstar/Preferences.swift +3 −2
@@ -18,14 +18,15 @@ enum Preferences {
1818 static func options(
1919 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int,
2020 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String,
21 hideDrawerStartup: Bool, hideBlockStartup: Bool
21 hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String
2222 ) -> EditingOptions {
2323 EditingOptions(
2424 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree,
2525 metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn,
2626 hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true,
2727 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)
2930 )
3031 }
3132}
Tests/OrgAppTests/EmacsImportTests.swift +3 −2
@@ -18,7 +18,7 @@ struct EmacsImportTests {
1818 org-agenda-files '("~/notes/" "~/work/plan.org")
1919 org-tags-column -80))
2020 (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)
2222 (setq org-agenda-prefix-format '((agenda . " %i %-12:c%?-12t% s%b") (todo . " %-8:c")))
2323 (setq fill-column (+ 70 2))
2424 (setq org-capture-templates
@@ -71,6 +71,7 @@ struct EmacsImportTests {
7171 #expect(s["org-hide-emphasis-markers"] == .bool(true))
7272 #expect(s["org-pretty-entities"] == .bool(true))
7373 #expect(s["org-log-done"] == .string("note"))
74 #expect(s["org-log-reschedule"] == .string("time"))
7475 #expect(s["org-log-into-drawer"] == .string("LOGBOOK"))
7576 #expect(s["org-agenda-prefix-format.agenda"] == .string(" %i %-12:c%?-12t% s%b"))
7677 #expect(s["org-agenda-prefix-format.todo"] == .string(" %-8:c"))
@@ -143,7 +144,7 @@ struct EmacsImportTests {
143144 let keymap = folder.appendingPathComponent("keymap.toml")
144145 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
145146 #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)")
147148 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
148149 #expect(defaults.integer(forKey: "themeFontSize") == 14)
149150 }
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}