Using web searches and bookmarks to quickly link placeholders in Org Mode

| org, emacs

I want to make it easy to write with more hyperlinks. This lets me develop thoughts over time, building them out of small chunks that I can squeeze into my day. It also makes it easy for you to dig into things that pique your curiosity without wading through lots of irrelevant details. Looking up the right URLs as I go along tends to disrupt my train of thought. I want to make it easier to write down my thoughts first and then go back and add the URLs. I might have 5 links in a post. I might have 15. Sounds like an automation opportunity.

If I use double square brackets around text to indicate where I want to add links, Orgzly Revived and Org Mode both display those as links, so I can preview what the paragraph might feel like. They're valid links. Org Mode prompts me to create new headings if I follow them. I never use these types of links to point to headings, though. Since I only use custom IDs for links, any [[some text]] links must be a placeholder waiting for a URL. I want to turn [[some text]] into something like [[https://example.com][some text]] in the Org Mode markup, which gets exported as a hyperlink like this: some text. To figure out the target, I might search the web, my blog, or my bookmarks, or use the custom link types I've defined for Org Mode with their own completion functions.

Most of my targets can be found with a web search. I can do that with consult-omni with a default search based on the link text, prioritizing my bookmarks and blog posts. Then I don't even have to retype the search keywords.

(defun my-org-set-link-target-with-search ()
  "Replace the current link's target with a web search.
Assume the target is actually supposed to be the description.  For
example, if the link is [[some text]], do a web search for 'some text',
prompt for the link to use as the target, and move 'some text' to the
description."
  (interactive)
  (let* ((bracket-pos (org-in-regexp org-link-bracket-re))
         (bracket-target (match-string 1))
         (bracket-desc (match-string 2))
         result)
    (when (and bracket-pos bracket-target
               (null bracket-desc)
               ;; try to trigger only when the target is plain text and doesn't have a protocol
               (not (string-match ":" bracket-target)))
      ;; we're in a bracketed link with no description and the target doesn't look like a link;
      ;; likely I've actually added the text for the description and now we need to include the link
      (let ((link (consult-omni bracket-target nil nil t)))
        (cond
         ((get-text-property 0 :url link)
          (setq result (org-link-make-string (get-text-property 0 :url link)
                                             bracket-target)))
         ((string-match ":" link)       ; might be a URL
          (setq result (org-link-make-string link bracket-target))))
        (when result
          (delete-region (car bracket-pos) (cdr bracket-pos))
          (insert result)
          result)))))

This is what that looks like:

Screencast: filling in a link URL based on the text

consult-omni shows me the title, first part of the URL, and a few words from the page. C-o in consult-omni previews search results, which could be handy.

Sometimes a web search isn't the fastest way to find something. Some links might be to my Emacs configuration, project files, or other things for which I've written custom Org link types. I can store links to those and insert them, or I can choose those with completion.

(defun my-org-set-link-target-with-org-completion ()
  "Replace the current link's target with `org-insert-link' completion.
Assume the target is actually supposed to be the description.  For
example, if the link is [[some text]], do a web search for 'some text',
prompt for the link to use as the target, and move 'some text' to the
description."
  (interactive)
  (let* ((bracket-pos (org-in-regexp org-link-bracket-re))
         (bracket-target (match-string 1))
         (bracket-desc (match-string 2))
         result)
    (when (and bracket-pos bracket-target
               (null bracket-desc)
               ;; try to trigger only when the target is plain text and doesn't have a protocol
               (not (string-match ":" bracket-target))
               (org-element-lineage (org-element-context) '(link) t)) ; ignore text in code blocks, etc.
      ;; we're in a bracketed link with no description and the target doesn't look like a link;
      ;; likely I've actually added the text for the description and now we need to include the link.
      ;; This is a hack so that we don't have to delete the link until the new link has been inserted
      ;; since org-insert-link doesn' tbreak out the link prompting code into a smaller function.
      (let ((org-link-bracket-re "{{{}}}"))
        (org-insert-link nil nil bracket-target))
      (delete-region (car bracket-pos) (cdr bracket-pos)))))

Here's an example:

Screencast showing how to specify a link with completion

If I decide a web search isn't the best way to find the target, I can use <up> and RET to get out of the consult-omni web search and then go to the usual Org link completion interface.

(defun my-org-set-link-target-dwim ()
  (interactive)
  (or (my-org-set-link-target-with-search)
      (my-org-set-link-target-with-org-completion)))

If I can't find the page either way, I can use C-g to cancel the prompt, look around some more, and get the URL. When I go back to the web search prompt for the link target, I can press <up> RET to switch to the Org completion mode, and then paste in the URL with C-y or type it in. Not elegant, but it will do.

I can now look for untargeted links and process each one. The (undo-boundary) in the function means I can undo one link at a time if I need to.

(defun my-org-scan-for-untargeted-links ()
  "Look for [[some text]] and prompt for the actual targets."
  (interactive)
  (while (re-search-forward org-link-bracket-re nil t)
    (when (and
           (not (match-string 2))
           (and (match-string 1) (not (string-match ":" (match-string 1))))
           (org-element-lineage (org-element-context) '(link) t)) ; ignore text in code blocks, etc.
      (undo-boundary)
      (my-org-set-link-target-dwim))))

Here's how that works:

Screencast showing how I can scan through the text for untargeted links and update them

Play by play
  1. 0:00:00 I started with a search for "build thoughts out of smaller chunks" and deleted the default text to put in "developing" so that I could select my post on developing thoughts further.
  2. 0:00:08 I linked to Orgzly Revived from my bookmarks.
  3. 0:00:10 I selected the Org Mode website from the Google search results.
  4. 0:00:12 I selected the consult-omni Github page from the search results.
  5. 0:00:18 I used <up> RET to skip the web search instead of selecting a candidate, and then I selected the bookmarks section of my configuration using Org link completion.
  6. 0:00:25 I changed the search query and selected the post about using consult-omni with blog posts.
  7. 0:00:31 I chose the p-search Github repository from the Google search results.

If my-org-scan-for-untargeted-links doesn't find anything, then the post is probably ready to go (at least in terms of links). That might help avoid accidentally posting placeholders. I'm going to experiment with going back to the default of having org-export-with-broken-links be nil again, so that's another safety net that should catch placeholders before they get published.

Next steps

I can look at the code for the web search and add the same kind of preview function for my bookmarks and blog posts.

I can modify my C-c C-l binding (my-org-insert-link-dwim) to have the same kind of behaviour: do a web/bookmark/blog search first, and fall back to Org link completion.

Someday it might be nice to add a font-locking rule so that links without proper targets can be shown in a different colour. org-export-with-broken-links and org-link-search both know about these types of links, so there might be a way to figure out font-locking.

I might not use the exact words from the title, so it would be good to be able to specify additional keywords and rank by relevance. The p-search talk from EmacsConf 2024 showed a possible approach that I haven't dug into yet. If I want to get really fancy, it would be neat to use the embedding of the link text to look up the most similar things (blog posts, bookmarks) and use that as the default.

I'm looking forward to experimenting with this. I think it will simplify linking to things when I'm editing my drafts on my computer. That way, it might be easier for me to write about whatever nifty idea I'm curious about while helping people pick up whatever background information they need to make sense of it all.

This is part of my Emacs configuration.
View org source for this post