Fault-tolerant Org Links
I’m sure many Org users have experienced this: you reorganize your notes, maybe renaming a file or moving a section to a different Org file, and a few weeks later you open a link in another note only to be greeted by an unexpected prompt: “No match - create this as a new heading?”. Org tries to be helpful, even creating a new buffer for the non-existent file, assuming all along that you are creating a wiki and normally insert in your text links to targets that don't exist yet. But what if that is not your use-case? What if, instead of popping a new buffer and disrupting your flow, you want to be told that you got a broken link (knowing full well that the link target exists somewhere)? Then you can utter an expletive and carry on reading whatever you were reading, or try to find the intended target and fix the link.
Broken Org links are an unfortunate fact of life when your files and headings change over time. In my case, I kept stumbling on dead links in my org notes that have been curated for decades and survived multiple moves between cloud storage providers, note management systems (remember remember.el
?), and other reorgs. I am not a big fan of spending a lot of time migrating my files and rewiring everything proactively. I wished for an Org setup that would detect a broken link and fix it right there and then, as I tried to follow it. In a sense, I wished for Org links to be fault-tolerant. At the same time, I didn't want a heavy solution, with its own consistency and maintenance burden, like globally unique Org Ids or a custom database.
I created a small set of tools to help detect and repair broken links in my Org files on the fly. My Org Link Repair
code consists of three little helpers:
- A checker hook
/org-test-file-link
that intercepts broken links before Org tries to apply its built-in 'nonexistent target' logic. - A transient menu
/olr-transient
to provide a quick interface for automated and manual broken link recovery tasks. - An interactive repair mode
/olr-manual-mode
that guides a user through fixing broken links one by one.
Together, these additions make it much easier to stay on top of link rot in my notes without altering how I normally create and use Org links. Let’s look at each part and how they work together in practice.
A side note on the UX: One of my design goals was to guide the user to perform the needed actions without relying on their familiarity with Org Link Repair
flow. I expect this flow to be exercised rarely enough that even a user who has done it before is not expected to remember key bindings or the steps to repair their broken link. The code should try to make the process seamless and straightforward.
The helpers that I show are meant as a starting point and can be adapted or extended. I implemented detection of broken file links and a manual (user-assisted) repair strategy, because file links were the ones breaking for me and the manual strategy is the most general (the correct target file may be in an abandoned Google drive, an encrypted file bundle, or anywhere). Other link types could be tested and different repair strategies could be implemented, including a fully automated strategy, if the likely target file location is known, or can be easily searched for. Even web links could be handled similarly: detect broken links to web pages that have disappeared, and rewrite them to use a web archive (like the Wayback machine).
If you can’t prevent links from breaking, at least make them easy to find and fix.
Catching Broken Links
The first thing to do is to change the value of org-link-search-must-match-exact-headline
from its default setting of query-to-create
. That eliminates the wiki-centric query to create a new heading when following a broken link. But it doesn't prevent Org from popping a new buffer for a link pointing to a nonexistent file name. To suppress that, we need to do a bit more work.
Luckily Org developers provided the org-open-at-point-functions
hook which makes it straightforward to intercept the link opening flow and detect a broken link due to non-existent file early. Here is my interceptor that checks for broken file links and bails out with a user error on non-existent files. It could be expanded to handle other link types and other broken link scenarios. Note that the error message tells the user what key binding to use to initiate the link repair.
(custom-set-variables '(org-link-search-must-match-exact-headline t)) (defun /org-test-file-link () "Check if the file link target file exists before following it." (let ((ctx (org-element-context))) (when (and (eq (org-element-type ctx) 'link) (string= (org-element-property :type ctx) "file")) (let ((file (org-element-property :path ctx))) ;; If the file exists, return nil to let org-open-at-point continue (if (not (file-exists-p file)) (user-error (concat "Target file not found; Use " (substitute-command-keys "\\[/olr-transient]") " to repair link to %s") file)))))) (add-hook 'org-open-at-point-functions #'/org-test-file-link)
A Transient Menu for Link Repair Tasks
I am using Emacs’ Transient library (the same engine behind Magit’s menus) to create a one-stop menu for all Org Link Repair
activities. The command /olr-transient
is a prefix command that, when invoked, pops up a transient menu with several relevant actions. This spares me from memorizing multiple separate commands or key bindings. I just hit one key sequence to get the menu, then select what I need. Here’s my initial definition of the transient menu:
(transient-define-prefix /olr-transient () "Transient menu for Org Link Repair." [:description "Org Link Repair transient: fix your broken links\n\n" ["Test/Repair" ("l" "Lint all links in the buffer" /org-lint-links :transient nil) ("m" "Manually find the new target" /olr-manual-mode :transient nil)] ["Display/Navigate" ("n" "Next link" org-next-link :transient t) ("p" "Previous link" org-previous-link :transient t) ("d" "Display toggle" org-toggle-link-display :transient t)] ["Other" ("q" "Quit" transient-quit-one :transient nil)]]) (global-set-key (kbd "<f2> <return>") #'/olr-transient)
The manual repair strategy is the only one offered for now. The menu also offers linting the links in the current buffer (I have a customized version of the built-in org-lint
for that), link navigation and display toggling commands.
Using a transient menu here feels like overkill for just a few commands, but I anticipate adding more link-related utilities over time. Even now, it’s nice to have a single “hub” for link management. I don’t use it every day, but when I suspect there might be broken links, I know where to go. It’s also convenient when a broken link does pop up unexpectedly. I can quickly bring up this menu and choose to repair it on the spot.
Manual Repair Strategy — Guided Link Fixing
This is the most general strategy which is why I implemented it first. The tradeoff is that it relies on the user knowing where the intended link target is and navigating to it. I found that I usually remember what happened to my abandoned Org files, even after years of not visiting them. I can usually recover them from an old archive, or one of my no-longer-used Dropbox accounts.
The strategy implements a global minor mode and a set of functions to initiate the repair flow and to complete it. When the user chooses to use this strategy, the code remembers the current location (the location of the broken link) and activates the /olr-manual-mode
minor mode while the user is free to do whatever they need to locate the correct target org file and a headline. A mode line lighter provides a visual clue that the repair flow is in progress. Once the target has been located, the user would hit C-c C-c
to complete the repair, which will interpret the current point as the intended link target. The code will replace the broken link at the starting location with the new link. The user is free to abandon the flow at any time with C-c C-k
.
Here is my code:
;; ;;; Org Link Repair - Manual (user-assisted) Strategy ;; (defvar /olr-manual-marker nil "Marker pointing at the original (broken) link.") (defvar /olr-manual-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "C-c C-c") #'/olr-manual-complete) (define-key map (kbd "C-c C-k") #'/olr-manual-abort) map) "Keymap for `/olr-manual-mode'.") (easy-menu-define /olr-manual-mode-menu /olr-manual-mode-map "Menu for OLR Manual Mode" '("OrgLinkRepairManualMode" ["Complete" /olr-manual-complete t] ["Abort" /olr-manual-abort t])) (define-minor-mode /olr-manual-mode "Global minor mode for Org Link Repair manual strategy. When enabled, the marker pointing at the link at point is saved. The user is expected to navigate to where the link should be pointing at and call `/olr-manual-complete' to repair the link, or `/olr-manual-abort' to cancel. Attempting to enable this minor mode outside an Org-mode derivative, or if the point is not at an Org link will fail with a user error." :lighter " LinkRepair" :global t (if (not /olr-manual-mode) (setq /olr-manual-marker nil) (unless (derived-mode-p 'org-mode) (user-error "Not in an Org buffer")) (unless (eq (org-element-type (org-element-context)) 'link) (user-error "Not at an Org link")) (setq /olr-manual-marker (point-marker)) (message (substitute-command-keys "Manual link repair mode initiated. Navigate to intended link target, press \\[/olr-manual-complete] to complete, or \\[/olr-manual-abort] to abort." )))) (defun /olr-manual-complete () "Complete Org Link Repair by replacing the broken link at saved marker with a new link targeted at point. The user is expected to have navigated to the location of the new link target. This function will call `org-store-link', then use `org-insert-all-links' to replace the broken link, location of which was saved by `/olr-manual-mode'." (interactive) (org-store-link nil t) (unless (and /olr-manual-marker (marker-position /olr-manual-marker)) (error "OrgLinkRepair: Lost marker to the original link location")) (switch-to-buffer (marker-buffer /olr-manual-marker)) (goto-char (marker-position /olr-manual-marker)) (/olr-manual-mode -1) (let* ((oldctx (org-element-context)) (oldstart (org-element-property :begin oldctx)) (oldend (org-element-property :end oldctx)) oldlink newlink) ;; Delete the old link at point (when (and oldstart oldend) (setq oldlink (buffer-substring oldstart oldend)) (delete-region oldstart oldend)) ;; Insert the new link (org-insert-all-links 1 "" "") (let* ((newctx (org-element-context)) (newstart (org-element-property :begin newctx)) (newend (org-element-property :end newctx))) (goto-char newstart) (setq newlink (buffer-substring newstart newend))) ;; Notify the user: audibly+visibly (hopefully after auto-revert messages) (ding) (run-with-idle-timer 0.2 nil (lambda () (message (concat "Modified buffer by replacing link %s with %s." "\nSave the buffer to keep changes!") oldlink newlink))))) (defun /olr-manual-abort () "Abort manual Org Link Repair." (interactive) (unless (and /olr-manual-marker (marker-position /olr-manual-marker)) (error "OrgLinkRepair: Lost marker to the original link location")) (switch-to-buffer (marker-buffer /olr-manual-marker)) (goto-char (marker-position /olr-manual-marker)) (/olr-manual-mode -1) ;; Notify the user (message "Org Link Repair aborted."))
Limitations and Next Steps
Not a Complete Solution: This toolkit currently provides early interception for broken file links only. It could be extended to catch other link types if doing it early would be beneficial. For example opening web links may pop a browser window, which is annoying if we could know ahead of time that it will fail. The manual repair strategy will work for any link type, as long as it is supported by org-store-link
. Again, not for web links opened in a browser.
Manual Effort: While the repair mode makes fixing easier, it’s still a manual process. I have to find the new targets or decide to remove links. There’s room for automation, e.g. suggesting likely new locations for a file (perhaps by searching for a filename in a known directory). At the moment, I actually prefer the manual control, but smarter suggestions could speed things up.
Workflow UX: I experimented with making a nicer user experience during the manual link repair workflow. I wanted to make it visually clear that the user is in the workflow and is expected to either complete it or abort it. The global minor mode lighter in the mode line doesn't seem to be enough. I tried sticking a header-line at the top, displaying a banner message and key bindings to complete/abort, but it was not reliable, and didn't look great either. I have some other ideas to try, but if you have a suggestion please let me know.
Despite these limitations, the gain in convenience has been huge for me. I can freely rename files or reorganize headings, knowing that if I forget to update a reference, Emacs will help me catch it later. And fixing it is straightforward. This is a relatively small addition to my Emacs config (just a few dozen lines of Elisp), but it solves an annoying real problem that used to steal time and momentum. And by the way, I do have LLM generated test cases for this code (see my previous blog post).
Enjoy the malleability of Emacs and the freedom it gives you!
Discuss this post on Reddit.