Categories: geek » emacs » org

View topic page - RSS - Atom - Subscribe via email

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

Getting a Google Docs draft ready for Mailchimp via Emacs and Org Mode

Posted: - Modified: | emacs, org

: Got it to include the dates in the TOC as well

I've been volunteering to help with the Bike Brigade newsletter. I like that there are people who are out there helping improve food security by delivering food bank hampers to recipients. Collecting information for the newsletter also helps me feel more appreciation for the lively Toronto biking scene, even though I still can't make it out to most events. The general workflow is:

  1. collect info
  2. draft the newsletter somewhere other volunteers can give feedback on
  3. convert the newsletter to Mailchimp
  4. send a test message
  5. make any edits requested
  6. schedule the email campaign

We have the Mailchimp Essentials plan, so I can't just export HTML for the whole newsletter. Someday I should experiment with services that might let me generate the whole newsletter from Emacs. That would be neat. Anyway, with Mailchimp's block-based editor, at least I can paste in HTML code for the text/buttons. That way, I don't have to change colours or define links by hand.

The logistics volunteers coordinate via Slack, so a Slack Canvas seemed like a good way to draft the newsletter. I've previously written about my workflow for copying blocks from a Slack Canvas and then using Emacs to transform the rich text, including recolouring the links in the section with light text on a dark background. However, copying rich text from a Slack Canvas turned out to be unreliable. Sometimes it would copy what I wanted, and sometimes nothing would get copied. There was no way to export HTML from the Slack Canvas, either.

I switched to using Google Docs for the drafts. It was a little less convenient to add items from Slack messages and I couldn't easily right-click to download the images that I pasted in. It was more reliable in terms of copying, but only if I used xclip to save the clipboard into a file instead of trying to do the whole thing in memory.

I finally got to spend a little time automating a new workflow. This time I exported the Google Doc as a zip that had the HTML file and all the images in a subdirectory. The HTML source is not very pleasant to work with. It has lots of extra markup I don't need. Here's what an entry looks like:

2025-09-17_09-22-35.png
Figure 1: Exported HTML for an entry

Things I wanted to do with the HTML:

  • Remove the google.com/url redirection for the links. Mailchimp will add its own redirection for click-tracking, but at least the links can look simpler when I paste them in.
  • Remove all the extra classes and styles.
  • Turn [ call to action ] into fancier Mailchimp buttons.

Also, instead of transforming one block at a time, I decided to make an Org Mode document with all the different blocks I needed. That way, I could copy and paste things in quick succession.

Here's what the result looks like. It makes a table of contents, adds the sign-up block, and adds the different links and blocks I need to paste into Mailchimp.

2025-09-17_10-03-27.png
Figure 2: Screenshot of newsletter Org file with blocks for easy copying

I need to copy and paste the image filenames into the upload dialog on Mailchimp, so I use my custom Org Mode link type for copying to the clipboard. For the HTML code, I use #+begin_src html ... #+end_src instead of #+begin_export html ... #+end_export so that I can use Embark and embark-org to quickly copy the contents of the source block. (That doesn't work for export blocks yet.) I have C-. bound to embark-act, the source block is detected by the functions that embark-org.el added to embark-target-finders, and the c binding in embark-org-src-block-map calls embark-org-copy-block-contents. So all I need to do is C-. c in a block to copy its contents.

