4
* TODO fix this bug asap
:PROPERTIES:
:UPDATE: writing test cases almost there!
:LASTUPDATED: 2019-11-25
:DEADLINE: 2019-11-30
:END:


* TODO update that documentation
:PROPERTIES:
:UPDATE: updating my awesome documentation!
:LASTUPDATED: 2019-11-20
:DEADLINE: 2019-11-30
:END:

Assuming today is 2019-11-27, when I press C-c C-x C-c i.e invoke org column view, I would like to see:

| Item                      | TODO | Days since lastupdate | Days to deadline |
|---------------------------+------+-----------------------+------------------|
| fix this bug asap         | TODO |                     2 |                3 |
| update that documentation | TODO |                     7 |                3 |

It's easy to get the first two columns, I need help to get the next two columns that involve date calculations. It will be nice to auto-update the LASTUPDATED property every time I change the UPDATE.

So far did some googling and arrived at date subtraction snippet

(-
 (org-time-string-to-absolute (format-time-string "%Y-%m-%d"))
 (org-time-string-to-absolute "2019-11-01")
 )

but need help to put together these things!

Essentially I am trying to track my tasks based on dates, I want to know how long a task has been left unattended and how far I am from deadlines, please let me know if there are other workflows/solutions to achieve this.

1 Answer 1

9
+50

The Org syntax for the DEADLINE event differs from what you use in your question. Instead of

* TODO fix this bug asap
:PROPERTIES:
:UPDATE: writing test cases almost there!
:LASTUPDATED: 2019-11-25
:DEADLINE: 2019-11-30
:END:

you should write:

* TODO fix this bug asap
DEADLINE: <2019-11-30 Sat>
:PROPERTIES:
:UPDATE: writing test cases almost there!
:LASTUPDATED: 2019-11-25
:END:

It is easy to insert the active timestamp for DEADLINE with the key sequence C-c . in Org-mode. The most important reason to change to the intended Org syntax for DEADLINE is that DEADLINE is treated special in org-colview.el. Its value string argument is empty in calls of functions registered at org-columns-modify-value-for-display-function when you only set it in the property drawer. Even if you use a reasonable value for it. On the other hand, if you define a DEADLINE as intended by the Org mode manual the value is correctly transmitted to the functions registered at org-columns-modify-value-for-display-function. I will use that in the Elisp code below.

The Org mode manual advertises org-columns-summary-types for influencing the representation of the entries for the column view. Setting org-columns-summary-types does not work for us since it only processes the properties of the subsections of a section.

So we must work with org-columns-modify-value-for-display-function which is not mentioned in the org manual but it has a documentation string. I cite the relevant part of the doc string:

Function that modifies values for display in column view. For example, it can be used to cut out a certain part from a time stamp. The function must take 2 arguments:

column-title The title of the column (not the property name) value The value that should be modified.

The function should return the value that should be displayed, or nil if the normal value should be used.

The functions my-days-since and my-days-until registered at my-column-display-alist filter the values for LASTUPDATED and DEADLINE, respectively. They are called through my-column-display-value-filter for org-columns-modify-value-for-display-function.

Note that the filters are selected by the exact match of the column title (not by the property name). If you change the column title you should also adapt my-column-display-alist.

The column format is set through the variable org-columns-default-format. There we use the label modifiers in parentheses.

The code also defines a new minor mode txt-modtime-mode that puts a text property modtime with time stamps on (almost) all buffer modifications.

Those time stamps are used to update the LASTUPDATED property in the property drawers at saving the buffer and on updates of the column view.

The function my-org-configure-lastupdated configures Org mode to automatically update the LASTUPDATED property. It can be hooked into org-mode-hook. This is already done for you in the following code.

The code is tested with emacs 26.3 and Org 9.2.6.

(require 'org-colview)

(defcustom my-column-display-alist
  '(("Days since lastupdate" . my-days-since)
    ("Days to deadline" . my-days-until))
  "Alist of column titles vs value transformation functions."
  :group 'org-column
  :type '(repeat
      (cons
       (string :tag "Column Title")
       (function :tag "Value Transformation Function"))))

