Categories: geek

RSS - Atom - Subscribe via email

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

Posted: - Modified: | org, emacs

[2025-07-23 Wed]: Handle Org link updates from the middle of a link.

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 "{{{}}}"))
        (goto-char (cdr bracket-pos))
        (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

Finding my blog posts with consult-omni

Posted: - Modified: | emacs

Sometimes I just want to quickly get to a blog post by title. I use consult-omni for quick web searches that I can jump to or insert as a link. Sure, I can limit this search to my blog by specifying site:sachachua.com or using the code I wrote to search my blog, notes, and sketches with consult-ripgrep and consult-omni, but the search sometimes gets confused by other text on the page. When I publish my blog with Eleventy, I also create a JSON file with all my blog post URLs and titles. Here's how I can use that data as a consult-omni source.

(defun my-consult-omni-blog-data ()
  (let ((base (replace-regexp-in-string "/$" "" my-blog-base-url))
        (json-object-type 'alist)
        (json-array-type 'list))
    (mapcar
     (lambda (o)
       (list :url (concat base (alist-get 'permalink o))
             :title (alist-get 'title o)
             :date (alist-get 'date o)))
     (sort (json-read-file "~/sync/static-blog/_site/blog/all/index.json")
           (lambda (a b)
             (string< (or (alist-get 'date b) "")
                      (or (alist-get 'date a) "")))))))
(unless (get 'my-consult-omni-blog-data :memoize-original-function)
  (memoize #'my-consult-omni-blog-data "5 minutes"))

(defun my-consult-omni-blog-titles-builder (input &rest args &key callback &allow-other-keys)
  (let* ((quoted (when input (regexp-quote input)))
         (list
          (if quoted
              (seq-filter
               (lambda (o)
                 ;; TODO: Someday figure out orderless?
                 (string-match quoted (concat (plist-get o :title) " - " (plist-get o :title))))
               (my-consult-omni-blog-data))
            (my-consult-omni-blog-data)))
         (candidates
          (mapcar
           (lambda (o)
             (propertize
              (concat (plist-get o :title))
              :source "Blog"
              :date (plist-get o :date)
              :title (plist-get o :title)
              :url (plist-get o :url)))
           (if quoted (seq-take list 3) list))))
    (when callback (funcall callback candidates))
    candidates))

(defun my-consult-omni-blog-annotation (s)
  (format " (%s)"
          (propertize (substring (or (get-text-property 0 :date s) "") 0 4)
                      'face 'completions-annotations)))

(with-eval-after-load 'consult-omni
  (consult-omni-define-source
   "Blog"
   :narrow-char ?b
   :type 'sync
   :request #'my-consult-omni-blog-titles-builder
   :on-return 'my-consult-org-bookmark-visit
   :group #'consult-omni--group-function
   :annotate #'my-consult-omni-blog-annotation
   :min-input 3
   :sort nil
   :require-match t))

Here's what it looks like by itself when I call consult-omni-my-blog:

2025-07-21_23-24-09.png
Figure 1: Screenshot of a search through my blog post titles

Then I can add it as one of the sources used by consult-omni:

  (setq consult-omni-multi-sources
        '(consult-omni--source-google
          consult-omni--source-my-org-bookmarks
          consult-omni--source-my-blog))

Here's what it looks like when I call consult-omni to search my bookmarks, blog posts, and Google results at the same time.

2025-07-21_23-29-06.png
Figure 2: Screenshot of a consult-omni search with multiple sources

Then I can press RET to open the blog post in my browser or C-. i l to insert the link using Embark (my-consult-omni-embark-insert-link).

Related:

Next steps: I want to find out how to get :sort nil to be respected so that more recent blog posts are listed first. Also, it might be fun to define a similar source for the sections of my Emacs configuration, like the way I can use my dotemacs: Org Mode link type with completion.

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

2025-07-21 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

2025-07-14 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

Emacs: Open URLs or search the web, plus browse-url-handlers

Posted: - Modified: | emacs, org
  • [2025-07-14 Mon]: Naturally, do this only with text you trust. =)
  • [2025-07-12 Sat]: Use cl-pushnew instead of add-to-list, correct browse-url-browser-browser-function to browse-url-browser-function, and add an example for eww.

On IRC, someone asked for help configuring Emacs to have a keyboard shortcut that would either open the URL at point or search the web for the region or the word at point. I thought this was a great idea that I would find pretty handy too.

Let's write the interactive function that I'll call from my keyboard shortcut.

  • First, let's check if there's an active region. If there isn't, let's assume we're looking at the thing at point (could be a URL, an e-mail address, a filename, or a word).
  • If there are links, open them.
  • Otherwise, if there are e-mail addresses, compose a message with all those email addresses in the "To" header.
  • Are we at a filename? Let's open that.
  • Otherwise, do a web search. Let's make that configurable. Most people will want to use a web browser to search their favorite search engine, such as DuckDuckGo or Google, so we'll make that the default.
(defcustom my-search-web-handler "https://duckduckgo.com/html/?q="
  "How to search. Could be a string that accepts the search query at the end (URL-encoded)
or a function that accepts the text (unencoded)."
  :type '(choice (string :tag "Prefix URL to search engine.")
                 (function :tag "Handler function.")))

(defun my-open-url-or-search-web (&optional text-or-url)
  (interactive (list (if (region-active-p)
                         (buffer-substring (region-beginning) (region-end))
                       (or
                        (and (derived-mode-p 'org-mode)
                             (let ((elem (org-element-context)))
                               (and (eq (org-element-type elem) 'link)
                                    (buffer-substring-no-properties
                                     (org-element-begin elem)
                                     (org-element-end elem)))))
                        (thing-at-point 'url)
                        (thing-at-point 'email)
                        (thing-at-point 'filename)
                        (thing-at-point 'word)))))
    (catch 'done
      (let (links)
        (with-temp-buffer
          (insert text-or-url)
          (org-mode)
          (goto-char (point-min))
          ;; We add all the links to a list first because following them may change the point
          (while (re-search-forward org-any-link-re nil t)
            (cl-pushnew (match-string-no-properties 0) links))
          (when links
            (dolist (link links)
              (org-link-open-from-string link))
            (throw 'done links))
          ;; Try emails
          (while (re-search-forward thing-at-point-email-regexp nil t)
            (cl-pushnew (match-string-no-properties 0) links))
          (when links
            (compose-mail (string-join links ", "))
            (throw 'done links)))
        ;; Open filename if specified, or do a web search
        (cond
         ((ffap-guesser) (find-file-at-point))
         ((functionp my-search-web-handler)
          (funcall my-search-web-handler text-or-url))
         ((stringp my-search-web-handler)
          (browse-url (concat my-search-web-handler (url-hexify-string text-or-url))))))))

I've been really liking how consult-omni lets me do quick searches as I type from within Emacs, which is actually really cool. I've even extended it to search my bookmarks as well, so that I can find things using my words for them and not trust the internet's words for them. So if I wanted to search using consult-omni, this is how I would do it instead.

(setopt my-search-web-handler #'consult-omni)

Now I can bind that to C-c o in my config with this bit of Emacs Lisp.

(keymap-global-set "C-c o" #'my-open-url-or-search-web)

Here's a quick demo:

Screencast showing it in use

Play by play
  1. Opening a URL: https://example.com
  2. Opening several URLs in a region:
  3. Opening several e-mail addresses:
    • test@example.com
    • another.test@example.com
    • maybe also yet.another.test@example.com
  4. A filename
    • ~/.config/emacs/init.el
  5. With DuckDuckGo handling searches: (setopt my-search-web-handler "https://duckduckgo.com/html?q=")
    • antidisestablishmentarianism
  6. With consult-omni handling searches: (setopt my-search-web-handler #'consult-omni)
    • antidisestablishmentarianism

Depending on the kind of URL, I might want to look at it in different browsers. For example, some websites like https://emacswiki.org work perfectly fine without JavaScript, so opening them in EWW (the Emacs Web Wowser) is great. Then it's right there within Emacs for easy copying, searching, etc. Some websites are a little buggy when run in anything other than Chromium. For example, MailChimp and BigBlueButton (which is the webconference server we use for EmacsConf) both behave a bit better under Google Chrome. There are some URLs I want to ignore because they don't work for me or they tend to be too paywalled, like permalink.gmane.org and medium.com. I want to open Mastodon URLs in mastodon.el. I want to open the rest of the URLs in Firefox, which is my current default browser.

To change the way Emacs opens URLs, you can customize browse-url-browser-function and browse-url-handlers. For example, to set up the behaviour I described, I can use:

(setopt browse-url-handlers
        '(("https?://?medium\\.com" . ignore)
          ("https?://[^/]+/@[^/]+/.*" . mastodon-url-lookup)
          ("https?://mailchimp\\.com" . browse-url-chrome)
          ("https?://bbb\\.emacsverse\\.org" . browse-url-chrome)
          ("https?://emacswiki.org" . eww)))
(setopt browse-url-browser-function 'browse-url-firefox)

If you wanted to use EWW as your default web browser, you could use (setopt browse-url-browser-function 'eww) instead.

Could be a fun tweak. I wonder if something like this might be handy for other people too!

View org source for this post

2025-07-07 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

2025-06-30 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post