2023-10-02 Emacs news

| emacs, emacs-news

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

#EmacsConf backstage: using e-mail templates for confirmations and acceptances

| emacsconf, emacs

We send a number of e-mails to speakers while coordinating EmacsConf. Here's a rough list of the standard e-mails:

  • confirmation that we've received their proposal and that we'll review it over the following week
  • acceptance
  • coordination with related talks
  • checking tentative schedule to see if people have any requests
  • instructions for uploading files
  • instructions for accessing the backstage area
  • confirmation that we've received their uploaded video
  • captions for review
  • miscellaneous todos
  • any large schedule changes
  • schedule confirmation and check-in instructions
  • thanks and resources
  • thanks and follow-up questions

Sending e-mails from within Emacs makes it super-easy to automate, of course.

I started off with putting e-mail templates in our public organizers' notebook because that made it easy to link to the drafts when asking other volunteers for feedback. As the templates settle down, I've been gradually moving some of them into our emacsconf-mail library so that I don't need to copy the template into each year's notebook.

The e-mail templates use the same template functions we use to make the wiki pages. Here's the function for confirming that we've received a submission and letting the speaker know when to expect comments:

emacsconf-mail-review: Let the speaker know we’ve received their proposal.
(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))))

I recently extended emacsconf-mail-prepare so that it can insert the template into a reply instead of always starting a new message. This allows the messages to be properly threaded in the emacsconf-submit list archives, which makes it easier to verify that all the submissions have been acknowledged.

