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:
3. First: org-mode link features
Before we get to the implementation I want to run through some of the default org link behaviour, because I don't find it intuitive.
Storing text search links
There's a function (org-store-link)
, which is the entry point for storing a link
to an object. You can call (org-store-link)
on an org item to store it in a data
structure[2], and then use (org-insert-link)
to insert the
link. If you imagine we've called (org-store-link)
on an item named My target
heading, the result would look like this:
I want to link to [[*My target heading][My target heading]].
Now if I press C-c C-o
on that link, it will run (org-open-at-point)
, which
will open the linked item. If you're linking to a heading in different file,
then the inserted link will contain a prefix like file:/path/to/myfile.org::
.
This is simple, but it has a significant downside: it uses text search to identify the target item, and it therefore depends on an exact match to the headline. If I edit My target heading at all, it will break my link. This makes it a non-starter for me because I consider all my org headlines to be mutable.
The <<mytarget>>
ID syntax
One way you can get around this problem is to use the <<mytarget>>
angle
bracket syntax to specify a target, similar to setting an ID on a HTML
element. You can reference this ID in a link. Eg.
* TODO <<mytarget>> My target heading * My other heading I want to link to [[mytarget][My target heading]].
This works, and it persists if you change the headline description, as long as you keep the angle bracket ID the same. It can also be used to flexibly link to any point in an org file, not just the headline. It still has a couple of downsides though:
- You have to manually think of your link name and make sure it's unique in the document.
- The
<<mytarget>>
annotations are going to be visible all over your document. I don't really want to pollute all my headlines like that.
The :ID:
property
A more reliable solution to this problem is provided by the included
org-id
library. This provides a set of features based around the :ID:
property, which gets stored in the :PROPERTIES:
drawer like this:
* TODO My target heading :PROPERTIES: :ID: 63F85B1C-68C7-4F15-B558-BAD6810812D1 :END:
One way you can get started with this is to call (org-id-get-create)
, which will
generate a UUID[3] if one doesn't already exist, and store it in the properties
draw.
Org maintains a map of your org files and their IDs in
~/.emacs.d/.org-id-locations
, so that the file path doesn't have to be encoded
in the ID.
For a long time I didn't find the org-id
package very intuitive, because it
didn't seem to play nicely with (org-store-link)
. For example, there's an
(org-id-store-link)
function, which automatically creates the :ID:
property if
it doesn't exist, and presumably saves it somewhere. But then if you call
(org-insert-link)
, it doesn't actually have awareness of the state saved by
(org-id-store-link)
, and if you use (org-store-link)
it still saves a
text-search version of the link.
org-id-link-to-org-use-id
The way I solved this weird mismatch turned out to be through a variable named
org-id-link-to-org-use-id
. If you set this to t
or another supported value
like 'create-if-interactive
, then it tells (org-store-link)
to always use the
:ID:
approach, instead of the default text-search approach. (org-store-link)
and (org-insert-link)
will then use the :ID:
linking method as you'd
expect.
The :CUSTOM_ID:
property
To further confuse the situation, there's another property that org understands
named :CUSTOM_ID:
. My understanding is that this is more like the angle
bracket ID syntax but is used to reference a headline item and stored in the
property drawer (whereas the angle bracket can be any point in the document). If
you're exporting your org document, it can be used to create readable anchors
instead of the random IDs that org gives you by default. To reference it in a
link, you have to prefix your link target with a hash character.
It seems that if org-id-link-to-use-id
is set to a non-nil value, then
(org-store-link)
will prefer to store a link to :ID:
, but will fall back to
:CUSTOM_ID:
if :ID:
doesn't exist. You can set org-id-link-to-org-use-id
to 'create-if-interactive-and-no-custom-id
to only create the :ID:
property
if :CUSTOM_ID:
doesn't already exist. This would allow you to use
:CUSTOM_ID:
manually where you prefer to specify it - but you have to make
sure it's a unique reference to the current document.
Sidenote on footnotes
Not strictly a link feature, but I only recently looked into the footnotes feature
for the first time. This allows you to use syntax like [fn::Here's my inline footnote]
to define a footnote inline, or [fn:1]
to link to a footnote definition that
you put somewhere else in the file.
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.
Updating the link state
Now I want my function which, if the cursor is on an org link, will lookup the
linked headline and update the link to match it. To start I'm only going to
support :ID:
links, because that's what I intend to use and I don't yet want
to spend time handling other link types.
The function ended up looking like this:
(defun md/org-link-sync ()
"Sync an org-link to show the target headline as the contents.
When the cursor is on an org-link that uses the ID type, lookup the current state of the linked
headline, and replace the link contents with the current headline value.
For example, an \"outdated\" link like this:
[[id:3C5473CB-3DCF-4A9B-9387-750730DAEB7B][My link contents description]]
Might be replaced by an up-to-date link like this:
[[id:3C5473CB-3DCF-4A9B-9387-750730DAEB7B][DONE [#A] The current description of the headline]]"
(interactive)
(let* ((link-context (org-element-context))
(type (org-element-property :type link-context))
(path (org-element-property :path link-context))
(point-begin (org-element-property :contents-begin link-context))
(point-end (org-element-property :contents-end link-context)))
(when (and path (equal type "id"))
(let ((new-link-text
(md/with-widened-buffer (md/find-file-buffer (org-id-find-id-file path))
(save-window-excursion
(org-open-at-point)
(org-get-heading t nil nil nil)))))
(goto-char point-begin)
(delete-region point-begin point-end)
(insert new-link-text))
(goto-char point-begin))))
Putting aside (md/with-widened-buffer)
for a second, the steps in the function are:
- Use
(org-element-context)
and(org-element-property)
to retrieve information about the link. This includes the type of link (we want "id" links), the path (ie. the ID value itself), and the point begin and end values which denote where the link description starts and ends (this is the part we want to overwrite). - Use
(org-open-at-point)
to follow the link, and(org-get-heading t)
to save our new heading description, which will include keywords but not tags[4].(save-window-excursion)
prevents our visible windows from changing when(org-open-at-point)
is called. - Use
(delete-region point-begin point-end)
to delete the existing contents portion of the link. We prefix this with(goto-char point-begin)
to put the cursor in the right place for insert. - With the cursor in the right place, we call
(insert new-link-text)
with the contents. We then call(goto-char point-begin)
again - this is just a quick way to put the cursor in a useful place, although it would be nicer if this could attempt to keep the cursor in the same place as it was originally, and only move it if it's now out of bounds of the new description.
That's it - the broad approach is pretty easy.
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.