Timestamps, planning and repeats !17

merged merged by cmc on 2026-10-05 05:08 UTC · krz/orgstar:phase2-timestamps into main

13 files changed, +1286 −5

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +10 −1
@@ -11,12 +11,18 @@ public struct EditingOptions: Sendable, Equatable, Codable {
11 public var metaReturnMaySplitLine: Bool 11 public var metaReturnMaySplitLine: Bool
12 /// `org-list-allow-alphabetical`: `a.` and `A)` are list bullets. 12 /// `org-list-allow-alphabetical`: `a.` and `A)` are list bullets.
13 public var listAllowAlphabetical: Bool 13 public var listAllowAlphabetical: Bool
14 /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note.
15 public var logRepeat: Bool
14 16
15 public init(tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, listAllowAlphabetical: Bool = false) { 17 public init(
18 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
19 listAllowAlphabetical: Bool = false, logRepeat: Bool = true
20 ) {
16 self.tagsColumn = tagsColumn 21 self.tagsColumn = tagsColumn
17 self.insertHeadingRespectContent = insertHeadingRespectContent 22 self.insertHeadingRespectContent = insertHeadingRespectContent
18 self.metaReturnMaySplitLine = metaReturnMaySplitLine 23 self.metaReturnMaySplitLine = metaReturnMaySplitLine
19 self.listAllowAlphabetical = listAllowAlphabetical 24 self.listAllowAlphabetical = listAllowAlphabetical
25 self.logRepeat = logRepeat
20 } 26 }
21 27
22 /// Org's own defaults. 28 /// Org's own defaults.
@@ -141,6 +147,9 @@ public enum Commands {
141 SetTags(), 147 SetTags(),
142 InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(), 148 InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(),
143 MoveItemUp(), MoveItemDown(), ToggleCheckbox(), 149 MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
150 InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true),
151 RemovePlanning(deadline: false), RemovePlanning(deadline: true),
152 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
144 ] 153 ]
145 154
146 public static func command(_ id: String) -> (any OrgCommand)? { 155 public static func command(_ id: String) -> (any OrgCommand)? {
Sources/OrgCore/Commands/EmacsBuffer.swift +7
@@ -27,6 +27,11 @@ final class EmacsBuffer {
27 let settings: OrgSettings 27 let settings: OrgSettings
28 /// Groups of the last successful match, as Emacs's match data. 28 /// Groups of the last successful match, as Emacs's match data.
29 var lastMatch: [Range<Int>?] = [] 29 var lastMatch: [Range<Int>?] = []
30 /// The clock and calendar commands see.
31 var now = Date()
32 var calendar = Calendar.current
33 /// `org-last-inserted-timestamp`.
34 var lastInsertedTimestamp: String?
30 35
31 let options: EditingOptions 36 let options: EditingOptions
32 37
@@ -434,6 +439,8 @@ final class EmacsBuffer {
434/// and the new caret. A user error becomes `.failed`. 439/// and the new caret. A user error becomes `.failed`.
435func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { 440func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
436 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) 441 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
442 buffer.now = context.now
443 buffer.calendar = context.calendar
437 do { 444 do {
438 try body(buffer) 445 try body(buffer)
439 } catch let error as EmacsBuffer.UserError { 446 } catch let error as EmacsBuffer.UserError {
Sources/OrgCore/Commands/HeadingCommands.swift +11 −2
@@ -220,9 +220,18 @@ public struct TodoCycle: OrgCommand {
220 /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are 220 /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are
221 /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags 221 /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags
222 /// are aligned. 222 /// are aligned.
223 /// `org-todo`, then the parent's TODO statistics. 223 /// `org-todo`, then the parent's TODO statistics, and the repeat of a repeating entry
224 /// that became done.
224 public func run(in context: EditContext) -> CommandStep { 225 public func run(in context: EditContext) -> CommandStep {
225 followUp(context, cycle(in: context)) { $0.updateParentTodoStatistics() } 226 let previous = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword
227 return followUp(context, cycle(in: context)) { buffer in
228 try buffer.saveExcursion {
229 buffer.updateParentTodoStatistics()
230 let settings = context.tree.settings
231 guard let new = buffer.todoKeyword, settings.isDone(new), !(previous.map(settings.isDone) ?? false) else { return }
232 if let (marker, note) = try buffer.autoRepeat(doneWord: new, previous: previous) { buffer.storeNote(marker, note) }
233 }
234 }
226 } 235 }
227 236
228 private func cycle(in context: EditContext) -> CommandStep { 237 private func cycle(in context: EditContext) -> CommandStep {
Sources/OrgCore/Commands/Repeat.swift added +162
@@ -0,0 +1,162 @@
1import Foundation
2
3// Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and the state note org logs for them
4// (`org-add-log-setup`, `org-store-log-note`, `org-log-beginning`), with `org-log-repeat`
5// `time`, `org-log-into-drawer` nil, `org-todo-repeat-to-state` nil.
6
7extension EmacsBuffer {
8 static let repeatPattern = "<[0-9]{4}-[0-9][0-9]-[0-9][0-9] [^>\\n]*?([.+]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?)"
9
10 /// `org-entry-end-position`: the next heading, or the end of the buffer.
11 var entryEnd: Int {
12 saveExcursion {
13 _ = try? backToHeading()
14 outlineNextHeading()
15 return point
16 }
17 }
18
19 /// `org-get-repeat`.
20 func repeatCookie() -> String? {
21 saveExcursion {
22 guard (try? backToHeading()) != nil else { return nil }
23 let end = entryEnd
24 return searchForward(Self.repeatPattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
25 }
26 }
27
28 /// The TODO keyword of the heading at point, if any.
29 var todoKeyword: String? {
30 saveExcursion {
31 guard (try? backToHeading()) != nil, let match = lookingAt(todoLinePattern), let keyword = match[2] else { return nil }
32 return substring(keyword.lowerBound, keyword.upperBound)
33 }
34 }
35
36 /// The keyword change `org-todo` makes, with its tag alignment and statistics update.
37 func setTodoKeyword(_ keyword: String?) {
38 saveExcursion {
39 guard (try? backToHeading()) != nil, let stars = lookingAt("\\*+ ") else { return }
40 point = stars[0]!.upperBound - 1
41 let names = keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|")
42 let match = lookingAt(" +(" + names + ")( +|[ \\t]*$)") ?? lookingAt("(?: *|[ \\t]*$)")!
43 replace(match[0]!, with: "")
44 point = match[0]!.lowerBound
45 insertBeforeMarkers(keyword.map { " " + $0 + " " } ?? " ")
46 alignTags(onLineAt: point)
47 updateParentTodoStatistics()
48 }
49 }
50
51 /// `org-auto-repeat-maybe` after `doneWord` replaced `previous`. Returns the note to log, if
52 /// any, with the marker it belongs at.
53 func autoRepeat(doneWord: String, previous: String?) throws -> (Marker, String)? {
54 guard let cookie = repeatCookie(), (Int(cookie.drop { !$0.isNumber }.prefix { $0.isNumber }) ?? 0) != 0 else { return nil }
55 let end = marker(entryEnd)
56 defer { release(end) }
57 let sequence = settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == previous } }
58 let head = sequence.flatMap { ($0.active + $0.done).first?.name }
59 let propertyState = inheritedProperty("REPEAT_TO_STATE")?.value
60 let toState = propertyState.flatMap { keywordsInOrder.contains($0) ? $0 : nil }
61 ?? (sequence?.kind == .type ? previous : head)
62 setTodoKeyword(toState)
63 try backToHeading()
64 try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed])
65 let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true)
66 if options.logRepeat { entryPut("LAST_REPEAT", stamp) }
67 let note = marker(point)
68 if let scheduled = planningValue(.scheduled), ("<" + scheduled + ">").range(of: Self.repeatPattern, options: .regularExpression) == nil {
69 saveExcursion { removePlanning(.scheduled) }
70 }
71 let units: [String: TimestampUnit] = ["h": .hour, "d": .day, "m": .month, "y": .year]
72 let today = calendar.startOfDay(for: now)
73 while let match = searchForward(Self.repeatPattern, bound: end.position, caseInsensitive: false) {
74 let stamp = substring(match[0]!.lowerBound, match[0]!.upperBound)
75 guard let parts = stamp.firstMatch(of: /([.+])?(\+[0-9]+)([hdwmy])/) else { continue }
76 var n = Int(parts.2.dropFirst())!
77 var what = String(parts.3)
78 if what == "w" { n *= 7; what = "d" }
79 guard let unit = units[what] else { continue }
80 if what == "h", stamp.range(of: "[0-9]{1,2}:[0-9]{2}", options: .regularExpression) == nil {
81 throw UserError(message: "Cannot repeat in \(n) hour(s) because no hour has been set")
82 }
83 if let kind = parts.1 {
84 let time = Self.parseTimeString(stamp).map(date)!
85 if kind == "." {
86 if what != "h" {
87 let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: time), to: today).day!
88 try changeTimestamp(days, what: .day)
89 }
90 } else {
91 var shifts = 0
92 var current = time
93 while shifts == 0 || (what == "h" ? current <= now : calendar.startOfDay(for: current) <= today) {
94 shifts += 1
95 if shifts > 100_000 { break }
96 try changeTimestamp(n, what: unit)
97 let found = inRegexp(Self.timestampPattern)!
98 current = Self.parseTimeString(substring(found[1]!.lowerBound, found[1]!.upperBound)).map(date)!
99 }
100 try changeTimestamp(-n, what: unit)
101 }
102 }
103 try saveExcursion { try changeTimestamp(n, what: unit, suppressDelay: true) }
104 }
105 guard options.logRepeat else {
106 release(note)
107 return nil
108 }
109 func quoted(_ text: String?) -> String { text.map { "\"\($0)\"" } ?? "" }
110 let line = "State " + quoted(doneWord).padding(toLength: max(12, quoted(doneWord).count), withPad: " ", startingAt: 0)
111 + " from " + quoted(previous).padding(toLength: max(12, quoted(previous).count), withPad: " ", startingAt: 0)
112 + " " + stamp
113 return (note, line)
114 }
115
116 /// `org-store-log-note` for a one-line note at `marker`.
117 func storeNote(_ marker: Marker, _ text: String) {
118 saveExcursion {
119 point = marker.position
120 release(marker)
121 point = logBeginning()
122 if !bolp {
123 insert("\n")
124 } else if lookingAt("[ \\t]*\\S") != nil {
125 saveExcursion { insert("\n") }
126 }
127 if let item = inItem() {
128 let structure = saveExcursion { point = item; return listStruct() }
129 indentLineTo(structure.ind(structure.top))
130 } else {
131 indentLineTo(0)
132 }
133 insert("- " + text)
134 }
135 }
136
137 /// `org-end-of-meta-data` without FULL.
138 func endOfMetaData() {
139 _ = try? backToHeading()
140 forwardLine(1)
141 if lookingAt(Self.planningLinePattern) != nil { forwardLine(1) }
142 if let drawer = looking(Self.propertyDrawerPattern) {
143 point = drawer[0]!.upperBound
144 forwardLine(1)
145 }
146 }
147
148 /// `org-log-beginning` without a log drawer, with `org-log-states-order-reversed` t: the
149 /// newest note comes first.
150 func logBeginning() -> Int {
151 saveExcursion {
152 endOfMetaData()
153 let endpos = point
154 skipForward([32, 9, 10])
155 forwardLine(0)
156 if point < endpos { point = endpos }
157 if bolp { return point }
158 let end = lineEnd(point)
159 return end < length ? end + 1 : length
160 }
161 }
162}
Sources/OrgCore/Commands/TimestampCommands.swift added +170
@@ -0,0 +1,170 @@
1import Foundation
2
3extension EmacsBuffer {
4 /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the
5 /// default date.
6 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)
14 }
15
16 func date(_ components: DateComponents) -> Date { calendar.date(from: components)! }
17
18 /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one.
19 func timestampCommand(_ answer: String, inactive: Bool) {
20 let existing = atTimestamp()
21 let stamp = existing.map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
22 let defaultDate = stamp.flatMap(Self.parseTimeString).map(date)
23 let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } }
24 let result = readDate(answer, default: defaultDate)
25 let components = result.components(calendar)
26 if let existing {
27 replace(existing.match[0]!, with: "")
28 insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
29 if let repeater {
30 point -= 1
31 insert(" " + repeater)
32 }
33 } else {
34 insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
35 }
36 }
37
38 /// The first timestamp after `kind` in the entry, as the date prompt's default.
39 func planningDefault(_ kind: PlanningKind) -> String? {
40 saveExcursion {
41 guard (try? backToHeading()) != nil else { return nil }
42 let end = saveExcursion { () -> Int in outlineNextHeading(); return point }
43 return searchForward(kind.pattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
44 }
45 }
46
47 /// `org--deadline-or-schedule` without a prefix argument.
48 func schedule(_ kind: PlanningKind, answer: String) throws {
49 let old = planningValue(kind)
50 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!) } }
52 let defaultDate = planningDefault(kind).flatMap(Self.parseTimeString).map(date)
53 let result = readDate(answer, default: defaultDate)
54 try addPlanningInfo(kind, time: result.components(calendar), withTime: result.timeGiven, endTime: result.endTime, remove: [.closed])
55 if let repeater, let inserted = lastInsertedTimestamp {
56 try saveExcursion {
57 try backToHeading()
58 let bound = saveExcursion { () -> Int in forwardLine(2); return lineEnd(point) }
59 if let match = searchForward(NSRegularExpression.escapedPattern(for: kind.keyword + " " + inserted), bound: bound, caseInsensitive: false) {
60 point = match[0]!.upperBound - 1
61 insert(" " + repeater)
62 }
63 }
64 }
65 }
66
67 /// `org--deadline-or-schedule` with C-u: removes the planning timestamp.
68 func removePlanning(_ kind: PlanningKind) {
69 guard planningValue(kind) != nil else { return }
70 saveExcursion {
71 guard let begin = try? backToHeading() else { return }
72 outlineNextHeading()
73 let pattern = "\\b" + NSRegularExpression.escapedPattern(for: kind.keyword) + " +<[^>\\n]+>[ \\t]*"
74 while let match = searchBackward(pattern, bound: begin, caseInsensitive: false) {
75 replace(match[0]!, with: "")
76 let before = substring(lineStart(point), point)
77 if before.contains(where: { !$0.isWhitespace }), character(at: point - 1) == 32 {
78 deleteRegion(point - 1, point)
79 } else if substring(lineStart(point), lineEnd(point)).allSatisfy({ $0 == " " || $0 == "\t" }) {
80 deleteRegion(lineStart(point), min(length, lineEnd(point) + 1))
81 }
82 }
83 }
84 }
85}
86
87private func timestampCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
88 commitBuffer(context, body)
89}
90
91private func atTimestamp(_ context: EditContext) -> Bool {
92 EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
93}
94
95/// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it.
96private func timeOfDayAtCaret(_ context: EditContext) -> String {
97 let buffer = EmacsBuffer(context.text, point: context.caret)
98 guard let match = buffer.atTimestamp()?.match[0] else { return "" }
99 return EmacsBuffer.compactTimeOfDay(buffer.substring(match.lowerBound, match.upperBound)) ?? ""
100}
101
102/// C-c . and C-c !: `org-timestamp`.
103public struct InsertTimestamp: OrgCommand {
104 let inactive: Bool
105 public init(inactive: Bool = false) { self.inactive = inactive }
106 public var id: String { inactive ? "org.timestamp.inactive" : "org.timestamp.active" }
107 public var title: String { inactive ? "Insert Inactive Timestamp" : "Insert Timestamp" }
108 public func applies(in context: EditContext) -> Bool { true }
109 public func run(in context: EditContext) -> CommandStep {
110 guard let answer = context.answers["date"] else {
111 return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context)))
112 }
113 return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) }
114 }
115}
116
117/// C-c C-s and C-c C-d: `org-schedule` and `org-deadline`.
118public struct SetPlanning: OrgCommand {
119 let deadline: Bool
120 public init(deadline: Bool) { self.deadline = deadline }
121 public var id: String { deadline ? "org.deadline" : "org.schedule" }
122 public var title: String { deadline ? "Set Deadline" : "Schedule" }
123 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
124 public func run(in context: EditContext) -> CommandStep {
125 guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
126 let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled
127 guard let answer = context.answers["date"] else {
128 let buffer = EmacsBuffer(context.text, point: context.caret)
129 let initial = buffer.planningDefault(kind).flatMap(EmacsBuffer.compactTimeOfDay) ?? ""
130 return .prompt(Prompt(key: "date", message: deadline ? "DEADLINE:" : "SCHEDULED:", initial: initial))
131 }
132 return timestampCommand(context) { try $0.schedule(kind, answer: answer) }
133 }
134}
135
136/// C-u C-c C-s and C-u C-c C-d.
137public struct RemovePlanning: OrgCommand {
138 let deadline: Bool
139 public init(deadline: Bool) { self.deadline = deadline }
140 public var id: String { deadline ? "org.deadline.remove" : "org.schedule.remove" }
141 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 }
143 public func run(in context: EditContext) -> CommandStep {
144 timestampCommand(context) { $0.removePlanning(deadline ? .deadline : .scheduled) }
145 }
146}
147
148/// S-up and S-down on a timestamp: `org-timestamp-up` and `org-timestamp-down`.
149public struct ShiftTimestamp: OrgCommand {
150 let up: Bool
151 public init(up: Bool) { self.up = up }
152 public var id: String { up ? "org.timestamp.up" : "org.timestamp.down" }
153 public var title: String { up ? "Increase Timestamp Part" : "Decrease Timestamp Part" }
154 public func applies(in context: EditContext) -> Bool { atTimestamp(context) }
155 public func run(in context: EditContext) -> CommandStep {
156 timestampCommand(context) { try $0.changeTimestamp(up ? 1 : -1, updown: true) }
157 }
158}
159
160/// S-right and S-left on a timestamp: `org-timestamp-up-day` and `org-timestamp-down-day`.
161public struct ShiftTimestampDay: OrgCommand {
162 let later: Bool
163 public init(later: Bool) { self.later = later }
164 public var id: String { later ? "org.timestamp.day-later" : "org.timestamp.day-earlier" }
165 public var title: String { later ? "Timestamp One Day Later" : "Timestamp One Day Earlier" }
166 public func applies(in context: EditContext) -> Bool { atTimestamp(context) }
167 public func run(in context: EditContext) -> CommandStep {
168 timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) }
169 }
170}
Sources/OrgCore/Commands/Timestamps.swift added +324
@@ -0,0 +1,324 @@
1import Foundation
2
3// Timestamps and planning, ported from Org 9.8.7: `org-at-timestamp-p`, `org-timestamp`,
4// `org-timestamp-change`, `org-modify-ts-extra`, `org-add-planning-info`,
5// `org--deadline-or-schedule`. `org-time-stamp-rounding-minutes` (0 5), `org-adapt-indentation`
6// nil, `org-log-reschedule` and `org-log-redeadline` nil.
7
8extension EmacsBuffer {
9 static let timestampCore = "(([0-9]{4})-([0-9]{2})-([0-9]{2})(?: *([^\\]+0-9>\\r\\n -]+))?( ([0-9]{1,2}):([0-9]{2}))?)"
10 /// `org-ts-regexp3`: groups 2 year, 3 month, 4 day, 5 day name, 7 hour, 8 minute.
11 static let timestampPattern = "[\\[<]" + timestampCore + "[^\\]>\\n]{0,16}[\\]>]"
12 /// `org-ts-regexp0`, for time strings.
13 static let timeStringPattern = "(([0-9]{4})-([0-9]{2})-([0-9]{2})( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))?)"
14
15 /// `org-in-regexp` on the current line: the match that contains point.
16 func inRegexp(_ pattern: String) -> [Range<Int>?]? {
17 let regex = Self.regex(pattern, caseInsensitive: false)
18 let lineRange = NSRange(lineStart(point)..<lineEnd(point))
19 for match in regex.matches(in: string, options: Self.searchOptions, range: lineRange) {
20 let range = match.range.location..<NSMaxRange(match.range)
21 guard range.lowerBound <= point else { break }
22 if range.upperBound >= point {
23 let groups = (0..<match.numberOfRanges).map { i -> Range<Int>? in
24 let r = match.range(at: i)
25 return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
26 }
27 lastMatch = groups
28 return groups
29 }
30 }
31 return nil
32 }
33
34 enum TimestampPart: Equatable {
35 case bracket, after, year, month, hour, minute, day
36 /// Characters past the minutes or day name, in the repeater or end time.
37 case extra(Int)
38 }
39
40 /// `org-at-timestamp-p` with `lax`: where in a timestamp point is, and the match.
41 func atTimestamp() -> (part: TimestampPart, match: [Range<Int>?])? {
42 guard let match = inRegexp(Self.timestampPattern) else { return nil }
43 let pos = point
44 func inGroup(_ i: Int) -> Bool { match[i].map { $0.lowerBound <= pos && pos <= $0.upperBound } ?? false }
45 let whole = match[0]!
46 let part: TimestampPart
47 if pos == whole.lowerBound || pos == whole.upperBound - 1 {
48 part = .bracket
49 } else if pos == whole.upperBound {
50 part = .after
51 } else if inGroup(2) {
52 part = .year
53 } else if inGroup(3) {
54 part = .month
55 } else if inGroup(7) {
56 part = .hour
57 } else if inGroup(8) {
58 part = .minute
59 } else if inGroup(4) || inGroup(5) {
60 part = .day
61 } else if let last = match[8]?.upperBound ?? match[5]?.upperBound, pos > last, pos < whole.upperBound {
62 part = .extra(pos - last)
63 } else {
64 part = .day
65 }
66 return (part, match)
67 }
68
69 /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent.
70 static func parseTimeString(_ text: String) -> DateComponents? {
71 guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil }
72 func group(_ i: Int) -> Int? { match.output[i].substring.flatMap { Int($0) } }
73 return DateComponents(year: group(2), month: group(3), day: group(4), hour: group(7) ?? 0, minute: group(8) ?? 0)
74 }
75
76 /// `format-time-string` with Org's timestamp formats, plus `extra` before the closing bracket.
77 func formatTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, extra: String? = nil) -> String {
78 let date = calendar.date(from: components)!
79 let formatter = DateFormatter()
80 formatter.locale = Locale(identifier: "en_US_POSIX")
81 formatter.calendar = calendar
82 formatter.timeZone = calendar.timeZone
83 formatter.dateFormat = withTime ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
84 return (inactive ? "[" : "<") + formatter.string(from: date) + (extra ?? "") + (inactive ? "]" : ">")
85 }
86
87 /// `org-insert-timestamp`: inserted before markers, as org does.
88 @discardableResult
89 func insertTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, endTime: String? = nil, extra: String? = nil) -> String {
90 var suffix = extra
91 if let endTime, let match = endTime.firstMatch(of: /([0-9]+):([0-9]+)/) {
92 suffix = String(format: "-%02d:%02d", Int(match.1)!, Int(match.2)!)
93 }
94 let stamp = formatTimestamp(components, withTime: withTime, inactive: inactive, extra: suffix)
95 insertBeforeMarkers(stamp)
96 lastInsertedTimestamp = stamp
97 return stamp
98 }
99
100 /// `org-modify-ts-extra`.
101 static func modifyTimestampExtra(_ extra: String, _ position: Int, _ increments: Int, _ step: Int) -> String {
102 let regex = try! NSRegularExpression(pattern: "(-([012][0-9]):([0-5][0-9]))?( +\\+([0-9]+)([dmwy]))?( +-([0-9]+)([dmwy]))?")
103 let ns = extra as NSString
104 guard let match = regex.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return extra }
105 func range(_ i: Int) -> Range<Int>? {
106 let r = match.range(at: i)
107 return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
108 }
109 func inGroup(_ i: Int) -> Bool { range(i).map { $0.lowerBound <= position && position <= $0.upperBound } ?? false }
110 func text(_ i: Int) -> String { ns.substring(with: match.range(at: i)) }
111 let order: [(String, Int)] = [("d", 0), ("w", 1), ("m", 2), ("y", 3), ("d", -1), ("y", 4)]
112 func shifted(_ unit: String) -> String? {
113 let index = order.first { $0.0 == unit }!.1 + increments
114 return order.first { $0.1 == index }?.0
115 }
116 var group: Int?
117 var new = ""
118 if inGroup(2) || inGroup(3) {
119 var minute = Int(text(3))!, hour = Int(text(2))!
120 if inGroup(2) {
121 hour += increments
122 } else {
123 let n = step * increments
124 let rem = minute % step
125 if rem != 0 { minute += n > 0 ? -rem : step - rem }
126 minute += n
127 }
128 if minute < 0 { minute += 60; hour -= 1 }
129 if minute > 59 { minute -= 60; hour += 1 }
130 hour = ((hour % 24) + 24) % 24
131 group = 1
132 new = String(format: "-%02d:%02d", hour, minute)
133 } else if inGroup(6) {
134 group = 6
135 new = shifted(text(6)) ?? ""
136 } else if inGroup(5) {
137 group = 5
138 new = String(max(1, increments + Int(text(5))!))
139 } else if inGroup(9) {
140 group = 9
141 new = shifted(text(9)) ?? ""
142 } else if inGroup(8) {
143 group = 8
144 new = String(max(0, increments + Int(text(8))!))
145 }
146 guard let group, let target = range(group) else { return extra }
147 return ns.replacingCharacters(in: NSRange(target), with: new)
148 }
149
150 enum TimestampUnit { case minute, hour, day, month, year }
151
152 /// `org-timestamp-change`. `what` nil changes the part at point.
153 func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false) throws {
154 let origin = point
155 guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") }
156 if what == nil, category == .bracket {
157 toggleTimestampType(match[0]!)
158 return
159 }
160 let target: TimestampPart = what.map { unit in
161 switch unit {
162 case .minute: .minute
163 case .hour: .hour
164 case .day: .day
165 case .month: .month
166 case .year: .year
167 }
168 } ?? category
169 let whole = match[0]!
170 let inactive = character(at: whole.lowerBound) == 91
171 let stamp = substring(whole.lowerBound, whole.upperBound)
172 replace(whole, with: "")
173 var extra: String?
174 if let found = stamp.firstMatch(of: try! Regex("((-[012][0-9]:[0-5][0-9])?( +[.+]?-?[-+][0-9]+[hdwmy](/[0-9]+[hdwmy])?)*)[\\]>]")) {
175 extra = found.output[1].substring.map(String.init)
176 if suppressDelay, let value = extra { extra = value.replacingOccurrences(of: " --[0-9]+[hdwmy]", with: "", options: .regularExpression) }
177 }
178 let withTime = stamp.range(of: "^.{10}.*?[0-9]+:[0-9][0-9]", options: .regularExpression) != nil
179 var time = Self.parseTimeString(stamp)!
180 var step = 5
181 var increment = n
182 if updown, target == .minute {
183 increment = step * n.signum()
184 let rem = time.minute! % step
185 if rem != 0 { time.minute! += n > 0 ? -rem : step - rem }
186 } else {
187 step = 1
188 }
189 switch target {
190 case .minute: time.minute! += increment
191 case .hour: time.hour! += increment
192 case .day: time.day! += increment
193 case .month: time.month! += increment
194 case .year: time.year! += increment
195 default: break
196 }
197 let normalized = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: calendar.date(from: time)!)
198 if target == .hour || target == .minute, let value = extra, value.range(of: "-([012][0-9]):([0-5][0-9])", options: .regularExpression) != nil {
199 extra = Self.modifyTimestampExtra(value, target == .hour ? 2 : 5, n, step)
200 }
201 if case .extra(let position) = target, let value = extra {
202 extra = Self.modifyTimestampExtra(value, position, n, step)
203 }
204 let insertion = point
205 insertTimestamp(normalized, withTime: withTime, inactive: inactive, extra: extra)
206 point = insertion
207 let after = looking(Self.timestampPattern, caseInsensitive: false)!
208 switch category {
209 case .day:
210 let end = after[5].map { $0.upperBound - 1 } ?? after[7]?.lowerBound ?? after[1]!.upperBound - 1
211 point = min(end, origin)
212 case .hour: point = min(after[7]!.upperBound, origin)
213 case .minute: point = min(after[8]!.upperBound - 1, origin)
214 case .extra: point = min(after[0]!.upperBound - 1, origin)
215 case .after: point = after[0]!.upperBound
216 default: point = origin
217 }
218 }
219
220 /// `org-toggle-timestamp-type`.
221 func toggleTimestampType(_ range: Range<Int>) {
222 saveExcursion {
223 for offset in range {
224 let swapped: String? = switch character(at: offset) {
225 case 91: "<"
226 case 93: ">"
227 case 60: "["
228 case 62: "]"
229 default: nil
230 }
231 if let swapped { replace(offset..<(offset + 1), with: swapped) }
232 }
233 }
234 }
235
236 /// `org-get-compact-tod`: the time of day in a timestamp, as the date prompt shows it.
237 static func compactTimeOfDay(_ stamp: String) -> String? {
238 guard let match = stamp.firstMatch(of: /(([012]?[0-9]):([0-5][0-9]))(-(([012]?[0-9]):([0-5][0-9])))?/) else { return nil }
239 let t1 = String(match.1)
240 guard let t2h = match.6, let t2m = match.7 else { return t1 }
241 var dh = Int(t2h)! - Int(match.2)!
242 var dm = Int(t2m)! - Int(match.3)!
243 if dm < 0 { dm += 60; dh -= 1 }
244 return t1 + "+" + String(dh) + (dm != 0 ? String(format: ":%02d", dm) : "")
245 }
246
247 // MARK: - Planning
248
249 static let planningLinePattern = "^[ \\t]*(CLOSED|DEADLINE|SCHEDULED):"
250 static let keywordTimeNotClock = "\\b(CLOSED|DEADLINE|SCHEDULED):[ \\t]*[\\[<]([^\\]>]+)[\\]>]"
251
252 enum PlanningKind {
253 case closed, deadline, scheduled
254
255 var keyword: String {
256 switch self {
257 case .closed: "CLOSED:"
258 case .deadline: "DEADLINE:"
259 case .scheduled: "SCHEDULED:"
260 }
261 }
262
263 var pattern: String {
264 switch self {
265 case .closed: "\\bCLOSED: *\\[([^\\]]+)\\]"
266 case .deadline: "\\bDEADLINE: *<([^>]+)>"
267 case .scheduled: "\\bSCHEDULED: *<([^>]+)>"
268 }
269 }
270 }
271
272 /// The time string of the entry's planning `kind`, as `org-entry-get`.
273 func planningValue(_ kind: PlanningKind) -> String? {
274 saveExcursion {
275 guard (try? backToHeading()) != nil else { return nil }
276 forwardLine(1)
277 guard lookingAt(Self.planningLinePattern) != nil,
278 let match = searchForward(kind.pattern, bound: lineEnd(point), caseInsensitive: false) else { return nil }
279 return substring(match[1]!.lowerBound, match[1]!.upperBound)
280 }
281 }
282
283 /// `org-add-planning-info` with a resolved time.
284 func addPlanningInfo(_ what: PlanningKind?, time: DateComponents?, withTime: Bool, endTime: String?, remove: [PlanningKind]) throws {
285 try saveExcursion {
286 try backToHeading()
287 let planning = saveExcursion { () -> Bool in
288 forwardLine(1)
289 return lookingAt(Self.planningLinePattern) != nil
290 }
291 if planning {
292 forwardLine(1)
293 skipForward(Self.blanks)
294 for type in (what.map { [$0] } ?? []) + remove {
295 saveExcursion {
296 guard let match = searchForward(type.pattern, bound: lineEnd(point), caseInsensitive: false) else { return }
297 let start = match[0]!.lowerBound
298 let end = searchForward(Self.keywordTimeNotClock, bound: lineEnd(point), caseInsensitive: false)?[0]!.lowerBound ?? lineEnd(point)
299 deleteRegion(start, end)
300 }
301 }
302 if lookingAt("[ \\t]*$") != nil, what == nil {
303 deleteRegion(lineStart(point) - 1, lineEnd(point))
304 } else {
305 let p = point
306 saveExcursion {
307 endOfLine()
308 let end = point
309 while point > p, let c = character(at: point - 1), c == 32 || c == 9 { point -= 1 }
310 if point < end { deleteRegion(point, end) }
311 }
312 }
313 } else if what != nil {
314 endOfLine()
315 insert("\n")
316 }
317 if let what, let time {
318 insert(what.keyword + " ")
319 insertTimestamp(time, withTime: withTime || what == .closed, inactive: what == .closed, endTime: endTime)
320 if !eolp { insert(" ") }
321 }
322 }
323 }
324}
Sources/OrgCore/Keymap/Keymap.swift +2 −1
@@ -103,7 +103,7 @@ public struct Keymap: Sendable, Equatable {
103 103
104/// Context names a binding's `when` can use. 104/// Context names a binding's `when` can use.
105public enum KeyContext { 105public enum KeyContext {
106 public static let names: Set<String> = ["heading", "table", "item", "region"] 106 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp"]
107 107
108 public static func holds(_ name: String, in context: EditContext) -> Bool { 108 public static func holds(_ name: String, in context: EditContext) -> Bool {
109 switch name { 109 switch name {
@@ -111,6 +111,7 @@ public enum KeyContext {
111 case "table": return enclosing(.table, context) != nil 111 case "table": return enclosing(.table, context) != nil
112 case "item": return enclosing(.item, context) != nil 112 case "item": return enclosing(.item, context) != nil
113 case "region": return context.selection.contains { !$0.isEmpty } 113 case "region": return context.selection.contains { !$0.isEmpty }
114 case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
114 default: return false 115 default: return false
115 } 116 }
116 } 117 }
Sources/OrgCore/Keymap/Presets.swift +56
@@ -254,6 +254,34 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
254 [[bind]] 254 [[bind]]
255 keys = "C-c $" 255 keys = "C-c $"
256 command = "app.archive" 256 command = "app.archive"
257 [[bind]]
258 keys = "C-c ."
259 command = "org.timestamp.active"
260 [[bind]]
261 keys = "C-c !"
262 command = "org.timestamp.inactive"
263 [[bind]]
264 keys = "C-c C-s"
265 command = "org.schedule"
266 [[bind]]
267 keys = "C-c C-d"
268 command = "org.deadline"
269 [[bind]]
270 keys = "S-<up>"
271 command = "org.timestamp.up"
272 when = "timestamp"
273 [[bind]]
274 keys = "S-<down>"
275 command = "org.timestamp.down"
276 when = "timestamp"
277 [[bind]]
278 keys = "S-<right>"
279 command = "org.timestamp.day-later"
280 when = "timestamp"
281 [[bind]]
282 keys = "S-<left>"
283 command = "org.timestamp.day-earlier"
284 when = "timestamp"
257 285
258 """ 286 """
259 287
@@ -346,6 +374,34 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
346 [[bind]] 374 [[bind]]
347 keys = "C-s-a" 375 keys = "C-s-a"
348 command = "app.archive" 376 command = "app.archive"
377 [[bind]]
378 keys = "C-s-."
379 command = "org.timestamp.active"
380 [[bind]]
381 keys = "C-M-s-."
382 command = "org.timestamp.inactive"
383 [[bind]]
384 keys = "C-s-s"
385 command = "org.schedule"
386 [[bind]]
387 keys = "C-s-d"
388 command = "org.deadline"
389 [[bind]]
390 keys = "C-s-<up>"
391 command = "org.timestamp.up"
392 when = "timestamp"
393 [[bind]]
394 keys = "C-s-<down>"
395 command = "org.timestamp.down"
396 when = "timestamp"
397 [[bind]]
398 keys = "C-s-<right>"
399 command = "org.timestamp.day-later"
400 when = "timestamp"
401 [[bind]]
402 keys = "C-s-<left>"
403 command = "org.timestamp.day-earlier"
404 when = "timestamp"
349 405
350 """ 406 """
351} 407}
Sources/OrgCore/Time/ReadDate.swift added +297
@@ -0,0 +1,297 @@
1import Foundation
2
3/// Org's date prompt (`org-read-date-analyze`, Org 9.8.7) and the `parse-time-string` it uses,
4/// with `org-read-date-prefer-future` t. `now` and `defaultDate` stand in for the clock.
5public enum ReadDate {
6 public struct Answer: Sendable, Equatable {
7 public var year: Int, month: Int, day: Int, hour: Int, minute: Int
8 /// The answer included a time (`org-time-was-given`).
9 public var timeGiven: Bool
10 /// An end time such as `11:00`, from `10:00-11:00` (`org-end-time-was-given`).
11 public var endTime: String?
12
13 /// Normalized like `encode-time` (day 32 becomes the next month).
14 public func components(_ calendar: Calendar) -> DateComponents {
15 var components = DateComponents(year: year, month: month, day: 1, hour: 0, minute: 0)
16 let base = calendar.date(from: components)!
17 let date = calendar.date(byAdding: DateComponents(month: 0, day: day - 1, hour: hour, minute: minute), to: base)!
18 components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .weekday], from: date)
19 return components
20 }
21 }
22
23 static let weekdays: [(String, Int)] = [
24 ("sun", 0), ("mon", 1), ("tue", 2), ("wed", 3), ("thu", 4), ("fri", 5), ("sat", 6),
25 ("sunday", 0), ("monday", 1), ("tuesday", 2), ("wednesday", 3), ("thursday", 4), ("friday", 5), ("saturday", 6),
26 ]
27 static let months: [(String, Int)] = [
28 ("jan", 1), ("feb", 2), ("mar", 3), ("apr", 4), ("may", 5), ("jun", 6), ("jul", 7), ("aug", 8), ("sep", 9),
29 ("oct", 10), ("nov", 11), ("dec", 12), ("january", 1), ("february", 2), ("march", 3), ("april", 4), ("june", 6),
30 ("july", 7), ("august", 8), ("september", 9), ("october", 10), ("november", 11), ("december", 12),
31 ]
32
33 enum Token: Equatable {
34 case number(Int)
35 case word(String)
36 }
37
38 /// `parse-time-tokenize` on the downcased string.
39 static func tokenize(_ string: String) -> [Token] {
40 let chars = Array(string.lowercased().unicodeScalars)
41 func kind(_ c: Unicode.Scalar) -> Character? {
42 switch c {
43 case "a"..."z": "a"
44 case "0"..."9": "0"
45 case "+", "-", ":": Character(c)
46 default: nil
47 }
48 }
49 var tokens: [Token] = []
50 var index = 0
51 while index < chars.count {
52 while index < chars.count, kind(chars[index]) == nil { index += 1 }
53 guard index < chars.count else { break }
54 let start = index
55 var digits = kind(chars[index]) == "0"
56 index += 1
57 while index < chars.count, let k = kind(chars[index]) {
58 digits = digits && k == "0"
59 index += 1
60 }
61 let text = String(String.UnicodeScalarView(chars[start..<index]))
62 tokens.append(digits ? .number(Int(text) ?? 0) : .word(text))
63 }
64 return tokens
65 }
66
67 /// `parse-time-string`'s fallback: (sec, min, hour, day, month, year, weekday).
68 static func parseTime(_ string: String) -> [Int?] {
69 var time: [Int?] = Array(repeating: nil, count: 9)
70 func digits(_ text: String, _ range: Range<Int>) -> Int? {
71 let chars = Array(text)
72 guard range.upperBound <= chars.count else { return nil }
73 return Int(String(chars[range]))
74 }
75 for token in tokenize(string) {
76 if time[6] == nil, case .word(let w) = token, let day = weekdays.first(where: { $0.0 == w }) {
77 time[6] = day.1
78 } else if time[3] == nil, case .number(let n) = token, (1...31).contains(n) {
79 time[3] = n
80 } else if time[4] == nil, case .word(let w) = token, let month = months.first(where: { $0.0 == w }) {
81 time[4] = month.1
82 } else if time[5] == nil, case .number(let n) = token, n >= 100 {
83 time[5] = n
84 } else if time[2] == nil, case .word(let w) = token, w.count == 8, Array(w)[2] == ":", Array(w)[5] == ":" {
85 (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), digits(w, 6..<8))
86 } else if time[5] == nil, case .word(let w) = token, w.count == 10, Array(w)[4] == "-", Array(w)[7] == "-" {
87 (time[5], time[4], time[3]) = (digits(w, 0..<4), digits(w, 5..<7), digits(w, 8..<10))
88 } else if time[2] == nil, case .word(let w) = token, w.count == 5, Array(w)[2] == ":" {
89 (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), 0)
90 } else if time[2] == nil, case .word(let w) = token, w.count == 4, Array(w)[1] == ":" {
91 (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), 0)
92 } else if time[2] == nil, case .word(let w) = token, w.count == 7, Array(w)[1] == ":" {
93 (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), digits(w, 5..<7))
94 } else if time[5] == nil, case .number(let n) = token, (50...110).contains(n) {
95 time[5] = 1900 + n
96 } else if time[5] == nil, case .number(let n) = token, (0...49).contains(n) {
97 time[5] = 2000 + n
98 }
99 }
100 return time
101 }
102
103 /// `org-small-year-to-year`.
104 static func smallYear(_ year: Int, currentYear: Int) -> Int {
105 guard year < 100 else { return year }
106 let century = currentYear / 100
107 let offset = year - currentYear % 100
108 if offset > 30 { return (century - 1) * 100 + year }
109 if offset > -70 { return century * 100 + year }
110 return (century + 1) * 100 + year
111 }
112
113 /// Replaces the first match of `pattern` in `string`, with `$n` group references.
114 private static func replaceFirst(_ string: inout String, _ match: NSTextCheckingResult, _ template: String) {
115 let ns = string as NSString
116 var result = ""
117 var chars = Array(template)
118 var i = 0
119 while i < chars.count {
120 if chars[i] == "\\", i + 1 < chars.count, let n = chars[i + 1].wholeNumberValue {
121 let range = match.range(at: n)
122 if range.location != NSNotFound { result += ns.substring(with: range) }
123 i += 2
124 } else {
125 result.append(chars[i])
126 i += 1
127 }
128 }
129 chars = []
130 string = ns.replacingCharacters(in: match.range, with: result)
131 }
132
133 private static func firstMatch(_ pattern: String, _ string: String, caseInsensitive: Bool = false) -> NSTextCheckingResult? {
134 let regex = try! NSRegularExpression(pattern: pattern, options: caseInsensitive ? [.caseInsensitive] : [])
135 return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length))
136 }
137
138 private static func group(_ match: NSTextCheckingResult, _ i: Int, _ string: String) -> String? {
139 let range = match.range(at: i)
140 return range.location == NSNotFound ? nil : (string as NSString).substring(with: range)
141 }
142
143 /// `org-read-date-get-relative`: (shift, unit, relative to the default).
144 static func relative(_ string: String, today: DateComponents, default def: DateComponents) -> (Int, String, Bool, NSTextCheckingResult)? {
145 let names = weekdays.map(\.0).joined(separator: "|")
146 guard let match = firstMatch("^[ \\t]*([-+]{0,2})([0-9]+)?([hdwmy]|(" + names + "))?([ \\t]|$)", string, caseInsensitive: true) else { return nil }
147 let sign = group(match, 1, string) ?? ""
148 guard !sign.isEmpty || match.range(at: 4).location != NSNotFound else { return nil }
149 let direction: Character = sign.last ?? "+"
150 let relativeToDefault = sign.count == 2
151 let n = group(match, 2, string).flatMap { Int($0) } ?? 1
152 let what = group(match, 3, string) ?? "d"
153 let date = relativeToDefault ? def : today
154 if let target = weekdays.first(where: { $0.0 == what.lowercased() })?.1 {
155 let weekday = (date.weekday ?? 1) - 1
156 var delta = (7 + target - weekday) % 7
157 if delta == 0 { delta = 7 }
158 if direction == "-" {
159 delta -= 7
160 if delta == 0 { delta = -7 }
161 }
162 if n > 1 { delta += (n - 1) * (direction == "-" ? -7 : 7) }
163 return (delta, "d", relativeToDefault, match)
164 }
165 return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match)
166 }
167
168 /// `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 {
170 let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday]
171 let def = calendar.dateComponents(units, from: defaultDate)
172 let today = calendar.dateComponents(units, from: now)
173 let currentYear = today.year!
174 var ans = input
175 if firstMatch("^[ \\t]*\\.[ \\t]*$", ans) != nil { ans = "+0" }
176 var delta: (Int, String, Bool)?
177 if let relative = relative(ans, today: today, default: def) {
178 delta = (relative.0, relative.1, relative.2)
179 ans = (ans as NSString).replacingCharacters(in: relative.3.range, with: "")
180 }
181 var isoYear: Int?, isoWeekday: Int?, isoWeek: Int?
182 if let match = firstMatch("\\b(?:([0-9]+)-)?[wW]([0-9]{1,2})(?:-([0-6]))?([ \\t]|$)", ans) {
183 isoYear = group(match, 1, ans).flatMap { Int($0) }.map { smallYear($0, currentYear: currentYear) }
184 isoWeekday = group(match, 3, ans).flatMap { Int($0) }
185 isoWeek = group(match, 2, ans).flatMap { Int($0) }
186 ans = (ans as NSString).replacingCharacters(in: match.range, with: "")
187 }
188 var killYear = false
189 if let match = firstMatch("^ *(([0-9]+)-)?([0-1]?[0-9])-([0-3]?[0-9])([^-0-9]|$)", ans) {
190 var year = group(match, 2, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }()
191 year = smallYear(year, currentYear: currentYear)
192 let month = Int(group(match, 3, ans)!)!, day = Int(group(match, 4, ans)!)!
193 replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5")
194 }
195 if let match = firstMatch("^ *(3[01]|0?[1-9]|[12][0-9])\\. ?(0?[1-9]|1[012])\\.( ?[1-9][0-9]{3})?", ans) {
196 let year = group(match, 3, ans).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? { killYear = true; return currentYear }()
197 let day = Int(group(match, 1, ans)!)!, month = Int(group(match, 2, ans)!)!
198 replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day))
199 }
200 if let match = firstMatch("^ *(0?[1-9]|1[012])/(0?[1-9]|[12][0-9]|3[01])(/([0-9]+))?([^/0-9]|$)", ans) {
201 var year = group(match, 4, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }()
202 year = smallYear(year, currentYear: currentYear)
203 let month = Int(group(match, 1, ans)!)!, day = Int(group(match, 2, ans)!)!
204 replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5")
205 }
206 let plainTime = "(^|[^+])[012]?[0-9]:[0-9][0-9]([ \\t\\n]|$)"
207 for _ in 0..<2 where firstMatch(plainTime, ans) == nil {
208 guard let match = firstMatch("([012]?[0-9])(:([0-5][0-9]))?(am|AM|pm|PM)\\b", ans) else { break }
209 var hour = Int(group(match, 1, ans)!)!
210 let minute = group(match, 3, ans).flatMap { Int($0) } ?? 0
211 let pm = group(match, 4, ans)!.lowercased() == "pm"
212 if hour == 12, !pm { hour = 0 } else if pm, hour < 12 { hour += 12 }
213 ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute))
214 }
215 for _ in 0..<2 where firstMatch(plainTime, ans) == nil {
216 guard let match = firstMatch("(?:([012]?[0-9])?h([0-5][0-9]))|(?:([012]?[0-9])h([0-5][0-9])?)\\b", ans) else { break }
217 let hour = (group(match, 1, ans) ?? group(match, 3, ans)).flatMap { Int($0) } ?? 0
218 let minute = (group(match, 2, ans) ?? group(match, 4, ans)).flatMap { Int($0) } ?? 0
219 ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute))
220 }
221 if let match = firstMatch("([012]?[0-9]):([0-6][0-9])\\+([012]?[0-9])(:([0-5][0-9]))?", ans) {
222 let hour = Int(group(match, 1, ans)!)!, minute = Int(group(match, 2, ans)!)!
223 var h2 = hour + Int(group(match, 3, ans)!)!
224 var m2 = minute + (group(match, 5, ans).flatMap { Int($0) } ?? 0)
225 if m2 >= 60 { h2 += 1; m2 -= 60 }
226 ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d-%02d:%02d", hour, minute, h2, m2))
227 }
228 var timeGiven = false
229 var endTime: String?
230 let timeOfDay = "(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b)(--?(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b))?"
231 if let match = firstMatch(timeOfDay, ans), let end = group(match, 8, ans) {
232 endTime = end
233 let range = match.range(at: 7)
234 ans = (ans as NSString).replacingCharacters(in: range, with: "")
235 }
236 let tl = parseTime(ans)
237 var futurep = false
238 var day = tl[3] ?? def.day!
239 let month: Int
240 if let m = tl[4] {
241 month = m
242 } else if tl[3] != nil {
243 futurep = true
244 month = day < today.day! ? today.month! + 1 : today.month!
245 } else {
246 month = def.month!
247 }
248 var year: Int
249 if !killYear, let y = tl[5] {
250 year = y
251 } else if futurep {
252 year = (month > today.month! || day >= today.day!) ? today.year! : today.year! + 1
253 } else if tl[4] != nil {
254 futurep = true
255 if month > today.month! { year = today.year! } else if month < today.month! { year = today.year! + 1 } else if day < today.day! { year = today.year! + 1 } else { year = today.year! }
256 } else {
257 year = def.year!
258 }
259 var month2 = month
260 var hour = tl[2] ?? def.hour!
261 let minute = tl[1] ?? def.minute!
262 var wday = tl[6]
263 if let isoWeek {
264 let isoYearValue = isoYear ?? year
265 let weekday = isoWeekday ?? wday ?? 1
266 wday = nil
267 var iso = Calendar(identifier: .iso8601)
268 iso.timeZone = calendar.timeZone
269 let isoComponents = DateComponents(weekday: weekday == 0 ? 1 : weekday + 1, weekOfYear: isoWeek, yearForWeekOfYear: isoYearValue)
270 if let date = iso.date(from: isoComponents) {
271 let c = iso.dateComponents([.year, .month, .day], from: date)
272 (year, month2, day) = (c.year!, c.month!, c.day!)
273 }
274 } else if let (n, unit, relativeToDefault) = delta {
275 if !relativeToDefault { (day, month2, year) = (today.day!, today.month!, today.year!) }
276 switch unit.lowercased() {
277 case "h", "":
278 timeGiven = true
279 hour += n
280 case "d": day += n
281 case "w": day += 7 * n
282 case "m": month2 += n
283 case "y": year += n
284 default: break
285 }
286 } else if let target = wday, tl[3] == nil {
287 let base = calendar.date(from: DateComponents(year: year, month: month2, day: 1))!
288 let date = calendar.date(byAdding: .day, value: day - 1, to: base)!
289 let current = calendar.component(.weekday, from: date) - 1
290 if target != current { day += ((target - current + 7) % 7 + 7) % 7 }
291 }
292 if tl[2] != nil { timeGiven = true }
293 if year < 100 { year += 2000 }
294 _ = futurep
295 return Answer(year: year, month: month2, day: day, hour: hour, minute: minute, timeGiven: timeGiven, endTime: endTime)
296 }
297}
Tests/OrgCoreTests/EmacsOracle.swift −1
@@ -29,7 +29,6 @@ enum EmacsOracle {
29 (require 'cl-lib) 29 (require 'cl-lib)
30 (setq org-todo-keywords '((sequence "TODO" "DONE")) 30 (setq org-todo-keywords '((sequence "TODO" "DONE"))
31 org-log-done nil 31 org-log-done nil
32 org-log-repeat nil
33 org-adapt-indentation nil 32 org-adapt-indentation nil
34 org-tags-column -77 33 org-tags-column -77
35 org-priority-highest ?A 34 org-priority-highest ?A
Tests/OrgCoreTests/ReadDateTests.swift added +73
@@ -0,0 +1,73 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Emacs Lisp that runs `body` with the clock stopped at `seconds` (UTC in the oracle).
6func frozenClock(_ seconds: Int, _ body: String) -> String {
7 """
8 (cl-letf* ((frozen (seconds-to-time \(seconds)))
9 (odt (symbol-function 'decode-time))
10 (oft (symbol-function 'format-time-string))
11 (otl (symbol-function 'time-less-p))
12 ((symbol-function 'current-time) (lambda () frozen))
13 ((symbol-function 'org-today) (lambda () (time-to-days frozen)))
14 ((symbol-function 'time-less-p) (lambda (a b) (funcall otl (or a frozen) (or b frozen))))
15 ((symbol-function 'decode-time) (lambda (&optional time zone form) (funcall odt (or time frozen) zone form)))
16 ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall oft f (or time frozen) zone))))
17 \(body))
18 """
19}
20
21struct ReadDateTests {
22 /// Thu 2026-09-10 00:26 UTC.
23 static let now = Date(timeIntervalSince1970: 1_789_000_000)
24 static var utc: Calendar {
25 var calendar = Calendar(identifier: .gregorian)
26 calendar.timeZone = TimeZone(identifier: "UTC")!
27 return calendar
28 }
29
30 static func format(_ answer: ReadDate.Answer) -> String {
31 let c = answer.components(utc)
32 let date = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
33 return (answer.timeGiven ? date + String(format: " %02d:%02d", c.hour!, c.minute!) : date) + "|" + (answer.endTime ?? "nil")
34 }
35
36 static let inputs = [
37 "", ".", "+0", "+1", "+2d", "-3d", "+1w", "+2m", "-1y", "++3", "fri", "+fri", "-fri", "+2fri", "mon",
38 "2026-10-07", "2026-10-7", "10-7", "7.10.", "7.10.2027", "10/7", "10/7/27", "12/31",
39 "3", "15", "sep 15", "15 sep", "jan", "dec 25", "2027",
40 "10:00", "9:30", "10am", "3pm", "12am", "12:30pm", "10h", "10h30", "h45",
41 "10:00-11:30", "10:00+1:30", "fri 10:00", "2026-10-07 14:15", "+2d 9:00", "+3h",
42 "w42", "2027-w01", "w3-5", "tomorrow", "nonsense",
43 ]
44
45 @Test func examples() {
46 let answer = ReadDate.analyze("fri 10:00", default: Self.now, now: Self.now, calendar: Self.utc)
47 #expect(Self.format(answer) == "2026-09-11 10:00|nil")
48 #expect(Self.format(ReadDate.analyze("10:00-11:30", default: Self.now, now: Self.now, calendar: Self.utc)) == "2026-09-10 10:00|11:30")
49 }
50
51 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
52 func readDateMatchesEmacs() throws {
53 try #require(EmacsOracle.isAvailable)
54 let cases = Self.inputs.map { input in
55 EmacsOracle.Case(text: "", point: 1, form: frozenClock(1_789_000_000, """
56 (eval '(let ((org-time-was-given nil) (org-end-time-was-given nil))
57 (let ((d (org-read-date nil nil "\(input)")))
58 (insert d "|" (or org-end-time-was-given "nil"))))
59 nil)
60 """))
61 }
62 var mismatches = 0
63 for (input, emacs) in zip(Self.inputs, try EmacsOracle.run(cases)) {
64 let ours = Self.format(ReadDate.analyze(input, default: Self.now, now: Self.now, calendar: Self.utc))
65 let theirs = emacs.error.isEmpty ? emacs.text : "error: \(emacs.error)"
66 if ours != theirs {
67 mismatches += 1
68 Issue.record("\(input.debugDescription): ours \(ours), emacs \(theirs)")
69 }
70 }
71 #expect(mismatches == 0)
72 }
73}
Tests/OrgCoreTests/TimestampCommandTests.swift added +137
@@ -0,0 +1,137 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct TimestampCommandTests {
6 static let now = ReadDateTests.now
7 static let calendar = ReadDateTests.utc
8
9 func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil) -> (text: String, caret: Int, failure: String?) {
10 let context = EditContext(
11 revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret],
12 now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:]
13 )
14 switch command.run(in: context) {
15 case .commit(let result):
16 let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
17 return (new, result.selection?.first?.lowerBound ?? caret, nil)
18 case .failed(let message): return (text, caret, message)
19 case .prompt(let prompt): return (text, caret, "prompt \(prompt.key)")
20 }
21 }
22
23 @Test func examples() {
24 #expect(run(InsertTimestamp(), "x\n", caret: 1, answer: "fri").text == "x<2026-09-11 Fri>\n")
25 #expect(run(SetPlanning(deadline: false), "* a\n", caret: 0, answer: "+1").text == "* a\nSCHEDULED: <2026-09-11 Fri>\n")
26 #expect(run(ShiftTimestampDay(later: true), "<2026-09-10 Thu>\n", caret: 5).text == "<2026-09-11 Fri>\n")
27 #expect(run(ShiftTimestamp(up: true), "<2026-09-10 Thu 10:07>\n", caret: 20).text == "<2026-09-10 Thu 10:10>\n")
28 }
29
30 static let texts = [
31 "* a\n",
32 "* a\nbody <2026-09-01 Tue> text\n",
33 "* a\nSCHEDULED: <2026-09-01 Tue +1w>\n",
34 "* a\nDEADLINE: <2026-09-20 Sun> SCHEDULED: <2026-09-01 Tue 10:00-11:30>\n",
35 "* a\nCLOSED: [2026-09-02 Wed 09:00] SCHEDULED: <2026-09-01 Tue>\n",
36 "* a\n[2026-12-31 Thu 23:58] <2026-01-31 Sat .+2m -3d>\n",
37 "text <2026-02-28 Sat 07:03>\n",
38 ]
39 static let answers = ["", "+1", "fri 9:00", "2027-01-15", "10:00-11:00", "++2d"]
40
41 static let commands: [(any OrgCommand, String)] = [
42 (InsertTimestamp(), "(org-timestamp nil)"),
43 (InsertTimestamp(inactive: true), "(org-timestamp nil t)"),
44 (SetPlanning(deadline: false), "(org-schedule nil)"),
45 (SetPlanning(deadline: true), "(org-deadline nil)"),
46 (RemovePlanning(deadline: false), "(org-schedule '(4))"),
47 (ShiftTimestamp(up: true), "(org-timestamp-up)"),
48 (ShiftTimestamp(up: false), "(org-timestamp-down)"),
49 (ShiftTimestampDay(later: true), "(org-timestamp-up-day)"),
50 (ShiftTimestampDay(later: false), "(org-timestamp-down-day)"),
51 ]
52
53 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
54 func timestampCommandsMatchEmacs() throws {
55 try #require(EmacsOracle.isAvailable)
56 var cases: [EmacsOracle.Case] = []
57 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
58 for text in Self.texts {
59 let tree = OrgParser.parse(text)
60 for caret in EmacsOracle.positions(text) {
61 for (command, form) in Self.commands {
62 let prompts = command is InsertTimestamp || command is SetPlanning
63 if command is RemovePlanning || command is SetPlanning, entryHeading(at: caret, in: tree) == nil { continue }
64 if command is ShiftTimestamp || command is ShiftTimestampDay, EmacsBuffer(text, point: caret).atTimestamp() == nil { continue }
65 for answer in prompts ? Self.answers : [""] {
66 let escaped = answer.replacingOccurrences(of: "\"", with: "\\\"")
67 let wrapped = frozenClock(1_789_000_000, """
68 (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(escaped)")))
69 (let ((org-read-date-popup-calendar nil)) \(form)))
70 """)
71 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped))
72 let result = run(command, text, caret: caret, answer: prompts ? answer : nil)
73 ours.append(("\(command.id) \(answer.debugDescription) at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
74 }
75 }
76 }
77 }
78 var mismatches = 0
79 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
80 let failed = !emacs.error.isEmpty
81 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
82 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
83 mismatches += 1
84 if mismatches <= 12 {
85 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
86 }
87 }
88 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
89 }
90
91 static let repeating = [
92 "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n",
93 "* TODO a\nDEADLINE: <2026-09-01 Tue .+1d>\n",
94 "* TODO a\nSCHEDULED: <2026-08-01 Sat ++1m>\nbody\n",
95 "* TODO a :t:\n<2026-09-08 Tue 10:00 +2d>\n",
96 "* TODO a\nDEADLINE: <2026-09-05 Sat +1w> SCHEDULED: <2026-09-01 Tue>\n",
97 "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n:PROPERTIES:\n:LAST_REPEAT: [2026-09-01 Tue 08:00]\n:END:\n- State \"DONE\" from \"TODO\" [2026-09-01 Tue 08:00]\nnotes\n",
98 "#+TODO: TODO STRT | DONE\n* STRT a\nSCHEDULED: <2026-09-01 Tue +1d>\n",
99 "* P [0/1]\n** TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n* Q\n",
100 "* TODO no repeat\nSCHEDULED: <2026-09-01 Tue>\n",
101 "* TODO hourly\nSCHEDULED: <2026-09-10 Thu 00:00 +1h>\n",
102 ]
103
104 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
105 func repeatsMatchEmacs() throws {
106 try #require(EmacsOracle.isAvailable)
107 var cases: [EmacsOracle.Case] = []
108 var ours: [(label: String, text: String, caret: Int)] = []
109 for text in Self.repeating {
110 let ns = text as NSString
111 let heading = ns.range(of: "\n\\*|^\\*", options: .regularExpression)
112 let start = heading.location == 0 ? 0 : heading.location + 1
113 let lineEnd = ns.range(of: "\n", range: NSRange(location: start, length: ns.length - start)).location
114 for caret in [start, start + 2, lineEnd, min(lineEnd + 3, ns.length)] {
115 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(1_789_000_000, "(progn (org-todo) (when org-log-setup (org-add-log-note)))")))
116 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Self.now, calendar: Self.calendar)
117 guard case .commit(let result) = TodoCycle().run(in: context) else {
118 ours.append(("failed at \(caret)", "", -1))
119 continue
120 }
121 let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
122 ours.append(("at \(caret) in \(text.debugDescription)", new, result.selection?.first?.lowerBound ?? caret))
123 }
124 }
125 var mismatches = 0
126 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
127 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
128 guard !emacs.error.isEmpty || mine.text != emacs.text || mine.caret != caret else { continue }
129 mismatches += 1
130 if mismatches <= 10 {
131 let (a, b) = excerpts(mine.text, emacs.text)
132 Issue.record("\(mine.label)\n ours: \(a.debugDescription) @\(mine.caret)\n emacs: \(emacs.error.isEmpty ? "\(b.debugDescription) @\(caret)" : emacs.error)")
133 }
134 }
135 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
136 }
137}
docs/plans/2026-10-05-timestamps.md added +37
@@ -0,0 +1,37 @@
1# Timestamps Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Org's date prompt, timestamp insertion (C-c ., C-c !), scheduling and deadlines (C-c C-s, C-c C-d, and removal), timestamp shifting (S-arrows on a timestamp), and repeating entries completed with DONE, all byte- and caret-exact against Emacs with a frozen clock.
6
7**Architecture:** `ReadDate` ports `org-read-date-analyze` with `parse-time-string`'s tokenizer and rules, `org-read-date-get-relative` and `org-small-year-to-year`. `Timestamps.swift` ports `org-at-timestamp-p` (point categories), `org-insert-timestamp`, `org-timestamp-change` with `org-modify-ts-extra`, `org-toggle-timestamp-type`, `org-add-planning-info`. `TimestampCommands.swift` holds `org-timestamp`, `org--deadline-or-schedule` and the shift commands. `Repeat.swift` ports `org-auto-repeat-maybe` and the state note (`org-store-log-note`, `org-log-beginning`); TODO cycling runs it when an entry becomes done. Commands read `now` and `calendar` from `EditContext`. A `timestamp` key context lets S-arrows shift timestamps and fall back to priority and TODO on headings.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle.
10
11**Spec:** `docs/design.md`, "Commands and keymaps" (revisions, time and prompts).
12
13## Global Constraints
14
15- Org defaults: `org-read-date-prefer-future` t, `org-time-stamp-rounding-minutes` (0 5), `org-log-repeat` `time`, `org-log-into-drawer` nil, `org-log-states-order-reversed` t, `org-todo-repeat-to-state` nil, `org-log-reschedule`/`org-log-redeadline` nil.
16- The oracle freezes `current-time`, `decode-time`, `format-time-string`, `org-today` and `time-less-p`, runs with `TZ=UTC`, stubs `read-string` with the answer, and stores the deferred log note as the post-command hook would.
17
18## Defaults chosen (user may change)
19
20- Mac preset: ⌃⌘. timestamp, ⌃⌥⌘. inactive, ⌃⌘S schedule, ⌃⌘D deadline, ⌃⌘ arrows shift a timestamp under the caret.
21- Not yet: date ranges in the prompt and S-arrows on ranges, the calendar picker, CLOCK line updates, deadline warning edits (C-u C-u).
22
23---
24
25### Task 1: Date prompt
26
27- [ ] Port; oracle over 52 inputs (relative, weekdays, ISO weeks, US and European dates, am/pm, `HHhMM`, ranges, durations).
28- [ ] Commit "Port the date prompt".
29
30### Task 2: Timestamps, planning, repeats
31
32- [ ] Port; oracle over 7 texts × every caret × 9 commands × 6 answers, and 10 repeating entries.
33- [ ] Commit "Port timestamp and planning commands".
34
35### Task 3: Keys
36
37- [ ] Bind both presets; `timestamp` context.