emacsconf-mail-prepare: Prepare the e-mail following TEMPLATE. Send it to EMAIL.
(defun emacsconf-mail-prepare (template email attrs)
  "Prepare the e-mail following TEMPLATE. Send it to EMAIL.
Use ATTRS to fill in the template."
  (let ((fields '((:reply-to "Reply-To")
                  (:mail-followup-to "Mail-Followup-To")
                  (:cc "Cc"))))
    (if (and (derived-mode-p 'message-mode) (string-match "unsent mail" (buffer-name)))
        ;; add to headers
        (progn
          (when (plist-get template :subject)
            (message-replace-header
             "Subject"
             (format "%s (was %s)"
                     (emacsconf-replace-plist-in-string attrs (or (plist-get template :subject) ""))
                     (message-field-value "Subject"))))
          (mapc (lambda (field)
                  (when (plist-get template (car field))
                    (message-replace-header
                     (cadr field)
                     (concat (emacsconf-replace-plist-in-string attrs (plist-get template (car field)))
                             (if (message-field-value (cadr field))
                                 (format
                                  (if (string= (cadr field) "Subject")
                                      " (was %s)"
                                    ", %s")
                                  (message-field-value (cadr field)))
                               "")))))
                fields))
      ;; compose a new message
      (compose-mail
       email
       (emacsconf-replace-plist-in-string attrs (or (plist-get template :subject) ""))
       (seq-keep (lambda (field)
                   (when (plist-get template (car field))
                     (cons
                      (cadr field)
                      (emacsconf-replace-plist-in-string
                       attrs
                       (plist-get template (car field))))))
                 fields))))
  (message-sort-headers)
  (message-goto-body)
  (save-excursion
    (insert (string-trim (emacsconf-replace-plist-in-string attrs (plist-get template :body)))
            "\n\n")
    (goto-char (point-min))
    (emacsconf-mail-merge-wrap)))

There's a little function that I can add to message-send-hook to prevent me from sending a message if it still has ZZZ in it.

emacsconf-mail-check-for-zzz-before-sending: Throw an error if the ZZZ todo marker is still in the message.
(defun emacsconf-mail-check-for-zzz-before-sending ()
  "Throw an error if the ZZZ todo marker is still in the message.
Good for adding to `message-send-hook'."
  (save-excursion
    (goto-char (point-min))
    (when (re-search-forward "ZZZ" nil t)
      (unless (yes-or-no-p "ZZZ marker found. Send anyway? ")
        (error "ZZZ marker found.")))))

Here's the function for sending an acceptance letter:

emacsconf-mail-accept-talk: Send acceptance letter.
(defun emacsconf-mail-accept-talk (talk)
  "Send acceptance letter."
  (interactive (list (emacsconf-complete-talk-info)))
  (emacsconf-mail-prepare '(:subject "${conf-name} ${year} acceptance: ${title}"
                       :cc "emacsconf-submit@gnu.org"
                       :slugs nil
                       :reply-to "emacsconf-submit@gnu.org, ${email}, ${user-email}"
                       :mail-followup-to "emacsconf-submit@gnu.org, ${email}, ${user-email}"
                       :body
                       "
Hi, ${speakers-short}!

Looks like all systems are a go for your talk. Thanks for
proposing it! Your talk page is now at ${url} . Please feel free
to update it or e-mail us if you'd like help with any changes.${fill}

If you want to get started on your talk early, we have some
instructions at ${base}${year}/prepare/ that might help.
We strongly encourage speakers to prepare a talk video by
${video-target-date} in order to reduce technical risks and make
things flow more smoothly. Plus, we might be able to get it captioned
by volunteers, just like the talks last year. We'll save ${time} minutes
for your talk, not including time for Q&A. Don't sweat it if
you're a few minutes over or under. If it looks like a much shorter or
longer talk once you start getting into it, let us know and we might
be able to adjust.${fill}

I'll follow up with the specific schedule for your talk once things
settle down. In the meantime, please let us know if you have any
questions or if there's anything we can do to help out!

${signature}"
                       :function emacsconf-mail-accept-talk
                       :log-note "accepted talk")
   (plist-get talk :email)
   (list
    :base emacsconf-base-url
    :user-email user-mail-address
    :title (plist-get talk :title)
    :email (plist-get talk :email)
    :time (plist-get talk :time)
    :signature user-full-name
    :conf-name emacsconf-name
    :speakers-short (plist-get talk :speakers-short)
    :url (concat emacsconf-base-url (plist-get talk :url))
    :video-target-date emacsconf-video-target-date
    :year emacsconf-year)))

We send confirmations and acceptances one at a time. Other e-mails are sent to all the speakers and it's easier to draft them in a batch. I'll cover that kind of mail merge in a separate post.

#EmacsConf backstage: adding a talk to the wiki

| emacsconf, emacs

The EmacsConf 2023 call for participation has finished, hooray! We've sent out acceptances and added talks to the wiki. We experimented with early acceptances this year, which was really nice because it gives people quick feedback and allows people to get started on their videos early. That meant that I needed to be able to easily add talks to the wiki throughout the call for participation. We use templates and an Ikiwiki directive to make it easier to consistently format talk pages. This post covers adding a talk to the wiki, and these functions are in the emacsconf-el repository.

Overview

Amin Bandali picked Ikiwiki for the wiki for emacsconf.org. I think it's because Ikiwiki works with plain text in a Git repository, which fits nicely with our workflow. I can use Emacs Lisp to generate files that are included in other files, and I can also use Emacs Lisp to generate the starting point for files that may be manually edited later on.

We organize conference pages by year. Under the directory for this year (2023/), we have:

talks
talk descriptions that can be manually edited
talks/SLUG.md
the description for the talk. Includes ../info/SLUG-nav.md, ../info/SLUG-before.md, and ../info/SLUG-after.md
info
automatically-generated files that are included before and after the talk description, and navigation links between talks
info/SLUG-nav.md
navigation links
info/SLUG-before.md
navigation links
info/SLUG-after.md
navigation links
schedule-details.md
list of talks
organizers-notebook/index.org
public planning notebook

The filenames and URLs for each talk are based on the ID for a talk. I store that in the SLUG property to make it easy to differentiate from CUSTOM_ID, since CUSTOM_ID is useful for lots of other things in Org Mode. I usually assign the slugs when I add the talks to our private conf.org file, although sometimes people suggest a specific ID.

Templating

Publishing to wiki pages and replying to e-mails are easier if I can substitute text into readable templates. 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 plists instead, since we use property lists (plists) all over the emacsconf-el library.

emacsconf-replace-plist-in-string: Replace ${keyword} from ATTRS in STRING.
(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))

It is also handy to be able to add text around another string only if the string is non-nil, and to provide a different string to use if it isn't specified..

emacsconf-surround: Concat BEFORE, TEXT, and AFTER if TEXT is specified, or return ALTERNATIVE.
(defun emacsconf-surround (before text after &optional alternative)
  "Concat BEFORE, TEXT, and AFTER if TEXT is specified, or return ALTERNATIVE."
  (if (and text (not (string= text "")))
      (concat (or before "") text (or after ""))
    alternative))

Getting the talk information

To get the data to fill in the template, we can run a bunch of different functions. This lets us add or remove functions when we need to. We pass the previous result to the next function in order to accumulate properties or modify them. The result is a property list for the current talk.

emacsconf-get-talk-info-for-subtree: Run ‘emacsconf-talk-info-functions’ to extract the info for this entry.
(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))

emacsconf-talk-info-functions: Functions to collect information.
(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.")

Getting the talk abstract

I add a *** Talk abstract subheading to the talk and put the rest of the submission under a *** Talk details subheading. This allows me to extract the text of the Talk abstract heading (or whatever matches emacsconf-abstract-heading-regexp, which is set to "abstract".).

emacsconf-get-subtree-entry: Return the text for the subtree matching HEADING-REGEXP.
(defun emacsconf-get-subtree-entry (heading-regexp)
  "Return the text for the subtree matching HEADING-REGEXP."
  (car
   (delq nil
         (org-map-entries
          (lambda ()
            (when (string-match heading-regexp (org-entry-get (point) "ITEM"))
              (org-get-entry)))
          nil 'tree))))

emacsconf-get-talk-abstract-from-subtree: Add the abstract from a subheading.
(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") ""))))

I include emacsconf-get-talk-abstract-from-subtree in emacsconf-talk-info-functions so that it retrieves that information when I call emacsconf-get-talk-info-for-subtree.

Publishing the talk page

We add accepted talks to the wiki so that people can see what kinds of talks will be at EmacsConf 2023. To add the talk to the wiki, I use emacsconf-publish-add-talk. It'll create the talk page without overwriting anything that's already there and redo the automatically-generated info pages that provide navigation, status, and so on.

emacsconf-publish-add-talk: Add the current talk to the wiki.
(defun emacsconf-publish-add-talk ()
  "Add the current talk to the wiki."
  (interactive)
  (emacsconf-publish-talk-page (emacsconf-get-talk-info-for-subtree))
  (emacsconf-publish-info-pages)
  (magit-status-setup-buffer emacsconf-directory))

The talk page includes the description and other resources.

emacsconf-publish-talk-page: Draft the talk page for O unless the page already exists or FORCE is non-nil.
(defun emacsconf-publish-talk-page (o &optional force)
  "Draft the talk page for O unless the page already exists or FORCE is non-nil."
  (interactive (list (emacsconf-get-talk-info-for-subtree)
                     (> (prefix-numeric-value current-prefix-arg) 1)))
  (let ((filename (expand-file-name (format "%s.md" (plist-get o :slug))
                                    (expand-file-name "talks" (expand-file-name emacsconf-year emacsconf-directory)))))
    (unless (file-directory-p (expand-file-name "talks" (expand-file-name emacsconf-year emacsconf-directory)))
      (mkdir (expand-file-name "talks" (expand-file-name emacsconf-year emacsconf-directory))))
    (when (or force (null (file-exists-p filename)))
      (with-temp-file filename
        (insert
         (emacsconf-replace-plist-in-string
          (emacsconf-convert-talk-abstract-to-markdown
           (append o (list
                      :speaker-info (emacsconf-publish-format-speaker-info o)
                      :meta "!meta"
                      :categories (if (plist-get o :categories)
                                      (mapconcat (lambda (o) (format "[[!taglink %s]]" o))
                                                 (plist-get o :categories)
                                                 " ")
                                    ""))))
          "[[${meta} title=\"${title}\"]]
[[${meta} copyright=\"Copyright © ${year} ${speakers}\"]]
[[!inline pages=\"internal(${year}/info/${slug}-nav)\" raw=\"yes\"]]

<!-- Initially generated with emacsconf-publish-talk-page and then left alone for manual editing -->
<!-- You can manually edit this file to update the abstract, add links, etc. --->\n

# ${title}
${speaker-info}

[[!inline pages=\"internal(${year}/info/${slug}-before)\" raw=\"yes\"]]

${abstract-md}

[[!inline pages=\"internal(${year}/info/${slug}-after)\" raw=\"yes\"]]

[[!inline pages=\"internal(${year}/info/${slug}-nav)\" raw=\"yes\"]]

${categories}
"))))))

Ikiwiki uses Markdown, so we can take advantage of Org's Markdown export.

emacsconf-convert-talk-abstract-to-markdown: Set the :abstract-md property to a Markdown version of the abstract.
(defun emacsconf-convert-talk-abstract-to-markdown (o)
  "Set the :abstract-md property to a Markdown version of the abstract."
  (plist-put o :abstract-md (org-export-string-as (or (plist-get o :abstract) "") 'md t)))

Publishing the list of talks

The list of talks at https://emacsconf.org/2023/talks/ is grouped by track.

2023-09-26_14-00-19.png
Figure 1: List of talks

emacsconf-publish-schedule: Generate the schedule or program.
(defun emacsconf-publish-schedule (&optional info)
  "Generate the schedule or program."
  (interactive)
  (emacsconf-publish-schedule-svg-snippets)
  (setq info (or info (emacsconf-publish-prepare-for-display info)))
  (with-temp-file (expand-file-name "schedule-details.md"
                                    (expand-file-name emacsconf-year emacsconf-directory))
    (insert
     (if (member emacsconf-publishing-phase '(cfp program))
         (let ((sorted (emacsconf-publish-prepare-for-display (or info (emacsconf-get-talk-info)))))
           (mapconcat
            (lambda (track)
              (concat
               "Jump to: "
               ;; links to other tracks
               (string-join (seq-keep (lambda (track-link)
                                        (unless (string= (plist-get track-link :id)
                                                         (plist-get track :id))
                                          (format "<a href=\"#%s\">%s</a>"
                                                  (plist-get track-link :id)
                                                  (plist-get track-link :name))))
                                      emacsconf-tracks)
                            " | ")
               "\n\n"
               (let ((track-talks (seq-filter (lambda (o) (string= (plist-get o :track)
                                                                   (plist-get track :name)))
                                              sorted)))
                 (format
                  "<h1 id=\"%s\" class=\"sched-track %s\">%s (%d talks)</h1>\n%s"
                  (plist-get track :id)
                  (plist-get track :name)
                  (plist-get track :name)
                  (length track-talks)
                  (emacsconf-publish-format-main-schedule track-talks)))))
            emacsconf-tracks "\n\n"))
       (emacsconf-publish-format-interleaved-schedule info))))
  (when (member emacsconf-publishing-phase '(cfp program))
    (with-temp-file (expand-file-name
                     "draft-schedule.md"
                     (expand-file-name emacsconf-year emacsconf-directory))
      (insert
       "[[!sidebar content=\"\"]]\n\n" 
       "This is a *DRAFT* schedule.\n"
       (let ((emacsconf-publishing-phase 'schedule))
         (emacsconf-publish-format-interleaved-schedule info))))))

The emacsconf-format-main-schedule function displays the information for the talks in each track. It's pretty straightforward, but I put it in a function because I call it from a number of places.

emacsconf-publish-format-main-schedule: Include the schedule information for INFO.
(defun emacsconf-publish-format-main-schedule (info)
  "Include the schedule information for INFO."
  (mapconcat #'emacsconf-publish-sched-directive info "\n"))

We define an Ikiwiki sched directive that conditionally displays things depending on what we specify, so it's easy to add more information during the schedule or conference phase. This is templates/sched.md in the EmacsConf wiki git repository:

<div data-start="<TMPL_VAR startutc>" data-end="<TMPL_VAR endutc>" class="sched-entry <TMPL_IF track>track-<TMPL_VAR track></TMPL_IF track>">
<div class="sched-meta">
<TMPL_IF start>
<span class="sched-time"><span class="sched-start"><TMPL_VAR start></span>
<TMPL_IF end> - <span class="sched-end"><TMPL_VAR end></span></TMPL_IF end>
</span></TMPL_IF start>
<TMPL_IF track> <span class="sched-track <TMPL_VAR track>"><TMPL_IF watch><a href="<TMPL_VAR watch>"></TMPL_IF><TMPL_VAR track><TMPL_IF watch></a></TMPL_IF></span></TMPL_IF track>
<TMPL_IF pad> <span class="sched-pad"><a href="<TMPL_VAR pad>">Etherpad</a></TMPL_IF pad>
<TMPL_IF q-and-a> <span class="sched-q-and-a">Q&amp;A: <TMPL_VAR q-and-a></span> </TMPL_IF q-and-a>
</div>
<div class="sched-title"><a href="<TMPL_VAR url>"><TMPL_VAR title></a></div>
<div class="sched-speakers"><TMPL_VAR speakers> <TMPL_IF note>- <TMPL_VAR note></TMPL_IF note></div>
<TMPL_IF resources>
<ul class="resources">
<TMPL_VAR resources>
</ul>
</TMPL_IF resources>
<TMPL_IF time><span class="sched-duration><TMPL_VAR time></span> minutes</TMPL_IF time>
<TMPL_IF slug> <span class="sched-slug">id:<TMPL_VAR slug></span></TMPL_IF slug>
</div>

This Emacs Lisp function converts a talk into that directive.

emacsconf-publish-sched-directive: Format the schedule directive with info for O.
(defun emacsconf-publish-sched-directive (o)
  "Format the schedule directive with info for O."
  (format "[[!template id=sched%s]]"
          (let ((result "")
                (attrs (append
                        (pcase emacsconf-publishing-phase
                          ('program
                           (list
                            :time (plist-get o :time)))
                          ((or 'schedule 'conference)
                           (list
                            :status (pcase (plist-get o :status)
                                      ("CAPTIONED" "captioned")
                                      ("PREREC_RECEIVED" "received")
                                      ("DONE" "done")
                                      ("STARTED" "now playing")
                                      (_ nil))
                            :time (plist-get o :time)
                            :q-and-a (plist-get o :qa-link) 
                            :pad (and emacsconf-publish-include-pads (plist-get o :pad-url))
                            :startutc (format-time-string "%FT%T%z" (plist-get o :start-time) t)
                            :endutc (format-time-string "%FT%T%z" (plist-get o :end-time) t)
                            :start (format-time-string "%-l:%M" (plist-get o :start-time) emacsconf-timezone)
                            :end (format-time-string "%-l:%M" (plist-get o :end-time) emacsconf-timezone)))
                          ('resources
                           (list
                            :pad nil
                            :channel nil
                            :resources (mapconcat (lambda (s) (concat "<li>" s "</li>"))
                                                  (emacsconf-link-file-formats-as-list
                                                   (append o
                                                           (list :base-url (format "%s%s/" emacsconf-media-base-url emacsconf-year)))
                                                   (append emacsconf-main-extensions (list "--answers.webm" "--answers.opus" "--answers.vtt")))
                                                  ""))))
                        (list
                         :title (plist-get o :title)
                         :url (concat "/" (plist-get o :url))
                         :speakers (plist-get o :speakers)
                         :track (if (member emacsconf-publishing-phase '(schedule conference)) (plist-get o :track))
                         :watch (plist-get o :watch-url)
                         :slug (plist-get o :slug)
                         :note
                         (string-join
                          (delq nil
                                (list
                                 (when (plist-get o :captions-edited)
                                   "captioned")
                                 (when (and (plist-get o :public)
                                            (or (plist-get o :toobnix-url)
                                                (plist-get o :video-file)))
                                   "video posted")))
                          ", ")
                         )
                        )))
            (while attrs
              (let ((field (pop attrs))
                    (val (pop attrs)))
                (when val
                  (setq result (concat result " " (substring (symbol-name field) 1) "=\"\"\"" val "\"\"\"")))))
            result)))

Publishing auto-generated navigation

It's nice to be able to navigate between talks without going back to the schedule page each time. This is handled by just 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.

2023-09-26_14-05-51.png
Figure 2: Navigation

emacsconf-publish-nav-pages: Generate links to the next and previous talks.
(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>
")))))))

Before the talk description

We include some details about the schedule in the talk page, before the description.

2023-09-26_14-09-07.png
Figure 3: Description

emacsconf-publish-before-page: Generate the page that has the info included before the abstract.
(defun emacsconf-publish-before-page (talk &optional info)
  "Generate the page that has the info included before the abstract.
This includes the intro note, the schedule, and talk resources."
  (interactive (list (emacsconf-complete-talk-info)))
  (setq info (or info (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))
  (with-temp-file (expand-file-name (format "%s-before.md" (plist-get talk :slug))
                                    (expand-file-name "info" (expand-file-name emacsconf-year emacsconf-directory)))
    
    (insert "<!-- Automatically generated by emacsconf-publish-before-page -->\n")
    (insert (emacsconf-surround "" (plist-get talk :intro-note) "\n\n" ""))
    (let ((is-live (emacsconf-talk-live-p talk)))
      (when is-live (emacsconf-publish-captions-in-wiki talk))
      (when (member emacsconf-publishing-phase '(schedule conference))
        (insert (emacsconf-publish-format-talk-schedule-image talk info)))
      (insert (emacsconf-publish-format-talk-schedule-info talk) "\n\n")
      (insert
       (if (plist-get talk :public) (emacsconf-wiki-talk-resources talk) "")
       "\n# Description\n"))
    (insert "<!-- End of emacsconf-publish-before-page -->")))

emacsconf-publish-format-talk-schedule-info: Format schedule information for O.
(defun emacsconf-publish-format-talk-schedule-info (o)
  "Format schedule information for O."
  (let ((friendly (concat "/" emacsconf-year "/talks/" (plist-get o :slug) ))
        (timestamp (org-timestamp-from-string (plist-get o :scheduled))))
    (emacsconf-replace-plist-in-string
     (append o
             (list
              :format
              (concat (or (plist-get o :video-time)
                          (plist-get o :time))
                      "-min talk"
                      (if (plist-get o :q-and-a)
                          (format " followed by %s Q&A%s"
                                  (plist-get o :q-and-a)
                                  (if (eq emacsconf-publishing-phase 'conference)
                                      (format " (%s)"
                                              (if (string-match "live" (plist-get o :q-and-a))
                                                  (if (eq 'after (emacsconf-bbb-status o))
                                                      "done"
                                                    (format "<https://emacsconf.org/current/%s/room>" (plist-get o :slug)))
                                                (emacsconf-publish-webchat-link o)))
                                    ""))
                        ""))
              :pad-info
              (if emacsconf-publish-include-pads
                  (format "Etherpad: <https://pad.emacsconf.org/%s-%s>  \n" emacsconf-year (plist-get o :slug))
                "")
              :irc-info
              (format "Discuss on IRC: [#%s](%s)  \n" (plist-get o :channel)
                      (plist-get o :webchat-url))
              :status-info
              (if (member emacsconf-publishing-phase '(cfp program schedule conference)) (format "Status: %s  \n" (plist-get o :status-label)) "")
              :schedule-info
              (if (and (member emacsconf-publishing-phase '(schedule conference))
                       (not (emacsconf-talk-all-done-p o))
                       (not (string= (plist-get o :status) "CANCELLED")))
                  (let ((start (org-timestamp-to-time (org-timestamp-split-range timestamp)))
                        (end (org-timestamp-to-time (org-timestamp-split-range timestamp t))))
                    (format
                     "<div>Times in different timezones:</div><div class=\"times\" start=\"%s\" end=\"%s\"><div class=\"conf-time\">%s</div><div class=\"others\"><div>which is the same as:</div>%s</div></div><div><a href=\"/%s/watch/%s/\">Find out how to watch and participate</a></div>"
                     (format-time-string "%Y-%m-%dT%H:%M:%SZ" start t)
                     (format-time-string "%Y-%m-%dT%H:%M:%SZ" end t)
                     (emacsconf-timezone-string o emacsconf-timezone)
                     (string-join (emacsconf-timezone-strings
                                   o
                                   (seq-remove (lambda (zone) (string= emacsconf-timezone zone))
                                               emacsconf-timezones)) "<br />")
                     emacsconf-year
                     (plist-get (emacsconf-get-track (plist-get o :track)) :id)))
                "")))
     (concat
      "[[!toc  ]]
Format: ${format}  
${pad-info}${irc-info}${status-info}${schedule-info}\n" 
      (if (plist-get o :alternate-apac)
          (format "[[!inline pages=\"internal(%s/inline-alternate)\" raw=\"yes\"]]  \n" emacsconf-year)
        "")
      "\n"))))

After the talk description

After the talk description, we include a footer that makes it easier for people to e-mail questions using either the PUBLIC_EMAIL property of the talk or the emacsconf-org-private e-mail address.

2023-09-26_14-10-35.png

emacsconf-publish-format-email-questions-and-comments: Invite people to e-mail either the public contact for TALK or the private list.
(defun emacsconf-publish-format-email-questions-and-comments (talk)
  "Invite people to e-mail either the public contact for TALK or the private list."
  (format "Questions or comments? Please e-mail %s"
          (emacsconf-publish-format-public-email talk
                                         (or
                                          (and (string= (plist-get talk :public-email) "t")
                                               (plist-get talk :email))
                                          (plist-get talk :public-email)
                                          "emacsconf-org-private@gnu.org"))))

emacsconf-publish-after-page: Generate the page with info included after the abstract.
(defun emacsconf-publish-after-page (talk &optional info)
  "Generate the page with info included after the abstract.
This includes captions, contact, and an invitation to participate."
  (interactive (list (emacsconf-complete-talk-info)))
  ;; Contact information
  (with-temp-file (expand-file-name (format "%s-after.md" (plist-get talk :slug))
                                    (expand-file-name "info" (expand-file-name emacsconf-year emacsconf-directory)))
    (insert
     "<!-- Automatically generated by emacsconf-publish-after-page -->\n"
     "\n\n"
     ;; main transcript
     (if (plist-get talk :public) (emacsconf-publish-format-captions talk) "")
     (emacsconf-publish-format-email-questions-and-comments talk) "\n"
     (if (eq emacsconf-publishing-phase 'cfp)
         (format "\n----\nGot an idea for an EmacsConf talk or session? We'd love to hear from you! Check out the [[Call for Participation|/%s/cfp]] for details.\n" emacsconf-year)
       "")
     "\n\n<!-- End of emacsconf-publish-after-page -->\n")))

Whenever the schedule changes

This function makes it easier to regenerate all those dynamic pages that need to be updated whenever the schedule changes.

emacsconf-publish-info-pages: Populate year/info/*-nav, -before, and -after files.
(defun emacsconf-publish-info-pages (&optional info)
  "Populate year/info/*-nav, -before, and -after files."
  (interactive (list nil))
  (setq info (or info (emacsconf-publish-prepare-for-display info)))
  (emacsconf-publish-with-wiki-change
    (emacsconf-publish-nav-pages info)
    (emacsconf-publish-schedule info)
    (mapc (lambda (o)
            (emacsconf-publish-before-page o info)
            (emacsconf-publish-after-page o info))
          info)))

Summary

So once the review period has passed and we're ready to accept the talk, I change the status to WAITING_FOR_PREREC and find room for it in the schedule. Then I use emacsconf-publish-add-talk to add the talk description to the wiki. I review the files it generated, tweak hyperlinks as needed, add the pages to the Git repository, and push the commit to the server. If I rearrange talks or change times, I just need to run emacsconf-publish-info-pages and all the dynamically-generated pages will be updated.

Org protocol: following Org links from outside Emacs

| org, emacs

_xor had an interesting idea: can we use org-protocol to link to things inside Emacs, so that we can have a webpage with bookmarks into our Org files? Here's a quick hack that reuses org-store-link and org-link-open.

(defun org-protocol-open-link (info)
  "Process an org-protocol://open style url with INFO."
  (org-link-open (car (org-element-parse-secondary-string (plist-get info :link) '(link)))))

(defun org-protocol-copy-open-link (arg)
  (interactive "P")
  (kill-new (concat "org-protocol://open?link=" (url-hexify-string (org-store-link arg)))))

(with-eval-after-load 'org
  (add-to-list 'org-protocol-protocol-alist
               '("org-open" :protocol "open" :function org-protocol-open-link)))

To make exporting and following easier, we also need a little code to handle org-protocol links inside Org.

(defun org-protocol-follow (path &rest _)
  "Follow the org-protocol link for PATH."
  (org-protocol-check-filename-for-protocol (concat "org-protocol:" path) nil nil))

(defun org-protocol-export (path desc format info)
  "Export an org-protocol link."
  (setq path (concat "org-protocol:" path))
  (setq desc (or desc path))
  (pcase format
    (`html (format "<a href=\"%s\">%s</a>" path desc))
    (`11ty (format "<a href=\"%s\">%s</a>" path desc))
    (`latex (org-latex-link path desc info))
    (`ascii (org-ascii-link path desc info))
    (`md (org-md-link path desc info))
    (_ path)))

