krz/orgstar

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

Commit d92c7f39e4

d92c7f39e46e785d7ec0f4297d1f0747c076f58e

parent: 15dd64c4f2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:43 UTC

Update the CLOCK duration after shifting its timestamps

org-timestamp-change ends with org-clock-update-time-maybe, so
S-up/S-down and the day shifts on a CLOCK line rewrite => H:MM.

Layout: unified · split

Sources/OrgCore/Commands/CtrlCCtrlC.swift +2 −2
@@ -93,12 +93,12 @@ extension EmacsBuffer {
93 match[2] != nil, let first = match[1] else { return } 93 match[2] != nil, let first = match[1] else { return }
94 saveExcursion { 94 saveExcursion {
95 point = first.lowerBound 95 point = first.lowerBound
96 try? changeTimestamp(0, what: .day) 96 try? changeTimestamp(0, what: .day, updateClock: false)
97 } 97 }
98 guard let again = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"), let second = again[3] else { return } 98 guard let again = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"), let second = again[3] else { return }
99 saveExcursion { 99 saveExcursion {
100 point = second.lowerBound 100 point = second.lowerBound
101 try? changeTimestamp(0, what: .day) 101 try? changeTimestamp(0, what: .day, updateClock: false)
102 } 102 }
103 guard let final = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"), 103 guard let final = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"),
104 let start = final[1], let end = final[3] else { return } 104 let start = final[1], let end = final[3] else { return }
Sources/OrgCore/Commands/Timestamps.swift +8 −2
@@ -149,8 +149,9 @@ extension EmacsBuffer {
149 149
150 enum TimestampUnit { case minute, hour, day, month, year } 150 enum TimestampUnit { case minute, hour, day, month, year }
151 151
152 /// `org-timestamp-change`. `what` nil changes the part at point. 152 /// `org-timestamp-change`. `what` nil changes the part at point. On a CLOCK line the
153 func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false) throws { 153 /// duration is then updated, unless `updateClock` is false.
154 func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false, updateClock: Bool = true) throws {
154 let origin = point 155 let origin = point
155 guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") } 156 guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") }
156 if what == nil, category == .bracket { 157 if what == nil, category == .bracket {
@@ -215,6 +216,11 @@ extension EmacsBuffer {
215 case .after: point = after[0]!.upperBound 216 case .after: point = after[0]!.upperBound
216 default: point = origin 217 default: point = origin
217 } 218 }
219 if updateClock {
220 let before = point
221 clockUpdateTime()
222 if point >= before { point = before }
223 }
218 } 224 }
219 225
220 /// `org-toggle-timestamp-type`. 226 /// `org-toggle-timestamp-type`.
Tests/OrgCoreTests/TimestampCommandTests.swift +31
@@ -100,6 +100,37 @@ 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 /// 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))
105 func clockShiftsMatchEmacs() throws {
106 let text = "* a\n:LOGBOOK:\nCLOCK: [2026-09-01 Tue 10:00]--[2026-09-01 Tue 11:30] => 1:30\n CLOCK: [2026-09-02 Wed 23:58]--[2026-09-03 Thu 00:02]\nCLOCK: [2026-09-04 Fri 09:00]\n:END:\n"
107 let commands: [(any OrgCommand, String)] = [
108 (ShiftTimestamp(up: true), "(org-timestamp-up)"), (ShiftTimestamp(up: false), "(org-timestamp-down)"),
109 (ShiftTimestampDay(later: true), "(org-timestamp-up-day)"), (ShiftTimestampDay(later: false), "(org-timestamp-down-day)"),
110 ]
111 var cases: [EmacsOracle.Case] = []
112 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
113 for caret in EmacsOracle.positions(text) where EmacsBuffer(text, point: caret).atTimestamp() != nil {
114 for (command, form) in commands {
115 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(1_789_000_000, form)))
116 let result = run(command, text, caret: caret)
117 ours.append(("\(command.id) at \(caret)", result.text, result.caret, result.failure != nil))
118 }
119 }
120 var mismatches = 0
121 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
122 let failed = !emacs.error.isEmpty
123 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
124 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
125 mismatches += 1
126 if mismatches <= 12 {
127 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
128 }
129 }
130 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
131 #expect(run(ShiftTimestamp(up: true), text, caret: (text as NSString).range(of: "11:30").location + 4).text.contains("11:35] => 1:35\n"))
132 }
133
103 static let repeating = [ 134 static let repeating = [
104 "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n", 135 "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n",
105 "* TODO a\nDEADLINE: <2026-09-01 Tue .+1d>\n", 136 "* TODO a\nDEADLINE: <2026-09-01 Tue .+1d>\n",