Categories: geek » emacs

View topic page - RSS - Atom - Subscribe via email
Recommended links:

Thinking about time travel with the Emacs text editor, Org Mode, and backups

| emacs, org

Sometimes I just need to rewind 15 minutes. That's the length of A+'s recess at virtual school, which she does at home. At recess, she often likes to get hugs and sometimes a snack. If I'm working on something that requires sustained thought, like code or a blog post, I can't hold those thoughts in my head for that long while cheerfully listening to A+ share the trivia she's read on the Stardew Valley wiki. If I try to keep my train of thought, I get grumpy. I'd rather get better at time travel instead. Naturally, this calls for Emacs.

For people who are unfamiliar with Emacs or Org Mode

GNU Emacs is a highly customizable program for editing text, writing code, and doing mostly whatever people want to get it to do. Org Mode is a package (or ecosystem of packages, really) that modifies GNU Emacs to make it easier to take notes, plan tasks, export documents, and so on. If you're not into Emacs yet, this post might be a little technical, but maybe there are ways to translate some of the ideas to things you're using.

What was I doing again?

Sometimes recess totally resets my brain and I can't even think of what I was just working on. To make it easier to hit the ground running, I try to make a habit of creating a task in Org Mode before I start working on it. Or, more realistically, halfway through, when I realize I have to first do another thing, so then I jot down a quick task for the work I was previously doing and another task for the tangent I'm about to go on. That way, I can quickly check my notes to see what I was doing. org-capture (which I've bound to C-c r) is handy for that. I have a template (t) that creates a timestamped TODO that links to the context I created it in (files, note headings, etc.) and saves it to my inbox file. Then I can jump to my inbox file with a keyboard shortcut and look at what I need to get back to doing.

Sometimes I vaguely remember that I've already created a task for this before and I can find it with C-u C-c C-w (org-refile). When org-refile is called with a universal prefix argument (C-u), it will prompt for a heading in org-refile-targets and jump to it. I have it set to complete the outline path, so I can try to find things by project. Failing that, I might have a quick rummage in my inbox. I usually don't remember the exact words I used in the the task title, though. Maybe someday I'll get the hang of org-ql or p-search (Emacsconf talk on p-search), resurrect the Remembrance Agent so that it can continuously do bag-of-words matching, or use embeddings to find semantically similar tasks and notes. In the meantime, capturing the task is more important than avoiding duplicates. I can find and clean up duplicates later on.

All of that is moot when I'm away from my computer, which is most of the time. My phone is pretty handy for quick notes, though. I use Orgzly Revived to capture a quick note in my inbox. This gets synchronized with my Org Mode notes using Syncthing.

Hmm, I gotta do this first…

Often the interruption doesn't even come from outside, but from my brain's endless stream of interesting ideas. Some of those ideas can be saved as tasks to work on eventually, but sometimes I need to pause my current task and work on the new idea. I have a template for an interrupting task (i) that automatically clocks out of the previous task and clocks into the new one.

My template for interrupting tasks

This is the entry in my org-capture-templates.

        ("i" "Interrupting task" entry
         (file ,my-org-inbox-file)
         "* STARTED %^{Task}\n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n"
         :clock-in :clock-resume
         :prepend t)

Okay, that's done, what was I doing before?

If I clock into tasks, I can use org-clock-goto along with the C-u universal prefix (C-u C-c C-x C-j) to see a list of recently-clocked-in tasks. This is great for "popping the stack," which is how I think of backtracking once I finished an interrupting task.

I usually forget to clock out. That's okay. I'm not looking for precise total times, just breadcrumbs.

… What was I thinking?

Sometimes a few keywords aren't enough to jog my memory. Whenever I think, "Ah, this is easy, I don't need to take notes," I inevitably regret it. Sometimes I realize I have to re-do my thinking fifteen minutes later, when singing 4-Town songs with A+ has pushed those thoughts out of my brain. Sometimes I have to re-do my thinking several months later, which is even harder.

Notes are super-helpful. I love the way Org Mode lets me write notes, paste in hyperlinks, add snippets of code, save the results of my explorations, include my sketches, and even export them as blog posts or documents to share.

Sometimes I have to go back farther into the past

It can take me months or even years before I can circle back to a project or idea. It can be hard to reconstruct my thinking after a lot of time has passed, so it's good to write down as much as possible. Taking notes feels slower than just plunging ahead, but they help me travel back in time to try to remember.

This really gets hammered in when I run into things I've forgotten, like when I dusted off my time-tracking code so I could make some changes. In the four years that elapsed between Aug 2020 (my last change) and Oct 2024 (when I decided to upgrade it to the latest version of Rails), I'd forgotten how to even run a development version of my code. Whoops. I ended up taking more notes along the way.

I try to keep project-related notes as close to the project files as possible, like a README.org in the project directory. Sometimes I don't even remember what the project is called. I try to keep another file that indexes things on my computer as well as things in real life.

Sometimes I know I wrote tasks or notes down before but I can't remember the exact words I used for them. I'm curious about whether embeddings might help me find those things again. So far it's been okay to just add a new task or note, and then periodically clean up entries that are no longer needed.

Going sideways

Sometimes I want to visit alternate timelines, trying different ways to do something. I really like the way undo works in Emacs. It's different from most programs. Emacs keeps the things you undo/redo.

Let's say I start writing a paragraph or a piece of code. I change my mind about something. Maybe I undo, maybe I cut, maybe I delete. I write again. I change my mind again. The first way was better, maybe. I can go back to that, step through any of the intermediate changes, consider the other version again. It's not lost.

Actually navigating the Emacs undo history can be tricky. I like using the vundo package for that. It shows a compact view of the different branches of this timeline so that I can easily jump between them or compare them.

If I'm working on something more complicated, like code, I might make changes over several sessions. This is where version control is handy. I like using the Git version control system, especially with the Magit package. I can commit versions of the files manually along with a quick note about what I changed or what I'm trying. This allows me to easily reset to a certain point.

Sometimes I'm good about picking the right point to commit: I've made decent progress and things are working. Sometimes I realize only later on that I probably should have saved a commit a little while ago, and now I'm halfway through another idea that I'm not going to have time to finish and that leaves my project in a non-working state. In that situation, sometimes I'll use the visual undo provided by the vundo package to go backwards to a version that looks about right, save that file, commit it with a quick note, and then go forward in time again.

Saving revisions in Git makes it much easier to go backwards in time even if I've restarted my computer. magit-blame and vc-annotate give me slightly different views showing me the changes in a file. They don't show me information on deleted sections, though. For that, I can use the magit-diff command to compare versions. Sometimes it's easier to flip through the history of a single file with git-timemachine.

Git lets me name different experimental timelines (branches) and logically group changes together. It means I don't have to worry so much about messing up a working file, since I can travel back in time to that version. It also means I can easily compare them to see what I've changed so far.

In addition to using version control for various projects, I also save backup files to a separate directory by setting my backup-directory-alist to (("." . "~/.config/emacs/backups")). Disk space is cheap; might as well keep all the backups. I sometimes manually go into this directory to find older versions of things. It occurs to me that it might be good to flip through the backups in the same way that git-time-machine makes it easy to flip through git revisions. I'm trying out lewang/backup-walker, which shows the incremental diffs between versions. It was last updated 12 years ago(!), but can easily be dusted off to work with Emacs 30 by defining some functions that it's looking for. Here's my config snippet:

(use-package backup-walker
  :vc (:url "https://github.com/lewang/backup-walker")
  :init
  (defalias 'string-to-int 'string-to-number)
  (defalias 'display-buffer-other-window 'display-buffer))

Into the future

It's not all about going back to the past. Sometimes I want to plan ahead: tasks that I want to schedule for a certain date, pre-mortems to help me make decisions, gifts for my future self. I use Google Calendar for appointments and other things I might want to share with W- for planning, but there are lots of other things that aren't tied to a specific time and date. The agenda feature of Org Mode is handy for scheduling things and moving them around.

Scheduled tasks don't work out so well if my agenda gets cluttered by things I ignore, so if I find myself procrastinating something a lot, I think about whether I really want to do whatever it is I've written down.

Some notes aren't associated with specific dates, but with other events that might happen. I have an Org Mode outline with various subheadings under "In case of…", although I often forget to check these or have a hard time finding them again. Maybe someday I can write a script that analyzes the words I use in my journal entries or tasks and finds the notes that approximately match those keywords.

Things I want to try

Thinking out loud more might be worth experimenting with, since I can do that while I'm working in a different file. I've used my audio recorder to record braindumps and I have a workflow for transcribing those with OpenAI Whisper. I think it would be even more useful to have an org-capture equivalent so that I can capture the thought by audio, save the recording in case there are recognition errors (highly likely because of the technical terms), and save the context. Or maybe an even neater interface that keeps an ear out for keywords, executes commands based on them, and saves the rest as notes? whisper-ctranslate2 has a live_transcribe option that works reasonably well after a short delay, and maybe I can use a process filter to pull the information out or write a custom Python script.

I appreciate how working with plain text can help me jump backward or forward in time. I'm looking forward to seeing how this can be even better!

This post was inspired by Emacs Carnival 2025-06: Take Two • Christian Tietze and IndieWeb Carnival: Take Two. Check those out for related blog posts!

This is, in fact, my second take on the topic. =) Here's my first one: Making and re-making: fabric is tuition

