EmacsConf.org

1. Overview

How we use Org Mode and TRAMP to organize and run a multi-track conference

Hi, I'm Sacha Chua. This presentation is a quick tour of some of the things we do to run EmacsConf. Since 2019, we've run it as an entirely online conference, and we do as much of the organization as possible within Emacs itself.

1.1. Reasons for making this presentation

I have three reasons for making this presentation.

  • Revisit scrambled-together code and document things along the way

    The first is entirely selfish: I need to figure out all the stuff I built for last year's EmacsConf, since it was a bit of a crazy scramble.

  • Show the process of thinking about a complex project and building it up

    The second is that I want to show people the process of thinking about a complex project, looking for little things to automate in Emacs, and building things up from small pieces. Maybe you'll get some ideas and start building tools for yourself, too.

  • Point to more information
    https://emacsconf.org/2023/talks/emacsconf

    The third is that you find any of these little tools interesting, I want to point you to blog posts and source code where you can find out more. That way, you don't need to try to read and understand everything quickly. You can find this presentation and other links on the talk page at emacsconf.org/2023/talks/emacsconf.

1.2. Map

There are a lot of different parts, so I'll try to use this map to help make sense of it all.

2. Organizing information

There's so much information to work with, so it probably doesn't surprise you that we use Org Mode a lot.

  • E-mail: Notmuch + Emacs

    Most of the conference coordination happens over e-mail, which I can quickly search with notmuch.

  • Private conf.org

    Some of the information is private, like emergency contact numbers. We store the talk information in a private Org file.

  • Public organizers' notebook

    I try to put as much as possible into our public organizers' notebook so that processes and decisions are documented.

  • Public webpages

    We need a public website. We use Ikiwiki to make the webpages because we can work with plain text files in a Git repository. We also make a few static HTML pages for things where Ikiwiki is a little awkward.

  • Mailing lists

    We post announcements to mailing lists. We also receive submissions in a private mailing list so that a number of people can review them.

  • Backstage area

    We have a backstage area for sharing files with volunteers and speakers.

  • Public media files

    We share those files publicly when the talk goes live.

  • Configuration, etc.

    And there's all the other stuff that goes into running EmacsConf, like shell scripts and configuration files.

Storing talk information as properties

First, speakers propose a talk by sending an e-mail. We take the info from that e-mail and store it in Org properties so that we can work with it later.

2.1. Basic talk properties

* WAITING_FOR_PREREC EmacsConf.org: How we use Org Mode and TRAMP to organize and run a multi-track conference
SCHEDULED: <2023-12-03 Sun 14:55-15:15>
:PROPERTIES:
:CUSTOM_ID:  emacsconf
:SLUG:       emacsconf
:NAME:       Sacha Chua
:NAME_SHORT: Sacha
:END:

Every talk is identified with an ID, but since :ID: and :CUSTOM_ID: have special meanings for Org, I use :SLUG: as the keyword.

Speakers' names go into the :NAME: property, and a short version goes into :NAME_SHORT: so that we can include that in a greeting.

2.2. Capturing a talk proposal from an e-mail

2023-09-05_13-09-57.png

If people follow the template closely…

2.3. Parsing a submission from e-mail

