Categories: geek » emacs » org

View topic page - RSS - Atom - Subscribe via email

Org Mode: Export HTML, copy files, and serve the results via simple-httpd so that media files work

| emacs, org

In Org Mode, when you use "Export to HTML - As HTML file and open", the resulting HTML file is loaded using a file:// URL. This means you can't load any media files. In my post about pronunciation practice, I wanted to test the playback without waiting for my 11ty-based static site generator to churn through the files.

simple-httpd lets you run a web server from Emacs. By default, the httpd-root is ~/public_html and httpd-port is 8085, but you can configure it to be somewhere else. Here I set it up to create a new temporary directory, and to delete that directory afterwards.

(use-package simple-httpd
  :config
  (setq httpd-root (make-temp-file "httpd" t))
  :hook
  (httpd-stop . my-simple-httpd-remove-temporary-root)
  (kill-emacs . httpd-stop))

(defun my-simple-httpd-remove-temporary-root ()
  "Remove `httpd-root' only if it's a temporary directory."
  (when (file-in-directory-p httpd-root temporary-file-directory)
    (delete-directory httpd-root t)))

The following code exports your Org buffer or subtree to a file in that directory, copies all the referenced local files (if they're newer) and updates the links in the HTML, and then serves it via simple-httpd. Note that it just overwrites everything without confirmation, so if you refer to files with the same name, only the last one will be kept.

(with-eval-after-load 'ox
  (org-export-define-derived-backend 'my-html-served 'html
    :menu-entry
    '(?s "Export to HTML and Serve"
         ((?b "Buffer"  my-org-serve--buffer)
          (?s "Subtree" my-org-serve--subtree)))))

(defun my-org-serve--buffer (&optional async _subtreep visible-only body-only ext-plist)
  (my-org-export-and-serve nil))

(defun my-org-serve--subtree (&optional async _subtreep visible-only body-only ext-plist)
  (my-org-export-and-serve t))

