Created and modified properties

There are a few reasons to add created and modified information to Org-mode headings. However, this isn't a justification of why it's useful, this is how I did this horrible thing. But one reason is to use the modification dates as a way to tell if a project or task is stale; for example that it hasn't been touched in a year.

A heading with created and modified properties looks a bit like this:

* Heading
:PROPERTIES:
:CREATED:  [2024-07-03 Wed]
:MODIFIED: [2024-07-06 Sat]
:ID:       33b5ca23-54e1-38e7-b6fc-ca42808dc271
:HASH:     71b34e866cb3b9f81ab4bba39f491e3dfb1d422e
:END:

I originally had plans to make all the dates fun, but I'm not doing that. I don't want to talk about it.

So there are three things we track:

The CREATED property should make intuitive sense as to what it is. Created dates are added to the current heading if they don't already exist. That means that the created date might be wrong, but the longer you use the created feature, the less likely this is to be an issue. The MODIFIED property also makes sense. If you save with the point inside an entry whenever you modify it, this will always be accurate. Unless you make the edit a second before midnight and save after midnight I guess. The HASH property is how we compare changes. The subtree is copied to a temporary buffer, then the MODIFIED and HASH lines are entirely removed and the hash is computed. If it's the same, the subtree wasn't modified.

(defun env-insert-created-timestamp ()
  "Insert a timestamp at the PROPERTY if none exist.
This function does not check to see if it's in an Org-mode document."
  (unless (org-entry-get (point) "CREATED" nil)
    (org-set-property "CREATED" (format-time-string "[%Y-%m-%d %a]"))))
(defun env-compute-subtree-hash ()
  "Create a SHA1 hash of the current subtree.
Removes the :HASH: and :MODIFIED: property lines if they exist."
  (save-excursion
    (save-restriction
    (org-narrow-to-subtree)
    (let ((buffer (current-buffer)))
      (with-temp-buffer
        (insert-buffer-substring-no-properties buffer)
        (flush-lines ":HASH:\\|:MODIFIED:" (point-min) (point-max))
        (sha1 (current-buffer)))))))
(defun env-update-modified-timestamp ()
  "Update the MODIFIED property if the subtree has been modified."
  (let ((hash (env-compute-subtree-hash)))
  (unless (equal (org-entry-get (point) "HASH" nil) hash)
    (org-entry-put (point) "HASH" hash)
    (org-entry-put (point) "MODIFIED" (format-time-string "[%Y-%m-%d %a]")))))
(defun env-timestamps-maybe ()
  "Wrapper function to update the CREATED and MODIFIED properties.
Does check to see if it's an Org-mode document so totally possible
\(and ideal) to run in a few hooks such as:

- `org-insert-heading-hook'
- `org-capture-prepare-finalize-hook'
- `before-save-hook'"
  (when (and (eq major-mode 'org-mode) (org-get-heading))
   (env-insert-created-timestamp)
   (env-update-modified-timestamp)))
(add-hook 'org-insert-heading-hook #'env-timestamps-maybe)
(add-hook 'org-capture-prepare-finalize-hook #'env-timestamps-maybe)
(add-hook 'before-save-hook #'env-timestamps-maybe)