Ihor Radchenko <[email protected]> writes:
> Morgan Smith <[email protected]> writes:
>
>> I have made the executive decision to allow negative durations in clock
>> lines. Personally I accidentally make negative durations all the time.
>> I'll edit a timestamp and hit "C-c C-c" and see a negative duration.
>> This lets me know I messed something up.
>>
>> The specific reason I need to allow negative durations for this change
>> is because I create negative duration in an intermediate step.
>
> Are you sure that we want to change Org syntax spec just to make the
> implementation simpler? That's going to be a breaking change.
Despite the time I've spent studying the parser, I honestly have no clue
what the implications of this change would be.
Anyways see attached for a version that doesn't need that change.
I previously got `org-clock-timestamps-change' confused with
`org-timestamp-{up|down}' and implemented the wrong logic. That logic
would've been tricky without negative durations. The correct logic
actually isn't that hard.
>From f09c5d438b95f214c3f31fc022fe9451ffb4a468 Mon Sep 17 00:00:00 2001
From: Morgan Smith <[email protected]>
Date: Sun, 5 Apr 2026 18:23:40 -0400
Subject: [PATCH 1/2] org-element: Add match groups to
`org-element-clock-line-re'
* lisp/org-element.el (org-element-clock-line-re): Add 3 match groups
and document them in the docstring.
(org-element-clock-line-re-no-group): New regex that has the old value
of 'org-element-clock-line-re'.
(org-element--set-regexps, org-element--current-element): Use
'org-element-clock-line-re-no-group'.
---
lisp/org-element.el | 56 +++++++++++++++++++++++++++------------------
1 file changed, 34 insertions(+), 22 deletions(-)
diff --git a/lisp/org-element.el b/lisp/org-element.el
index 313922ecc..fe78dfb2c 100644
--- a/lisp/org-element.el
+++ b/lisp/org-element.el
@@ -122,26 +122,38 @@ org-element-citation-prefix-re
"Regexp matching a citation prefix.
Style, if any, is located in match group 1.")
-(defconst org-element-clock-line-re
- (let ((duration ; "=> 212:12"
- '(seq
- (1+ (or ?\t ?\s)) "=>" (1+ (or ?\t ?\s))
- (1+ digit) ":" digit digit)))
- (rx-to-string
- `(seq
- line-start (0+ (or ?\t ?\s))
- "CLOCK:"
- (or
- (seq
- (1+ (or ?\t ?\s))
- (regexp ,org-ts-regexp-inactive)
- (opt "--"
- (regexp ,org-ts-regexp-inactive)
- ,duration))
- ,duration)
- (0+ (or ?\t ?\s))
- line-end)))
- "Regexp matching a clock line.")
+(rx-let
+ ((duration
+ (seq
+ (1+ (or ?\t ?\s)) "=>" (1+ (or ?\t ?\s))
+ (1+ digit) ":" digit digit))
+ (before (seq line-start (0+ (or ?\t ?\s)) "CLOCK:"))
+ (after (seq (0+ (or ?\t ?\s)) line-end)))
+ (defconst org-element-clock-line-re-no-group
+ (rx before
+ (or
+ (seq (1+ (or ?\t ?\s))
+ (regexp org-ts-regexp-inactive)
+ (opt "--"
+ (regexp org-ts-regexp-inactive)
+ duration))
+ duration)
+ after)
+ "Regexp matching a clock line.")
+ (defconst org-element-clock-line-re
+ (rx before
+ (or
+ (seq (1+ (or ?\t ?\s))
+ (group-n 1 (regexp org-ts-regexp-inactive))
+ (opt "--"
+ (group-n 2 (regexp org-ts-regexp-inactive))
+ (group-n 3 duration)))
+ (group-n 3 duration))
+ after)
+ "Regexp matching a clock line.
+The first timestamp is in match group 1.
+The second timestamp is in match group 2.
+The duration is in match group 3."))
(defconst org-element-comment-string "COMMENT"
"String marker for commented headlines.")
@@ -244,7 +256,7 @@ org-element--set-regexps
;; LaTeX environments.
"\\\\begin{\\([A-Za-z0-9*]+\\)}" "\\|"
;; Clock lines.
- org-element-clock-line-re "\\|"
+ org-element-clock-line-re-no-group "\\|"
;; Lists.
(let ((term (pcase org-plain-list-ordered-item-terminator
(?\) ")") (?. "\\.") (_ "[.)]")))
@@ -4823,7 +4835,7 @@ org-element--current-element
;; a footnote definition: next item is always a paragraph.
((not (bolp)) (org-element-paragraph-parser limit (list (point))))
;; Clock.
- ((looking-at-p org-element-clock-line-re) (org-element-clock-parser limit))
+ ((looking-at-p org-element-clock-line-re-no-group) (org-element-clock-parser limit))
;; Inlinetask.
(at-task? (org-element-inlinetask-parser limit raw-secondary-p))
;; From there, elements can have affiliated keywords.
base-commit: 4dc39c7eb3d481984fabfe2bfe578da51fb9c779
--
2.54.0
>From a0a1c96ae90f1b742414c2da47b1f3531061c958 Mon Sep 17 00:00:00 2001
From: Morgan Smith <[email protected]>
Date: Sun, 29 Mar 2026 14:12:54 -0400
Subject: [PATCH 2/2] lisp/org-clock.el: Rewrite 'org-clock-timestamps-change'
The previous implementation had a couple bugs.
1. It did strange things when the timestamps changed length
mid-operation (like if the weekday name got added during the call to
`org-timestamp-change').
2. In certain instances it double incremented the first timestamp,
leaving the second one unchanged. I assume this was also due to
managing the point wrong.
3. When used on a single timestamp, it would find the next timestamp
in the buffer and increment that one too.
* lisp/org-clock.el (org-clock-timestamps-change): Rewrite.
* testing/lisp/test-org-clock.el
(test-org-clock/org-clock-timestamps-change): Use 'string-equal'
instead of 'equal'. Add a test for a case that was previously failing.
Allow testing up to day 28 in month. Test that point does not change.
---
lisp/org-clock.el | 72 +++++++++++++---------------------
testing/lisp/test-org-clock.el | 46 +++++++++++++++-------
2 files changed, 58 insertions(+), 60 deletions(-)
diff --git a/lisp/org-clock.el b/lisp/org-clock.el
index a1de045fe..f3cdf017e 100644
--- a/lisp/org-clock.el
+++ b/lisp/org-clock.el
@@ -61,6 +61,7 @@ org-element-use-cache
(defvar org-frame-title-format-backup nil)
(defvar org-state)
(defvar org-link-bracket-re)
+(defvar org-element-clock-line-re)
(defgroup org-clock nil
"Options concerning clocking working time in Org mode."
@@ -1932,52 +1933,33 @@ org-clock-timestamps-change
"Change CLOCK timestamps synchronously at cursor.
UPDOWN tells whether to change `up' or `down'.
Optional argument N tells to change by that many units."
- (let ((tschange (if (eq updown 'up) 'org-timestamp-up
- 'org-timestamp-down))
- (timestamp? (org-at-timestamp-p 'lax))
- ts1 begts1 ts2 begts2 updatets1 tdiff)
+ ;; FIXME: this is the wrong way to deal with prefix arguments and is
+ ;; in conflict with the docstring
+ (setq n (prefix-numeric-value n))
+ (let ((original-point (point))
+ (tschange (if (eq updown 'up) n (- n)))
+ (timestamp? (org-at-timestamp-p 'lax)))
(when (not (memq timestamp? '(nil bracket after)))
- (save-excursion
- (move-beginning-of-line 1)
- (re-search-forward org-ts-regexp3 nil t)
- (setq ts1 (match-string 0) begts1 (match-beginning 0))
- (when (re-search-forward org-ts-regexp3 nil t)
- (setq ts2 (match-string 0) begts2 (match-beginning 0))))
- ;; Are we on the second timestamp?
- (if (<= begts2 (point)) (setq updatets1 t))
- (if (not ts2)
- ;; fall back on org-timestamp-up if there is only one
- (funcall tschange n)
- (funcall tschange n)
- (let ((ts (if updatets1 ts2 ts1))
- (begts (if updatets1 begts1 begts2)))
- (setq tdiff
- (time-subtract
- (org-time-string-to-time
- (save-excursion
- (goto-char (if updatets1 begts2 begts1))
- (looking-at org-ts-regexp3)
- (match-string 0)))
- (org-time-string-to-time ts)))
- ;; `save-excursion' won't work because
- ;; `org-timestamp-change' deletes and re-inserts the
- ;; timestamp.
- (let ((origin (point)))
- (save-excursion
- (goto-char begts)
- (org-timestamp-change
- (round (/ (float-time tdiff)
- (pcase-exhaustive timestamp?
- (`minute 60)
- (`hour 3600)
- (`day (* 24 3600))
- (`month (* 24 3600 31))
- (`year (* 24 3600 365.2)))))
- timestamp? 'updown))
- ;; Move back to initial position, but never beyond updated
- ;; clock.
- (unless (< (point) origin)
- (goto-char origin))))))))
+ (forward-line 0)
+ (when (looking-at org-element-clock-line-re)
+ ;; Negative durations break the clock parser so we need to
+ ;; decide the increment order carefully.
+ (let* ((first-stop (or (and (> tschange 0) 2) 1))
+ (second-stop (1+ (% first-stop 2))))
+ (goto-char (match-beginning first-stop))
+ (org-timestamp-change tschange timestamp? 'updown)
+ ;; Now that the first timestamp has been changed, the
+ ;; timestamp length and point might be different. So we have
+ ;; to run the search again from scratch.
+ (forward-line 0)
+ (looking-at org-element-clock-line-re)
+ (goto-char (match-beginning second-stop))
+ (org-timestamp-change tschange timestamp? 'updown)))
+ ;; `save-excursion' or markers won't work because
+ ;; `org-timestamp-change' deletes and re-inserts the
+ ;; timestamp. Jump back to the numerical point which will be
+ ;; wrong if the timestamp has changed length
+ (goto-char original-point))))
;;;###autoload
(defun org-clock-cancel ()
diff --git a/testing/lisp/test-org-clock.el b/testing/lisp/test-org-clock.el
index befd0e3e3..e336def5d 100644
--- a/testing/lisp/test-org-clock.el
+++ b/testing/lisp/test-org-clock.el
@@ -100,7 +100,7 @@ test-org-clock/org-clock-timestamps-change
(let ((sun (org-test-get-day-name "Sun"))
(mon (org-test-get-day-name "Mon")))
(should
- (equal
+ (string-equal
(format "CLOCK: [2023-02-19 %s 21:30]--[2023-02-19 %s 23:35] => 2:05"
sun sun)
(org-test-with-temp-text
@@ -108,7 +108,7 @@ test-org-clock/org-clock-timestamps-change
(org-clock-timestamps-change 'down 1)
(buffer-string))))
(should
- (equal
+ (string-equal
(format "CLOCK: [2023-02-20 %s 00:00]--[2023-02-20 %s 00:40] => 0:40"
mon mon)
(org-test-with-temp-text
@@ -116,12 +116,28 @@ test-org-clock/org-clock-timestamps-change
(org-clock-timestamps-change 'up 1)
(buffer-string))))
(should
- (equal
+ (string-equal
(format "CLOCK: [2023-02-20 %s 00:30]--[2023-02-20 %s 01:35] => 1:05"
mon mon)
(org-test-with-temp-text
"CLOCK: [2023-02-19 Sun 2<point>3:30]--[2023-02-20 Mon 00:35] => 1:05"
(org-clock-timestamps-change 'up 1)
+ (buffer-string))))
+ (should
+ (string-equal
+ (format "CLOCK: [2026-03-29 %s 23:33]--[2026-03-30 %s 00:33] => 1:00"
+ sun mon)
+ (org-test-with-temp-text
+ "CLOCK: [2026-03-2<point>8 23:33]--[2026-03-29 00:33] => 1:00"
+ (org-clock-timestamps-change 'up 1)
+ (buffer-string))))
+ ;; Don't touch things that aren't clocks
+ (should
+ (string-equal
+ "[2026-03-28 23:33]\n[2026-03-29 00:33]"
+ (org-test-with-temp-text
+ "[2026-03-2<point>8 23:33]\n[2026-03-29 00:33]"
+ (org-clock-timestamps-change 'up 1)
(buffer-string)))))
(let ((org-time-stamp-rounding-minutes '(1 1)) ;; No rounding!
(now (decode-time)) test-text point)
@@ -129,7 +145,7 @@ test-org-clock/org-clock-timestamps-change
;; 28th. This particular test is easier to write if the
;; days don't change when modifying the month.
(setf (decoded-time-day now)
- (min (decoded-time-day now) 27))
+ (min (decoded-time-day now) 28))
(setq now (encode-time now))
;; loop over regular timestamp formats and weekday-less timestamp
;; formats
@@ -149,19 +165,19 @@ test-org-clock/org-clock-timestamps-change
(while (not (eolp))
;; change the timestamp unit at point one down, two up,
;; one down, which should give us the original timestamp
- ;; again. However, point can move backward during that
- ;; operation, so take care of that. *Not* using
- ;; `save-excursion', which fails to restore point since
- ;; the timestamp gets completely replaced.
+ ;; again.
(setq point (point))
(org-clock-timestamps-down)
- (let ((current-prefix-arg '(2)))
- ;; We supply the prefix argument 2 to increment the
- ;; minutes by 2. We supply the function argument 2 for
- ;; everything else. Is this a bug? Probably. FIXME.
- (org-clock-timestamps-up 2))
- (org-clock-timestamps-down)
- (goto-char point)
+ (org-test-ignore-duplicate
+ (should (eq point (point)))
+ (let ((current-prefix-arg '(2)))
+ ;; We supply the prefix argument 2 to increment the
+ ;; minutes by 2. We supply the function argument 2 for
+ ;; everything else. Is this a bug? Probably. FIXME.
+ (org-clock-timestamps-up 2))
+ (should (eq point (point)))
+ (org-clock-timestamps-down)
+ (should (eq point (point))))
(should (string=
(buffer-substring (point-min) (point-max))
test-text))
--
2.54.0