View org source for this post

Transforming HTML clipboard contents with Emacs to smooth out Mailchimp annoyances: dates, images, comments, colours

| emacs

I've recently started handling the Bike Brigade newsletter, so now I'm itching to solve the little bits of friction that get in my way when I work with the rich-text Mailchimp block editor.

I'm not quite ready to generate everything with Org Mode. Sometimes other people go in and edit the newsletter through the web interface, so I shouldn't just dump a bunch of HTML in. (We don't have the more expensive plan that would allow me to make editable templates.) I draft the newsletter as a Slack canvas so more people can weigh in with their suggestions:

2025-06-20_20-58-49.png
Figure 1: Screenshot of Slack canvas

And then I redo it in Mailchimp:

2025-06-20_21-01-08.png
Figure 2: Screenshot of Mailchimp design

My process is roughly:

  1. Duplicate blocks.
  2. Copy the text for each item and paste it in. Adjust formatting.
  3. Update the dates and links. Flip back and forth between the dispatch webpage and Mailchimp, getting the links and the dates just right.
  4. Download images one by one.
  5. Replace the images by uploading the saved images. Hunt through lots of files named image (3).png, image (4).png, and so on. Update their attributes and links.
  6. Change text and link colours as needed by manually selecting the text, clicking on the colour button in the toolbar, and selecting the correct colour.
  7. Change the text on each button. Switch to Slack, copy the link, switch back to Mailchimp, and update the link.

I think I can get Emacs to make things easier.

Automating buttons

The newsletter includes a button to make it easier to volunteer for deliveries. In case people want to plan ahead, I also include a link to the following week's signups.

Dates are fiddly and error-prone, so I want to automate them. I can use a Mailchimp code block to paste in some HTML directly, since I don't think other people will need to edit this button. Here I take advantage of org-read-date's clever date parsing so that I can specify dates like +2Sun to mean two Sundays from now. That way, I don't have to do any date calculations myself.

This code generates something like this:

2025-06-20_21-09-44.png
Figure 3: Screenshot of buttons
Text from the screenshot

SIGN UP NOW TO DELIVER JUN 23-29
You can also sign up early to deliver Jun 30-Jul 6

Here's the code. It calculates the dates, formats the HTML code. I use format-time-string to format just the month part of the dates and compare them to tell if I can skip the month part of the end date. After the HTML is formatted, the code uses xdotool (a Linux command-line tool) to switch to Google Chrome so that I can paste it in.

(defun my-brigade-copy-signup-block ()
  (interactive)
  (let* ((newsletter-date (org-read-date nil nil "+Sun"))
         (current-week (org-read-date nil t "+Mon"))
         (current-week-end (org-read-date nil t "+2Sun"))
         (next-week (org-read-date nil t "+2Mon"))
         (next-week-end (org-read-date nil t "+3Sun")))
    (kill-new
     (format
      "<div style=\"background-color: #223f4d; text-align: center; max-width: 384px; margin: auto; margin-bottom: 12px;\"><a href=\"https://dispatch.bikebrigade.ca/campaigns/signup?current_week=%s\" target=\"_blank\" class=\"mceButtonLink\" style=\"background-color:#223f4d;border-radius:0;border:2px solid #223f4d;color:#ffffff;display:block;font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;font-size:16px;font-weight:normal;font-style:normal;padding:16px 28px;text-decoration:none;text-align:center;direction:ltr;letter-spacing:0px\" rel=\"noreferrer\">SIGN UP NOW TO DELIVER %s-%s</a>
</div>
<p style=\"text-align: center; font-family: 'Helvetica Neue', Helvetica, Arial, Verdana\"><a href=\"https://dispatch.bikebrigade.ca/campaigns/signup?current_week=%s\" style=\"color: #476584; margin-top: 12px; margin-bottom: 12px;\" target=\"_blank\">You can also sign up early to deliver %s-%s</a></p>"
      (format-time-string "%Y-%m-%d" current-week)
      (upcase (format-time-string "%b %e" current-week))
      (format-time-string
       (if (string= (format-time-string "%m" current-week)
                    (format-time-string "%m" current-week-end))
           "%-e"
         "%b %-e")
       current-week-end)
      (format-time-string "%Y-%m-%d" next-week)
      (format-time-string "%b %e" next-week)
      (format-time-string
       (if (string= (format-time-string "%m" next-week)
                    (format-time-string "%m" next-week-end))
           "%-e"
         "%b %-e")
       next-week-end)))
    (shell-command "xdotool search  --onlyvisible --all Chrome windowactivate windowfocus")))

Now I can use an Org Mode link like elisp:my-brigade-copy-signup-block to generate the HTML code that I can paste into a Mailchimp code block. The button link is underlined even though the inline style says text-decoration:none, but it's easy enough to remove that with Ctrl+u.

Transforming HTML

The rest of the newsletter is less straightforward. I copy parts of the newsletter draft from the canvas in Slack to the block editor in Mailchimp. When I paste it in, I need to do a lot to format the results neatly.

I think I'll want to use this technique of transforming HTML data on the clipboard again in the future, so let's start with a general way to do it. This uses the xclip tool for command-line copying and pasting in X11 environments. It parses the HTML into a document object model (DOM), runs it through various functions sequentially, and copies the transformed results. Using DOMs instead of regular expressions means that it's easier to handle nested elements.

(defvar my-transform-html-clipboard-functions nil "List of functions to call with the clipboard contents.
Each function should take a DOM node and return the resulting DOM node.")
(defun my-transform-html-clipboard (&optional activate-app-afterwards functions text)
  "Parse clipboard contents and transform it.
This calls FUNCTIONS, defaulting to `my-transform-html-clipboard-functions'.
If ACTIVATE-APP-AFTERWARDS is non-nil, use xdotool to try to activate that app's window."
  (with-temp-buffer
    (let ((text (or text (shell-command-to-string "unbuffer -p xclip -o -selection clipboard -t text/html 2>& /dev/null"))))
      (if (string= text "")
          (error "Clipboard does not contain HTML.")
        (insert (concat "<div>"
                        text
                        "</div>"))))
    (let ((dom (libxml-parse-html-region (point-min) (point-max))))
      (erase-buffer)
      (dom-print (seq-reduce
                  (lambda (prev val)
                    (funcall val prev))
                  (or functions my-transform-html-clipboard-functions)
                  dom)))
    (shell-command-on-region
     (point-min) (point-max)
     "xclip -i -selection clipboard -t text/html -filter 2>& /dev/null"))
    (when activate-app-afterwards
      (call-process "xdotool" nil nil nil "search" "--onlyvisible" "--all" activate-app-afterwards "windowactivate" "windowfocus")))

Saving images

Images from Slack don't transfer cleanly to Mailchimp. I can download images from Slack one at a time, but Slack saves them with generic filenames like image (2).png. Each main newsletter item has one image, so I'd like to automatically save the image using the item title.

When I copy HTML from the Slack canvas, images are included as data URIs. The markup looks like this: <img src='data:image/png;base64,iVBORw0KGgo... With the way I do the draft in Slack, images are always followed by the item title as an h2 heading. If there isn't a heading, the image just doesn't get saved. If there's no image in a section, the code clears the variable, so that's fine too. I can parse and save the images like this:

(defun my-transform-html-save-images (dom dir &optional file-prefix transform-fn)
  (let (last-image)
    (dom-search dom
                (lambda (node)
                  (pcase (dom-tag node)
                    ('img
                     (let ((data (dom-attr node 'src)))
                       (with-temp-buffer
                         (insert data)
                         (goto-char (point-min))
                         (when (looking-at "data:image/\\([^;]+?\\);base64,")
                           (setq last-image (cons (match-string 1)
                                                  (buffer-substring (match-end 0) (point-max))))))))
                    ('h2
                     (when last-image
                       (with-temp-file
                           (expand-file-name
                            (format "%s%s.%s"
                                    (or file-prefix "")
                                    (if transform-fn
                                        (funcall transform-fn (dom-texts node))
                                      (dom-texts node))
                                    (car last-image))
                            dir)
                         (set-buffer-file-coding-system 'binary)
                         (insert (base64-decode-string (cdr last-image)))))
                     (setq last-image nil)))))
    dom))

I wrapped this in a small function for newsletter-specific processing:

(defvar my-brigade-newsletter-images-directory "~/proj/bike-brigade/newsletter/images")
(defun my-brigade-save-newsletter-images (dom)
  (my-transform-html-save-images
   dom
   my-brigade-newsletter-images-directory
   (concat (org-read-date nil nil "+Sun")
           "-news-")
   (lambda (heading)
     (replace-regexp-in-string
      "[^-a-z0-9]" ""
      (replace-regexp-in-string
       " +"
       "-"
       (string-trim (downcase heading)))))))

For easier testing, I used xclip -o -selection clipboard -t text/html > ~/Downloads/test.html to save the clipboard. To run the code with the saved clipboard, I can call it like this:

(my-transform-html-clipboard
 nil
 '(my-brigade-save-newsletter-images)
 (with-temp-buffer (insert-file-contents "~/Downloads/test.html") (buffer-string)))

Cleaning up

Now that I've saved the images, I can remove them:

(defun my-transform-html-remove-images (dom)
  (dolist (img (dom-by-tag dom 'img))
    (dom-remove-node dom img))
  dom)

I can also remove the italics that I use for comments.

(defun my-transform-html-remove-italics (dom)
  (dolist (node (dom-by-tag dom 'i))
    (dom-remove-node dom node))
  dom)

Here's how I can test it:

(my-transform-html-clipboard
 nil
 '(my-brigade-save-newsletter-images
   my-transform-html-remove-images
   my-transform-html-remove-italics)
 (with-temp-buffer (insert-file-contents "~/Downloads/test.html") (buffer-string)))

Removing sections

I put longer comments and instructions under "Meta" headings, which I can automatically remove.

(defvar my-brigade-section nil)
(defun my-brigade-remove-meta-recursively (node &optional recursing)
  "Remove <h1>Meta</h1> headings in NODE and the elements that follow them.
Resume at the next h1 heading."
  (unless recursing (setq my-brigade-section nil))
  (cond
   ((eq (dom-tag node) 'h1)
    (setq my-brigade-section (string-trim (dom-texts node)))
    (if (string= my-brigade-section "Meta")
        nil
      node))
   ((string= my-brigade-section "Meta")
    nil)
   (t
    (let ((processed
           (seq-keep
            (lambda (child)
              (if (stringp child)
                  (unless (string= my-brigade-section "Meta")
                    child)
                (my-brigade-remove-meta-recursively child t)))
            (dom-children node))))
      `(,(dom-tag node) ,(dom-attributes node) ,@processed)))))