;; Based on org-11ty--copy-files-and-replace-links
;; Might be a good idea to use something DOM-based instead
(defun my-html-copy-files-and-replace-links (info &optional destination-dir)
  (let ((file-regexp "\\(?:src\\|href\\|poster\\)=\"\\(\\(file:\\)?.*?\\)\"")
        (destination-dir (or destination-dir (file-name-directory (plist-get info :file-path))))
        file-all-urls file-name beg
        new-file file-re
        unescaped)
    (unless (file-directory-p destination-dir)
      (make-directory destination-dir t))
    (unless (file-directory-p destination-dir)
      (error "%s is not a directory." destination-dir))
    (save-excursion
      (goto-char (point-min))
      (while (re-search-forward file-regexp nil t)
        (setq file-name (or (match-string 1) (match-string 2)))
        (unless (or (string-match "^#" file-name)
                    (get-text-property 0 'changed file-name))
          (setq file-name
                (replace-regexp-in-string
                 "\\?.+" ""
                 (save-match-data (if (string-match "^file:" file-name)
                                      (substring file-name 7)
                                    file-name))))
          (setq unescaped
                (replace-regexp-in-string
                 "%23" "#"
                 file-name))
          (setq new-file (concat
                          (if info (plist-get info :permalink) "")
                          (file-name-nondirectory unescaped)))
          (unless (org-url-p file-name)
            (let ((new-file-name (expand-file-name (file-name-nondirectory unescaped)
                                                   destination-dir)))
              (condition-case err
                  (when (or (not (file-exists-p new-file-name))
                            (file-newer-than-file-p unescaped new-file-name))
                    (copy-file unescaped new-file-name t))
                (error nil))
              (when (file-exists-p new-file-name)
                (save-excursion
                  (goto-char (point-min))
                  (setq file-re (concat "\\(?: src=\"\\| href=\"\\| poster=\"\\)\\(\\(?:file://\\)?" (regexp-quote file-name) "\\)"))
                  (while (re-search-forward file-re nil t)
                    (replace-match
                     (propertize
                      (save-match-data (replace-regexp-in-string "#" "%23" new-file))
                      'changed t)
                     t t nil 1)))))))))))

(defun my-org-export-and-serve (&optional subtreep)
  "Export current org buffer (or subtree if SUBTREEP) to HTML and serve via simple-httpd."
  (interactive "P")
  (require 'simple-httpd)
  (httpd-stop)
  (unless httpd-root (error "Set `httpd-root'."))
  (unless (file-directory-p httpd-root)
    (make-directory httpd-root t))
  (unless (file-directory-p httpd-root)
    (error "%s is not a directory." httpd-root))
  (let* ((out-file (expand-file-name (concat (file-name-base (buffer-file-name)) ".html")
                                     httpd-root))
         (html-file (org-export-to-file 'my-html-served out-file nil subtreep)))
    ;; Copy all the files and rewrite all the links
    (with-temp-file out-file
      (insert-file-contents out-file)
      (my-html-copy-files-and-replace-links
       `(:permalink "/") httpd-root))
    (httpd-start)
    (browse-url (format "http://localhost:%d/%s"
                        httpd-port
                        (file-name-nondirectory html-file)))))

Now I can use C-c C-e (org-export-dispatch), select the subtree with C-s, and use s s to export a subtree to a webserver and have all the media files work. This took 0.46 seconds for my post on pronunciation practice and automatically opens the page in a browser window. In comparison, my 11ty static site generator took 5.18 seconds for a subset of my site (1630 files copied, 214 files generated), and I haven't yet hooked up monitoring it to Emacs, so I have to take an extra step to open the page in the browser when I think it's finished. I think exporting to HTML and serving it with simple-httpd will be much easier for simple cases like this, and then I can export to 11ty once I'm done with the basic checks.

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

Comparing pronunciation recordings across time

Posted: - Modified: | french, emacs, org, subed
  • : Added reference audio for the second set.
  • : I added pronunciation segments for the new set of tongue-twisters I got on Mar 13.
  • : I added a column for Feb 20, the first session with the sentences. I also added keyboard shortcuts (1..n) for playing the audio of the row that the mouse is on.

2026-02-20: First set: Maman peint un grand lapin blanc, etc.

My French tutor gave me a list of sentences to help me practise pronunciation.

I can fuzzy-match these with the word timing JSON from WhisperX, like this.

Extract all approximately matching phrases
(subed-record-extract-all-approximately-matching-phrases
   sentences
   "/home/sacha/sync/recordings/2026-02-20-raphael.json"
   "/home/sacha/proj/french/analysis/virelangues/2026-02-20-raphael-script.vtt")
Sentences
  • Maman peint un grand lapin blanc.
  • Un enfant intelligent mange lentement.
  • Le roi croit voir trois noix.
  • Le témoin voit le chemin loin.
  • Moins de foin au loin ce matin.
  • La laine beige sèche près du collège.
  • La croquette sèche dans l'assiette.
  • Elle mène son frère à l'hôtel.
  • Le verre vert est très clair.
  • Elle aimait manger et rêver.
  • Le jeu bleu me plaît peu.
  • Ce neveu veut un jeu.
  • Le feu bleu est dangereux.
  • Le beurre fond dans le cœur chaud.
  • Les fleurs de ma sœur sentent bon.
  • Le hibou sait où il va.
  • L'homme fort mord la pomme.
  • Le sombre col tombe.
  • L'auto saute au trottoir chaud.
  • Le château d'en haut est beau.
  • Le cœur seul pleure doucement.
  • Tu es sûr du futur ?
  • Trois très grands trains traversent trois trop grandes rues.
  • Je veux deux feux bleus, mais la reine préfère la laine beige.
  • Vincent prend un bain en chantant lentement.
  • La mule sûre court plus vite que le loup fou.
  • Luc a bu du jus sous le pont où coule la boue.
  • Le frère de Robert prépare un rare rôti rouge.
  • La mule court autour du mur où hurle le loup.

Then I can use subed-record to manually tweak them, add notes, and so on. I end up with VTT files like 2026-03-06-raphael-script.vtt. I can assemble the snippets for a session into a single audio file, like this:

I wanted to compare my attempts over time, so I wrote some code to use Org Mode and subed-record to build a table with little audio players that I can use both within Emacs and in the exported HTML. This collects just the last attempts for each sentence during a number of my sessions (both with the tutor and on my own). The score is from the Microsoft Azure pronunciation assessment service. I'm not entirely sure about its validity yet, but I thought I'd add it for fun. * indicates where I've added some notes from my tutor, which should be available as a title attribute on hover. (Someday I'll figure out a mobile-friendly way to do that.)

Calling it with my sentences and files
(my-lang-summarize-segments
 sentences
 '(("/home/sacha/proj/french/analysis/virelangues/2026-02-20-raphael-script.vtt" . "Feb 20")
 ;("~/sync/recordings/processed/2026-02-20-raphael-tongue-twisters.vtt" . "Feb 20")
        ("~/sync/recordings/processed/2026-02-22-virelangues-single.vtt" . "Feb 22")
        ("~/proj/french/recordings/2026-02-26-virelangues-script.vtt" . "Feb 26")
        ("~/proj/french/recordings/2026-02-27-virelangues-script.vtt" . "Feb 27")
        ("~/proj/french/recordings/2026-03-03-virelangues.vtt" . "Mar 3")
        ("/home/sacha/sync/recordings/processed/2026-03-03-raphael-reference-script.vtt" . "Mar 3")
        ("~/proj/french/analysis/virelangues/2026-03-06-raphael-script.vtt" . "Mar 6")
        ("~/proj/french/analysis/virelangues/2026-03-12-virelangues-script.vtt" . "Mar 12"))
 "clip"
 #'my-lang-subed-record-get-last-attempt
 #'my-lang-subed-record-cell-info
 t
 )
Feb 20 Feb 22 Feb 26 Feb 27 Mar 3 Mar 3 Mar 6 Mar 12 Text
▶️ 63* ▶️ 96 ▶️ 95 ▶️ 94 ▶️ 83 ▶️ 83* ▶️ 81* ▶️ 88 Maman peint un grand lapin blanc.
▶️ 88* ▶️ 95 ▶️ 99 ▶️ 99 ▶️ 96 ▶️ 89* ▶️ 92* ▶️ 83 Un enfant intelligent mange lentement.
▶️ 84* ▶️ 97 ▶️ 97 ▶️ 96 ▶️ 94 ▶️ 95* ▶️ 98* ▶️ 99 Le roi croit voir trois noix.
▶️ 80* ▶️ 85 ▶️ 77 ▶️ 94 ▶️ 97   ▶️ 92* ▶️ 88 Le témoin voit le chemin loin.
▶️ 72* ▶️ 97 ▶️ 95 ▶️ 77 ▶️ 92   ▶️ 89* ▶️ 86 Moins de foin au loin ce matin.
▶️ 79* ▶️ 95 ▶️ 76 ▶️ 95 ▶️ 76 ▶️ 90* ▶️ 90* ▶️ 79 La laine beige sèche près du collège.
▶️ 67* ▶️ 99 ▶️ 85 ▶️ 81 ▶️ 85 ▶️ 99* ▶️ 97* ▶️ 97 La croquette sèche dans l'assiette.
▶️ 88* ▶️ 99 ▶️ 100 ▶️ 100 ▶️ 98 ▶️ 100* ▶️ 99* ▶️ 100 Elle mène son frère à l'hôtel.
▶️ 77* ▶️ 87 ▶️ 99 ▶️ 93 ▶️ 87   ▶️ 87* ▶️ 99 Le verre vert est très clair.
▶️ 100* ▶️ 94 ▶️ 100 ▶️ 99 ▶️ 99 ▶️ 99* ▶️ 100* ▶️ 100 Elle aimait manger et rêver.
▶️ 78* ▶️ 98 ▶️ 99 ▶️ 98 ▶️ 98 ▶️ 92*   ▶️ 88 Le jeu bleu me plaît peu.
▶️ 78* ▶️ 97 ▶️ 85 ▶️ 95 ▶️ 85     ▶️ 85 Ce neveu veut un jeu.
▶️ 73* ▶️ 95 ▶️ 95 ▶️ 96 ▶️ 97     ▶️ 100 Le feu bleu est dangereux.
▶️ 87* ▶️ 76 ▶️ 65 ▶️ 97 ▶️ 85 ▶️ 74* ▶️ 85* ▶️ 96 Le beurre fond dans le cœur chaud.
▶️ 84* ▶️ 43 ▶️ 85 ▶️ 79 ▶️ 75     ▶️ 98 Les fleurs de ma sœur sentent bon.
▶️ 70* ▶️ 86 ▶️ 79 ▶️ 76 ▶️ 87 ▶️ 84   ▶️ 98 Le hibou sait où il va.
▶️ 92* ▶️ 95 ▶️ 86 ▶️ 92 ▶️ 98 ▶️ 99*   ▶️ 94 L'homme fort mord la pomme.
▶️ 83* ▶️ 73 ▶️ 69 ▶️ 81 ▶️ 60 ▶️ 96*   ▶️ 81 Le sombre col tombe.
▶️ 39* ▶️ 49 ▶️ 69 ▶️ 56 ▶️ 69 ▶️ 96*   ▶️ 94 L'auto saute au trottoir chaud.
▶️ 82 ▶️ 84 ▶️ 85 ▶️ 98 ▶️ 94 ▶️ 96*   ▶️ 99 Le château d'en haut est beau.
▶️ 89 ▶️ 85 ▶️ 75 ▶️ 91 ▶️ 52 ▶️ 75* ▶️ 70* ▶️ 98 Le cœur seul pleure doucement.
▶️ 98*   ▶️ 99 ▶️ 99 ▶️ 95 ▶️ 93* ▶️ 97* ▶️ 99 Tu es sûr du futur ?
    ▶️ 97 ▶️ 93 ▶️ 92 ▶️ 85*   ▶️ 90 Trois très grands trains traversent trois trop grandes rues.
    ▶️ 94 ▶️ 85 ▶️ 97 ▶️ 82*   ▶️ 92 Je veux deux feux bleus, mais la reine préfère la laine beige.
    ▶️ 91 ▶️ 79 ▶️ 87 ▶️ 82*   ▶️ 94 Vincent prend un bain en chantant lentement.
    ▶️ 89 ▶️ 91 ▶️ 91 ▶️ 84*   ▶️ 92 La mule sûre court plus vite que le loup fou.
    ▶️ 91 ▶️ 93 ▶️ 93 ▶️ 92*   ▶️ 96 Luc a bu du jus sous le pont où coule la boue.
    ▶️ 88 ▶️ 71 ▶️ 94 ▶️ 86*   ▶️ 92 Le frère de Robert prépare un rare rôti rouge.
    ▶️ 81 ▶️ 84 ▶️ 88 ▶️ 67*   ▶️ 94 La mule court autour du mur où hurle le loup.

Pronunciation still feels a bit hit or miss. Sometimes I say a sentence and my tutor says "Oui," and then I say it again and he says "Non, non…" The /ʁ/ and /y/ sounds are hard.

I like seeing these compact links in an Org Mode table and being able to play them, thanks to my custom audio link type. It should be pretty easy to write a function that lets me use a keyboard shortcut to play the audio (maybe using the keys 1-9?) so that I can bounce between them for comparison.

If I screen-share from Google Chrome, I can share the tab with audio, so my tutor can listen to things at the same time. Could be fun to compare attempts so that I can try to hear the differences better. Hmm, actually, let's try adding keyboard shortcuts that let me use 1-8, n/p, and f/b to navigate and play audio. Mwahahaha! It works!

2026-03-14: Second set: Mon oncle peint un grand pont blanc, etc.

Update 2026-03-14: My tutor gave me a new set of tongue-twisters. When I'm working on my own, I find it helpful to loop over an audio reference with a bit of silence after it so that I can repeat what I've heard. I have several choices for reference audio:

  • I can generate an audio file using text-to-speech, like a local instance of Kokoro TTS, or a hosted service like Google Translate (via gtts-cli), ElevenLabs, or Microsoft Azure.
  • I can extract a recording of my tutor from one of my sessions.
  • I can extract a recording of myself from one of my tutoring sessions where my tutor said that the pronunciation is alright.

Here I stumble through the tongue-twisters. I've included reference audio from Kokoro, gtts, and ElevenLabs for comparison.

(my-subed-record-analyze-file-with-azure
 (subed-record-keep-last
  (subed-record-filter-skips
   (subed-parse-file
    "/home/sacha/proj/french/analysis/virelangues/2026-03-13-raphael-script.vtt")))
 "~/proj/french/analysis/virelangues-2026-03-13/2026-03-13-clip")
Gt Kk Az Me ID Comments All Acc Flu Comp Conf  
👂🏼 👂🏼 👂🏼 ▶️ 1 X: pont 93 99 90 100 86 Mon oncle peint un grand pont blanc. {pont}
👂🏼 👂🏼 👂🏼 ▶️ 2 C'est mieux 68 75 80 62 87 Un singe malin prend un bon raisin rond.
👂🏼 👂🏼 👂🏼 ▶️ 3 Ouais, c'est ça 83 94 78 91 89 Dans le vent du matin, mon chien sent un bon parfum.
👂🏼 👂🏼 👂🏼 ▶️ 4 ok 75 86 63 100 89 Le soin du roi consiste à joindre chaque coin du royaume.
👂🏼 👂🏼 👂🏼 ▶️ 5 Ouais, c'est ça, parfait 83 94 74 100 88 Dans un coin du bois, le roi voit trois points noirs.
👂🏼 👂🏼 👂🏼 ▶️ 6 Ouais, parfait 90 92 87 100 86 Le feu de ce vieux four chauffe peu.
👂🏼 👂🏼 👂🏼 ▶️ 7 Ouais 77 85 88 71 86 Deux peureux veulent un peu de feu.
👂🏼 👂🏼 👂🏼 ▶️ 8   77 78 75 83 85 Deux vieux bœufs veulent du beurre.
👂🏼 👂🏼 👂🏼 ▶️ 9 Ouais, parfait 92 94 89 100 89 Elle aimait marcher près de la rivière.
👂🏼 👂🏼 👂🏼 ▶️ 10 Ok, c'est bien 93 98 89 100 90 Je vais essayer de réparer la fenêtre.
👂🏼 👂🏼 👂🏼 ▶️ 11 Okay 83 87 76 100 89 Le bébé préfère le lait frais.
👂🏼 👂🏼 👂🏼 ▶️ 12   77 92 70 86 90 Charlotte cherche ses chaussures dans la chambre.
👂🏼 👂🏼 👂🏼 ▶️ 13 Okay 91 90 94 91 88 Un chasseur sachant chasser sans son chien est-il un bon chasseur ?
👂🏼 👂🏼 👂🏼 ▶️ 14 Ouais 91 88 92 100 91 Le journaliste voyage en janvier au Japon.
👂🏼 👂🏼 👂🏼 ▶️ 15 C'est bien (X: dans un) 91 88 94 100 88 Georges joue du jazz dans un grand bar. {dans un}
👂🏼 👂🏼 👂🏼 ▶️ 16 C'est bien 88 87 94 88 85 Un jeune joueur joue dans le grand gymnase.
👂🏼 👂🏼 👂🏼 ▶️ 17   95 94 96 100 91 Le compagnon du montagnard soigne un agneau.
👂🏼 👂🏼 👂🏼 ▶️ 18   85 88 84 86 89 La cigogne soigne l’agneau dans la campagne.
👂🏼 👂🏼 👂🏼 ▶️ 19 grenouille 71 80 68 75 86 La grenouille fouille les feuilles dans la broussaille.

The code

Code for summarizing the segments
(defun my-lang-subed-record-cell-info (item file-index file sub)
  (let* ((sound-file (expand-file-name (format "%s-%s-%d.opus"
                                               prefix
                                               (my-transform-html-slugify item)
                                               (1+ file-index))))
         (score (car (split-string
                      (or
                       (subed-record-get-directive "#+SCORE" (elt sub 4)) "")
                      ";")))
         (note (replace-regexp-in-string
                (concat "^" (regexp-quote (cdr file))
                        "\\(: \\)?")
                ""
                (or (subed-record-get-directive "#+NOTE" (elt sub 4)) ""))))
    (when (or always-create (not (file-exists-p sound-file)))
      (subed-record-extract-audio-for-current-subtitle-to-file sound-file sub))
    (org-link-make-string
     (concat "audio:" sound-file "?icon=t"
             (format "&source=%s&source-start=%s" (car file) (elt sub 1))
             (format "&title=%s"
                     (url-hexify-string
                      (if (string= note "")
                          (cdr file)
                        (concat (cdr file) ": " note)))))
     (concat
      "▶️"
      (if score (format " %s" score) "")
      (if (string= note "") "" "*")))))

(defun my-lang-subed-record-get-last-attempt (item file)
  "Return the last subtitle matching ITEM in FILE."
  (car
   (last
    (seq-remove
     (lambda (o) (string-match "#\\+SKIP" (or (elt o 4) "")))
     (learn-lang-subed-record-collect-matching-subtitles
      item
      (list file)
      nil
      nil
      'my-subed-simplify)))))

(defun my-lang-summarize-segments (items files prefix attempt-fn cell-fn &optional always-create)
  (cons
   (append
    (seq-map 'cdr files)
    (list "Text"))
   (seq-map
    (lambda (item)
      (append
       (seq-map-indexed
        (lambda (file file-index)
          (let* ((sub (funcall attempt-fn item file)))
            (if sub
                (funcall cell-fn item file-index file sub)
              "")))
        files)
       (list item)))
    items)))

(defun my-subed-record-analyze-file-with-azure (subtitles prefix &optional always-create)
  (cons
   '("Gt" "Kk" "Az" "Me" "ID" "Comments" "All" "Acc" "Flu" "Comp" "Conf")
   (seq-map-indexed
    (lambda (sub i)
      (let ((sound-file (expand-file-name (format "%s-%02d.opus"
                                                  prefix
                                                  (1+ i))))
            (tts-services
             '(("gtts" . learn-lang-tts-gtts-say)
               ("kokoro" . learn-lang-tts-kokoro-fastapi-say)
               ("azure" . learn-lang-tts-azure-say)))
            tts-files
            (note (subed-record-get-directive "#+NOTE" (elt sub 4))))
        (when (or always-create (not (file-exists-p sound-file)))
          (subed-record-extract-audio-for-current-subtitle-to-file sound-file sub))
        (setq
         tts-files
         (mapcar
          (lambda (row)
            (let ((reference (format "%s-%s-%02d.opus" prefix (car row) (1+ i) )))
              (when (or always-create (not (file-exists-p reference)))
                (funcall (cdr row)
                         (subed-record-simplify (elt sub 3))
                         'sync
                         reference))
              (org-link-make-string
               (concat "audio:" reference "?icon=t&note=" (url-hexify-string (car row)))
               "👂🏼")))
          tts-services))
        (append
         tts-files
         (list
          (org-link-make-string
           (concat "audio:" sound-file "?icon=t"
                   (format "&source-start=%s" (elt sub 1))
                   (if (and note (not (string= note "")))
                       (format "&title=%s"
                               (url-hexify-string note))
                     ""))
           "▶️")
          (format "%d" (1+ i))
          (or note ""))
         (learn-lang-azure-subed-record-parse (elt sub 4))
         (list
          (elt sub 3)))))
    subtitles)))

Some code for doing this stuff is in sachac/learn-lang on Codeberg.

View Org source for this post

Sorting completion candidates, such as sorting Org headings by level

| emacs, org

: Made the code even neater with :key, included the old code as well

At this week's Emacs Berlin meetup, someone wanted to know how to change the order of completion candidates. Specifically, they wanted to list the top level Org Mode headings before the second level headings and so on. They were using org-ql to navigate Org headings, but since org-ql sorts its candidates by the number of matches according to the code in the org-ql-completing-read function, I wasn't quite sure how to get it to do what they wanted. (And I realized my org-ql setup was broken, so I couldn't fiddle with it live. Edit: Turns out I needed to update the peg package) Instead, I showed folks consult-org-heading which is part of the Consult package, which I like to use to jump around the headings in a single Org file. It's a short function that's easy to use as a starting point for something custom.

Here's some code that allows you to use consult-org-heading to jump to an Org heading in the current file with completions sorted by level.

(with-eval-after-load 'consult-org
  (advice-add
   #'consult-org--headings
   :filter-return
   (lambda (candidates)
     (sort candidates
           :key (lambda (o) (car (get-text-property 0 'consult-org--heading o)))))))
2026-02-26_13-42-58.png
Figure 1: Screenshot showing where the candidates transition from top-level headings to second-level headings

My previous approach defined a different function based on consult-org-heading, but using the advice feels a little cleaner because it will also make it work for any other function that uses consult-org--headings. I've included the old code in case you're curious. Here, we don't modify the function's behaviour using advice, we just make a new function (my-consult-org-heading) that calls another function that processes the results a little (my-consult-org--headings).

Old code, if you're curious
(defun my-consult-org--headings (prefix match scope &rest skip)
  (let ((candidates (consult-org--headings prefix match scope)))
    (sort candidates
          :lessp
          (lambda (a b)
            (let ((level-a (car (get-text-property 0 'consult-org--heading a)))
                  (level-b (car (get-text-property 0 'consult-org--heading b))))
              (cond
               ((< level-a level-b) t)
               ((< level-b level-a) nil)
               ((string< a b) t)
               ((string< b a) nil)))))))

(defun my-consult-org-heading (&optional match scope)
  "Jump to an Org heading.

MATCH and SCOPE are as in `org-map-entries' and determine which
entries are offered.  By default, all entries of the current
buffer are offered."
  (interactive (unless (derived-mode-p #'org-mode)
                 (user-error "Must be called from an Org buffer")))
  (let ((prefix (not (memq scope '(nil tree region region-start-level file)))))
    (consult--read
     (consult--slow-operation "Collecting headings..."
       (or (my-consult-org--headings prefix match scope)
           (user-error "No headings")))
     :prompt "Go to heading: "
     :category 'org-heading
     :sort nil
     :require-match t
     :history '(:input consult-org--history)
     :narrow (consult-org--narrow)
     :state (consult--jump-state)
     :annotate #'consult-org--annotate
     :group (and prefix #'consult-org--group)
     :lookup (apply-partially #'consult--lookup-prop 'org-marker))))

I also wanted to get this to work for C-u org-refile, which uses org-refile-get-location. This is a little trickier because the table of completion candidates is a list of cons cells that don't store the level, and it doesn't pass the metadata to completing-read to tell it not to re-sort the results. We'll just fake it by counting the number of "/", which is the path separator used if org-outline-path-complete-in-steps is set to nil.

(with-eval-after-load 'org
  (advice-add
   'org-refile-get-location
   :around
   (lambda (fn &rest args)
     (let ((completion-extra-properties
            '(:display-sort-function
              (lambda (candidates)
                (sort candidates
                      :key (lambda (s) (length (split-string s "/"))))))))
       (apply fn args)))))
2026-02-26_14-01-28.png
Figure 2: Screenshot of sorted refile entries

In general, if you would like completion candidates to be in a certain order, you can specify display-sort-function either by calling completing-read with a collection that's a lambda function instead of a table of completion candidates, or by overriding it with completion-category-overrides if there's a category you can use or completion-extra-properties if not.

Here's a short example of passing a lambda to a completion function (thanks to Manuel Uberti):

(defun mu-date-at-point (date)
  "Insert current DATE at point via `completing-read'."
  (interactive
   (let* ((formats '("%Y%m%d" "%F" "%Y%m%d%H%M" "%Y-%m-%dT%T"))
          (vals (mapcar #'format-time-string formats))
          (opts
           (lambda (string pred action)
             (if (eq action 'metadata)
                 '(metadata (display-sort-function . identity))
               (complete-with-action action vals string pred)))))
     (list (completing-read "Insert date: " opts nil t))))
  (insert date))

If you use consult--read from the Consult completion framework, there is a :sort property that you can set to either nil or your own function.

This entry is part of the Emacs Carnival for Feb 2026: Completion.

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

Playing around with org-db-v3 and consult: vector search of my blog post Org files, with previews

Posted: - Modified: | emacs, org

: Sort my-org-db-v3-to-emacs-rag-search by similarity score.

I tend to use different words even when I'm writing about the same ideas. When I use traditional search tools like grep, it can be hard to look up old blog posts or sketches if I can't think of the exact words I used. When I write a blog post, I want to automatically remind myself of possibly relevant notes without requiring words to exactly match what I'm looking for.

Demo

Here's a super quick demo of what I've been hacking together so far, doing vector search on some of my blog posts using the .org files I indexed with org-db-v3:

Screencast of my-blog-similar-link

Play by play:

  • 0:00:00 Use M-x my-blog-similar-link to look for "forgetting things", flip through results, and use RET to select one.
  • 0:00:25 Select "convert the text into a link" and use M-x my-blog-similar-link to change it into a link.
  • 0:00:44 I can call it with C-u M-x my-blog-similar-link and it will do the vector search using all of the post's text. This is pretty long, so I don't show it in the prompt.
  • 0:00:56 I can use Embark to select and insert multiple links. C-SPC selects them from the completion buffer, and C-. A acts on all of them.
  • 0:01:17 I can also use Embark's C-. S (embark-collect) to keep a snapshot that I can act on, and I can use RET in that buffer to insert the links.

Background

A few weeks ago, John Kitchin demonstrated a vector search server in his video Emacs RAG with LibSQL - Enabling semantic search of org-mode headings with Claude Code - YouTube. I checked out jkitchin/emacs-rag-libsql and got the server running. My system's a little slow (no GPU), so (setq emacs-rag-http-timeout nil) was helpful. It feels like a lighter-weight version of Khoj (which also supports Org Mode files) and maybe more focused on Org than jwiegley/rag-client. At the moment, I'm more interested in embeddings and vector/hybrid search than generating summaries or using a conversational interface, so something simple is fine. I just want a list of possibly-related items that I can re-read myself.

Of course, while these notes were languishing in my draft file, John Kitchin had already moved on to something else. He posted Fulltext, semantic text and image search in Emacs - YouTube, linking to a new vibe-coded project called org-db-v3 that promises to offer semantic, full-text, image, and headline search. The interface is ever so slightly different: POST instead of GET, a different data structure for results. Fortunately, it was easy enough to adapt my code. I just needed a small adapter function to make the output of org-db-v3 look like the output from emacs-rag-search.

(use-package org-db-v3
  :load-path "~/vendor/org-db-v3/elisp"
  :init
  (setq org-db-v3-auto-enable nil))

(defun my-org-db-v3-to-emacs-rag-search (query &optional limit filename-pattern)
  "Search org-db-v3 and transform the data to look like emacs-rag-search's output."
  (org-db-v3-ensure-server)
  (setq limit (or limit 100))
  (mapcar (lambda (o)
            `((source_path . ,(assoc-default 'filename o))
              (line_number . ,(assoc-default 'begin_line o))
              ,@o))
          (sort
           (assoc-default 'results
                          (plz 'post (concat (org-db-v3-server-url) "/api/search/semantic")
                            :headers '(("Content-Type" . "application/json"))
                            :body (json-encode `((query . ,query)
                                                 (limit . ,limit)
                                                 (filename_pattern . ,filename-pattern)))
                            :as #'json-read))
           :key (lambda (o) (alist-get 'similarity_score o))
           :reverse t)))

I'm assuming that org-db-v3 is what John's going to focus on instead of emacs-rag-search (for now, at least). I'll focus on that for the rest of this post, although I'll include some of the emacs-rag-search stuff just in case.

Indexing my Org files

Both emacs-rag and org-db-v3 index Org files by submitting them to a local web server. Here are the key files I want to index:

  • organizer.org: my personal projects and reference notes
  • reading.org: snippets from books and webpages
  • resources.org: bookmarks and frequently-linked sites
  • posts.org: draft posts
(dolist (file '("~/sync/orgzly/organizer.org"
                "~/sync/orgzly/posts.org"
                "~/sync/orgzly/reading.org"
                "~/sync/orgzly/resources.org"))
  (org-db-v3-index-file-async file))

(emacs-rag uses emacs-rag-index-file instead.)

Indexing blog posts via exported Org files

Then I figured I'd index my recent blog posts, except for the ones that are mostly lists of links, like Emacs News or my weekly/monthly/yearly reviews. I write my posts in Org Mode before exporting them with ox-11ty and converting them with the 11ty static site generator. I'd previously written some code to automatically export a copy of my Org draft in case people wanted to look at the source of a blog post, or in case I wanted to tweak the post in the future. (Handy for things like Org Babel.) This was generally exported as an index.org file in the post's directory. I can think of a few uses for a list of these files, so I'll make a function for it.

(defun my-blog-org-files-except-reviews (after-date)
  "Return a list of recent .org files except for Emacs News and weekly/monthly/yearly reviews.
AFTER-DATE is in the form yyyy, yyyy-mm, or yyyy-mm-dd."
  (setq after-date (or after-date "2020"))
  (let ((after-month (substring after-date 0 7))
        (posts (my-blog-posts)))
    (seq-keep
     (lambda (filename)
       (when (not (string-match "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-emacs-news" filename))
         (when (string-match "/blog/\\([0-9]+\\)/\\([0-9]+\\)/" filename)
           (let ((month (match-string 2 filename))
                 (year (match-string 1 filename)))
             (unless (string> after-month
                              (concat year "-" month))
               (let ((info (my-blog-post-info-for-url (replace-regexp-in-string "~/proj/static-blog\\|index\\.org$\\|\\.org$" "" filename) posts)))
                 (let-alist info

                   (when (and
                          info
                          (string> .date after-date)
                          (not (seq-intersection .categories
                                                 '("emacs-news" "weekly" "monthly" "yearly")
                                                 'string=)))
                     filename))))))))
     (sort
      (directory-files-recursively "~/proj/static-blog/blog" "\\.org$")
      :lessp #'string<
      :reverse t))))

This is in the Listing exported Org posts section of my config. I have a my-blog-post-info-for-url function that helps me look up the categories. I get the data out of the JSON that has all of my blog posts in it.

Then it's easy to index those files:

(mapc #'org-db-v3-index-file-async (my-blog-org-files-except-reviews))

Searching my blog posts

Now that my files are indexed, I want to be able to turn up things that might be related to whatever I'm currently writing about. This might help me build up thoughts better, especially if a long time has passed in between posts.

org-db-v3-semantic-search-ivy didn't quite work for me out of the box, but I'd written an Consult-based interface for emacs-rag-search-vector that was easy to adapt. This is how I put it together.

First I started by looking at emacs-rag-search-vector. That shows the full chunks, which feels a little unwieldy.

2025-10-09_10-05-58.png
Figure 1: Screenshot showing the chunks returned by a search for "semantic search"

Instead, I wanted to see the years and titles of the blog posts as a quick summary, with the ability to page through them for a quick preview. consult.el lets me define a custom completion command with that behavior. Here's the code:

(defun my-blog-similar-link (link)
  "Vector-search blog posts using `emacs-rag-search' and insert a link.
If called with \\[universal-argument\], use the current post's text.
If a region is selected, use that as the default QUERY.
HIDE-INITIAL means hide the initial query, which is handy if the query is very long."
  (interactive (list
                (if embark--command
                    (read-string "Link: ")
                  (my-blog-similar
                   (cond
                    (current-prefix-arg (my-11ty-post-text))
                    ((region-active-p)
                     (buffer-substring (region-beginning)
                                       (region-end))))
                   current-prefix-arg))))
  (my-embark-blog-insert-link link))

(defun my-embark-blog--inject-target-url (&rest args)
  "Replace the completion text with the URL."
  (delete-minibuffer-contents)
  (insert (my-blog-url (get-text-property 0 'consult--candidate (plist-get args :target)))))

(with-eval-after-load 'embark
  (add-to-list 'embark-target-injection-hooks '(my-blog-similar-link my-embark-blog--inject-target-url)))

(defun my-11ty-interactive-context (use-post)
  "Returns (query hide-initial) for use in interactive arguments.
If USE-POST is non-nil, query is the current post text and hide-initial is t.
If the region is active, returns that as the query."
  (list (cond
         (embark--command (read-string "Input: "))
         (use-post (my-11ty-post-text))
         ((region-active-p)
          (buffer-substring (region-beginning)
                            (region-end))))
        use-post))

(defun my-blog-similar (&optional query hide-initial)
  "Vector-search blog posts using org-db-v3 and present results via Consult.
If called with \\[universal-argument\], use the current post's text.
If a region is selected, use that as the default QUERY.
HIDE-INITIAL means hide the initial query, which is handy if the query is very long."
  (interactive (my-11ty-interactive-context current-prefix-arg))
  (consult--read
   (if hide-initial
       (my-org-db-v3-blog-post--collection query)
     (consult--dynamic-collection
         #'my-org-db-v3-blog-post--collection
       :min-input 3 :debounce 1))
   :lookup #'consult--lookup-cdr
   :prompt "Search blog posts (approx): "
   :category 'my-blog
   :sort nil
   :require-match t
   :state (my-blog-post--state)
   :initial (unless hide-initial query)))

(defvar my-blog-semantic-search-source 'org-db-v3)
(defun my-org-db-v3-blog-post--collection (input)
  "Perform the RAG search and format the results for Consult.
Returns a list of cons cells (DISPLAY-STRING . PLIST)."
  (let ((posts (my-blog-posts)))
    (mapcar (lambda (o)
              (my-blog-format-for-completion
               (append o
                       (my-blog-post-info-for-url (alist-get 'source_path o)
                                                  posts))))
            (seq-uniq
               (my-org-db-v3-to-emacs-rag-search input 100 "%static-blog%")
               (lambda (a b) (string= (alist-get 'source_path a)
                                      (alist-get 'source_path b)))))))

It uses some functions I defined in other parts of my config:

When I explored emacs-rag-search, I also tried hybrid search (vector + full text). At first, I got "database disk image is malformed". I fixed this by dumping the SQLite3 database. Using hybrid search, I tended to get less-relevant results based on the repetition of common words, though, so that might be something for future exploration. Anyway, my-emacs-rag-search and my-emacs-rag-search-hybrid are in the emacs-rag-search part of my config just in case.

Along the way, I contributed some notes to consult.el's README.org so that it'll be easier to figure this stuff out in the future. In particular, it took me a while to figure out how to use :lookup #'consult--lookup-cdr to get richer information after selecting a completion candidate, and also how to use consult--dynamic-collection to work with slower dynamic sources.

Quick thoughts and next steps

It is kinda nice being able to look up posts without using the exact words.

Now I can display a list of blog posts that are somewhat similar to what I'm currently working on. It should be pretty straightforward to filter the list to show only posts I haven't linked to yet.

I can probably get this to index the text versions of my sketches, too.

It might also be interesting to have a multi-source Consult command that starts off with fast sources (exact title or headline match) and then adds the slower sources (Google web search, semantic blog post search via org-db-v3) as the results become available.

I'll save that for another post, though!

View org source for this post

Org Mode: calculating table sums using tag hierarchies

| org, elisp

While collecting posts for Emacs News, I came across this question about adding up Org Mode table data by tag hierarchy, which might be interesting if you want to add things up in different combinations. I haven't needed to do something like that myself, but I got curious about it. It turns out that you can define a tag hierarchy like this:

#+STARTUP: noptag
#+TAGS:
#+TAGS: [ GT1 : tagA tagC tagD ]
#+TAGS: [ GT2 : tagB tagE ]
#+TAGS: [ GT3 : tagB tagC tagD ]

The first two lines remove any other tags you've defined in your config aside from those in org-tag-persistent-alist, but can be omitted if you want to also include other tags you've defined in org-tag-alist. Note that it doesn't have to be a strict tree. Tags can belong to more than one tag group.

EduMerco wanted to know how to use those tag groups to sum up rows in a table. I added a #+NAME header to the table so that I could refer to it with :var source=source later on.

#+NAME: source
| tag  | Q1 | Q2 |
|------+----+----|
| tagA |  9 |    |
| tagB |  4 |  2 |
| tagC |  1 |  4 |
| tagD |    |  5 |
| tagE |    |  6 |
(defun my-sum-tag-groups (source &optional groups)
  "Sum up the rows in SOURCE by GROUPS.
If GROUPS is nil, use `org-tag-groups-alist'."
  (setq groups (or groups org-tag-groups-alist))
  (cons
   (car source)
   (mapcar
    (lambda (tag-group)
      (let ((tags (org--tags-expand-group (list (car tag-group))
                                          groups nil)))
        (cons (car tag-group)
              (seq-map-indexed
               (lambda (colname i)
                 (apply '+
                        (mapcar (lambda (tag)
                                  (let ((val (or (elt (assoc-default tag source) i) "0")))
                                    (if (stringp val)
                                        (string-to-number val)
                                      (or val 0))))
                                tags)))
               (cdr (car source))))))
    groups)))

Then that can be used with the following code:

#+begin_src emacs-lisp :var source=source :colnames no :results table
(my-sum-tag-groups source)
#+end_src

to result in:

tag Q1 Q2
GT1 10 9
GT2 4 8
GT3 5 11

Because org--tags-expand-group takes the groups as a parameter, you could use it to sum things by different groups. The #+TAGS: directives above set org-tag-groups-alist to:

(("GT1" "tagA" "tagC" "tagD")
 ("GT2" "tagB" "tagE")
 ("GT3" "tagB" "tagC" "tagD"))

Following the same format, we could do something like this:

(my-sum-tag-groups source '(("Main" "- Subgroup 1" "- Subgroup 2")
                            ("- Subgroup 1" "tagA" "tagB")
                            ("- Subgroup 2" "tagC" "tagD")
                            ))
tag Q1 Q2
Main 14 11
- Subgroup 1 13 2
- Subgroup 2 1 9

I haven't specifically needed to add tag groups in tables myself, but I suspect the recursive expansion in org--tags-expand-group might come in handy even in a non-Org context. Hmm…

View org source for this post

Org Mode: a LaTeX letter that includes PDFs and hyperlinked page numbers

| org

I messed up on one of my tax forms, so I needed to send the tax agency a single document that included the amended tax return and the supporting slips, with my name, social insurance number, and reference number on every page. It turned out to be rather complicated trying to get calculated \pageref to work with \includepdf, so I just used \hyperlink with hard-coded page numbers. I also needed to use qpdf --decrypt input.pdf output.pdf to decrypt a PDF I downloaded from one of my banks before I could include it with \includepdf.

Here's what I wanted to do with this Org Mode / LaTeX example:

  • Coloured header on all pages with info and page numbers
  • Including PDFs
  • Hyperlinks to specific pages
* Letter
#+DATE: 2025-09-24
#+LATEX_CLASS: letter
#+OPTIONS: toc:nil ^:nil title:nil
#+LATEX_HEADER: \usepackage[margin=1in]{geometry}
#+LATEX_HEADER: \hypersetup{hidelinks}
#+LATEX_HEADER: \usepackage{pdfpages}
#+LATEX_HEADER: \usepackage{fancyhdr}
#+LATEX_HEADER: \usepackage{lastpage}
#+LATEX_HEADER: \usepackage{xcolor}
#+LATEX_HEADER: \signature{FULL NAME GOES HERE}
#+LATEX_HEADER: \fancypagestyle{plain}{
#+LATEX_HEADER: \fancyhf{}
#+LATEX_HEADER: \fancyhead[L]{\color{teal}\hyperlink{page.1}{HEADER INFO}}
#+LATEX_HEADER: \fancyhead[R]{\color{teal}\thepage\ of \pageref{LastPage}}
#+LATEX_HEADER: }
#+LATEX_HEADER: \pagestyle{plain}
#+LATEX_HEADER: \makeatletter
#+LATEX_HEADER: \let\ps@empty\ps@plain
#+LATEX_HEADER: \let\ps@firstpage\ps@plain
#+LATEX_HEADER: \makeatother
#+LATEX_HEADER: \renewcommand{\headrulewidth}{0pt}
#+LATEX_HEADER: \newcommand{\pdf}[1]{\includepdf[link,pages=-, scale=.8]{#1}}
#+LATEX_HEADER: \newcommand{\pages}[2]{\hyperlink{page.#1}{#1}-\hyperlink{page.#2}{#2}}
#+LATEX: \begin{letter}{}
#+LATEX: \opening{Dear person I am writing to:}

Text of the letter goes here.
Please find attached:

| Pages                             | |
| @@latex:\pages{2}{10}@@           | Description of filename1.pdf |
| @@latex:\hyperlink{page.5}{5}@@ | Can link to a specific page |
| @@latex:\pages{11}{15}@@           | Description of filename2.pdf |

#+LATEX:\closing{Best regards,}

#+LATEX: \end{letter}

#+LATEX: \pdf{filename1.pdf}
#+LATEX: \pdf{filename2.pdf}

After filling it in, I exported it with C-c C-e (org-export) C-s (to limit it to the subtree) l p (to export a PDF via LaTeX).

Not the end of the world. At least I learned a little more LaTeX and Org Mode along the way!

View org source for this post

Adding Org Mode link awesomeness elsewhere: my-org-insert-link-dwim

Posted: - Modified: | emacs, org

: Changed my mind, I want the clipboard URL to be used by default. More bugfixes. : Fix bug in my-page-title. Add mastodon-toot-mode-map.

I love so many things about Org Mode's links. I can use C-c C-l (org-insert-link) to insert a link. If I've selected some text, C-c C-l turns the text into the link's description. I can define my own custom link types with interactive completion, default descriptions, and export formats. This is so nice, I want it in all the different places I write links in:

  • Markdown, like on the EmacsConf wiki; then I don't have to remember Markdown's syntax for links
  • mastodon.el toots
  • Oddmuse, like on EmacsWiki
  • HTML/Web mode
  • Org Mode HTML export blocks

Some considerations inspired by Emacs DWIM: do what ✨I✨ mean, which I used as a starting point:

  • I want Emacs to use the URL from the clipboard.
  • If I haven't already selected some text, I want to use the page title or the custom link description as a default description.
  • I want to be able to use my custom link types for completion, but I want it to insert the external web links if I'm putting the link into a non-Org Mode buffer (or in a source or export block that isn't Org Mode). For example, let's say I select dotemacs:my-org-insert-link-dwim with completion. In Org Mode, it should use that as the link target so that I can follow the link to my config and have it exported as an HTML link. In Markdown, it should be inserted as [Adding Org Mode niceties elsewhere: my-org-insert-link-dwim](https://sachachua.com/dotemacs#my-org-insert-link-dwim).

Mostly, this is motivated by my annoyance with having to work with different link syntaxes:

HTML <a href="https://example.com">title</a>
Org [[https://example.com][title]]
Plain text title https://example.com
Markdown [https://example.com](title)
Oddmuse [https://example.com title]

I want things to Just Work.

Screencast showing how I insert links

Play by play:

  1. 0:00:00 inserting a custom dotemacs link with completion
  2. 0:00:11 inserting a link to a blog post
  3. 0:00:28 selecting text in an HTML export block and adding a link to it
  4. 0:00:48 adding a bookmark link as a plain text link in a Python src block

Here's the my-org-insert-link-dwim function, using my-org-link-as-url from Copy web link and my-org-set-link-target-with-search from Using web searches and bookmarks to quickly link placeholders in Org Mode:

(defun my-org-insert-link-dwim ()
  "Like `org-insert-link' but with personal dwim preferences."
  (interactive)
  (let* ((point-in-link (and (derived-mode-p 'org-mode) (org-in-regexp org-link-any-re 1)))
         (point-in-html-block (and (derived-mode-p 'org-mode)
                                   (let ((elem (org-element-context)))
                                     (and (eq (org-element-type elem) 'export-block)
                                          (string= (org-element-property :type elem) "HTML")))))
         (point-in-src-or-export-block
          (and (derived-mode-p 'org-mode)
               (let ((elem (org-element-context)))
                 (and (member (org-element-type elem) '(src-block export-block))
                      (not (string= (org-element-property :type elem) "Org"))))))
         (url (cond
               ((my-org-in-bracketed-text-link-p) nil)
               ((not point-in-link) (my-org-read-link
                                     ;; clipboard
                                     (when (string-match-p "^http" (current-kill 0))
                                       (current-kill 0))
                                     ))))
         (region-content (when (region-active-p)
                           (buffer-substring-no-properties (region-beginning)
                                                           (region-end))))
         (title (or region-content
                    (when (or (string-match (regexp-quote "*new toot*") (buffer-name))
                              (derived-mode-p '(markdown-mode web-mode oddmuse-mode))
                              point-in-html-block
                              point-in-src-or-export-block
                              (not (and (derived-mode-p 'org-mode)
                                        point-in-link)))
                      (read-string "Title: "
                                   (or (my-org-link-default-description url nil)
                                       (my-page-title url)))))))
    ;; resolve the links; see my-org-link-as-url in  https://sachachua.com/dotemacs#web-link
    (unless (and (derived-mode-p 'org-mode)
                 (not (or point-in-html-block point-in-src-or-export-block)))
      (setq url (my-org-link-as-url url)))
    (when (region-active-p) (delete-region (region-beginning) (region-end)))
    (cond
     ((or (string-match (regexp-quote "*new toot*") (buffer-name))
          (derived-mode-p 'markdown-mode))
      (insert (format "[%s](%s)" title url)))
     ((or (derived-mode-p '(web-mode html-mode)) point-in-html-block)
      (insert (format "<a href=\"%s\">%s</a>" url title)))
     ((derived-mode-p 'oddmuse-mode)
      (insert (format "[%s %s]" url title)))
     ((or point-in-src-or-export-block
          (not (derived-mode-p 'org-mode)))
      (insert title " " url))
     ((and region-content url (not point-in-link))
      (insert (org-link-make-string url region-content)))
     ((and url (not point-in-link))
      (insert (org-link-make-string
               url
               (or title
                   (read-string "Title: "
                                (or (my-org-link-default-description url nil)
                                    (my-page-title url)))))))
     ;; bracketed [[plain text]]; see Using web searches and bookmarks to quickly link placeholders in Org Mode https://sachachua.com/dotemacs#completion-consult-consult-omni-using-web-searches-and-bookmarks-to-quickly-link-placeholders-in-org-mode
     ((my-org-set-link-target-with-search))
     ;; In Org Mode, edit the link
     ((call-interactively 'org-insert-link)))))

Consistent keybindings mean less thinking.

(dolist (group '((org . org-mode-map)
                 (markdown-mode . markdown-mode-map)
                 (mastodon-toot . mastodon-toot-mode-map)
                 (web-mode . web-mode-map)
                 (oddmuse-mode . oddmuse-mode-map)
                 (text-mode . text-mode-map)
                 (html-mode . html-mode-map)))
  (with-eval-after-load (car group)
    (keymap-set (symbol-value (cdr group))  "C-c C-l" #'my-org-insert-link-dwim)))

All right, let's dig into the details. This code gets the page title so that we can use it as the link's description. I like to simplify some page titles. For example, when I link to Reddit or HN discussions, I just want to use "Reddit" or "HN".

(defun my-page-title (url)
  "Get the page title for URL. Simplify some titles."
  (condition-case nil
      (pcase url
        ((rx "reddit.com") "Reddit")
        ((rx "news.ycombinator.com") "HN")
        ((rx "lobste.rs") "lobste.rs")
        (_
         (with-current-buffer (url-retrieve-synchronously url)
           (string-trim
            (replace-regexp-in-string
             "[ \n]+" " "
             (replace-regexp-in-string
              "\\(^Github - \\|:: Sacha Chua\\)" ""
              (or
               (dom-texts (car
                           (dom-by-tag (libxml-parse-html-region
                                        (point-min)
                                        (point-max))
                                       'title)))
               "")))))))
    (error nil)))

Let's use that as the default for https: links too.

(defun my-org-link-https-insert-description (link desc)
  "Default to the page title."
  (unless desc (my-page-title link)))

(with-eval-after-load 'org
  (org-link-set-parameters "https" :insert-description #'my-org-link-https-insert-description))

I want to get the default description for a link, even if it uses a custom link type. I extracted this code from org-insert-link.

(defun my-org-link-default-description (link desc)
  "Return the default description for an Org Mode LINK.
This uses :insert-description if defined."
  (let* ((abbrevs org-link-abbrev-alist-local)
         (all-prefixes (append (mapcar #'car abbrevs)
                               (mapcar #'car org-link-abbrev-alist)
                               (org-link-types)))
         (type
          (cond
           ((and all-prefixes
                 (string-match (rx-to-string `(: string-start (submatch (or ,@all-prefixes)) ":")) link))
            (match-string 1 link))
           ((file-name-absolute-p link) "file")
           ((string-match "\\`\\.\\.?/" link) "file"))))
    (when (org-link-get-parameter type :insert-description)
      (let ((def (org-link-get-parameter type :insert-description)))
        (condition-case nil
            (cond
             ((stringp def) def)
             ((functionp def)
              (funcall def link desc)))
          (error
           nil))))))

Now I want an Emacs Lisp function that interactively reads a link with completion, but doesn't actually insert it. I extracted this logic from org-read-link.

my-org-read-link, extracted from org-read-link
(defun my-org-read-link (&optional default)
  "Act like `org-insert-link'. Return link."
  (let* ((wcf (current-window-configuration))
         (origbuf (current-buffer))
         (abbrevs org-link-abbrev-alist-local)
         (all-prefixes (append (mapcar #'car abbrevs)
                               (mapcar #'car org-link-abbrev-alist)
                               (org-link-types)))

         link)
    (unwind-protect
        ;; Fake a link history, containing the stored links.
        (let ((org-link--history
               (append (mapcar #'car org-stored-links)
                       org-link--insert-history)))
          (setq link
                (org-completing-read
                 (org-format-prompt "Insert link" (or default (caar org-stored-links)))
                 (append
                  (mapcar (lambda (x) (concat x ":")) all-prefixes)
                  (mapcar #'car org-stored-links)
                  ;; Allow description completion.  Avoid "nil" option
                  ;; in the case of `completing-read-default' when
                  ;; some links have no description.
                  (delq nil (mapcar 'cadr org-stored-links)))
                 nil nil nil
                 'org-link--history
                 (or default (caar org-stored-links))))
          (unless (org-string-nw-p link) (user-error "No link selected"))
          (dolist (l org-stored-links)
            (when (equal link (cadr l))
              (setq link (car l))))
          (when (or (member link all-prefixes)
                    (and (equal ":" (substring link -1))
                         (member (substring link 0 -1) all-prefixes)
                         (setq link (substring link 0 -1))))
            (setq link (with-current-buffer origbuf
                         (org-link--try-special-completion link)))))
      (when-let* ((window (get-buffer-window "*Org Links*" t)))
        (quit-window 'kill window))
      (set-window-configuration wcf)
      (when (get-buffer "*Org Links*")
        (kill-buffer "*Org Links*")))
    link))

So now the my-org-insert-link-dwim function can read a link with completion (unless I'm getting it from the clipboard), get the default description from the link (using custom links' :insert-description or the webpage's title), and either wrap the link around the region or insert it in whatever syntax makes sense.

On a related note, you might also enjoy:

And elsewhere:

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