Here's the code to process the newsletter draft
(defun my-brigade-process-latest-newsletter-draft (date)
  "Create an Org file with the HTML for different blocks."
  (interactive (list (if current-prefix-arg (org-read-date nil t nil "Date: ")
                       (org-read-date nil t "+Sun"))))
  (when (stringp date) (setq date (date-to-time date)))
  (let ((default-directory "~/Downloads/newsletter")
        file
        dom
        sections)
    (call-process "unzip" nil nil nil "-o" (my-latest-file "~/Downloads" "\\.zip$"))
    (setq file (my-latest-file default-directory))
    (with-temp-buffer
      (insert-file-contents-literally file)
      (goto-char (point-min))
      (setq dom (my-brigade-simplify-html (libxml-parse-html-region (point-min) (point-max))))
      (my-brigade-save-newsletter-images dom)
      (setq sections
            (my-html-group-by-tag
             'h1
             (dom-children
              (dom-by-tag
               dom 'body)))))
    (with-current-buffer (get-buffer-create "*newsletter*")
      (erase-buffer)
      (org-mode)
      (insert
       (format-time-string "%B %-e, %Y" date) "\n"
       "* In this e-mail\n#+begin_src html\n"
       "<p>Hi Bike Brigaders! Here’s what's happening this week, with quick signup links. In this e-mail:</p>"
       (replace-regexp-in-string
        "<li>" "\n<li>"
        (with-temp-buffer
          (svg-print
           (apply 'dom-node
                  'ul nil
                  (append
                   (my-brigade-toc-items (assoc-default "Bike Brigade" sections 'string=))
                   (my-brigade-toc-items (assoc-default "In our community" sections 'string=)))))
          (buffer-string)))
       "\n<br />\n"
       (my-brigade-copy-signup-block date)
       "\n#+end_src\n\n")
      (dolist (sec '("Bike Brigade" "In our community"))
        (insert "* " sec "\n"
                (mapconcat
                 (lambda (group)
                   (let* ((item (apply 'dom-node 'div nil
                                       (append
                                        (list (dom-node 'h2 nil (car group)))
                                        (cdr group))))
                          (image (my-brigade-image (car group))))
                     (format "** %s\n\n%s\n%s\n\n#+begin_src html\n%s\n#+end_src\n\n"
                             (car group)
                             (if image (org-link-make-string (concat "copy:" image)) "")
                             (or (my-html-last-link-href item) "")
                             (my-transform-html
                              (delq nil
                                    (list
                                     'my-transform-html-remove-images
                                     'my-transform-html-remove-italics
                                     'my-brigade-format-buttons
                                     (when (string= sec "In our community")
                                       'my-brigade-recolor-recursively)))
                              item))))
                 (my-html-group-by-tag 'h2 (cdr (assoc sec sections 'string=)))
                 "")))
      (insert "* Other updates\n"
              (format "#+begin_src html\n<h2>Other updates</h2>%s\n#+end_src\n\n"
                      (my-transform-html
                       '(my-transform-html-remove-images
                         my-transform-html-remove-italics)
                       (car (cdr (assoc "Other updates" sections 'string=))))))
      (goto-char (point-min))
      (display-buffer (current-buffer)))))

(defun my-brigade-toc-items (section-children)
  "Return a list of <li /> nodes."
  (mapcar
   (lambda (group)
     (let* ((text (dom-texts (cadr group)))
            (regexp (format "^%s \\([A-Za-z]+ [0-9]+\\)"
                            (regexp-opt '("Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"))))
            (match (when (string-match regexp text) (match-string 1 text))))
       (dom-node 'li nil
                 (org-html-encode-plain-text
                  (if match
                      (format "%s: %s" match (car group))
                    (car group))))))
   (my-html-group-by-tag 'h2 section-children)))

(defun my-html-group-by-tag (tag dom-list)
  "Use TAG to divide DOM-LIST into sections. Return an alist of (section . children)."
  (let (section-name current-section results)
    (dolist (node dom-list)
      (if (and (eq (dom-tag node) tag)
               (not (string= (string-trim (dom-texts node)) "")))
          (progn
            (when current-section
              (push (cons section-name (nreverse current-section))  results)
              (setq current-section nil))
            (setq section-name (string-trim (dom-texts node))))
        (when section-name
          (push node current-section))))
    (when current-section
      (push (cons section-name (reverse current-section))  results)
      (setq current-section nil))
    (nreverse results)))

(defun my-html-last-link-href (node)
  "Return the last link HREF in NODE."
  (dom-attr (car (last (dom-by-tag node 'a))) 'href))

(defun my-brigade-image (heading)
  "Find the latest image related to HEADING."
  (car
   (nreverse
    (directory-files my-brigade-newsletter-images-directory
                        t (regexp-quote (my-brigade-newsletter-heading-to-image-file-name heading))))))

Some of the functions it uses are in my config, particularly the section on Transforming HTML clipboard contents with Emacs to smooth out Mailchimp annoyances: dates, images, comments, colours.

Along the way, I learned that svg-print is a good way to turn document object models back into HTML.

When I saw two more events and one additional link that I wanted to include, I was glad I already had this code sorted out. It made it easy to paste the images and details into the Google Doc, reformat it slightly, and get the info through the process so that it ended up in the newsletter with a usefully-named image and correctly-coloured links.

I think this is a good combination of Google Docs for getting other people's feedback and letting them edit, and Org Mode for keeping myself sane as I turn it into whatever Mailchimp wants.

My next step for improving this workflow might be to check out other e-mail providers in case I can get Emacs to make the whole template. That way, I don't have to keep switching between applications and using the mouse to duplicate blocks and edit the code.

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

My Emacs writing experience

| writing, emacs, org

I've been enjoying reading people's responses to the Emacs Carnival July theme of writing experience. I know I don't need complicated tools to write. People can write in composition notebooks and on typewriters. But I have fun learning more about the Emacs text editor and tweaking it to support me. Writing is one of the ways I think, and I want to think better. I'll start with the kinds of things I write in my public and private notes, and then I'll think about Emacs specifically.

Types of notes

Text from sketch

What kinds of posts do I write? How? Improvements?

2025-07-25-05

  • Emacs News
    • why: collecting & connecting → fun!
    • how:
      • phone: Reddit: upvotes
      • YouTube: playlist
    • RSS
    • Mastodon: Scrape boosts?
    • Dedupe, categorize: classifier?
    • Blog
    • Mailing list
    • emacs.tv
    • emacslife.com/calendar
  • Bike Brigade newsletter
    • why: help out, connect
    • Reddit + X + Slack -> Slack canvas -> MailChimp
    • Need more regular last-min sweep
    • Copying from Slack sucks; Google Docs?
  • Tech notes
    • Why: figure things out, remember, share
    • code
    • literate programming: notes + code
    • debugger?
    • more notes?
    • thinking out loud?
  • Life reflections
    • Why: figure things out, remember
    • tangled thoughts
    • sketch: habit? more doodles
    • audio braindump
    • snippets on phone
    • learning to think
    • laptop: write
    • audio input?
    • themes, thoughts
      • LLM? reflection questions, topics to learn more about
  • Book notes
    • Why: study, remember, share
    • paper: draw while reading
    • e-book: highlight
      • quotes
    • sketch
      • smaller chunks?
    • blog
  • Monthly/yearly reviews
    • Why: plan, remember
    • phone: daily journal
    • tablet: draw moment of the day
    • phone: time records
    • Emacs: raw data
    • themes, next steps: LLM? reflection questions?
    • blog post

Emacs News

I put together a weekly list of categorized links about the interesting ways people use Emacs. This takes me about an hour or two each week. I enjoy collecting all these little examples of people's curiosity and ingenuity. Organizing the links into a list helps people find things they might be interested in and connect with other people.

I start by skimming r/emacs and r/orgmode on my phone, upvoting posts that I want to include. I also search YouTube and add videos to an Emacs News playlist. I review aggregated posts from Planet Emacslife. I have an Emacs Lisp function that collects all the data and formats them as a list, with all the items at the same level.

For Mastodon, I check #emacs search results from a few different servers. I have a keyboard shortcut that boosts a post and captures the text to an Org Mode file, and then I have another function that prompts me to summarize toots, defaulting to the title of the first link. I have more functions that help me detect duplicates and categorize links. I use ox-11ty to export the post to my blog, which uses the Eleventy static site generator. I also use emacstv.el to add the videos to the Org file I use for emacs.tv.

Some ways to improve this:

  • I probably have enough data that it might be interesting to learn how to write a classifier. On the other hand, regular expression matches on the titles get most of them correctly, so that might be sufficient.
  • YouTube videos are a little annoying to go through because of interface limitations and unrelated or low-effort videos. I can probably figure out something that checks the RSS feeds of various channels.

Bike Brigade newsletter

I also put together a weekly newsletter for Bike Brigade, which coordinates volunteer cyclists to deliver food bank hampers and other essentials. Writing this mostly involves collecting ideas from a number of social media feeds as well as the other volunteers in the community, putting together a draft, and then copying it over to Mailchimp. I'm still figuring out my timing and workflows so that I can stay on top of last-minute requests coming in from people on Slack, and so that I can repurpose newsletter items as updates in the Facebook group or maybe even a blog. If I set aside some regular time to work on things, like a Sunday morning sweep for last-minute requests, that might make it easier to work with other people.

Tech notes

I like coding, and I come up with lots of ideas as I use my computer. I enjoy figuring out workflow tweaks like opening lots of URLs in a region or transforming HTML clipboard contents. My Org files have accumulated quite a few. My main limiting factor here is actually sitting down to make those things happen. Fortunately, I have recently discovered that it's possible for me to spend an hour or two a day playing Stardew Valley, so I can swap some of that time for Emacs tweaking instead. Coding doesn't handle interruptions as well as playing does, but taking notes along the way might be able to help with that. I can jump to the section of my Org file with the ideas I wanted to save for more focus time, pick something from that screen, and get right to it.

Other things that might help me do this more effectively would be:

  • getting better at using my tools (debugger, documentation, completion, etc.),
  • taking the opportunity to plug in an external monitor, and
  • using my non-computer time to mull over the ideas so that I can hit the ground running.

I like taking notes at virtual meetups. I usually do this with Etherpad so that other people can contribute to the notes too. I don't have a real-time read-write Emacs interface to this yet (that would be way cool!), but I do have some functions for working with the Etherpad text.

Life reflections

When I notice something I want to figure out or remember, I use sketches, audio braindumps, or typing to start to untangle that thought. Sometimes I use all three, shifting from one tool to another depending on what I can do at the moment. I have a pretty comfortable workflow for converting sketches (Google Vision) or audio (OpenAI Whisper) to text so that I can work with it more easily, and I'm sure that will get even smoother as the technology improves. I switch from one tool to another as I figure out the shape of my thoughts.

Maybe I can use microblogging to let smaller ideas out into the world, just in case conversations build them up into more interesting ideas. I don't quite trust my ability to manage my GoToSocial instance yet (backups? upgrades?), so that might be a good reason to use a weekly or monthly review to revisit and archive those posts in plain text.

I've been reading my on this day list of blog posts and sketches more regularly now that it's in my feed reader. I like the way this helps me revisit old thoughts, and I've saved a few that I want to follow up on. It feels good to build on a thought over time.

I'd like to do more of this remembering and thinking out loud because memories are fleeting. Maybe developing more trust in my private journals and files will help. (Gotta have those backups!) Then I'll be more comfortable writing about the things we're figuring out about life while also respecting A+ and W-'s privacy, and I can post the stuff I'm figuring out about my life that I'm okay with sharing. I might think something is straightforward, like A+'s progress in learning how to swim. I want to write about how that's a microcosm of how she's learning how to learn more independently and my changing role in supporting her. Still, she might have other opinions about my sharing that, either now or later on. I can still reflect on it and keep that in a private journal as we figure things out together.

Even though parenting takes up most of my time and attention at the moment, it will eventually take less. There are plenty of things for me to learn about and share outside parenting, like biking, gardening, and sewing. I've got books to read and ideas to try out.

I'm experimenting with doing more writing on my phone so that I can get better at using these little bits of time. Swiping letters on a keyboard is reasonably fast, and the bottleneck is my thinking time anyway. I use Orgzly Revived so that Syncthing can synchronize it with my Org Mode files on my laptop when I get back home. There are occasional conflicts, but since I mostly add to an inbox.org when I'm on my phone, the conflicts are usually easy to resolve.

Adding doodles to my reflections can make them more fun. I can draw stick figures from scratch, and I can also trace my photos using the iPad as a way to add visual anchors and practise drawing. If I get the hang of using a smaller portion of my screen like the way I used to draw index cards, that might make thoughts more granular and easier to complete.

When I write on my computer, I often use writeroom-mode so that things feel less cluttered. I like having big margins and short lines. I have hl-line-mode turned on to help me focus on the current paragraph. This seems to work reasonably well.

2025-07-26_00-33-44.png
Figure 1: Screenshot showing writeroom-mode and hl-line-mode

Monthly and yearly reviews

I like the rhythm of drawing daily moments and keeping a web-based journal of brief descriptions of our day. I like how I've been digging into them deeper to reflect on themes. The monthly drawings and posts make it easier to review a whole year. Maybe someday I'll get back to weekly reviews as well, but for now, this is working fine.

My journal entries do a decent job of capturing the facts of our days: where we went, what we did. Maybe spending more time writing life reflections can help me capture more of what goes on in my head and what I want to learn more about.

Book notes

I draw single-page summaries of books I like because they're easier to remember and share. E-books are convenient because I can highlight text and extract that data even after I've returned the book, but I can also retype things from paper books or use the text recognition feature on my phone camera. I draw the summaries on my iPad using Noteful, and then I run it through my Google Vision workflow to convert the text from it so that I can include it in a blog post.

The main limiting factor here is my patience in reading a book. There are so many other wonderful things to explore, and sometimes it feels like books have a bit of filler. When I have a clear topic I'm curious about or a well-written book to enjoy, it's easier to study a book and make notes.

Emacs workflow thoughts

Aside from considering the different types of writing I do, I've also been thinking about the mechanics of writing in Emacs. Sanding down the rough parts of my workflow makes writing more enjoyable, and sometimes a small tweak lets me squeeze more writing into fragments of time.

There are more commands I want to call than there are keyboard shortcuts I can remember. I tend to use M-x to call commands by name a lot, and it really helps to have some kind of completion (I use vertico) and orderless matching.

I'm experimenting with more voice input because that lets me braindump ideas quickly on my phone. Long dictation sessions are a little difficult to edit. Maybe shorter snippets using the voice input mode on the phone keyboard will let me flesh out parts of my outline. I wonder if the same kind of quick input might be handy on my computer. I'm trying out whisper.el with my Bluetooth earbuds. Dictating tends to be stop-and-go, since I feel self-conscious about dictating when other people are around and I probably only have solo time late at night.

Misrecognized words can be annoying to correct on my phone. They're much easier to fix on my computer. Some corrections are pretty common, like changing Emax to Emacs. I wrote some code for fixing common errors (my-subed-fix-common-errors), but I don't use this often enough to have it in my muscle memory. I probably need to tweak this so that it's a bit more interactive and trustworthy.

When I see a word I want to change, I jump to it with C-s (isearch-forward) or C-r (isearch-backward), or I navigate to it with M-f (forward-word). I want to get the hang of using Avy because of Karthik's awesome post about it. That post is from 2021 and I still haven't gotten used to it. I probably just need deliberate practice using the shortcut I've mapped to M-j (avy-goto-char-timer). Or maybe I just don't do this kind of navigation enough yet to justify this micro-optimization (no matter how neat it could be), and isearch is fine for now.

Sometimes I want to work with sentences. expand-region is another thing I want to get used to. I've bound C-= to er/expand-region from that package. Then I should be able to easily kill the text and type a replacement or move things around. In the meantime, I can usually remember to use my keyboard shortcut of M-z for avy-zap-up-to-char-dwim for deleting something.

Even in vanilla Emacs, there's so much that I think I'll enjoy getting the hang of. oantolin's post on his writing experience helped me learn about M-E, which marks the region from the point to the end of the sentence and is a natural extension from M-e. Similarly, M-F selects the next word. I could use this kind of shift-selection more. I occasionally remember to transpose words with M-t, but I've been cutting and pasting sentences when I could've been using transpose-sentences all this time. I'm going to add (keymap-global-set "M-T" #'transpose-sentences) to my config and see if I remember it.

I like using Org Mode headings to collapse long text into a quick overview so I can see the big picture, and they're also handy for making tables of contents. It might be neat to have one more level of overview below that, maybe displaying only the first line of each paragraph. In the meantime, I can use toggle-truncate-lines to get that sort of view.

If I'm having a hard time fitting the whole shape of a thought into my working memory, I sometimes find it easier to work with plain list outlines that go all the way down to sentences instead of working with paragraphs. I can expand/collapse items and move them around easily using Org's commands for list items. In addition, org-toggle-item toggles between items and plain text, and org-toggle-heading can turn items into headings.

I could probably write a command that toggles a whole section between an outline and a collection of paragraphs. The outline would be a plain list with two levels. The top level items would be the starting sentences of each paragraph, and each sentence after that would be a list item underneath it. Sometimes I use actual lists. Maybe those would be a third level. Then I can use Org Mode's handy list management commands even when a draft is further along. Alternatively, maybe I can use M-S-left and M-S-right to move sentences around in a paragraph.

Sometimes I write something and then change my mind about including it. Right now, I tend to either use org-capture to save it or put it under a heading and then refile it to my Scraps subtree, but the palimpsest approach might be interesting. Maybe a shortcut to stash the current paragraph somewhere…

I use custom Org link types to make it easier to link to topics, project files, parts of my Emacs configuration, blog posts, sketches, videos, and more. It's handy to have completion, and I can define how I want them to be exported or followed.

Custom Org link types also let me use Embark for context-sensitive actions. For example, I have a command for adding categories to a blog post when my cursor is on a link to the post, which is handy when I've made a list of matching posts. Embark is also convenient for doing things from other commands. It's nice being able to use C-. i to insert whatever's in the minibuffer, so I can use that from C-h f (describe-function), C-h v (describe-variable), or other commands.

I also define custom Org block types using org-special-block-extras. This lets me easily make things like collapsible sections with summaries.

I want to get better at diagrams and charts using things like graphviz, mermaidjs, matplotlib, and seaborn. I usually end up searching for an example I can build on and then try to tweak it. Sometimes I just draw something on my iPad and stick it in. It's fine. I think it would be good to learn computer-based diagramming and charting, though. They can be easier to update and re-layout when I realize I've forgotten to add something to the graph.

Figuring out the proper syntax for diagrams and charts might be one of the reasonable use cases for large-language models, actually. I'm on the fence about LLMs in general. I sometimes use claude.ai for dealing with the occasional tip of the tongue situation like "What's a word or phrase that describes…" and for catching when I've forgotten to finish a sentence. I don't think I can get it to think or write like me yet. Besides, I like doing the thinking and writing.

I love reading about other people's workflows. If they share their code, that's fantastic, but even descriptions of ideas are fine. I learn so many things from the blog posts I come across on Planet Emacslife in the process of putting together Emacs News. I also periodically go through documentation like the Org Mode manual or release notes, and I always learn something new each time.

This post was really hard to write! I keep thinking of things I want to start tweaking. I treat Emacs-tweaking as a fun hobby that sometimes happens to make things better for me or for other people, so it's okay to capture lots of ideas to explore later on. Sometimes something is just a quick 5-minute hack. Sometimes I end up delving into the source code, which is easy to do because hey, it's Emacs. It's comforting and inspiring to be surrounded by all this parenthetical evidence of other people's thinking about their workflows.

Each type of writing helps me with a different type of thinking, and each config tweak makes thoughts flow more smoothly. I'm looking forward to learning how to think better, one note at a time.

Check out the Emacs Carnival July theme: writing experience post for more Emacs ideas. Thanks to Greg Newman for hosting!

View org source for this post