(defun emacsconf-mail-parse-submission (body)
  "Extract data from EmacsConf submissions in BODY."
  (when (listp body) (setq body (plist-get (car body) :content)))
  (let* ((data (list :body body))
         (fields '((:title "^[* ]*Talk title")
                   (:description "^[* ]*Talk description")
                   (:format "^[* ]*Format")
                   (:intro "^[* ]*Introduction for you and your talk")
                   (:name "^[* ]*Speaker name")
                   (:availability "^[* ]*Speaker availability")
                   (:q-and-a "^[* ]*Preferred Q&A approach")
                   (:public "^[* ]*Public contact information")
                   (:private "^[* ]*Private emergency contact information")
                   (:release "^[* ]*Please include this speaker release")))
         field
         (field-regexp (mapconcat
                        (lambda (o)
                          (concat "\\(?:" (cadr o) "\\)"))
                        fields "\\|")))
    (with-temp-buffer
      (insert body)
      (goto-char (point-min))
      ;; Try to parse it
      (catch 'done
        (while (not (eobp))
          ;; skip the field title
          (unless (looking-at field-regexp)
            (unless (re-search-forward field-regexp nil t)
              (throw 'done nil)))
          (goto-char (match-beginning 0))
          (setq field (seq-find (lambda (o)
                                  (looking-at (cadr o)))
                                fields))
          (when field
            ;; get the text between this and the next field
            (re-search-forward "\\(:[ \t\n]+\\|\n\n\\)" nil t)
            (setq data
                  (plist-put
                   data
                   (car field)
                   (buffer-substring
                    (point)
                    (or (and
                         (re-search-forward field-regexp nil t)
                         (goto-char (match-beginning 0))
                         (point))
                        (point-max))))))))
      (if (string-match "[0-9]+" (or (plist-get data :format) ""))
          (plist-put data :time (match-string 0 (or (plist-get data :format) ""))))
      data)))

…we can even automatically fill in the Org subtree for their talk. We can use regular expressions to recognize the text and extract the properties.

2.4. Setting the property to a region

(defun my-org-set-property (property value)
  "In the current entry, set PROPERTY to VALUE.
Use the region if active."
  (interactive
   (list
    (org-read-property-name)
    (when (region-active-p)
      (replace-regexp-in-string
       "[ \n\t]+" " "
       (buffer-substring (point) (mark))))))
  (org-set-property property value))

Other properties need to be set by hand. I often mess things up when I retype them. To avoid typos, I have a function that sets a property based on the current region. I bind that to C-c C-x p.

2.5. Setting properties

That makes it much easier to set properties that couldn't automatically be recognized.

3. Calculating and then editing properties

Sometimes it makes sense to dynamically generate a property and then edit it, like with filenames.

3.1. Calculating and then editing properties

:FILE_PREFIX: emacsconf-year-slug--title--speakers

We like to name all the talk files the same way, but sometimes special characters in talk titles or speaker names need a little tweaking. I'll put that in a FILE_PREFIX property so I can edit it.

3.2. Setting properties that are unset

(defun emacsconf-set-file-prefixes ()
  "Set the FILE_PREFIX property for each talk entry that needs it."
  (interactive)
  (org-map-entries
   (lambda ()
     (org-entry-put
      (point) "FILE_PREFIX"
      (emacsconf-file-prefix (emacsconf-get-talk-info-for-subtree))))
   "SLUG={.}-FILE_PREFIX={.}"))

An Org property match can map over all the talk entries that don't have FILE_PREFIX defined.

4. Renaming files

We can use that FILE_PREFIX to rename files from Emacs.

4.1. Renaming files with Emacs Lisp

(defun emacsconf-rename-files (talk &optional filename)
  "Rename the marked files or the current file to match TALK.
If FILENAME is specified, use that as the extra part of the filename after the prefix.
This is useful for distinguishing files with the same extension.
Return the list of new filenames."
  (interactive (list (emacsconf-complete-talk-info)))
  (prog1
      (mapcar
       (lambda (file)
         (let* ((extra
                 (or filename
                     (read-string (format "Filename (%s): " (file-name-base file)))))
                (new-filename
                 (expand-file-name
                  (concat (plist-get talk :file-prefix)
                          (if (string= extra "")
                              ""
                            (concat "--" extra))
                          "."
                          (file-name-extension file))
                  (file-name-directory file))))
           (rename-file file new-filename t)
           new-filename))
       (or (dired-get-marked-files) (list (buffer-file-name))))
    (when (derived-mode-p 'dired-mode)
      (revert-buffer))))

With that property, we can then rename files using that prefix, some extra text, and the file extension.

5. Renaming files with shell scripts

Sometimes it's easier to work with the data outside Emacs, like when I want to rename files with a shell script.

5.1. Exporting the information as JSON

(defun emacsconf-publish-talks-json ()
  "Return JSON format with a subset of talk information."
  (let ((emacsconf-publishing-phase 'conference))
    (json-encode
     (list
      :talks
      (mapcar
       (lambda (o)
         (apply
          'list
          (cons :start-time (format-time-string "%FT%T%z" (plist-get o :start-time) t))
          (cons :end-time (format-time-string "%FT%T%z" (plist-get o :end-time) t))
          (mapcar
           (lambda (field)
             (cons field (plist-get o field)))
           '(:slug :title :speakers :pronouns :pronunciation :url :track :file-prefix
                   :qa-url
                   :qa-type
                   :qa-backstage-url))))
       (emacsconf-filter-talks (emacsconf-get-talk-info)))))))

If I export a subset of the data as JSON or JavaScript Object Notation using json-encode

5.2. Using the data with JQ

roles/prerec/templates/rename-original.sh

#!/bin/bash
# {{ ansible_managed }}
# Usage: rename-original.sh $slug $file [$extra] [$talks-json]
SLUG=$1
FILE=$2
TALKS_JSON=${4:-~/current/talks.json}
EXTRA=""
if [ -z ${3-unset} ]; then
    EXTRA=""
elif [ -n "$3" ]; then
    EXTRA="$3"
elif echo "$FILE" | grep -e '\(webm\|mp4\|mov\)'; then
    EXTRA="--original"
fi
filename=$(basename -- "$FILE")
extension="${filename##*.}"
filename="${filename%.*}"
FILE_PREFIX=$(jq -r '.talks[] | select(.slug=="'$SLUG'")["file-prefix"]' < $TALKS_JSON)
if echo "$FILE" | grep -q \\. ; then
    mv "$FILE" $FILE_PREFIX$EXTRA.$extension
    echo $FILE_PREFIX$EXTRA.$extension
else
    mv "$FILE" $FILE_PREFIX$EXTRA
    echo $FILE_PREFIX$EXTRA
fi
# Copy to original if needed
if [ -f $FILE_PREFIX--original.webm ] && [ ! -f $FILE_PREFIX--main.$extension ]; then
    cp $FILE_PREFIX--original.$extension $FILE_PREFIX--main.webm
fi

… then I can extract the data with jq and use it in shell scripts.

6. Parsing availability

Another example of semi-structured information is speaker availability. We have speakers from all over the world, so we try to schedule live Q&A sessions when they're around. That means working with timezones.

6.1. Setting the timezone

Completion makes it much easier to set the timezone property without worrying about typos.

6.2. The code

(defun emacsconf-timezone-set (timezone)
  "Set the timezone for the current Org entry."
  (interactive
   (list
    (progn
      (require 'tzc)
      (completing-read "Timezone: " tzc-time-zones))))
  (org-entry-put (point) "TIMEZONE" timezone))

We can take advantage of the timezone list from the tzc package, which works with Unix timezone definitions.

6.3. Converting timezones and encoding the availability

<= 11:00 EST

Then we can convert times using Emacs. Using a standard format to encode the availability makes it easier to parse.

6.4. Validating errors

I can use those availability constraints to report errors when I'm experimenting with the schedule.

7. Scheduling talks

Now that I have the availability information, I can think about scheduling.

7.0.1. Schedule as table

2023-09-10-11-41-42.svg

When we were planning EmacsConf 2022, the schedule was so full, I wanted to see if we could make it more manageable by splitting it up into two tracks. It was hard to think about times with just a table.

7.0.2. Schedule as SVG

I was able to turn the schedule information into an SVG to convince the other organizers to get on board with this crazy plan. And the nice thing about SVGs is that they can even be clickable on the wiki.

opening-talk.gif

7.0.3. Testing different schedules

output-2023-09-10-12:20:14.gif

Being able to quickly make SVGs of different schedules also helped me test scheduling ideas and think out loud. I could change the time between talks, the order of the talks, and even what tracks the talks were in. This was helpful when I needed to include some late submissions or availability changes and I wanted to ask speakers what they thought. They could see the different schedule options themselves.

It's really nice to have Emacs Lisp support for working with SVGs. I also love how I can have an Emacs Lisp block in an Org Mode document that updates an SVG that I can view right there in my text editor.

8. Translating times

Setting the timezone lets me automatically translate times to the speaker's local timezone when I e-mail them.

8.1. Translating times

As of the time I write this e-mail, your tentative schedule is:

  Improving compiler diagnostics with Overlays
  Dec 2 Sat 1:00 pm EST
  translated to your local timezone US/Pacific: Dec 2 Sat 10:00 am PST

I'm using ">= 11:00 -0500 (08:00 US/Pacific)" as the availability
constraint for you when planning the talks, but since I sometimes mess
up encoding these things, could you please doublecheck that this works
for you?

format-time-string

That's mostly a matter of using format-time-string with a timezone. We use that in our templates, which I'll talk about a bit later.

9. Templates

It's not just about graphics. There's also a lot of text to work with, which means templates are super handy.

9.1. A little function for templates

  • tempo?
  • s-lex-format: (let ((x 1)) (s-lex-format "x is: ${x}"))
  • format: something %s something %s something %s … ?
  • concat: hard to get overall view

There are a number of templating functions for Emacs Lisp, like the built-in tempo.el or s-lex-format from s.el. I ended up writing something that works with property lists (plists) instead, since we use plists all over the emacsconf-el library. All it does is replace ${variable} with the value from a property list. I use this mostly because I have a hard time keeping track of which %s is which when I use format, and it's hard to get an overall view if I just use concat.

9.2. The code

(defun emacsconf-replace-plist-in-string (attrs string)
  "Replace ${keyword} from ATTRS in STRING."
  (let ((a attrs) name val)
    (while a
      (setq name (pop a) val (pop a))
      (when (stringp val)
        (setq string
              (replace-regexp-in-string
               (regexp-quote
                (concat "${" (substring (symbol-name name) 1) "}"))
               (or val "")
               string t t))))
    string))

The code looks for the properties and replaces them with the values. I just find it a little easier to think about sometimes.

(emacsconf-replace-plist-in-string
 '(:test "Hello" :another-var "world")
 "Test 1: ${test} Test 2: ${another-var}")
;; Test 1: Hello Test 2: world

9.3. Getting all the talk information

(defun emacsconf-get-talk-info ()
  (with-current-buffer (find-file-noselect emacsconf-org-file)
    (save-excursion
      (save-restriction
        (widen)
        (let (results)
          (org-map-entries
           (lambda ()
             (when (or (org-entry-get (point) "TIME")
                       (org-entry-get (point) "SLUG")
                       (org-entry-get (point) "INCLUDE_IN_INFO"))
               (setq results
                     (cons (emacsconf-get-talk-info-for-subtree)
                           results)))))
          (nreverse results))))))

Getting all the information is just a matter of going over all the talk entries using org-map-entries.

9.4. Talk info functions

(defun emacsconf-get-talk-info-for-subtree ()
  "Run `emacsconf-talk-info-functions' to extract the info for this entry."
  (seq-reduce (lambda (prev val) (save-excursion (save-restriction (funcall val prev))))
              emacsconf-talk-info-functions
              nil))

(defvar emacsconf-talk-info-functions
  '(emacsconf-get-talk-info-from-properties
    emacsconf-get-talk-categories
    emacsconf-get-talk-abstract-from-subtree
    emacsconf-get-talk-logbook
    emacsconf-add-talk-status
    emacsconf-add-checkin-time
    emacsconf-add-timezone-conversions
    emacsconf-add-speakers-with-pronouns
    emacsconf-add-live-info)
  "Functions to collect information.")

This builds the talk info by running a bunch of functions.

9.5. Getting info from the Org file

(defun emacsconf-get-talk-abstract-from-subtree (o)
  "Add the abstract from a subheading.
The subheading should match `emacsconf-abstract-heading-regexp'."
  (plist-put o :abstract (substring-no-properties (or (emacsconf-get-subtree-entry "abstract") ""))))

Some functions get the information from the Org file.

9.6. Modifying talk info

(defun emacsconf-add-talk-status (o)
  "Add status label and public info."
  (plist-put o :status-label
             (or (assoc-default (plist-get o :status)
                                emacsconf-status-types 'string= "")
                 (plist-get o :status)))
  (when (or
         (member (plist-get o :status)
                 (split-string "PLAYING CLOSED_Q OPEN_Q UNSTREAMED_Q TO_ARCHIVE TO_EXTRACT TO_FOLLOW_UP DONE"))
         (time-less-p (plist-get o :start-time)
                      (current-time)))
    (plist-put o :public t))
  o)

Other functions use the info already collected.

9.7. Performance

memoize for performance

This can take a while to do again and again. It's useful to memoize this function when I know I'll be using it a lot, like when I export the organizers notebook. Memoize caches recent values.

10. Updating the conference wiki

We combine this templating function with the talk information to fill in the conference wiki, since that's a matter of writing templated strings to files. The talk pages are generated once and then left alone for manual editing, while the navigation is regenerated every time we change the details.

10.1. The conference wiki

2023-09-26_14-09-07.png

Here are some examples of how we fill in the conference wiki. We put in the format of the talk, how Q&A works, and what the status is. Once the talk is live, we include the video and the links to the files, too.

10.2. The code

;; from emacsconf-publish-format-talk-schedule-info
(emacsconf-replace-plist-in-string
 (append o
         (list
          :irc-info
          (format "Discuss on IRC: [#%s](%s)  \n" (plist-get o :channel)
                  (plist-get o :webchat-url))
          ; ... more entries
          ))
 "[[!toc  ]]
Format: ${format}
${pad-info}${irc-info}${status-info}${schedule-info}\n
${alternate-apac-info}\n")

The code is a little bit long, but the important part is that we fill in a plist with the values we calculate, and then we can use emacsconf-replace-plist-in-string to put that all together.

10.3. Schedule

2023-09-26_14-00-19.png

The schedule is a little more complicated. I wrote an Ikiwiki directive so that the markup is more manageable, and the Emacs Lisp function uses that.

10.3.1. The Ikiwiki directive

The Ikiwiki markup looks like this:

[[!template id=sched time="""20"""
q-and-a="""<a href="https://media.emacsconf.org/2023/current/bbb-emacsconf.html">BBB</a>"""
startutc="""2023-12-02T19:50:00+0000""" endutc="""2023-12-02T20:10:00+0000"""
start="""2:50""" end="""3:10"""
title="""EmacsConf.org: How we use Org Mode and TRAMP to organize and run a multi-track conference"""
url="""/2023/talks/emacsconf""" speakers="""Sacha Chua""" track="""Development"""
watch="""https://emacsconf.org/2023/watch/dev""" slug="""emacsconf""" note=""""""]]

The Ikiwiki directive takes all the data and turns it into HTML…

10.3.2. Emacs Lisp

emacsconf-publish-sched-directive in emacsconf-publish.el

  (format "[[!template id=sched%s]]"
          (let ((result "")
                (attrs
                 ;; ... some code to make a property list based on the talk ...
                 ))
            (while attrs
              (let ((field (pop attrs))
                    (val (pop attrs)))
                (when val
                  (setq result
                        (concat result " "
                                (substring (symbol-name field) 1) "=\"\"\"" val "\"\"\"")))))
            result))

…so we can use Emacs Lisp to iterate over a slightly smaller property list and put them into the format Ikiwiki expects.

10.4. Navigation

2023-09-26_14-05-51.png

It's nice to be able to navigate between talks without going back to the schedule page each time.

10.5. Emacs Lisp

(defun emacsconf-publish-nav-pages (&optional talks)
  "Generate links to the next and previous talks.
During the schedule and conference phase, the talks are sorted by time.
Otherwise, they're sorted by track and then schedule."
  (interactive (list (emacsconf-publish-prepare-for-display (or emacsconf-schedule-draft (emacsconf-get-talk-info)))))
  (let* ((next-talks (cdr talks))
         (prev-talks (cons nil talks))
         (label (if (member emacsconf-publishing-phase '(schedule conference))
                    "time"
                  "track")))
    (unless (file-directory-p (expand-file-name "info" (expand-file-name emacsconf-year emacsconf-directory)))
      (mkdir (expand-file-name "info" (expand-file-name emacsconf-year emacsconf-directory))))
    (while talks
      (let* ((o (pop talks))
             (next-talk (emacsconf-format-talk-link (pop next-talks)))
             (prev-talk (emacsconf-format-talk-link (pop prev-talks))))
        (with-temp-file (expand-file-name (format "%s-nav.md" (plist-get o :slug))
                                          (expand-file-name "info" (expand-file-name emacsconf-year emacsconf-directory)))
          (insert (concat "\n<div class=\"talk-nav\">
Back to the [[talks]]  \n"
                          (if prev-talk (format "Previous by %s: %s  \n" label prev-talk) "")
                          (if next-talk (format "Next by %s: %s  \n" label next-talk) "")
                          (if (plist-get o :track) ; tagging doesn't work here because ikiwiki will list the nav page
                              (format "Track: <span class=\"sched-track %s\">%s</span>  \n" (plist-get o :track) (plist-get o :track))
                            "")
                          "</div>
")))))))

This is handled by keeping two extra copies of the list: one with the first talk popped off, and one with an extra element added to the beginning. Then we can use the heads of those lists for next/previous links.

11. Etherpad

Links to the next talks are also handy on the collaborative Etherpad documents that we use for collecting questions, answers, and notes during each talk.

11.1. API

(defun emacsconf-pad-set-html (pad-id html)
  "Set PAD-ID contents to the given HTML."
  (interactive "MPad ID: \nMHTML: ")
  (let ((url-request-data (concat "html=" (url-hexify-string html)))
        (url-request-method "POST")
        (url-request-extra-headers '(("Content-Type" . "application/x-www-form-urlencoded"))))
    (emacsconf-pad-json-request (format "%sapi/1/setHTML?apikey=%s&padID=%s"
                           emacsconf-pad-base
                           (url-hexify-string emacsconf-pad-api-key)
                           (url-hexify-string pad-id))
                   (called-interactively-p 'any))))

Etherpad has an API…

11.2. Pad template

<div>
<div>All talks: ${talks}</div>
<div><strong>${title}</strong></div>
<div>${base-url}${url} - ${speakers} - Track: ${track}</div>
<div>Watch/participate: ${watch}</div>
${bbb-info}
<div>IRC: ${irc-nick-details} https://chat.emacsconf.org/#/connect?join=emacsconf,emacsconf-${track-id} or #emacsconf-${track-id} on libera.chat network</div>
<div>Guidelines for conduct: ${base-url}conduct</div>
<div>See end of file for license (CC Attribution-ShareAlike 4.0 + GPLv3 or later)</div>
<div>----------------------------------------------------------------</div>
<div>Notes, discussions, links, feedback:</div>
<ul>${notes}</ul>
<div>----------------------------------------------------------------</div>
...

…so I can start the pads off with a template before the conference.

11.3. Checking the pad timestamp

(defun emacsconf-pad-prepopulate-talk-pad (o)
  "Fill in the pad for O."
  (interactive
   (list
    (let ((info (emacsconf-include-next-talks (emacsconf-get-talk-info) emacsconf-pad-number-of-next-talks)))
      (emacsconf-complete-talk-info info))))
  (let ((pad-id (emacsconf-pad-id o)))
    (emacsconf-pad-create-pad pad-id)
    (when (or emacsconf-pad-force-all
              (not (emacsconf-pad-modified-p pad-id))
              (progn
                (browse-url (emacsconf-pad-url o))
                (y-or-n-p (format "%s might have been modified. Reset? " (plist-get o :slug)))))
      (emacsconf-pad-set-html
       pad-id
       (emacsconf-pad-initial-content o))
      (save-window-excursion
        (emacsconf-with-talk-heading (plist-get o :slug)
          (let-alist (emacsconf-pad-get-last-edited pad-id)
            (org-entry-put (point) "PAD_RESET" (number-to-string .data.lastEdited))))))))

I don't want to accidentally overwrite a pad that has been manually edited. We can save the timestamp of the last modification and then compare it before overwriting.

12. Mail merge

Templates are also very handy when it comes to e-mail.

12.0.1. Mail merge

(defun emacsconf-mail-review (talk)
  "Let the speaker know we've received their proposal."
  (interactive (list (emacsconf-complete-talk-info)))
  (emacsconf-mail-prepare '(:subject "${conf-name} ${year} review: ${title}"
                       :cc "emacsconf-submit@gnu.org"
                       :reply-to "emacsconf-submit@gnu.org, ${email}, ${user-email}"
                       :mail-followup-to "emacsconf-submit@gnu.org, ${email}, ${user-email}"
                       :body "
Hi, ${speakers-short}!

Thanks for submitting your proposal! (ZZZ TODO: feedback)

We'll wait a week (~ ${notification-date}) in case the other volunteers want to chime in regarding your talk. =)

${signature}
")
            (plist-get talk :email)
            (list
             :user-email user-mail-address
             :signature user-full-name
             :title (plist-get talk :title)
             :email (plist-get talk :email)
             :conf-name emacsconf-name
             :speakers-short (plist-get talk :speakers-short)
             :year emacsconf-year
             :notification-date (plist-get talk :date-to-notify))))

Sometimes we send e-mails one at a time, like when we let a speaker know that we've received their proposal. That's mostly a matter of plugging the talk's properties into the right places in the template.

12.1. Mass e-mails

(defun emacsconf-mail-upload-and-backstage-info (group)
  "E-mail upload and backstage access information to GROUP."
  (interactive (list (emacsconf-mail-complete-email-group)))
  (emacsconf-mail-prepare
   (list
    :subject "${conf-name} ${year}: Upload instructions, backstage info"
    :reply-to "emacsconf-submit@gnu.org, ${email}, ${user-email}"
    :mail-followup-to "emacsconf-submit@gnu.org, ${email}, ${user-email}"
    :log-note "sent backstage and upload information"
    :body
    "${email-notes}Hi ${name}!

Hope things are going well for you! I got the upload service up
and running so you can upload your video${plural} and other talk
resources (ex: speaker notes, Org files, slides, etc.).  You can
access it at ${upload-url} with the password
\"${upload-password}\". Please let me know if you run into technical issues.${fill}

If you can get your file(s) uploaded by ${video-target-date},
that would give us plenty of time to reencode it, edit captions,
and so on. If life has gotten a bit busier than expected, that's
okay. You can upload it when you can or we can plan to do your
presentation live.
(Late submissions and live presentations are a bit more stressful, but
it'll probably work out.)${fill}

We've also set up ${backstage} as the backstage area where you
can view the videos and resources uploaded so far. You can access
it with the username \"${backstage-user}\" and the password
\"${backstage-password}\".  Please keep the backstage password
and other speakers' talk resources secret. This is a manual
process, so your talk won't immediately show up there once you've
upload it. Once we've processed your talk, you can use the
backstage area to check if your talk has been correctly reencoded
and see the progress on captions. You can also check out other
people's talks. Enjoy!${fill}

Thank you so much for contributing to ${conf-name} ${year}!

${signature}")
   (car group)
   (list
    :email-notes (emacsconf-surround "ZZZ: " (plist-get (cadr group) :email-notes) "\n\n" "")
    :backstage (emacsconf-replace-plist-in-string
                (list :year emacsconf-year)
                "https://media.emacsconf.org/${year}/backstage/")
    :plural (if (= 1 (length (cdr group))) "" "s")
    :backstage-user emacsconf-backstage-user
    :backstage-password emacsconf-backstage-password
    :upload-url
    (concat "https://ftp-upload.emacsconf.org/?sid="
            emacsconf-upload-password
            "-"
            (mapconcat (lambda (o) (plist-get o :slug)) (cdr group) "-"))
    :upload-password emacsconf-upload-password
    :video-target-date emacsconf-video-target-date
    :name (plist-get (cadr group) :speakers-short)
    :email (car group)
    :user-email user-mail-address
    :signature user-full-name
    :conf-name emacsconf-name
    :year emacsconf-year)))

Sometimes we send e-mails to lots of speakers at the same time, like when we send them instructions for uploading their files. Instead of sending one e-mail and Bcc-ing everyone, or sending people multiple e-mails because they have multiple talks, I like to draft these as individual e-mails to each speaker (or group of speakers, if more than one person is associated with a talk). That gives me an opportunity to personalize it further.

13. BigBlueButton

Many speakers answer questions live in BigBlueButton web conference rooms.

13.1. Creating BigBlueButton rooms from Emacs Lisp with Spookfox

(defun emacsconf-spookfox-create-bbb (group)
  "Create a BBB room for this group of talks.
GROUP is (email . (talk talk talk)).
Needs a Spookfox connection."
  (let* ((bbb-name
          (format "%s (%s) - %s%s"
                  (mapconcat (lambda (o) (plist-get o :slug)) (cdr group) ", ")
                  (plist-get (cadr group) :speakers)
                  emacsconf-id
                  emacsconf-year))
         path
         (retrieve-command
          (format
           "window.location.origin + [...document.querySelectorAll('h4.room-name-text')].find((o) => o.textContent.trim() == '%s').closest('tr').querySelector('.delete-room').getAttribute('data-path')"
           bbb-name))
         (create-command (format "document.querySelector('#create-room-block').click();
document.querySelector('#create-room-name').value = \"%s\";
document.querySelector('#room_mute_on_join').click();
document.querySelector('.create-room-button').click();"
                                 bbb-name)))
    (setq path (spookfox-js-injection-eval-in-active-tab retrieve-command t))
    (unless path
      (dolist (cmd (split-string create-command ";"))
        (spookfox-js-injection-eval-in-active-tab cmd t)
        (sleep-for 2))
      (sleep-for 2)
      (setq path (spookfox-js-injection-eval-in-active-tab retrieve-command t)))
    (when path
      (dolist (talk (cdr group))
        (save-window-excursion
          (emacsconf-with-talk-heading talk
            (org-entry-put (point) "ROOM" path))))
      (cons bbb-name path))))

Setting up one room per group of speakers makes it easy to give the speakers the details and associate the recorded video with the talk afterwards. For EmacsConf 2023, I used Spookfox to control Mozilla Firefox from Emacs so that I could automate creating the rooms and adding the URLs to the talk properties in my Org file.

14. Check-in instructions

Then I can use mail merge to send each speaker the check-in instructions for their specific room.

14.1. Sending check-in instructions

(defun emacsconf-mail-checkin-instructions-to-all ()
  "Draft check-in instructions for all speakers."
  (interactive)
  (let* ((talks (seq-filter (lambda (o) (and (plist-get o :email)
                                             (plist-get o :q-and-a)))
                            (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))
         (by-attendance (seq-group-by (lambda (o) (null (string-match "after" (plist-get o :q-and-a))))
                                      talks)))
    (dolist (group (emacsconf-mail-groups (assoc-default nil by-attendance)))
      (emacsconf-mail-checkin-instructions-for-nonattending-speakers group))
    (dolist (group (emacsconf-mail-groups (assoc-default t by-attendance)))
      (emacsconf-mail-checkin-instructions-for-attending-speakers group))))

Some speakers will take questions by e-mail after the conference instead of attending live, so we send them shorter instructions just in case they want to drop by.

15. Redirect

Live Q&A sessions start off with just the speaker and the host. After the first rush of questions, we can open it up for other people to join.

15.1. Dynamic redirects

(defun emacsconf-publish-bbb-redirect (talk &optional status)
  "Update the publicly-available redirect for TALK."
  (interactive (list (emacsconf-complete-talk-info)))
  (let ((bbb-filename (expand-file-name (format "bbb-%s.html" (plist-get talk :slug))
                                        emacsconf-publish-current-dir))
        (bbb-redirect-url (concat "https://media.emacsconf.org/" emacsconf-year "/current/bbb-" (plist-get talk :slug) ".html"))
        (status (or status (emacsconf-bbb-status (if (boundp 'org-state) (append (list :status org-state) talk) talk)))))
    (with-temp-file bbb-filename
      (insert
       (emacsconf-replace-plist-in-string
        (append talk (list :base-url emacsconf-base-url :bbb-redirect-url bbb-redirect-url))
        (pcase status
          ('before
           "<html><head><meta http-equiv=\"refresh\" content=\"5; URL=${bbb-redirect-url}\"></head><body>
The Q&A room for ${title} is not yet open. This page will refresh every 5 seconds until the BBB room is marked as open, or you can refresh it manually.</body></html>")
          ('open
           "<html><head><meta http-equiv=\"refresh\" content=\"0; URL=${bbb-room}\"></head><body>
The live Q&A room for ${title} is now open. You should be redirected to <a href=\"${bbb-room}\">${bbb-room}</a> automatically, but if not, please visit the URL manually to join the Q&A.</body></html>")
          ('after
           "<html><head><body>
The Q&A room for ${title} has finished. You can find more information about the talk at <a href=\"${base-url}${url}\">${base-url}${url}</a>.</body></html>")
          (_
           "<html><head><body>
There is no live Q&A room for ${title}. You can find more information about the talk at <a href=\"${base-url}${url}\">${base-url}${url}</a>.</body></html>"
           )))))))

This is handled by changing the public page from one that just refreshes in a loop to one that redirects to the actual web conference room.

15.2. Static redirects

(defun emacsconf-publish-bbb-static-redirects ()
  "Create emergency redirects that can be copied over the right location."
  (interactive)
  (mapc (lambda (state)
          (let ((emacsconf-publish-current-dir
                 (expand-file-name
                  state
                  (expand-file-name
                   "redirects"
                   (expand-file-name "assets" emacsconf-backstage-dir)))))
            (unless (file-directory-p emacsconf-publish-current-dir)
              (make-directory emacsconf-publish-current-dir t))
            (mapc
             (lambda (talk)
               (emacsconf-publish-bbb-redirect talk (intern state)))
             (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))))
        '("before" "open" "after")))

Just in case, we also generate static copies of those redirects so that we can copy them if needed. That way, I don't have to count on Emacs being able to publish them over TRAMP.

16. Jumping to a talk

During the conference, I'm often jumping from talk to talk.

16.1. emacsconf-go-to-talk

output-2023-09-10-14:04:30.gif

Instead of going to the Org file and then searching for the talk, I've made a little Hydra with keyboard shortcuts. One of these shortcuts lets me jump to a talk with completion so that I can just type in part of the talk ID, title, or speaker name.

16.2. Embark actions

I've also defined some Embark actions so that I can act on a talk right from the completion menu. For example, I might want to jump to the wiki page or e-mail the speaker.

17. Adding notes to the Org Mode logbook

I can also add notes to a talk while looking at an email, like when a speaker lets me know that their video will be late.

17.1. Adding a note

Making it easy to add a note turns Emacs into a very basic contact relationship management system, or CRM.

17.2. The code

(defun emacsconf-mail-add-to-logbook (email note)
  "Add to logbook for all matching talks from this speaker."
  (interactive
   (let* ((email (mail-strip-quoted-names
                  (plist-get (plist-get (notmuch-show-get-message-properties) :headers)
                             :From)))
          (talks (emacsconf-mail-talks email)))
     (list
      email
      (read-string (format "Note for %s: "
                           (mapconcat (lambda (o) (plist-get o :slug))
                                      talks", "))))))
  (save-window-excursion
    (mapc
     (lambda (talk)
       (emacsconf-add-to-talk-logbook talk note))
     (emacsconf-mail-talks email))))

The way this works is that we have a function that lists all the email addresses associated with a talk. We can then map that over the list of talks, look up the author of the current email, prompt the user for the talk to add the note to, and add the note.

18. Captions

On to captions. We've been doing captions for the last couple of years, and now we have a small army of volunteer captioners. They get early access to the recorded talks and fix up misrecognized words, format keyboard shortcuts to follow Emacs conventions, spell names correctly, and do all sorts of other wonderful things. One of our evil plans with EmacsConf is to get cool stuff out of people's heads into videos and also make captions so that those videos can be searched.

19. Backstage index with TRAMP

19.1. The backstage index

2023-10-28_21-32-21.png

To make that possible, we first need a backstage area where volunteers can get the files. This is just a simple password-protected directory with a static HTML page that lists the talks by status and shows the files related to each talk. As a talk moves through the process, I update its TODO state and republish this index. Talks that are ready to be captioned show up in that section, and volunteers can call dibs on the talk they're interested in.

19.2. Saving the file over TRAMP

(setq emacsconf-backstage-dir
      "/ssh:orga@media.emacsconf.org:/var/www/media.emacsconf.org/2023/backstage")
(setq filename (or filename (expand-file-name "index.html" emacsconf-backstage-dir)))
;; ...
(with-temp-file filename
  ;; ...
)

That's all done with a function that formats the information and uses TRAMP to save the file directly to the server.

19.3. The captioning process

https://emacsconf.org/captioning

You can find more details on our captioning process at emacsconf.org/captioning. I like using subed to edit subtitles within Emacs.

20. Crontab

Let's talk about actually playing the talks.

20.1. Crontab

Using timers and TRAMP to play talks automatically

Using crontab to play talks automatically

For EmacsConf 2022, we tried using Emacs timers to run the talks. It turns out that you can't call TRAMP from a timer when you're already using TRAMP from another timer at the same time. I thought about just tweaking the schedule so that we always start things at different times, but I figured there's probably a more elegant way to do this.

20.2. Crontab format

PATH=/usr/local/bin:/usr/bin
MAILTO=""
XDG_RUNTIME_DIR="/run/user/2002"
35 11 26 10 * /usr/bin/screen -dmS play-sat-open bash -c "DISPLAY=:5 TEST_MODE=1 /usr/local/bin/handle-session sat-open | tee -a ~/track.log"
36 11 26 10 * /usr/bin/screen -dmS play-uni bash -c "DISPLAY=:5 TEST_MODE=1 /usr/local/bin/handle-session uni | tee -a ~/track.log"
38 11 26 10 * /usr/bin/screen -dmS play-adventure bash -c "DISPLAY=:5 TEST_MODE=1 /usr/local/bin/handle-session adventure | tee -a ~/track.log"

This year, I'm planning to experiment with using cron to start talks on autopilot.

20.3. handle-session

#!/bin/bash
# {{ ansible_managed }}
#
# Handle the intro/talk/Q&A for a session
# Usage: handle-session $SLUG

YEAR="{{ emacsconf_year }}"
BASE_DIR="{{ emacsconf_caption_dir }}"
FIREFOX_NAME=firefox-esr
SLUG=$1

# Kill background music if playing
if screen -list | grep -q background; then
    screen -S background -X quit
fi

# Update the status
sudo -u {{ emacsconf_user }} talk $SLUG PLAYING &

# Update the overlay
overlay $SLUG

# Play the intro if it exists. If it doesn't exist, switch to the intro slide and stop processing.

if [[ -f $BASE_DIR/assets/intros/$SLUG.webm ]]; then
  killall -s TERM $FIREFOX_NAME
  mpv $BASE_DIR/assets/intros/$SLUG.webm
else
  firefox --kiosk $BASE_DIR/assets/in-between/$SLUG.png
  exit 0
fi

# Play the video if it exists. If it doesn't exist, switch to the BBB room and stop processing.
if [ "x$TEST_MODE" = "x" ]; then
  LIST=($BASE_DIR/assets/stream/{{ emacsconf_id }}-{{ emacsconf_year }}-$SLUG*--main.webm)
else
  LIST=($BASE_DIR/assets/test/{{ emacsconf_id }}-{{ emacsconf_year }}-$SLUG*--main.webm)
fi
FILE="${LIST[0]}"
if [ ! -f "$FILE" ]; then
    # Is there an original file?
    LIST=($BASE_DIR/assets/stream/{{ emacsconf_id }}-{{ emacsconf_year }}-$SLUG*--original.{webm,mp4,mov})
    FILE="${LIST[0]}"
fi

if [[ -f $FILE ]]; then
  killall -s TERM $FIREFOX_NAME
  mpv $FILE
else
  /usr/local/bin/bbb $SLUG
  exit 0
fi

sudo -u {{ emacsconf_user }} talk $SLUG CLOSED_Q &

# Open the appropriate Q&A URL
QA=$(jq -r '.talks[] | select(.slug=="'$SLUG'")["qa-backstage-url"]' < $BASE_DIR/talks.json)
QA_TYPE=$(jq -r '.talks[] | select(.slug=="'$SLUG'")["qa-type"]' < $BASE_DIR/talks.json)
echo "QA_TYPE $QA_TYPE QA $QA"
if [ "$QA_TYPE" = "live" ]; then
  /usr/local/bin/bbb $SLUG
elif [ "$QA" != "null" ]; then
  /usr/local/bin/music &
  /usr/bin/firefox $QA
  # i3-msg 'layout splith'
fi
wait

The shell scripts will take care of playing the videos…

20.4. Q&A

# ... from handle-session ...
# Open the appropriate Q&A URL
QA=$(jq -r '.talks[] | select(.slug=="'$SLUG'")["qa-backstage-url"]' < $BASE_DIR/talks.json)
QA_TYPE=$(jq -r '.talks[] | select(.slug=="'$SLUG'")["qa-type"]' < $BASE_DIR/talks.json)
echo "QA_TYPE $QA_TYPE QA $QA"
if [ "$QA_TYPE" = "live" ]; then
  /usr/local/bin/bbb $SLUG
elif [ "$QA" != "null" ]; then
  /usr/local/bin/music &
  /usr/bin/firefox $QA
  # i3-msg 'layout splith'
fi

… figuring out the appropriate Q&A…

20.5. BigBlueButton

b#!/bin/bash
# Open the Big Blue Button room using the backstage link
# {{ ansible_managed }}

# Kill the background music if playing
if screen -list | grep -q background; then
    screen -S background -X quit
fi

# Update the overlay
SLUG=$1
overlay $SLUG
killall -s TERM firefox-esr
firefox https://media.emacsconf.org/{{ emacsconf_year }}/backstage/assets/redirects/open/bbb-$SLUG.html &
sleep 5
xdotool search --class firefox windowactivate --sync
xdotool key Return
xdotool key F11
wait

… and joining the web conference if needed.

20.6. Emacs Lisp

(defun emacsconf-stream-format-crontab (track talks &optional test-mode)
  "Return crontab entries for TALKS.
Use the display specified in TRACK.
If TEST-MODE is non-nil, load the videos from the test directory."
  (concat
   (format
    "PATH=/usr/local/bin:/usr/bin
MAILTO=\"\"
XDG_RUNTIME_DIR=\"/run/user/%d\"
" (plist-get track :uid))
   (mapconcat
    (lambda (talk)
      (format "%s /usr/bin/screen -dmS play-%s bash -c \"DISPLAY=%s TEST_MODE=%s /usr/local/bin/handle-session %s | tee -a ~/track.log\"\n"
              ;; cron times are UTC
              (format-time-string "%-M %-H %-d %m *" (plist-get talk :start-time))
              (plist-get talk :slug)
              (plist-get track :vnc-display)
              (if test-mode "1" "")
              (plist-get talk :slug)))
    (emacsconf-filter-talks talks))))

We just need to format the information…

20.7. Installing the crontabs

(defun emacsconf-stream-crontabs (&optional test-mode info)
  "Write the streaming users' crontab files.
If TEST-MODE is non-nil, use the videos in the test directory.
If INFO is non-nil, use that as the schedule instead."
  (interactive)
  (let ((emacsconf-publishing-phase 'conference))
    (setq info (or info (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))
    (dolist (track emacsconf-tracks)
      (let ((talks (seq-filter (lambda (talk)
                                 (string= (plist-get talk :track)
                                          (plist-get track :name)))
                               info))
            (crontab (expand-file-name (concat (plist-get track :id) ".crontab")
                                       (concat (plist-get track :tramp) "~"))))
        (with-temp-file crontab
          (when (plist-get track :autopilot)
            (insert (emacsconf-stream-format-crontab track talks test-mode))))
        (emacsconf-stream-track-ssh track (concat "crontab ~/" (plist-get track :id) ".crontab"))))))

…and install it as the track's crontab.

20.8. Cancelling the crontabs

(defun emacsconf-stream-cancel-crontab (track)
  "Remove crontab for TRACK."
  (interactive (list (emacsconf-complete-track)))
  (plist-put track :autopilot nil)
  (emacsconf-stream-track-ssh track "crontab -r"))

It's useful to be able to switch tracks to manual mode independently, just in case things go haywire. Then we can start everything manually.

21. Transitions

I can also manually update a talk's status, like when the host tells me that it's okay to open up the Q&A.

21.1. Updating the status via emacsclient

#!/bin/bash
# {{ ansible_managed }}
# How to use: talk slug from-status-regexp to-status
# or talk slug to-status

SLUG="$1"
FROM_STATUS="$2"
TO_STATUS="$3"
XDG_RUNTIME_DIR=/run/user/{{ getent_passwd[emacsconf_user].1 }}

if [ "x$TO_STATUS" == "x" ]; then
    FROM_STATUS=.
    TO_STATUS="$2"
fi
cd {{ emacsconf_private_dir }}
#echo "Pulling conf.org..."
#git pull
echo "Updating status..."
XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR emacsclient --eval "(emacsconf-with-todo-hooks (emacsconf-update-talk-status \"$SLUG\" \"$FROM_STATUS\" \"$TO_STATUS\"))" -a emacs
#echo "Committing and pushing in the background"
#git commit -m "Update task status for $SLUG from $FROM_STATUS to $TO_STATUS" conf.org
#git push &

The shell scripts we run from the crontab can also update the talk status themselves. Then a bunch of things happen automatically based on the talk status changes.

21.2. org-after-todo-state-change-hook

(defun emacsconf-org-after-todo-state-change ()
  "Run all the hooks in `emacsconf-todo-hooks'.
If an `emacsconf-todo-hooks' entry is a list, run it only for the
tracks with the ID in the cdr of that list."
  (let* ((talk (emacsconf-get-talk-info-for-subtree))
         (track (emacsconf-get-track (plist-get talk :track))))
    (mapc
     (lambda (hook-entry)
       (cond
        ((symbolp hook-entry) (funcall hook-entry talk))
        ((member (plist-get track :id) (cdr hook-entry))
         (funcall (car hook-entry) talk))))
     emacsconf-todo-hooks)))

This uses org-after-todo-state-change-hook. We get the talk information and pass it to a list of functions.

22. Announcing on IRC

Internet Relay Chat or IRC is an easy way for people to join the conversation around EmacsConf.

22.1. Talk status changes

(defun emacsconf-erc-announce-on-change (talk)
  "Announce talk."
  (let ((emacsconf-publishing-phase 'conference)
        (func
         (pcase org-state
           ("PLAYING" #'erc-cmd-NOWPLAYING)
           ("CLOSED_Q" #'erc-cmd-NOWCLOSEDQ)
           ("OPEN_Q" #'erc-cmd-NOWOPENQ)
           ("UNSTREAMED_Q" #'erc-cmd-NOWUNSTREAMEDQ)
           ("TO_ARCHIVE" #'erc-cmd-NOWDONE))))
    (when func
      (funcall func talk))))

We announce a talk whenever it changes state.

22.2. Announcing talks on IRC

(defun erc-cmd-NOWPLAYING (talk)
  "Set the channel topics to announce TALK."
  (interactive (list (emacsconf-complete-talk-info)))
  (when (stringp talk)
    (setq talk (or (emacsconf-find-talk-info talk) (error "Could not find talk %s" talk))))
  ;; Announce it in the track's channel
  (if (emacsconf-erc-recently-announced (format "---- %s:" (plist-get talk :slug)))
      (message "Recently announced, skipping")
    (when (plist-get talk :track)
      (emacsconf-erc-with-channels (list (concat "#" (plist-get talk :channel)))
        (erc-cmd-TOPIC
         (format
          "%s: %s (%s) pad: %s Q&A: %s | %s"
          (plist-get talk :slug)
          (plist-get talk :title)
          (plist-get talk :speakers)
          (plist-get talk :pad-url)
          (plist-get talk :qa-info)
          (car (assoc-default
                (concat "#" (plist-get talk :channel))
                emacsconf-topic-templates))))
        (erc-send-message (format "---- %s: %s - %s ----"
                                  (plist-get talk :slug)
                                  (plist-get talk :title)
                                  (plist-get talk :speakers-with-pronouns)))
        (erc-send-message
         (concat "Add your notes/questions to the pad: " (plist-get talk :pad-url)))
        (cond
         ((string-match "live" (or (plist-get talk :q-and-a) ""))
          (erc-send-message (concat "Live Q&A: " (plist-get talk :bbb-redirect))))
         ((plist-get talk :irc)
          (erc-send-message (format "or discuss the talk on IRC (nick: %s)"
                                    (plist-get talk :irc)))))))
    ;; Short announcement in #emacsconf
    (emacsconf-erc-with-channels (list emacsconf-erc-hallway emacsconf-erc-org)
      (erc-send-message (format "-- %s track: %s: %s (watch: %s, pad: %s, channel: #%s)"
                                (plist-get talk :track)
                                (plist-get talk :slug)
                                (plist-get talk :title)
                                (plist-get talk :watch-url)
                                (plist-get talk :pad-url)
                                (plist-get talk :channel))))))

For example, when a talk starts, we post the URLs to the talk webpage and the Etherpad for questions. We change the topic as well, so anyone can see the current talk's information even if they're a little late. This is easy to do with a little bit of Emacs Lisp because (of course!) Emacs has an IRC client.

23. Little by little

It seems like a lot of automation and Emacs Lisp, but really, all of this was just built up little by little.

24. Lots of fun

And tinkering with this is fun, you know? It's like always being able to ask, "Hey, wouldn't it be cool if…" and then actually being able to go and do it. Sometimes it feels like EmacsConf is an excuse for me to play with Emacs.

25. Combining pieces

It's pretty amazing what you can do by combining a bunch of pieces. A way to store slightly-structured information. A way to get it out again. Templates. TRAMP, for working with remote files and running remote commands. A way to talk to a web browser. A way to work with SVGs. An email client. A chat client. You can smoosh them all together in a way that you couldn't if they were all separate things.

26. Links

Happy hacking!

The code is in the emacsconf-el repository. It's a bit of a tangle because it's accumulating organically and I haven't really had the brainspace to step back and clean it up. But if you spotted anything interesting in this presentation, you can go check it out and see what you can scavenge. The link and this presentation are available from this talk's webpage at https://emacsconf.org/2023/talks/emacsconf . Let's figure out how to make Emacsconf even awesomer next year!

Back to top | E-mail me