Auto-show latest heading state in org-mode links

1. Intro

I just added some features to my org-mode setup to easily update links to other org headings, to pull in the current keyword and headline from the linked item.

The idea here is that I want to treat links to org items more like a current reference to the original heading item, instead of a stale duplicate. Org-agenda is a good example of doing this: you run the agenda command and it shows an up-to-date view of the current items, their keyword state and tags, and you can easily jump from the item to the original source. Links on the other hand, have no awareness of their target item's state, and even if I copy the name of the headline item when I create the link, it will became stale as soon as I edit the original headline[1].

The reason I want this is because sometimes I want to build ad-hoc, informal collections of org items regardless of their current state. Eg. maybe I want to pull in some items that I need on hand this week, without having to formalise what those items are with keywords or tags, and without having to keep editing the link description to always be in sync with the original item.

2. Demo

To make it easier to understand, here it is in action:

4. What I'm implementing

The main thing I want is a function which, if my cursor is on a link, will update the description/contents of the link to reflect the current headline and its current todo keyword, overwriting whatever the previous description was. I want to somehow hook this into C-c C-c, because I'm used to pressing that to update different things in org.

Additionally, I want configuration and bindings to:

  • Use org :ID: links automatically instead of the default text search method.
  • Easily store an :ID: link in org-mode and org-agenda-mode.
  • Easily insert the stored :ID: link in org-mode.

5. Implementation

Using IDs instead of text match

This part is easy - I just have to do:

(setq org-id-link-to-org-use-id 'create-if-interative)

Now (org-store-link) will automatically generate and use :ID: links.

I could set also this to t. The documentation suggests there are circumstances where it might not be desirable to always do this though - something to do with (org-capture), so I'm starting out by keeping it interactive-only.

Handling narrowed buffers

The one complication here is narrowed/restricted buffers. If you've narrowed the buffer that the link points to, then (org-open-at-point) will open the right buffer but won't jump to the right place because the buffer contents will be restricted, and (org-get-heading) will then return the wrong information. AFAIK org just doesn't handle following links to a narrowed buffer.

Emacs does provide a (save-restriction) macro, which works like (save-excursion) or (save-window-excursion) but for restoring any current buffer restrictions. So the goal here is that we'll need to jump to the buffer that the link points to, save the restriction, widen that buffer, then go back to the original buffer, and call (org-open-at-point) to follow the link - and it should always hit the correct heading because it will have access to the full widened buffer. And then then the (save-restriction) macro should exit and restore any restrictions in the linked buffer.

The ordering of these operations is a bit awkward, because (save-restriction) operates on the current buffer at time of calling. And so I encapsulated it in a macro (md/with-widened-buffer), which accepts a buffer object (or name of the buffer) that you want to widen and restore.

  (defmacro md/with-widened-buffer (buffer-or-name &rest body)
    "Widen the given BUFFER-OR-NAME, execute BODY in the context of your current buffer, and restore restrictions on the given buffer.

This allows the calling code to not have to worry about manually handling
narrowed vs widened state."
    (let ((orig-buffer (gensym "orig-buffer")))
      `(let ((,orig-buffer (current-buffer)))
         (with-current-buffer ,buffer-or-name
           (save-restriction
             (save-excursion
               (widen)
               (with-current-buffer ,orig-buffer
                 ,@body)))))))

One detail here is that we use (gensym) to ensure that our orig-buffer variable is unique and doesn't leak into the outer code.

The final detail is that we need to grab the source file from the org link before we call (org-open-at-point), so we can jump to that buffer and widen it first. org-id provides the (org-id-find-id-file) function to grab the file path associated with a particular UUID. We then need to convert the returned file path to a buffer object in order to pass it to our macro. The (md/find-file-buffer) helper function handles this:

  (defun md/find-file-buffer (path)
    "Get or create a buffer visiting PATH without affecting current windows.

This is useful in situations where you have functions that accept a buffer object but you
only have the file path."
    (save-window-excursion
      (find-file path)
      (current-buffer)))

Arguably this could just be inlined somewhere but I figured it won't be the only time I need to do this and I don't like having to manually manage the restore macros like (save-window-excursion) in the calling code.

Hooking into C-c C-c

With the function in place, how do we call it? I want to hook into C-c C-c, which feels intuitive because it's the binding you hit in org-mode to update various different elements.

I thought this would require advising (org-ctrl-c-ctrl-c), but org actually provides a hook named org-ctrl-c-ctrl-c-hook, which is designed exactly for this - it lets you extend C-c C-c to support your own behaviour. The function has to lookup the current org element/context, do whatever it wants to do, and then return t if it did something, and nil if it didn't.

  (defun md/org-ctrl-c-ctrl-c ()
    "I use this to add custom handlers and behaviour to C-c C-c.

For example, C-c- C-c is often used to update the state of org elements, and so
it feels like a natural way for me to call md/org-link-sync, because that
function updates the state of a ID link to be in sync with the target heading."
    (condition-case nil
        (let* ((link-context (org-element-context))
               (type (org-element-property :type link-context)))
          (cond
           ((and (eq (car link-context) 'link) (equal type "id"))
            (md/org-link-sync)
            t)  ; Returning t tells org-ctrl-c-ctrl-c that we did something
           (t nil)))  ; Tell org-ctrl-c-ctrl-c there was no match
      (error nil)))  ; Catch any errors in case org-element-context failed

  (add-hook 'org-ctrl-c-ctrl-c-hook 'md/org-ctrl-c-ctrl-c)

The implementation looks similar to the link update function - we use (org-element-context) and (org-element-property) to detect if we're on a supported element, and if so we call (md/org-link-sync). Now if I hit C-c C-c on an ID link, it will call the function and update the link to show the latest keyword and headline.

Bindings

The last thing I wanted was some bindings - these aren't very interesting. I mapped (org-store-link) to be accessible via C-c y in both org-mode and org-agenda-mode, and I mapped C-c L to run (org-insert-last-stored-link), which I find nicer than (org-insert-link) as I never actually need the menu of choices that (org-insert-link) forces you to choose from.

6. Next steps - informal org agendas?

This all seems to work well and I think it's a nice improvement that makes links more useful for me. There are a few things I could look into next:

  • You could take the "mini informal org-agenda" idea further, and update keyword or tag state from the link in the same way you can with org agenda. Eg. maybe if I press C-c C-t on an org link, it could update the keyword state on the linked item.
  • You could automatically update multiple links at once, or update links on save, rather than requiring the links to be updated manually - this way they become live references to other heading items.
  • (org-insert-last-stored-link) inserts a newline after the link, and doesn't include the keyword. It would be nice if I could insert the link and automatically call (md/org-link-sync) to see the keyword, instead of having to insert the link and immediately call the function.
  • (md/org-link-sync) is inserting the target headline into the mark ring and posting a message about it - not sure that I really need this.
  • Supporting :CUSTOM_ID: links could be useful.

You can find the code I'm actually using in my dotfiles.

2023-Aug-09