(with-eval-after-load 'org
  (org-link-set-parameters "org-protocol"
                           :follow #'org-protocol-follow
                           :export #'org-protocol-export))

Now I can use org-protocol-copy-open-link to copy a link to the current location, and I can put it into my Org files.

Example bare link to the Org manual, which will work only if you have open in the org-protocol-protocol-alist:

org-protocol://open?link=%5B%5Binfo%3Aorg%23Protocols%5D%5Borg%23Protocols%5D%5D

With a description:

Org manual - Protocols

This is part of my Emacs configuration.

2023-09-25 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Hacker News, lobste.rs, kbin, programming.dev, communick.news, lemmy, 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!

2023-09-18 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Hacker News, lobste.rs, kbin, programming.dev, communick.news, lemmy, 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!

EmacsConf backstage: making it easier to do talk-specific actions

| emacs, emacsconf

During an EmacsConf talk, we:

  • copy the talk overlay images and use them in the streaming software (OBS)
  • play videos
    • a recorded introduction if it exists
    • any extra videos we want to play
    • the main talk
  • and open up browser windows
    • the BigBlueButton web conference room for a live Q&A session
    • the talk's Etherpad collaborative document for questions
    • the Internet Relay Chat channel, if that's where the speaker wants to handle questions

To minimize the work involved in copying and pasting filenames and URLs, I wanted to write scripts that could perform the right action given the talk ID. I automated most of it so that it could work from Emacs Lisp, and I also wrote shell scripts so that I (or someone else) could run the appropriate commands from the terminal.

The shell scripts are in the emacsconf-ansible repository and the Emacs Lisp functions are in emacsconf-stream.el.

Change the image overlay

We display the conference logo, talk title, and speaker name on the screen while the video is playing. This is handled with an OBS scene that includes whatever image is at ~/video.png or ~/other.png, since that results in a nicer display than using text in OBS. I'll go into how we make the overlay images in a different blog post. This post focuses on including the right image, which is just a matter of copying the right file over ~/video.png.

sat-open-video.png
Figure 1: Sample overlay file
2023-09-12_10-52-07.png
Figure 2: OBS scene with the overlay

This is copied by set-overlay.

FILE=$1
if [[ ! -f $FILE ]]; then
    LIST=(/data/emacsconf/assets/stream/emacsconf-[0-9][0-9][0-9][0-9]-$FILE*.webm)
    FILE="${LIST[0]}"
    BY_SLUG=1
fi
shift
SLUG=$(echo "$FILE" | perl -ne 'if (/^emacsconf-[0-9]*-(.*?)--/) { print $1; } else { print; }')
if [[ -f /data/emacsconf/assets/overlays/$SLUG-other.png ]]; then
    echo "Found other overlay for $SLUG, copying"
    cp /data/emacsconf/assets/overlays/$SLUG-other.png ~/other.png
else
    echo "Could not find /data/emacsconf/assets/overlays/$SLUG-other.png, please override ~/other.png manually"
    cp /data/emacsconf/assets/overlays/blank-other.png ~/other.png
fi
if [[ -f /data/emacsconf/assets/overlays/$SLUG-video.png ]]; then
    echo "Found video overlay for $SLUG, copying"
    cp /data/emacsconf/assets/overlays/$SLUG-video.png ~/video.png
else
    echo "Could not find /data/emacsconf/assets/overlays/$SLUG-video.png, override ~/video.png manually"
    cp /data/emacsconf/assets/overlays/blank-video.png ~/video.png
fi

set-overlay is called by the Emacs Lisp function emacsconf-stream-set-overlay:

emacsconf-stream-set-overlay: Reset the overlay for TALK, just in case.
(defun emacsconf-stream-set-overlay (talk)
  "Reset the overlay for TALK, just in case.
With a prefix argument (\\[universal-argument]), clear the overlay."
  (interactive (list
                (if current-prefix-arg
                    (emacsconf-complete-track)
                  (emacsconf-complete-talk-info))))
  (emacsconf-stream-track-ssh
   (emacsconf-get-track talk)
   "overlay"
   (if current-prefix-arg
       "blank"
     (plist-get talk :slug))))

Play the intro video or display the intro slide

buttons.png
Figure 3: Sample intro slide

We wanted to display the talk titles, speaker names, and URLs for both the previous talk and the next talk. We generated all the intro slides, and then as time permitted, we recorded introduction videos so that we could practise saying people's names instead of panicking during a full day. Actually generating the intro slide or video is a topic for another blog post. This post just focuses on playing the appropriate video or displaying the right image, which is handled by the intro script.

#!/bin/bash
# 
# Kill the background music if playing
if screen -list | grep -q background; then
    screen -S background -X quit
fi
# Update the overlay
SLUG=$1
FILE=$1
if [[ ! -f $FILE ]]; then
    LIST=(/data/emacsconf/assets/stream/emacsconf--$FILE--*.webm)
    FILE="${LIST[0]}"
    BY_SLUG=1
else
    SLUG=$(echo "$FILE" | perl -ne 'if (/emacsconf-[0-9]*-(.*?)--/) { print $1; } else { print; }')
fi
shift
overlay $SLUG
if [[ -f /data/emacsconf/assets/intros/$SLUG.webm ]]; then
  mpv /data/emacsconf/assets/intros/$SLUG.webm
else
  firefox /data/emacsconf/assets/in-between/$SLUG.png
fi

This is easy to call from Emacs Lisp.

emacsconf-stream-play-intro: Play the recorded intro or display the in-between slide for TALK.
(defun emacsconf-stream-play-intro (talk)
  "Play the recorded intro or display the in-between slide for TALK."
  (interactive (list (emacsconf-complete-talk-info)))
  (setq talk (emacsconf-resolve-talk talk))
  (emacsconf-stream-track-ssh talk "nohup" "intro" (plist-get talk :slug)))

Play just the video

Sometimes we might need to restart a video without playing the introduction again. The ready-to-stream videos are all in one directory following the naming convention emacsconf-year-slug--title--speakers--main.webm. We update the --main.webm video as we go through the process of reencoding the video, normalizing sound, and adding captions. We can play the latest video by doing a wildcard match based on the slug.

roles/obs/templates/play

#!/bin/bash
# Play intro if recorded, then play files
# 

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

# Update the overlay
FILE=$1
if [[ ! -f $FILE ]]; then
    LIST=(/data/emacsconf/assets/stream/emacsconf--$FILE*--main.webm)
    FILE="${LIST[0]}"
    BY_SLUG=1
fi
shift
SLUG=$(echo "$FILE" | perl -ne 'if (/emacsconf-[0-9]*-(.*?)--/) { print $1; } else { print; }')
overlay $SLUG
mpv $FILE $* &

emacsconf-stream-play-video: Play just the video for TALK.
(defun emacsconf-stream-play-video (talk)
  "Play just the video for TALK."
  (interactive (list (emacsconf-complete-talk-info)))
  (setq talk (emacsconf-resolve-talk talk))
  (emacsconf-stream-track-ssh
   talk "nohup" "play" (plist-get talk :slug)))

Play the intro and then the video

The easiest way to go through a talk is to play the introduction and the video without further manual intervention. This shell script updates the overlay, plays the intro if available, and then continues with the video.

roles/obs/templates/play-with-intro

#!/bin/bash
# Play intro if recorded, then play files
# 

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

# Update the overlay
FILE=$1
if [[ ! -f $FILE ]]; then
    LIST=(/data/emacsconf/assets/stream/emacsconf--$FILE*.webm)
    FILE="${LIST[0]}"
    BY_SLUG=1
fi
shift
SLUG=$(echo "$FILE" | perl -ne 'if (/emacsconf-[0-9]*-(.*?)--/) { print $1; } else { print; }')
overlay $SLUG
# Play the video
if [[ -f /data/emacsconf/assets/intros/$SLUG.webm ]]; then
    intro $SLUG
fi
mpv $FILE $* &

Along the lines of minimizing manual work, this more complex Emacs Lisp function considers different combinations of intros and talks:

  Recorded intro Live intro
Recorded talk automatically play both show intro slide; remind host to play video
Live talk play intro; host joins BBB join BBB room automatically

emacsconf-stream-play-talk-on-change: Play the talk.
(defun emacsconf-stream-play-talk-on-change (talk)
  "Play the talk."
  (interactive (list (emacsconf-complete-talk-info)))
  (setq talk (emacsconf-resolve-talk talk))
  (when (or (not (boundp 'org-state)) (string= org-state "PLAYING"))
    (if (plist-get talk :stream-files)
        (progn
          (emacsconf-stream-track-ssh
           talk
           "overlay"
           (plist-get talk :slug))
          (emacsconf-stream-track-ssh
           talk
           (append
            (list
             "nohup"
             "mpv")
            (split-string-and-unquote (plist-get talk :stream-files))
            (list "&"))))
      (emacsconf-stream-track-ssh
       talk
       (cons
        "nohup"
        (cond
         ((and
           (plist-get talk :recorded-intro)
           (plist-get talk :video-file)) ;; recorded intro and recorded talk
          (message "should automatically play intro and recording")
          (list "play-with-intro" (plist-get talk :slug))) ;; todo deal with stream files
         ((and
           (plist-get talk :recorded-intro)
           (null (plist-get talk :video-file))) ;; recorded intro and live talk; play the intro and join BBB
          (message "should automatically play intro; join %s" (plist-get talk :bbb-backstage))
          (list "intro" (plist-get talk :slug)))
         ((and
           (null (plist-get talk :recorded-intro))
           (plist-get talk :video-file)) ;; live intro and recorded talk, show slide and use Mumble; manually play talk
          (message "should show intro slide; play %s afterwards" (plist-get talk :slug))
          (list "intro" (plist-get talk :slug)))
         ((and
           (null (plist-get talk :recorded-intro))
           (null (plist-get talk :video-file))) ;; live intro and live talk, join the BBB
          (message "join %s for live intro and talk" (plist-get talk :bbb-backstage))
          (list "bbb" (plist-get talk :slug)))))))))

Open the Etherpad

We used Etherpad collaborative documents to collect people's questions during the conference. I made an index page that linked to the Etherpads for the different talks so that I could open it in the browser used for streaming.

2023-09-13_08-44-52.png
Figure 4: Backstage index

I also had an Emacs Lisp function that opened up the pad in the appropriate stream.

emacsconf-stream-open-pad: Open the Etherpad collaborative document for TALK.
(defun emacsconf-stream-open-pad (talk)
  "Open the Etherpad collaborative document for TALK."
  (interactive (list (emacsconf-complete-talk-info)))
  (setq talk (emacsconf-resolve-talk talk))
  (emacsconf-stream-track-ssh
   talk
   "nohup"
   "firefox"
   (plist-get talk :pad-url)))

I think I'll add a shell script to make it more consistent, too.

roles/obs/templates/pad

#!/bin/bash
# Display the Etherpad collaborative document
# 

# Update the overlay
SLUG=$1
overlay $SLUG
firefox https://pad.emacsconf.org/-$SLUG

Open the Big Blue Button web conference

Most Q&A sessions are done live through a BigBlueButton web conference. We use redirects to make it easier to go to the talk URL. Backstage redirects are protected by a username and password which is shared with volunteers and saved in the browser used for streaming.

roles/obs/templates/bbb

#!/bin/bash
# Open the Big Blue Button room using the backstage link
# 

# 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
firefox https://media.emacsconf.org//backstage/current/room/$SLUG

Public redirect URLs start off with a refresh loop and then are overwritten with a redirect to the actual page when the host is okay with opening up the Q&A for general participation. This is done by changing the TODO status of the talk from CLOSED_Q to OPEN_Q.

emacsconf-publish-bbb-redirect: Update the publicly-available redirect for TALK.
(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
          ('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>")
          ('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>")
          ('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>"
           )))))))

Open up the stream chat

The IRC chat is the same for the whole track instead of changing for each talk. Since we might close the window, it's useful to be able to quickly open it again.

emacsconf-stream-join-chat: Join the IRC chat for TALK.
(defun emacsconf-stream-join-chat (talk)
  "Join the IRC chat for TALK."
  (interactive (list (emacsconf-complete-talk-info)))
  (setq talk (emacsconf-resolve-talk talk))
  (emacsconf-stream-track-ssh
   talk
   "nohup"
   "firefox"
   (plist-get talk :webchat-url)))

Set up for the right Q&A type

An Emacs Lisp function makes it easier to do the right thing depending on the type of Q&A planned for the talk.

emacsconf-stream-join-qa: Join the Q&A for TALK.
(defun emacsconf-stream-join-qa (talk)
  "Join the Q&A for TALK.
This uses the BBB room if available, or the IRC channel if not."
  (interactive (list (emacsconf-complete-talk-info)))
  (if (and (null (plist-get talk :video-file))
           (string-match "live" (plist-get talk :q-and-a)))
      (emacsconf-stream-track-ssh
       talk
       "nohup"
       "firefox"
       "-new-window"
       (plist-get talk :pad-url)) 
    (emacsconf-stream-track-ssh
     talk
     "nohup"
     "firefox"
     "-new-window"
     (pcase (plist-get talk :q-and-a)
       ((or 'nil "" (rx "Mumble"))
        (plist-get talk :qa-slide-url))
       ((rx "live")
        (plist-get talk :bbb-backstage))
       ((rx "IRC")
        (plist-get talk :webchat-url))
       ((rx "pad")
        (plist-get talk :pad-url))
       (_ (plist-get talk :qa-slide-url))))))

Summary

Combining shell scripts (roles/obs/templates) and Emacs Lisp functions (emacsconf-stream.el) help us simplify the work of taking talk-specific actions that depend on the kind of talk or Q&A session. Using simple identifiers and consistent file name conventions means that we can refer to talks quickly and use wildcards in shell scripts.

We started the conference with me jumping around and running most of the commands, since I had hastily written them in the weeks leading up to the conference and I was the most familiar with them. As the conference went on, other organizers got the hang of the commands and took over running their streams. Yay!