krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 14aff41909

14aff41909f6440a3c08fe5aa97884f0b51e5c01

parent: d4c76b8503

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 04:33 UTC

Replace a range's end with C-c . between its dashes

org-timestamp reads its default from the range's start before the dashes and from its end from the second-to-last dash on, replacing that stamp. Twice in a row it defaults the new range end to the stamp at point instead of today.

Layout: unified · split

Sources/OrgCore/Commands/TimestampCommands.swift +12 −9
@@ -14,12 +14,17 @@ extension EmacsBuffer {
14 14
15 /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one. 15 /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one.
16 func timestampCommand(_ answer: String, inactive: Bool) { 16 func timestampCommand(_ answer: String, inactive: Bool) {
17 let existing = atTimestamp() 17 let stamp = timestampForUpdate()
18 let stamp = existing.map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
19 let defaultDate = stamp.flatMap(Self.parseTimeString).map(date) 18 let defaultDate = stamp.flatMap(Self.parseTimeString).map(date)
20 let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } } 19 let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } }
21 let result = readDate(answer, default: defaultDate) 20 let result = readDate(answer, default: defaultDate)
22 let components = result.components(calendar) 21 let components = result.components(calendar)
22 // Between a range's dashes, the end is replaced.
23 var existing = atTimestamp()
24 if stamp != nil, existing == nil {
25 skipForward([45])
26 existing = atTimestamp()
27 }
23 if let existing { 28 if let existing {
24 replace(existing.match[0]!, with: "") 29 replace(existing.match[0]!, with: "")
25 insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime) 30 insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
@@ -95,8 +100,8 @@ private func atTimestamp(_ context: EditContext) -> Bool {
95/// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it. 100/// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it.
96private func timeOfDayAtCaret(_ context: EditContext) -> String { 101private func timeOfDayAtCaret(_ context: EditContext) -> String {
97 let buffer = EmacsBuffer(context.text, point: context.caret) 102 let buffer = EmacsBuffer(context.text, point: context.caret)
98 guard let match = buffer.atTimestamp()?.match[0] else { return "" } 103 guard let stamp = buffer.timestampForUpdate() else { return "" }
99 return EmacsBuffer.compactTimeOfDay(buffer.substring(match.lowerBound, match.upperBound)) ?? "" 104 return EmacsBuffer.compactTimeOfDay(stamp) ?? ""
100} 105}
101 106
102/// C-c . and C-c !: `org-timestamp`. 107/// C-c . and C-c !: `org-timestamp`.
@@ -110,16 +115,14 @@ public struct InsertTimestamp: OrgCommand {
110 guard let answer = context.answers["date"] else { 115 guard let answer = context.answers["date"] else {
111 let buffer = EmacsBuffer(context.text, point: context.caret) 116 let buffer = EmacsBuffer(context.text, point: context.caret)
112 buffer.calendar = context.calendar 117 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), 118 return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context),
116 date: .init(defaultDate: repeated ? nil : buffer.dateDefault(stamp), inactive: inactive))) 119 date: .init(defaultDate: buffer.dateDefault(buffer.timestampForUpdate()), inactive: inactive)))
117 } 120 }
118 // Twice in a row on a timestamp: a range to a second one. 121 // 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" 122 let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive"
120 if repeated, EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil { 123 if repeated, EmacsBuffer(context.text, point: context.caret).timestampForUpdate() != nil {
121 return timestampCommand(context) { buffer in 124 return timestampCommand(context) { buffer in
122 let result = buffer.readDate(answer, default: nil) 125 let result = buffer.readDate(answer, default: buffer.dateDefault(buffer.timestampForUpdate()))
123 buffer.insert("--") 126 buffer.insert("--")
124 buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive) 127 buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive)
125 } 128 }
Sources/OrgCore/Commands/Timestamps.swift +25
@@ -66,6 +66,31 @@ extension EmacsBuffer {
66 return (part, match) 66 return (part, match)
67 } 67 }
68 68
69 /// `org-at-date-range-p` with INACTIVE-OK: the range around point, groups 1 and 2 the insides
70 /// of its two timestamps.
71 func atDateRange() -> [Range<Int>?]? {
72 let pattern = "[\\[<](" + AgendaSource.tsInternal + ")[\\]>]--?-?[\\[<](" + AgendaSource.tsInternal + ")[\\]>]"
73 return saveExcursion {
74 let pos = point
75 for _ in 0..<2 {
76 while let c = character(at: point - 1), ![91, 60, 13, 10].contains(c) { point -= 1 }
77 skipBackward([60, 91])
78 if let match = lookingAt(pattern), match[0]!.upperBound >= pos { return match }
79 }
80 return nil
81 }
82 }
83
84 /// The timestamp `org-timestamp` takes its defaults from: in a date range the start before
85 /// the dashes and the end from the second-to-last dash on, otherwise the one at point.
86 func timestampForUpdate() -> String? {
87 if let range = atDateRange() {
88 let stamp = point < range[2]!.lowerBound - 2 ? range[1]! : range[2]!
89 return substring(stamp.lowerBound, stamp.upperBound)
90 }
91 return atTimestamp().map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
92 }
93
69 /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent. 94 /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent.
70 static func parseTimeString(_ text: String) -> DateComponents? { 95 static func parseTimeString(_ text: String) -> DateComponents? {
71 guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil } 96 guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil }
Tests/OrgCoreTests/TimestampCommandTests.swift +46 −2
@@ -6,10 +6,10 @@ struct TimestampCommandTests {
6 static let now = ReadDateTests.now 6 static let now = ReadDateTests.now
7 static let calendar = ReadDateTests.utc 7 static let calendar = ReadDateTests.utc
8 8
9 func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil) -> (text: String, caret: Int, failure: String?) { 9 func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil, lastCommand: String? = nil) -> (text: String, caret: Int, failure: String?) {
10 let context = EditContext( 10 let context = EditContext(
11 revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], 11 revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret],
12 now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:] 12 now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:], lastCommand: lastCommand
13 ) 13 )
14 switch command.run(in: context) { 14 switch command.run(in: context) {
15 case .commit(let result): 15 case .commit(let result):
@@ -100,6 +100,50 @@ struct TimestampCommandTests {
100 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") 100 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
101 } 101 }
102 102
103 /// `org-timestamp` in and around date ranges: the start before the dashes, the end from the
104 /// second dash on, and twice in a row a new range wherever there is a stamp to default from.
105 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
106 func timestampsInRangesMatchEmacs() throws {
107 try #require(EmacsOracle.isAvailable)
108 let texts = [
109 "* a\n:LOGBOOK:\nCLOCK: [2026-09-01 Tue 10:00]--[2026-09-01 Tue 11:30] => 1:30\n:END:\n",
110 "x <2026-09-01 Tue>--<2026-09-03 Thu> y\n",
111 "[2026-09-01 Tue]-[2026-09-02 Wed]\n",
112 "<2026-09-01 Tue 10:00>---<2026-09-01 Tue 12:00 +1w>\n",
113 ]
114 var cases: [EmacsOracle.Case] = []
115 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
116 for text in texts {
117 for caret in EmacsOracle.positions(text) {
118 for (command, form) in [(InsertTimestamp(), "(org-timestamp nil)"), (InsertTimestamp(inactive: true), "(org-timestamp nil t)")] {
119 for answer in ["", "+1", "10:00", "++2d"] {
120 for repeated in [false, true] {
121 let commands = repeated ? "(last-command 'org-timestamp) (this-command 'org-timestamp)" : ""
122 let wrapped = frozenClock(1_789_000_000, """
123 (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(answer)")))
124 (let ((org-read-date-popup-calendar nil) \(commands)) \(form)))
125 """)
126 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped))
127 let result = run(command, text, caret: caret, answer: answer, lastCommand: repeated ? "org.timestamp.active" : nil)
128 ours.append(("\(command.id) \(answer.debugDescription)\(repeated ? " repeated" : "") at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
129 }
130 }
131 }
132 }
133 }
134 var mismatches = 0
135 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
136 let failed = !emacs.error.isEmpty
137 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
138 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
139 mismatches += 1
140 if mismatches <= 12 {
141 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
142 }
143 }
144 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
145 }
146
103 /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`). 147 /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`).
104 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 148 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
105 func clockShiftsMatchEmacs() throws { 149 func clockShiftsMatchEmacs() throws {