Let's try it out:

(my-transform-html-clipboard
 nil
 '(my-brigade-save-newsletter-images
   my-transform-html-remove-images
   my-transform-html-remove-italics
   my-brigade-remove-meta-recursively)
 (with-temp-buffer (insert-file-contents "~/Downloads/test.html") (buffer-string)))

Formatting calls to action

Mailchimp recommends using buttons for calls to action so that they're larger and easier to click than links. In my Slack canvas draft, I use [ link text ] to indicate those calls to action. Wouldn't it be nice if my code automatically transformed those into centered buttons?

(defun my-brigade-format-buttons (dom)
  (dolist (node (dom-by-tag dom 'a))
    (let ((text (dom-texts node)))
      (if (string-match "\\[ *\\(.+?\\) *\\]" text)
          ;; button, wrap in a table
          (with-temp-buffer
            (insert
             (format "<table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" data-block-id=\"627\" class=\"mceButtonContainer\" style=\"margin: auto; text-align: center\"><tbody><tr class=\"mceStandardButton\"><td style=\"background-color:#000000;border-radius:0;text-align:center\" valign=\"top\" class=\"mceButton\"><a href=\"%s\" target=\"_blank\" class=\"mceButtonLink\" style=\"background-color:#000000;border-radius:0;border:2px solid #000000;color:#ffffff;display:block;font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;font-size:16px;font-weight:normal;font-style:normal;padding:16px 28px;text-decoration:none;text-align:center;direction:ltr;letter-spacing:0px\" rel=\"noreferrer\">%s</a></td></tr></tbody></table>"
                     (dom-attr node 'href)
                     (match-string 1 text)))
            (dom-add-child-before
             (dom-parent dom node)
             (car (dom-by-tag (libxml-parse-html-region (point-min) (point-max)) 'table)) node)
            (dom-remove-node dom node)))))
  dom)

Now I can test those functions in combination:

(my-transform-html-clipboard
 nil
 '(my-brigade-save-newsletter-images
   my-transform-html-remove-images
   my-transform-html-remove-italics
   my-brigade-remove-meta-recursively
   my-brigade-format-buttons)
 (with-temp-buffer (insert-file-contents "~/Downloads/test.html") (buffer-string)))
2025-06-20_21-10-38.png
Figure 4: Screenshot of button

Wrapping it up

Now that I've made all those little pieces, I can put them together in two interactive functions. The first function will be for the regular colour scheme, and the second function will be for the light-on-dark colour scheme. For convenience, I'll have it activate Google Chrome afterwards so that I can paste the results into the right block.

(defun my-brigade-transform-html (&optional recolor file)
  (interactive (list (when current-prefix-arg (read-file-name "File: "))))
  (my-transform-html-clipboard
  "Chrome"
  (append
   '(my-brigade-save-newsletter-images
     my-transform-html-remove-images
     my-transform-html-remove-italics
     my-brigade-remove-meta-recursively
     my-brigade-format-buttons)
   (if recolor '(my-brigade-recolor-recursively)))
  (when file
    (with-temp-buffer (insert-file-contents file) (buffer-string)))))

(defun my-brigade-transform-community-html (&optional file)
  (interactive (list (when current-prefix-arg (read-file-name "File: "))))
  (my-brigade-transform-html t file))

And then I can use links like this for quick shortcuts:

  • [[elisp:(my-brigade-transform-html nil "~/Downloads/test.html")]]
  • [[elisp:(my-brigade-transform-community-html "~/Downloads/test.html")]]
  • [[elisp:(my-brigade-transform-html)]]

Since this pastes the results as formatted text, it's editable using the usual Mailchimp workflow. That way, other people can make last-minute updates.

With embedded images, the saved HTML is about 8 MB. The code makes quick work of it. This saves about 10-15 minutes per newsletter, so the time investment probably won't directly pay off. But it also reduces annoyance, which is even more important than raw time savings. I enjoyed figuring all this out. I think this technique of transforming HTML in the clipboard will come in handy. By writing the functions as small, composable parts, I can change how I want to transform the clipboard.

Next steps

It would be interesting to someday automate the campaign blocks while still making them mostly editable, as in the following examples:

Maybe someday!

(Also, hat tip to this Reddit post that helped me get xclip to work more reliably from within Emacs by adding -filter 2>& /dev/null to the end of my xclip call so it didn't hang.)

View org source for this post

Run source blocks in an Org Mode subtree by custom ID

| emacs, org

I like the way Org Mode lets me logically group functions into headings. If I give the heading a CUSTOM_ID property (which is also handy for exporting to HTML, as it turns into an link anchor), I can use that property to find the subtree. Then I can use org-babel-execute-subtree to execute all source blocks in that subtree, which means I can mix scripting languages if I want to.

Here's the code:

(defun my-org-execute-subtree-by-custom-id (id &optional filename)
  "Prompt for a CUSTOM_ID value and execute the subtree with that ID.
If called with \\[universal-argument], prompt for a file, and then prompt for the ID."
  (interactive (if current-prefix-arg
                   (let ((file (read-file-name "Filename: ")))
                     (list
                      (with-current-buffer (find-file-noselect file)
                        (completing-read
                         "Custom ID: "
                         (org-property-values "CUSTOM_ID")))
                      file))
                 (list
                  (completing-read "Custom ID: " (org-property-values "CUSTOM_ID")))))
  (with-current-buffer (if filename (find-file-noselect filename) (current-buffer))
    (let ((pos (org-find-property "CUSTOM_ID" id)))
      (if pos
          (org-babel-execute-subtree)
        (if filename(error "Could not find %s in %s" id filename)
          (error "Could not find %s" id))))))

For example, in Using Org Mode, Emacs Lisp, and TRAMP to parse meetup calendar entries and generate a crontab, I have a Emacs Lisp source block that generates a crontab on a different computer, and a shell source block that installs it on that computer.

Technical notes: org-babel-execute-subtree narrows to the current subtree, so if I want anything from the rest of the buffer, I need to widen the focus again. Also, it's wrapped in a save-restriction and a save-excursion, so someday I might want to figure out how to handle the cases where I want to change what I'm looking at.

elisp: links in Org Mode let me call functions by clicking on them or following them with C-c C-o (org-open-at-point). This means I can make links that execute subtrees that might even be in a different file. For example, I can define links like these:

  • [[elisp:(my-org-execute-subtree-by-custom-id "update" "~/sync/emacs-calendar/README.org")][Update Emacs calendar]]
  • [[elisp:(my-org-execute-subtree-by-custom-id "crontab" "~/sync/emacs-calendar/README.org")][Update Emacs meetup crontab]]

That could be a good starting point for a dashboard.

Related: Execute a single named Org Babel source block

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

Using Org Mode, Emacs Lisp, and TRAMP to parse meetup calendar entries and generate a crontab

| org, emacs

Times and time zones trip me up. Even with calendar notifications, I still fumble scheduled events. Automation helps me avoid embarrassing hiccups.

We run BigBlueButton as a self-hosted web conferencing server for EmacsConf. It needs at least 8 GB of RAM when active. When it's dormant, it fits on a 1 GB RAM virtual private server. It's easy enough to scale the server up and down as needed. Using the server for Emacs meetups in between EmacsConfs gives people a way to get together, and it also means I can regularly test the infrastructure. That makes scaling it up for EmacsConf less nerve-wracking.

I have some code that processes various Emacs meetup iCalendar files (often with repeating entries) and combines them into one iCal file that people can subscribe to calendar, as well as Org files in different timezones that they can include in their org-agenda-files. The code I use to parse the iCal seems to handle time zones and daylight savings time just fine. I set it up so that the Org files have simple non-repeating entries, which makes them easy to parse. I can use the Org file to determine the scheduled jobs to run with cron on a home server (named xu4) that's up all the time.

This code parses the Org file for schedule information, then generates pairs of crontab entries. The first entry scales the BigBlueButton server up 1 hour before the event using my bbb-testing script, and the second entry scales the server down 6 hours after the event using my bbb-dormant script (more info). That gives organizers time to test it before the event starts, and it gives people plenty of time to chat. A shared CPU 8 GB RAM Linode costs USD 0.072 per hour, so that's USD 0.50 per meetup hosted.

Using #+begin_src emacs-lisp :file "/ssh:xu4:~/bbb.crontab" :results file as the header for my code block and using an SSH agent for authentication lets me use TRAMP to write the file directly to the server. (See Results of Evaluation (The Org Manual))

(let* ((file "/home/sacha/sync/emacs-calendar/emacs-calendar-toronto.org")
       (time-format "%M %H %d %m")
       (bbb-meetups "OrgMeetup\\|Emacs Berlin\\|Emacs APAC")
       (scale-up "/home/sacha/bin/bbb-testing")
       (scale-down "/home/sacha/bin/bbb-dormant"))
  (mapconcat
   (lambda (o)
     (let ((start-time (format-time-string time-format (- (car o) 3600 )))
           (end-time (format-time-string time-format (+ (car o) (* 6 3600)))))
       (format "# %s\n%s * %s\n%s * %s\n"
               (cdr o)
               start-time
               scale-up
               end-time
               scale-down)))
   (delq nil
         (with-temp-buffer
           (insert-file-contents file)
           (org-mode)
           (goto-char (point-min))
           (org-map-entries
            (lambda ()
              (when (and
                     (string-match bbb-meetups (org-entry-get (point) "ITEM"))
                     (re-search-forward org-tr-regexp (save-excursion (org-end-of-subtree)) t))
                (let ((time (match-string 0)))
                  (cons (org-time-string-to-seconds time)
                        (format "%s - %s" (org-entry-get (point) "ITEM") time)))))
            "LEVEL=1")))
   "\n"))

The code makes entries that look like this:

# OrgMeetup (virtual) - <2025-06-11 Wed 12:00>--<2025-06-11 Wed 14:00>
00 11 11 06 * /home/sacha/bin/bbb-testing
00 18 11 06 * /home/sacha/bin/bbb-dormant

# Emacs Berlin (hybrid, in English) - <2025-06-25 Wed 12:30>--<2025-06-25 Wed 14:30>
30 11 25 06 * /home/sacha/bin/bbb-testing
30 18 25 06 * /home/sacha/bin/bbb-dormant

# Emacs APAC: Emacs APAC meetup (virtual) - <2025-06-28 Sat 04:30>--<2025-06-28 Sat 06:00>
30 03 28 06 * /home/sacha/bin/bbb-testing
30 10 28 06 * /home/sacha/bin/bbb-dormant

This works because meetups don't currently overlap. If there were, I'll need to tweak the code so that the server isn't downscaled in the middle of a meetup. It'll be a good problem to have.

I need to load the crontab entries by using crontab bbb.crontab. Again, I can tell Org Mode to run this on the xu4 home server. This time I use the :dir argument to specify the default directory, like this:

#+begin_src sh :dir "/ssh:xu4:~" :results silent
crontab bbb.crontab
#+end_src

Then cron can take care of things automatically, and I'll just get the e-mail notifications from Linode telling me that the server has been resized. This has already come in handy, like when I thought of Emacs APAC as being on Saturday, but it was actually on Friday my time.

I have another Emacs Lisp block that I use to retrieve all the info and update the list of meetups. I can add (goto-char (org-find-property "CUSTOM_ID" "crontab")) to find this section and use org-babel-execute-subtree to execute all the code blocks. That makes it an automatic part of my process for updating the Emacs Calendar and Emacs News. Here's the code that does the calendar part (Org source):

(defun my-prepare-calendar-for-export ()
  (interactive)
  (with-current-buffer (find-file-noselect "~/sync/emacs-calendar/README.org")
    (save-restriction
      (widen)
      (goto-char (point-min))
      (re-search-forward "#\\+NAME: event-summary")
      (org-ctrl-c-ctrl-c)
      (org-export-to-file 'html "README.html")
      ;; (unless my-laptop-p (my-schedule-announcements-for-upcoming-emacs-meetups))
      ;; update the crontab
      (goto-char (org-find-property "CUSTOM_ID" "crontab"))
      (org-babel-execute-subtree)
      (when my-laptop-p
        (org-babel-goto-named-result "event-summary")
        (re-search-forward "^- ")
        (goto-char (match-beginning 0))
        (let ((events (org-babel-read-result)))
          (oddmuse-edit "EmacsWiki" "Usergroups")
          (goto-char (point-min))
          (delete-region (progn (re-search-forward "== Upcoming events ==\n\n") (match-end 0))
                         (progn (re-search-forward "^$") (match-beginning 0)))
          (save-excursion (insert (mapconcat (lambda (s) (concat "* " s "\n")) events ""))))))))
(my-prepare-calendar-for-export)

I used a similar technique to generate the EmacsConf crontabs for automatically switching to the next talk. For that one, I used Emacs Lisp to write the files directly instead of using the :file header argument for Org Mode source blocks. That made it easier to loop over multiple files.

Hmm. Come to think of it, the technique of "go to a specific subtree and then execute it" is pretty powerful. In the past, I've found it handy to execute source blocks by name. Executing a subtree by custom ID is even more useful because I can easily mix source blocks in different languages or include other information. I think that's worth adding a my-org-execute-subtree-by-custom-id function to my Emacs configuration. Combined with an elisp: link, I can make links that execute functional blocks that might even be in different files. That could be a good starting point for a dashboard.

I love the way Emacs can easily work with files and scripts in different languages on different computers, and how it can help me with times and time zones too. This code should help me avoid brain hiccups and calendar mixups so that people can just enjoy getting together. Now I don't have to worry about whether I remembered to set up cron entries and if I did the math right for the times. We'll see how it holds up!

View org source for this post

2025-06-16 Emacs news

| emacs, emacs-news

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

View org source for this post

2025-06-09 Emacs news

| emacs, emacs-news

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

View org source for this post

2025-06-02 Emacs news

| emacs, emacs-news

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

View org source for this post