1

Suppose we have a org headline like:

* Org headline with clock entries
:LOGBOOK:
CLOCK: [2022-07-09 Sat 12:25]--[2022-07-09 Sat 12:39] =>  0:14
CLOCK: [2022-07-09 Sat 12:54]--[2022-07-09 Sat 13:19] =>  0:25
CLOCK: [2022-07-09 Sat 13:19]--[2022-07-09 Sat 14:19] =>  1:00
:END:

Is there a way to

  1. Find the earliest start time of all of the CLOCK entries?
  2. Find the sum (in minutes) of all of the CLOCK entries?
  3. Replace the CLOCK entries with a single CLOCK entry which begins at (1) and ends at (1) + (2)?

EDIT: So in this case, the headline should be mutated to the following:

* Org headline with clock entries
:LOGBOOK:
CLOCK: [2022-07-09 Sat 12:25]--[2022-07-09 Sat 14:04] =>  1:39
:END:
4
  • You have to clarify what you want in 3. : in your example there is a gap between the end of (1) and the beginning of (2), but not between the end of (2) and the beginning of (3). So I could understand combining (2) and (3), but do you really want to combine (1) and (2)? If so, how?
    – NickD
    Jul 11, 2022 at 1:19
  • 2
    He wants to fudge his timesheet ;-)
    – user31220
    Jul 12, 2022 at 10:00
  • My goal is to keep the total time I spent doing something, but force it to be contiguous over a simple time period (so it's easier to parse).
    – George
    Nov 13, 2022 at 19:45
  • You are aware of org-clock-sum? Maybe, you do not need to compact the clock entries if you use that function.
    – Tobias
    Nov 14, 2022 at 8:15

2 Answers 2

1
+50

The commando org+-collaps-clocks from the following Elisp code collects the clock entries of the headline at point and collapses them into one clock entry with the minimal start time of the original entries and the total duration of all original entries.

The code is split into a set of small helper functions.
All of them are documented and
most of them have a trailing comment with a test that you can evaluate.

It was surprising how little the Org api was prepared for this task.
For an example there is no Org timestamp element generator that accepts
the start time and the end time in internal time format.
The code below contains org+-timestamp-from-time as extension of
org-timestamp-from-time that allows the generation of timestamps with end time.

The code is new and therefore not widely tested. So, use it with care!

(require 'cl-lib)
(require 'org-element)

(defun org+-element-parse-headline (&optional granularity visible-only)
  "Parse current headline.
GRANULARITY and VISIBLE-ONLY are like the args of `org-element-parse-buffer'."
  (let ((level (org-current-level)))
    (org-element-map
    (org-element-parse-buffer granularity visible-only)
    'headline
      (lambda (el)
    (and
     (eq (org-element-property :level el) level)
     (<= (org-element-property :begin el) (point))
     (<= (point) (org-element-property :end el))
     el))
      nil 'first-match 'no-recursion)))

(defun org+-element-clock (clock)
  "If CLOCK is a string parse it to an Org clock element.
Otherwise return CLOCK."
  (if (stringp clock)
      (with-temp-buffer
    (insert clock)
    (goto-char (point-min))
    (org-element-clock-parser (point-max)))
    clock))
;; Test:
;; (org+-element-clock "CLOCK: [2022-11-11 Fri 11:13]--[2022-11-15 Tue 11:14]")

(defun org+-clock-to-time (clock &optional start-or-end decode)
  "Transform Org CLOCK element to internal time.
If START-OR-END is nil or equal to 'start transform the start time
otherwise transform the end time.
If DECODE is non-nil return the decoded time."
  (when (eq start-or-end 'start)
    (setq start-or-end nil))
  (let* ((timestamp (org-element-property :value (org+-element-clock clock)))
     (time (org-timestamp-to-time timestamp start-or-end)))
    (if decode
    (decode-time time)
      time)))
;; Test:
;; (org+-clock-to-time "CLOCK: [2022-11-12 Sat 13:14]--[2022-11-12 Sat 13:20]" 'start t)

(defun org+-encode-time (time)
  "Encode TIME if it is the result of `decode-time'.
Otherwise return TIME untransformed."
  (if (eq (length time) 9) ;; decoded time
      (encode-time time)
    time))

(cl-defun org+-timestamp-from-time (start &optional with-time inactive &key end minutes)
  "Convert START time into a Org timestamp object.
START is an Emacs internal time representation, as returned, e.g.,
by ‘current-time’.
See `org-timestamp-from-time' for the meaning of WITH-TIME and INACTIVE.
Additional to `org-timestamp-from-time' this function also allows
to specify the end time by an internal time END or the number of MINUTES."
  (setq start (org+-encode-time start))
  (when (numberp minutes)
    (setq end (time-add start (* 60 minutes))))
  (let ((timestamp (org-timestamp-from-time start with-time inactive)))
    (when end
      (let ((end-timestamp (org-timestamp-from-time end with-time inactive)))
    (cl-loop for prop on (cadr end-timestamp) by 'cddr
         for name = (symbol-name (car prop))
         when (string-suffix-p "-start" name)
         do
         (org-element-put-property timestamp (intern (concat (substring name nil (- (length "-start"))) "-end")) (cadr prop)))))
    timestamp))
;; Tests:
;; (org-element-timestamp-interpreter (org+-timestamp-from-time (current-time) t t :minutes 60) nil)
;; (org-element-timestamp-interpreter (org+-timestamp-from-time (current-time) t t) nil)

(defun org+-element-delete (element &optional kill)
  "Delete ELEMENT from :begin to :end.
Save into `kill-ring' if KILL is non-nil."
  (let ((b (org-element-property :begin element))
    (e (org-element-property :end element)))
    (if kill
    (kill-region b e)
      (delete-region b e))))

(defsubst org+-clock-lessp (clock1 clock2 &optional start-or-end)
  "Return non-nil if the start of CLOCK1 is less than that one of CLOCK2.
START-OR-END can be 'start or 'end.
START-OR-END defaults to 'start."
  (time-less-p
   (org+-clock-to-time clock1 start-or-end)
   (org+-clock-to-time clock2 start-or-end)))
;; Test:
;; (org+-clock-lessp "CLOCK: [2022-11-11 Fri 11:11]--[2022-11-11 Fri 12:22]" "CLOCK: [2022-11-12 Fri 01:00]--[2022-11-12 Fri 2:00]")

(defun org+-clock-minutes (clock)
  "Return duration of CLOCK line.
CLOCK may be string or a parsed clock line."
  (let* ((tStart (org+-clock-to-time clock))
     (tEnd (org+-clock-to-time clock 'end)))
    (round (/ (time-to-seconds (time-subtract tEnd tStart)) 60.0))))
;; Test:
;; (org+-clock-minutes "CLOCK: [2022-11-11 Fri 11:11]--[2022-11-11 Fri 12:22]")

(defun org+-clock-time-limits (clocks)
  "Return the limits of the smallest time interval containing CLOCKS.
CLOCKS is a list of Org elements of clocks either as strings
or as parsed elements.
The limits are returned as list (tMin tMax)."
  (let* ((clock (car clocks))
     (tMin (org+-clock-to-time clock 'start))
     (tMax (org+-clock-to-time clock 'end)))
    (dolist (clock (cdr clocks))
      (let ((tStart (org+-clock-to-time clock 'start))
        (tEnd (org+-clock-to-time clock 'end)))
    (when (time-less-p tStart tMin)
      (setq tMin tStart))
    (when (time-less-p tMax tEnd)
      (setq tMax tEnd))))
    (list tMin tMax)))
;; Test:
;; (org+-clock-time-limits '("CLOCK: [2022-11-11 Fri 11:11]--[2022-11-11 Fri 12:22]" "CLOCK: [2022-11-11 Fri 01:00]--[2022-11-11 Fri 2:00]"))

(defun org+-clocks-same-day-p (clocks)
  "Return non-nil if all CLOCKS have the same day."
  (cl-multiple-value-bind (tMin tMax) (org+-clock-time-limits clocks)
     (cl-every
      (lambda (idx)
    (eq (nth idx tMin) (nth idx tMax)))
      '(
    3 ;; day
    4 ;; month
    5 ;; year
    ))))
;; Tests:
;; (org+-clocks-same-day-p '("CLOCK: [2022-11-11 Fri 11:11]--[2022-11-11 Fri 12:22]" "CLOCK: [2022-11-11 Fri 01:00]--[2022-11-11 Fri 2:00]"))
;; (org+-clocks-same-day-p '("CLOCK: [2022-11-11 Fri 11:11]--[2022-11-11 Fri 12:22]" "CLOCK: [2022-11-12 Fri 01:00]--[2022-11-12 Fri 2:00]"))

(defun org+-collaps-clocks ()
  "Collaps CLOCKS of the heading at point."
  (interactive)
  (let* ((clocks (org-element-map (org+-element-parse-headline) '(clock) #'identity))
     (clock (car clocks))
     (first-pos (org-element-property :begin clock))
     (minutes (org+-clock-minutes clock))
     (clockMin clock)
     (clockMax (org+-clock-to-time clock 'end)))
    (dolist (clock (cdr clocks))
      (when (org+-clock-lessp clock clockMin)
    (setq clockMin clock))
      (when (org+-clock-lessp clockMax clock)
    (setq clockMax clock))
      (cl-incf minutes (org+-clock-minutes clock)))
    (unless (org+-clocks-same-day-p (list clockMin clockMax))
      (user-error "The clocks span several days"))
    (dolist (clock (nreverse clocks))
      (org+-element-delete clock))
    (goto-char first-pos)
    (let ((timestamp (org+-timestamp-from-time
              (org-timestamp-to-time
               (org-element-property :value clockMin))
              t t
              :minutes minutes)))
      (org-element-put-property timestamp :type 'inactive-range)
      (insert
       (org-element-clock-interpreter
    (org-element-create
     'clock
     (list
      :status
      'running
      :value
      timestamp
      ))
    nil)
       "\n"))
    ))
;; Tests:
;; (with-temp-buffer (insert "* heading\nCLOCK: [2022-11-11 Fri 08:00]--[2022-11-11 Fri 09:00]\nCLOCK: [2022-11-11 Fri 11:00]--[2022-11-11 Fri 12:00]\nSome text.") (org-mode) (goto-char 2) (org+-collaps-clocks) (buffer-substring-no-properties (point-min) (point-max)))
1
  • Super thorough and helpful. Thank you so much!
    – George
    Nov 20, 2022 at 21:45
0

I had this code ready already in Novemeber, but I was waiting for an answer to the question in the last comment by Tobias before posting. Anyway, now that I see that somehow the function might still be useful, I am posting my alternative (to Tobias his) solution here. One necessary requirement is that your cursor is placed on the headline (for the narrowing to work correctly):

(require 'org-element)

(defun org+-fudge-single-timesheet ()
  (let* ((d (car (org-element-map
                     (org-element-parse-buffer 'element)
                     'drawer
                   #'identity)))
         (contents-begin (org-element-property :contents-begin d))
         (contents-end (org-element-property :contents-end d))
         (clocks (org-element-map
                     d
                     'clock
                   #'identity))
         (timestamps (mapcar (apply-partially #'org-element-property :value)
                             clocks))
         (durations (mapcar (apply-partially #'org-element-property :duration)
                            clocks))
         (earliest (car (seq-sort #'time-less-p
                                  (mapcar #'org-timestamp-to-time timestamps))))
         (end-time (time-convert (+ (* 60 (apply #'+ (mapcar #'org-duration-to-minutes durations)))
                                    (time-convert earliest 'integer)))))
    (delete-region contents-begin contents-end)
    (goto-char contents-begin)
    (insert "CLOCK: ")
    (org-insert-time-stamp earliest t t)
    (insert "--")
    (org-insert-time-stamp end-time t t)
    ;; (insert "\n")
    (forward-line -1)
    (org-evaluate-time-range t)))

(defun org+-fudge-timesheet-current-headline ()
  (interactive)
  (save-restriction
    (let* ((h (org-element-at-point))
           (begin (org-element-property :begin h))
           (end (org-element-property :end h)))
      (narrow-to-region begin end)
      (org+-fudge-single-timesheet))))

You can test it by placing your cursor on a heading and then do M-x fudge-timesheet-current-headline.

Sorry for not adding comments, but luckily the code/solution is quite simple. The first function simply 'fudges' (thanks user31220) the timesheet of the first headline in a buffer. The second function simply narrows to the current headline, so that the first function will work on any headline. I am not sure if the code is simpler (although it looks like that) or more robust than Tobias his solution (my solution is also not thoroughly tested), as I did not take the time to study his solution. But I guess you can find out for yourself.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.