(defun my-days-diff (value &optional since)
  "Get time difference from today to VALUE in number of days.
VALUE is a time-stamp, e.g., given as DEADLINE property.
If SINCE is non-nil calculate the difference since VALUE to today."
  (condition-case err
      (let ((date (org-time-string-to-time value)))
    (when date
      (number-to-string (- (time-to-days (time-subtract (and (null since) date) (and since date)))
                   (time-to-days '(0 0 0 0))))))
    (error value)))

(defun my-days-since (value)
  "Get number of days since VALUE.
VALUE is a string that can be decoded by `org-time-string-to-time'."
  (my-days-diff value 'since))

(defun my-days-until (value)
  "Get number of days until VALUE.
VALUE is a string that can be decoded by `org-time-string-to-time'."
  (my-days-diff value))

(defun my-column-display-value-filter (column-title value)
  "Apply transformation function associated with COLUMN-TITLE on VALUE.
The association is definied through `my-column-display-alist'.
If the COLUMN-TITLE is not associated with any entry of `my-column-display-alist'
use the standard formatting for value."
  (let ((fun (cdr-safe (assoc-string column-title my-column-display-alist))))
    (when (functionp fun)
      (funcall fun value))))

(setq org-columns-modify-value-for-display-function #'my-column-display-value-filter)

(setq org-columns-default-format "%ITEM %TODO %LASTUPDATED(Days since lastupdate) %DEADLINE(Days to deadline)")

(defun txt-modtime-function (start end &rest _ignore)
  "Propertize region from START to END with modtime text property.
There are no property changes if the special variable txt-modtime-mode is nil.
Logging the modification time in the buffer without interpreting the log-change
as modification can be done by let-binding `txt-modtime-mode' to nil."
  (when (and
     txt-modtime-mode
     (null buffer-read-only)
     (> (buffer-size) 0))
    (if (eq start end) ;; Killed text.
      (cond ;; This could be improved! (Try to stay on line.)
       ((< (point-min) start)
        (put-text-property (1- start) end 'modtime (current-time)))
       ((< end (point-max))
        (put-text-property start (1+ end) 'modtime (current-time)))
       (t
        (save-restriction
          (widen)
          (txt-modtime-function start end))))
      (put-text-property start end 'modtime (current-time)))))

(define-minor-mode txt-modtime-mode
  "Remember modification time of text stretches."
  :lighter ""
  (if txt-modtime-mode
      (add-hook 'after-change-functions #'txt-modtime-function t t)
    (remove-hook 'after-change-functions #'txt-modtime-function t)
    (save-restriction
      (widen)
      (with-silent-modifications
    (remove-text-properties (point-min) (point-max)
                '(modtime nil))))))

(defun txt-modtime (&optional begin end interactive)
  "Get last modtime in region from BEGIN to END.
If INTERACTIVE is non-nil print modification time in message buffer."
  (interactive (list
        (or (and (use-region-p) (region-beginning))
            (point-min))
        (or (and (use-region-p) (region-end))
            (point-max))
        t))
  (let ((ret
     (cl-loop
      with modtime = (get-text-property begin 'modtime)
      with nextmodtime
      for int being the intervals from begin to end property 'modtime
      if (setq nextmodtime (get-text-property (car int) 'modtime))
      when (or (null modtime)
           (time-less-p modtime nextmodtime))
      do (setq modtime nextmodtime)
      finally return modtime)))
    (when interactive
      (message "Modification time: %s"
           (format-time-string "%F %H:%M %S''+%3Nms" ret)))
    ret))

(defvar org-columns-hook nil
  "Hook called at start of `org-columns'.")

(advice-add #'org-columns :before
        (lambda (&rest _ignore)
          (run-hooks 'org-columns-hook)))

(defun my-org-update-lastupdated ()
  "Update the LASTUPDATED property of all top-level headings."
  (unless buffer-read-only
    (let (pos
      org-modtime-mode)
      (save-excursion
    (save-restriction
      (widen)
      (goto-char (point-min))
      (while (or (null pos)
             (>
              (progn
            (org-forward-heading-same-level 1 t)
            (point))
              pos))
        (setq pos (point))
        (let ((modtime
           (txt-modtime (point) (save-excursion (org-end-of-subtree t) (point)))))
          (when modtime
        (org-entry-put (point) "LASTUPDATED" (format-time-string "%F" modtime))))))))))

(defun my-org-configure-lastupdated ()
  "Configure Org mode to keep the LASTUPDATED up-to-date."
  (txt-modtime-mode)
  (add-hook 'write-contents-functions #'my-org-update-lastupdated nil t)
  (add-hook 'org-columns-hook #'my-org-update-lastupdated nil t))

(add-hook 'org-mode-hook #'my-org-configure-lastupdated)
2
  • 1
    Impressive! One nit: the key combo for entering a DEADLINE timestamp is C-c C-d; similarly C-c C-s enters a SCHEDULED timestamp; C-c . enters an active timestamp but without a scheduling keyword.
    – NickD
    Dec 7, 2019 at 3:11
  • awesome and intimidating! Awesome because this is impressive elisp judo + org-mod API knowledge... intimidating because not sure how many hours of emacs hacking should I put in to reach this level of problem-solving proficiency! I was also thinking about cutting down the effort to ascend this knowledge curve by simply using python parsers! Thanks so much for your time and am in awe about your elisp+org-mode knowledge!
    – ticktock
    Dec 9, 2019 at 11:28

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.