2023-09-11 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: jumping to and working with talks using Embark

| emacs, emacsconf

In the course of organizing and running EmacsConf, I often need to jump to or act on specific talks. I have a function that jumps to the talk heading so that I can look up additional information or add notes.

Figure 1: Jumping to a talk

emacsconf-go-to-talk: Jump to the talk heading matching SEARCH.
(defun emacsconf-go-to-talk (search)
  "Jump to the talk heading matching SEARCH."
  (interactive (list (emacsconf-complete-talk)))
  (find-file emacsconf-org-file)
   ((plist-get search :slug)
    (goto-char (org-find-property "SLUG" (plist-get search :slug))))
   ((emacsconf-get-slug-from-string search)
    (goto-char (org-find-property "SLUG" (emacsconf-get-slug-from-string search))))
     (catch 'found
        (lambda ()
          (when (string-match search
                               (concat (org-entry-get (point) "SLUG") " - "
                                       (org-entry-get (point) "ITEM") " - "
                                       (org-entry-get (point) "NAME") " - "
                                       (org-entry-get (point) "EMAIL"))
            (throw 'found (point))))

Most of the work is done in a completion function that makes it easy to specify a talk using the slug (talk ID), title, or speaker names.

emacsconf-complete-talk: Offer talks for completion.
(defun emacsconf-complete-talk (&optional info)
  "Offer talks for completion.
If INFO is specified, limit it to that list."
  (let ((choices
         (if (and (null info) emacsconf-complete-talk-cache)
           (mapcar (lambda (o)
                      (delq nil
                            (mapcar (lambda (f) (plist-get o f))
                                    '(:slug :title :speakers :irc)))
                      " - "))
                   (or info (emacsconf-get-talk-info))))))
     "Talk: " 
     (lambda (string predicate action)
       (if (eq action 'metadata)
           '(metadata (category . emacsconf))
         (complete-with-action action choices string predicate))))))

In addition to jumping to the Org heading for a talk, there are a bunch of other things I might want to do. Embark lets me add a bunch of shortcuts for working with a talk. I could open the caption file, edit the talk's wiki page, change a talk's property, e-mail the speaker, or more. Here's the Embark-related code from emacsconf.el:

Embark-related code
;;; Embark
(defun emacsconf-embark-finder ()
  "Identify when we're on a talk subtree."
  (when (and (derived-mode-p 'org-mode)
             (org-entry-get-with-inheritance "SLUG"))
    (cons 'emacsconf (org-entry-get-with-inheritance "SLUG"))))

(defun emacsconf-insert-talk-title (search)
  "Insert the talk title matching SEARCH."
  (interactive (list (emacsconf-complete-talk)))
  (insert (plist-get (emacsconf-search-talk-info search) :title)))

(with-eval-after-load 'embark
  (add-to-list 'embark-target-finders 'emacsconf-embark-finder)
  (defvar-keymap embark-emacsconf-actions
    :doc "Keymap for emacsconf-related things"
    "a" #'emacsconf-announce
    "c" #'emacsconf-find-captions-from-slug
    "d" #'emacsconf-find-caption-directives-from-slug
    "p" #'emacsconf-set-property-from-slug
    "w" #'emacsconf-edit-wiki-page
    "s" #'emacsconf-set-start-time-for-slug
    "W" #'emacsconf-browse-wiki-page
    "u" #'emacsconf-update-talk
    "t" #'emacsconf-insert-talk-title
    "m" #'emacsconf-mail-speaker-from-slug
    "n" #'emacsconf-notmuch-search-mail-from-entry
    "f" #'org-forward-heading-same-level
    "b" #'org-backward-heading-same-level
    "RET" #'emacsconf-go-to-talk)
  (add-to-list 'embark-keymap-alist '(emacsconf . embark-emacsconf-actions)))

;;; Status updates

For example, I sometimes need to open the wiki page for a talk in order to update the talk description.

emacsconf-edit-wiki-page: Open the wiki page for the talk matching SEARCH.
;;; Embark
(defun emacsconf-embark-finder ()
  "Identify when we're on a talk subtree."
  (when (and (derived-mode-p 'org-mode)
             (org-entry-get-with-inheritance "SLUG"))
    (cons 'emacsconf (org-entry-get-with-inheritance "SLUG"))))

Embark can also act on completion candidates, so I can call any of those actions from my C-c e t shortcut for emacsconf-go-to-talk. This is specified by the (metadata (category . emacsconf)) in emacsconf-complete-talk and the (add-to-list 'embark-keymap-alist '(emacsconf . embark-emacsconf-actions)) in my Embark configuration.

C-. is the embark-act shortcut in my configuration. When I need to remember what the shortcuts are, I can use C-h (embark-keymap-help) to list the keyboard shortcuts or select the command with completion.

Figure 2: Embark help for Emacsconf talks

The code above and related functions are in emacsconf.el or other files in the emacsconf-el repository.

EmacsConf backstage: scheduling with SVGs

| emacs, emacsconf

The EmacsConf 2023 call for participation deadline is coming up next Friday (Sept 15), so I'm getting ready to draft the schedule.

Here's a quick overview of how I experiment with schedules for EmacsConf. I have all the talk details in Org subtrees. There's a SLUG property that has the talk ID, a TIME property that says how long a talk is, and a Q_AND_A property that says what kind of Q&A the speaker wants: live, IRC, Etherpad, or after the event. Some talks have fixed starting times, like the opening remarks. Others start when the Q&A for the previous session ends. I generate an SVG so that I can quickly see how the schedule looks. Are there big gaps? Did I follow everyone's availability constraints? Does the schedule flow reasonably logically?

Code to define a schedule
(require 'emacsconf-schedule)
(setq emacsconf-schedule-tracks
      '((:label "Saturday"
                :start "2023-12-02 9:00"
                :end "2023-12-02 18:00"
                :tracks ("General"))
        (:label "Sunday"
                :start "2023-12-03 9:00"
                :end "2023-12-03 18:00"
                :tracks ("General"))))
(setq emacsconf-schedule-default-buffer-minutes 10
      emacsconf-schedule-default-buffer-minutes-for-live-q-and-a 30
      emacsconf-schedule-break-time 10
      emacsconf-schedule-lunch-time 60
      emacsconf-schedule-max-time 30
(setq emacsconf-schedule-plan
      '(("GEN Saturday, Dec 2" :start "2023-12-02 09:00")
        adventure writing one uni lunch hyperdrive ref mentor flat        (hn :start "15:30")
        (web :start "16:00") 
        ("GEN Sunday, Dec 3" :start "2023-12-03 09:00")
        windows extending lunch lspocaml sharing emacsen
        voice llm))
(setq emacsconf-schedule-draft (emacsconf-schedule-prepare (emacsconf-schedule-inflate-sexp emacsconf-schedule-plan)))
(emacsconf-schedule-validate emacsconf-schedule-draft)
(let ((emacsconf-schedule-svg-modify-functions
  (with-temp-file "schedule.svg"
    (svg-print (emacsconf-schedule-svg 800 200 emacsconf-schedule-draft))))
Figure 1: Sample schedule draft

Common functions are in emacsconf-schedule.el in the emacsconf-el repository, while conference-specific code is in our private conf.org file.


I was working on the schedule for EmacsConf 2022 when I ran into a problem. I wanted more talks than could fit into the time that we had, even if I scheduled it as tightly as possible with just a few minutes of transition between talks. This had been the scheduling strategy we used in previous years, squishing all the talks together with just enough time to let people know where they could go to ask questions. We had experimented with an alternate track for Q&A during EmacsConf 2021, so that had given us a little more space for discussion, but it wasn't going to be enough to fit in all the talks we wanted for EmacsConf 2022.

Could I convince the other organizers to take on the extra work needed for a multi-track conference? I knew it would be a lot of extra technical risk, and we'd need another host and streamer too. Could I make the schedule easy to understand on the wiki if we had two tracks? The Org Mode table I'd been using for previous years was not going to be enough. I was getting lost in the text.

Figure 2: Scheduling using Org Mode tables

Visualizing the schedule as an SVG

I'd been playing around with SVGs in Emacs. It was pretty straightforward to write a function that used time for the X axis and displayed different tracks. That showed us how full the conference was going to be if I tried to pack everything into two days.

Figure 3: One full day

emacsconf-schedule-svg-track: Draw the actual rectangles and text for the talks.
(defun emacsconf-schedule-svg-track (svg base-x base-y width height start-time end-time info)
  "Draw the actual rectangles and text for the talks."
  (let ((scale (/ width (float-time (time-subtract end-time start-time)))))
     (lambda (o)
       (let* ((offset (floor (* scale (float-time (time-subtract (plist-get o :start-time) start-time)))))
              (size (floor (* scale (float-time (time-subtract (plist-get o :end-time) (plist-get o :start-time))))))
              (x (+ base-x offset))
              (y base-y)
              (node (dom-node
                      (cons 'x x)
                      (cons 'y y)
                      (cons 'opacity "0.8")
                      (cons 'width size)
                      (cons 'height (1- height))
                      (cons 'stroke "black")
                      (cons 'stroke-dasharray
                            (if (string-match "live" (or (plist-get o :q-and-a) "live"))
                      (cons 'fill
                             ((string-match "BREAK\\|LUNCH" (plist-get o :title)) "white")
                             ((plist-get o :invalid) "red")
                             ((string-match "EST"
                                            (or (plist-get o :availability) ""))
                             (t "lightgreen"))))))
              (parent (dom-node
                        (cons 'href
                               (if emacsconf-use-absolute-url
                               (plist-get o :url)))
                        (cons 'title (plist-get o :title))
                        (cons 'data-slug (plist-get o :slug)))
                       (dom-node 'title nil
                                 (concat (format-time-string "%l:%M-" (plist-get o :start-time) emacsconf-timezone)
                                         (format-time-string "%l:%M " (plist-get o :end-time) emacsconf-timezone)
                                         (plist-get o :title)))
                        `((transform . ,(format "translate(%d,%d)"
                                                (+ x size -2) (+ y height -2))))
                          (cons 'fill "black")
                          (cons 'x 0)
                          (cons 'y 0)
                          (cons 'font-size 10)
                          (cons 'transform "rotate(-90)"))
                         (svg--encode-text (or (plist-get o :slug) (plist-get o :title))))))))
          o node parent)

emacsconf-schedule-svg-day: Add the time scale and the talks on a given day.
(defun emacsconf-schedule-svg-day (elem label width height start end tracks)
  "Add the time scale and the talks on a given day."
  (let* ((label-margin 15)
         (track-height (/ (- height (* 2 label-margin)) (length tracks)))
         (x 0) (y label-margin)
         (scale (/ width (float-time (time-subtract end start))))
         (time start))
    (dom-append-child elem (dom-node 'title nil (concat "Schedule for " label)))
    (svg-rectangle elem 0 0 width height :fill "white")
    (svg-text elem label :x 3 :y (- label-margin 3) :fill "black" :font-size "10")
    (mapc (lambda (track)
             elem x y width track-height
             start end track)
            (setq y (+ y track-height)))
    ;; draw grid
    (while (time-less-p time end)
      (let ((x (* (float-time (time-subtract time start)) scale)))
          `((transform . ,(format "translate(%d,%d)" x label-margin)))
           `((stroke . "darkgray")
             (x1 . 0)
             (y1 . 0)
             (x2 . 0)
             (y2 . ,(- height label-margin label-margin))))
           `((fill . "black")
             (x . 0)
             (y . ,(- height 2 label-margin))
             (font-size . 10)
             (text-anchor . "left"))
           (svg--encode-text (format-time-string "%-l %p" time emacsconf-timezone)))))
        (setq time (time-add time (seconds-to-time 3600)))))

emacsconf-schedule-svg-days: Display multiple days.
(defun emacsconf-schedule-svg-days (width height days)
  "Display multiple days."
  (let ((svg (svg-create width height))
        (day-height (/ height (length days)))
        (y 0))
    (dom-append-child svg (dom-node 'title nil "Graphical view of the schedule"))
     (lambda (day)
       (let ((group (dom-node 'g `((transform . ,(format "translate(0,%d)" y))))))
         (dom-append-child svg group)
         (emacsconf-schedule-svg-day group
                   (plist-get day :label)
                   width day-height
                   (date-to-time (plist-get day :start))
                   (date-to-time (plist-get day :end))
                   (plist-get day :tracks)))
       (setq y (+ y day-height)))

emacsconf-schedule-svg: Make the schedule SVG for INFO.
(defun emacsconf-schedule-svg (width height &optional info)
  "Make the schedule SVG for INFO."
  (setq info (emacsconf-prepare-for-display (or info (emacsconf-get-talk-info))))
  (let ((days (seq-group-by (lambda (o)
                              (format-time-string "%Y-%m-%d" (plist-get o :start-time) emacsconf-timezone))
                            (sort (seq-filter (lambda (o)
                                                (or (plist-get o :slug)
                                                    (plist-get o :include-in-info)))
     width height
     (mapcar (lambda (o)
               (let ((start (concat (car o) "T" emacsconf-schedule-start-time emacsconf-timezone-offset))
                     (end (concat (car o) "T" emacsconf-schedule-end-time emacsconf-timezone-offset)))
                 (list :label (format-time-string "%A" (date-to-time (car o)))
                       :start start
                       :end end
                       :tracks (emacsconf-by-track (cdr o)))))

With that, I was able to show the other organizers what a two-track conference could look like.

Figure 4: Two-track conference
Defining a schedule with two tracks
 (emacsconf-time-constraints '(("LUNCH" "11:30" "13:30")))
 (emacsconf-schedule-default-buffer-minutes 15)
 (emacsconf-schedule-default-buffer-minutes-for-live-q-and-a 25)
   '(("GEN Saturday, December 3" . "2022-12-03 09:00")
     "Saturday opening remarks"
     survey orgyear rolodex
     links buttons 
     hyperorg        realestate    health 
     jupyter workflows
     ("Saturday closing remarks" . "2022-12-03 17:00")
     ("GEN Sunday, December 4" . "2022-12-04 09:00")
     "Sunday opening remarks"
               school  science   lunch
               meetups buddy
      orgvm indieweb  fanfare
     ("Sunday closing remarks" . "2022-12-04 17:00")
     ("DEV Saturday, December 3" . "2022-12-03 10:00")
     localizing  treesitter lspbridge
     lunch sqlite
mail     eev python break wayland (haskell . "2022-12-03 16:00")
     ("DEV Sunday, December 4" . "2022-12-04 10:00")
     justl eshell 
     detached rde 
                tramp async
     asmblox dbus maint           )) )
 (emacsconf-schedule-break-time 10)
 (emacsconf-schedule-lunch-time 60)
 (emacsconf-schedule-max-time 30)
 (emacsconf-schedule-tweaked-allocations '(("indieweb" . 20)
                                           ("maint" . 20)
                                           ("workflows" . 20)))
 (emacsconf-scheduling-strategies '(emacsconf-schedule-override-breaks
 (tracks '((:label "Saturday"
                   :start "2022-12-03 9:00"
                   :end "2022-12-03 18:00"
                   :tracks (("^GEN Sat" "^GEN Sun")
                            ("^DEV Sat" "^DEV Sun")))
          (:label "Sunday"
                  :start "2022-12-04 9:00"
                  :end "2022-12-04 18:00"
                  :tracks (("^GEN Sun" "^DEV Sat")
                           ("^DEV Sun"))))))

When the other organizers saw the two schedules, they were strongly in favour of the two-track option. Yay!

Changing scheduling strategies

As I played around with the schedule, I wanted a quick way to test different scheduling strategies, such as changing the length of Q&A sessions for live web conferences versus IRC/Etherpad/email Q&A. Putting those into variables allowed me to easily override them with a let form, and I used a list of functions to calculate or modify the schedule from the Org trees.

Figure 5: Changing the default Q&A times

emacsconf-schedule-allocate-buffer-time: Allocate buffer time based on whether INFO has live Q&A.
(defun emacsconf-schedule-allocate-buffer-time (info)
  "Allocate buffer time based on whether INFO has live Q&A.
Uses `emacsconf-schedule-default-buffer-minutes' and
  (mapcar (lambda (o)
            (when (plist-get o :slug)
              (unless (plist-get o :buffer)
                (plist-put o :buffer
                            (if (string-match "live" (or (plist-get o :q-and-a) "live"))

This is actually applied by emacsconf-schedule-prepare, which runs through the list of strategies defined in emacsconf-schedule-strategies.

emacsconf-schedule-prepare: Apply ‘emacsconf-schedule-strategies’ to INFO to determine the schedule.
(defun emacsconf-schedule-prepare (&optional info)
  "Apply `emacsconf-schedule-strategies' to INFO to determine the schedule."
   (seq-reduce (lambda (prev val) (funcall val prev))
               (or info (emacsconf-get-talk-info)))))

Type of Q&A

I wanted to see which sessions had live Q&A via web conference and which ones had IRC/Etherpad/asynchronous Q&A. I set the talk outlines so that dashed lines mean asynchronous Q&A and solid lines mean live. Live Q&As take a little more work on our end because the host starts it up and reads questions, but they're more interactive. This is handled by

(cons 'stroke-dasharray
      (if (string-match "live" (or (plist-get o :q-and-a) "live"))

in the emacsconf-schedule-svg-track function.

Moving talks around

I wanted to be able to quickly reorganize talks by moving their IDs around in a list instead of just using the order of the subtrees in my Org Mode file. I wrote a function that took a list of symbols, looked up each of the talks, and returned a list of talk info property lists. I also added the ability to override some things about talks, such as whether something started at a fixed time.

emacsconf-schedule-inflate-sexp: Takes a list of talk IDs and returns a list that includes the scheduling info.
(defun emacsconf-schedule-inflate-sexp (sequence &optional info include-time)
  "Takes a list of talk IDs and returns a list that includes the scheduling info.
Pairs with `emacsconf-schedule-dump-sexp'."
  (setq info (or info (emacsconf-get-talk-info)))
  (let ((by-assoc (mapcar (lambda (o) (cons (intern (plist-get o :slug)) o))
                          (emacsconf-filter-talks info)))
     (lambda (seq)
       (unless (listp seq) (setq seq (list seq)))
       (if include-time
           (error "Not yet implemented")
         (let ((start-prop (or (plist-get (cdr seq) :start)
                               (and (stringp (cdr seq)) (cdr seq))))
               (time-prop (or (plist-get (cdr seq) :time) ; this is duration in minutes
                              (and (numberp (cdr seq)) (cdr seq))))
               (track-prop (plist-get (cdr seq) :track)))
            ;; overriding 
            (when start-prop
              (if (string-match "-" start-prop)
                  (setq date (format-time-string "%Y-%m-%d" (date-to-time start-prop)))
                (setq start-prop  (concat date " " start-prop)))
               :scheduled (format-time-string (cdr org-time-stamp-formats) (date-to-time start-prop)
               :start-time (date-to-time start-prop)
               :fixed-time t))
            (when track-prop
              (list :track track-prop))
            (when time-prop
              (list :time (if (numberp time-prop) (number-to-string time-prop) time-prop)))
            ;; base entity
             ((eq (car seq) 'lunch)
              (list :title "LUNCH" :time (number-to-string emacsconf-schedule-lunch-time)))
             ((eq (car seq) 'break)
              (list :title "BREAK" :time (number-to-string emacsconf-schedule-break-time)))
             ((symbolp (car seq))
              (assoc-default (car seq) by-assoc))
             ((stringp (car seq))
              (or (seq-find (lambda (o) (string= (plist-get o :title) (car seq))) info)
                  (list :title (car seq))))
             (t (error "Unknown %s" (prin1-to-string seq))))))))

That allowed me to specify a test schedule like this:

(setq emacsconf-schedule-plan
      '(("GEN Saturday, Dec 2" :start "2023-12-02 09:00")
        adventure writing one uni lunch hyperdrive ref mentor flat
        (hn :start "15:30")
        (web :start "16:00") 
        ("GEN Sunday, Dec 3" :start "2023-12-03 09:00")
        windows extending lunch lspocaml sharing emacsen
        voice llm))
(setq emacsconf-schedule-draft
       (emacsconf-schedule-inflate-sexp emacsconf-schedule-plan)))

Validating the schedule

Now that I could see the schedule visually, it made sense to add some validation. As mentioned in my post about timezones, I wanted to validate live Q&A against the speaker's availability and colour sessions red if they were outside the times I'd written down.

I also wanted to arrange the schedule so that live Q&A sessions didn't start at the same time, giving me a little time to switch between sessions in case I needed to help out. I wrote a function that checked if the Q&A for a session started within five minutes of the previous one. This turned out to be pretty useful, since I ended up mostly taking care of playing the videos and opening the browsers for both streams. With that in place, I could just move the talks around until everything fit well together.

emacsconf-schedule-validate-live-q-and-a-sessions-are-staggered: Try to avoid overlapping the start of live Q&A sessions.
(defun emacsconf-schedule-validate-live-q-and-a-sessions-are-staggered (schedule)
  "Try to avoid overlapping the start of live Q&A sessions.
Return nil if there are no errors."
  (when emacsconf-schedule-validate-live-q-and-a-sessions-buffer
    (let (last-end)
      (delq nil
            (mapcar (lambda (o)
                          (when (and last-end
                                      (plist-get o :end-time)
                                      (time-add last-end (seconds-to-time (* emacsconf-schedule-validate-live-q-and-a-sessions-buffer 60)))))
                            (plist-put o :invalid (format "%s live Q&A starts at %s within %d minutes of previous live Q&A at %s"
                                                          (plist-get o :slug)
                                                          (format-time-string "%m-%d %-l:%M"
                                                                              (plist-get o :end-time))
                                                          (format-time-string "%m-%d %-l:%M"
                            (plist-get o :invalid))
                        (setq last-end (plist-get o :end-time))))
                     (seq-filter (lambda (o) (string-match "live" (or (plist-get o :q-and-a) "")))
                     (lambda (a b)
                       (time-less-p (plist-get a :end-time) (plist-get b :end-time)))

Publishing SVGs in the wiki

When the schedule settled down, it made perfect sense to include the image on the schedule page as well as on each talk page. I wanted people to be able to click on a rectangle and load the talk page. That meant including the SVG in the markup and allowing the attributes in ikiwiki's HTML sanitizer. In our htmlscrubber.pm, I needed to add svg rect text g title line to allow, and add version xmlns x y x1 y1 x2 y2 fill font-size font-weight stroke stroke-width stroke-dasharray transform opacity to the default attributes.

Figure 6: Opening a talk from the SVG

For the public-facing pages, I wanted to colour the talks based on track. I specified the colours in emacsconf-tracks (peachpuff and skyblue) and used them in emacsconf-schedule-svg-color-by-track.

emacsconf-schedule-svg-color-by-track: Color sessions based on track.
(defun emacsconf-schedule-svg-color-by-track (o node &optional parent)
  "Color sessions based on track."
  (let ((track (emacsconf-get-track (plist-get o :track))))
    (when track
      (dom-set-attribute node 'fill (plist-get track :color)))))

I wanted talk pages to highlight the talk on the schedule so that people could easily find other sessions that conflict. Because a number of people in the Emacs community browse with Javascript turned off, I used Emacs Lisp to generate a copy of the SVG with the current talk highlighted.

emacsconf-publish-format-talk-page-schedule: Add the schedule image for TALK based on INFO.
(defun emacsconf-publish-format-talk-page-schedule (talk info)
  "Add the schedule image for TALK based on INFO."
   "\nThe following image shows where the talk is in the schedule for "
   (format-time-string "%a %Y-%m-%d" (plist-get talk :start-time) emacsconf-timezone) ". Solid lines show talks with Q&A via BigBlueButton. Dashed lines show talks with Q&A via IRC or Etherpad."
   (format "<div class=\"schedule-in-context schedule-svg-container\" data-slug=\"%s\">\n" (plist-get talk :slug))           
   (let* ((width 800) (height 150)
          (talk-date (format-time-string "%Y-%m-%d" (plist-get talk :start-time) emacsconf-timezone))
          (start (date-to-time (concat talk-date "T" emacsconf-schedule-start-time emacsconf-timezone-offset)))
          (end (date-to-time (concat talk-date "T" emacsconf-schedule-end-time emacsconf-timezone-offset)))
       (setq svg (emacsconf-schedule-svg-day
                  (svg-create width height)
                  (format-time-string "%A" (plist-get talk :start-time) emacsconf-timezone)
                  width height
                  start end
                   (seq-filter (lambda (o) (string= (format-time-string "%Y-%m-%d" (plist-get o :start-time) emacsconf-timezone)
       (mapc (lambda (node)
               (let ((rect (car (dom-by-tag node 'rect))))
                 (if (string= (dom-attr node 'data-slug) (plist-get talk :slug))
                       (dom-set-attribute rect 'opacity "0.8")
                       (dom-set-attribute rect 'stroke-width "3")
                       (dom-set-attribute (car (dom-by-tag node 'text)) 'font-weight "bold"))
                   (dom-set-attribute rect 'opacity "0.5"))))
             (dom-by-tag svg 'a))
       (svg-print svg)
   (emacsconf-format-talk-schedule-info talk) "\n\n"))

Colouring talks based on status

As the conference approached, I wanted to colour talks based on their status, so I could see how much of our schedule already had videos and even how many had been captioned. That was just a matter of adding a modifier function to change the SVG rectangle colour depending on the TODO status.

Figure 7: Changing talk colour based on status

emacsconf-schedule-svg-color-by-status: Set talk color based on status.
(defun emacsconf-schedule-svg-color-by-status (o node &optional _)
  "Set talk color based on status.
Processing: palegoldenrod,
Waiting to be assigned a captioner: yellow,
Captioning in progress: lightgreen,
Ready to stream: green,
Other status: gray"
  (unless (plist-get o :invalid)
    (dom-set-attribute node 'fill
                       (pcase (plist-get o :status)
                         ((rx (or "TO_PROCESS"
                         ((rx (or "TO_ASSIGN"))
                         ((rx (or "TO_CAPTION"))
                         ((rx (or "TO_STREAM"))
                         (_ "gray")))))


Common functions are in emacsconf-schedule.el in the emacsconf-el repository, while conference-specific code is in our private conf.org file.

Some information is much easier to work with graphically than in plain text. Seeing the schedules as images made it easier to spot gaps or errors, tinker with parameters, and communicate with other people. Writing Emacs Lisp functions to modify my data made it easier to try out different things without rearranging the text in my Org file. Because Emacs can display images inside the editor, it was easy for me to make changes and see the images update right away. Using SVG also made it possible to export the image and make it interactive. Handy technique!

EmacsConf backstage: converting timezones

| emacsconf, emacs
  • [2023-10-12 Thu]: Update screenshots to use the overlay talk
  • [2023-10-10 Tue]: Updated translation schedule to use emacsconf-mail-format-talk-schedule.
  • 2023-09-07: It looks like I can use Etc/GMT-2 to mean GMT+2 - note the reversed sign.

EmacsConf is a virtual conference with speakers from all over the world. We like to plan the schedule so that the speakers can come for live Q&A sessions without having to wake up too early or stay up too late.

Timezones are tricky for me. Sometimes I mess up timezone names (like the time I misspelled Tbilisi and ended up with UTC conversion) or get the timezone conversion wrong because of daylight savings time, and it's annoying to go to a website to convert the timezones.

Fortunately, the tzc package provides a way to convert times from one timeone to another in Emacs, and it includes a list of timezones in tzc-time-zones loaded from /usr/share/zoneinfo. Here's how I use it to make organizing EmacsConf easier.

Setting the timeone with completion

To reduce data entry errors, I use completion when setting the timezone.

Figure 1: Setting the timezone

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

Sometimes speakers specify their timezone as an offset from GMT or UTC, such as GMT+2. It turns out that I can use timezones like Etc/GMT-2 to capture that, although it's important to note that the sign for Etc/GMT timezones is reversed (so Etc/GMT-2 = GMT+2).

Converting timezones

In Toronto, we switch from daylight savings time to standard time sometime in November, so I need to make sure that my time conversions for speaker availability uses the date of the conference (emacsconf-date, 2023-12-02 this year). emacsconf-convert-from-timezone makes it easy to convert times on emacsconf-date so that I don't have to keep re-entering the date part.

Figure 2: Converting from a timezone

(defun emacsconf-convert-from-timezone (timezone time)
  (interactive (list (progn
                       (require 'tzc)
                       (if (and (derived-mode-p 'org-mode)
                                (org-entry-get (point) "TIMEZONE"))
                           (completing-read (format "From zone (%s): "
                                                    (org-entry-get (point) "TIMEZONE"))
                                            tzc-time-zones nil nil nil nil
                                            (org-entry-get (point) "TIMEZONE"))
                         (completing-read "From zone: " tzc-time-zones nil t)))
                     (read-string "Time: ")))
  (let* ((from-offset (format-time-string "%z" (date-to-time emacsconf-date) timezone))
           (concat emacsconf-date "T" (string-pad time 5 ?0 t)  ":00.000"
    (message "%s = %s"
              "%b %d %H:%M %z"
              "%b %d %H:%M %z"

I can use this to convert times like 8:00 in US/Pacific to 11:00 EST.

Validating schedule constraints

Once I get the availability into a standard format, I can use that to validate that sessions are scheduled during the times that speakers have indicated that they're available. So far, I've been using text like >= 10:00 EST at the beginning of the talk's AVAILABILITY property, since that's easy to parse and validate. I can use that to colour invalid talks red in an SVG, and I can make a list of invalid talks as well.

Figure 3: Validating time constraints in a draft schedule

How does that work? First, we get the time constraint out of the AVAILABILITY property with emacsconf-schedule-get-time-constraint.

(defun emacsconf-schedule-get-time-constraint (o)
  (when (emacsconf-schedule-q-and-a-p o)
    (let ((avail (or (plist-get o :availability) ""))
          (pos 0)
          (result (list nil nil nil)))
      (while (string-match "\\([<>]\\)=? *\\([0-9]+:[0-9]+\\) *EST" avail pos)
        (setf (elt result (if (string= (match-string 1 avail) ">")
              (match-string 2 avail))
          (setq pos (match-end 0)))
      (when (string-match "[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]" avail)
        (setf (elt result 2) (match-string 0 avail)))

Then we can return a warning if a talk is scheduled outside those time constraints.

emacsconf-schedule-check-time: FROM-TIME and TO-TIME should be nil strings like HH:MM in EST.
(defun emacsconf-schedule-check-time (label o &optional from-time to-time day)
  "FROM-TIME and TO-TIME should be nil strings like HH:MM in EST.
DAY should be YYYY-MM-DD if specified.
Both start and end time are tested."
  (let* ((start-time (format-time-string "%H:%M" (plist-get o :start-time)))
         (end-time (format-time-string "%H:%M" (plist-get o :end-time)))
    (setq result
           (and (null o) (format "%s: Not found" label))
           (and from-time (string< start-time from-time)
                (format "%s: Starts at %s before %s" label start-time from-time))
           (and to-time (string< to-time end-time)
                (format "%s: Ends at %s after %s" label end-time to-time))
           (and day
                (not (string= (format-time-string "%Y-%m-%d" (plist-get o :start-time))
                (format "%s: On %s instead of %s"
                        (format-time-string "%Y-%m-%d" (plist-get o :start-time))
    (when result (plist-put o :invalid result))

So then we can check all the talks as scheduled, and set the :invalid property if it's outside the availability constraints.

(defun emacsconf-schedule-validate-time-constraints (info &rest _)
  (let* ((info (or info (emacsconf-get-talk-info)))
         (results (delq nil
                          (lambda (o)
                            (apply #'emacsconf-schedule-check-time
                                   (car o)
                                   (emacsconf-search-talk-info (car o) info)
                                   (cdr o)))
                          (lambda (o)
                            (let (result
                                  (constraint (emacsconf-schedule-get-time-constraint o)))
                              (when constraint
                                (setq result (apply #'emacsconf-schedule-check-time
                                                    (plist-get o :slug)
                                (when result (plist-put o :invalid result))
    (if (called-interactively-p 'any)
        (message "%s" (string-join results "\n"))

Here are more details on how I made the schedule SVG. It's handy to have a quick way to check availability in both text and graphical format.

Translating schedules into local times

When we e-mail speakers their schedules, we also include a translation to their local time if we know it.

Figure 4: Sample e-mail for schedule feedback

That's handled by the emacsconf-mail-format-talk-schedule, which handles three cases:

  • timezone is the same as the conference: show just that time
  • UTC offset is the same as the conferenc, just a different timezone: mention that
  • UTC offset is different: translate to local time and make it clear that this is a translation, not a second event

(If we haven't noted the timezone for the talk, we ask the speaker.)

emacsconf-mail-format-talk-schedule: Format the schedule for O for inclusion in mail messages etc.
(defun emacsconf-mail-format-talk-schedule (o)
  "Format the schedule for O for inclusion in mail messages etc."
  (interactive (list (emacsconf-complete-talk)))
  (when (stringp o)
    (setq o
           (emacsconf-get-slug-from-string o)
           (or emacsconf-schedule-draft (emacsconf-get-talk-info)))))
  (let ((result
          (plist-get o :title) "\n"
          (format-time-string "%b %-e %a %-I:%M %#p %Z" (plist-get o :start-time) emacsconf-timezone) "\n"
          (if (and (plist-get o :timezone) (not (string= (plist-get o :timezone) emacsconf-timezone)))
              (if (string= (format-time-string "%z" (plist-get o :start-time) (plist-get o :timezone))
                           (format-time-string "%z" (plist-get o :start-time) emacsconf-timezone))
                  (format "which is the same time in your local timezone %s\n"
                          (emacsconf-schedule-rename-etc-timezone (plist-get o :timezone)))
                (format "translated to your local timezone %s: %s\n"
                        (emacsconf-schedule-rename-etc-timezone (plist-get o :timezone))
                        (format-time-string "%b %-e %a %-I:%M %#p %Z" (plist-get o :start-time) (plist-get o :timezone))))
    (when (called-interactively-p 'any)
      (insert result))

The Etc/GMT... timezones are a little confusing, because the signs are opposite from what you'd expect (GMT-3 = UTC+0300). So we have a little function that turns those into regular UTC offsets.

emacsconf-schedule-rename-etc-timezone: Change Etc/GMT-3 etc. to UTC+3 etc., since Etc uses negative signs and this is confusing.
(defun emacsconf-schedule-rename-etc-timezone (s)
  "Change Etc/GMT-3 etc. to UTC+3 etc., since Etc uses negative signs and this is confusing."
  (cond ((string-match "Etc/GMT-\\(.*\\)" s) (concat "UTC+" (match-string 1 s)))
        ((string-match "Etc/GMT\\+\\(.*\\)" s) (concat "UTC-" (match-string 1 s)))
        (t s)))

So that's how we work with timezones in EmacsConf!

EmacsConf backstage: capturing submissions from e-mails

| emacsconf, emacs, org

2023-09-11: Updated code for recognizing fields.

People submit proposals for EmacsConf sessions via e-mail following this submission template. (You can still submit a proposal until Sept 14!) I mostly handle acceptance and scheduling, so I copy this information into our private conf.org file so that we can use it to plan the draft schedule, mail-merge speakers, and so on. I used to do this manually, but I'm experimenting with using functions to create the heading automatically so that it includes the date, talk title, and e-mail address from the e-mail, and it calculates the notification date for early acceptances as well. I use Notmuch for e-mail, so I can get the properties from (notmuch-show-get-message-properties).

Figure 1: E-mail submission

emacsconf-mail-add-submission: Add the submission from the current e-mail.
(defun emacsconf-mail-add-submission (slug)
  "Add the submission from the current e-mail."
  (interactive "MTalk ID: ")
  (let* ((props (notmuch-show-get-message-properties))
         (from (or (plist-get (plist-get props :headers) :Reply-To)
                   (plist-get (plist-get props :headers) :From)))
         (body (plist-get
                 (plist-get props :body))
         (date (format-time-string "%Y-%m-%d"
                                   (date-to-time (plist-get (plist-get props :headers) :Date))))
         (to-notify (format-time-string
                      (days-to-time emacsconf-review-days)
                      (date-to-time (plist-get (plist-get props :headers) :Date)))))
         (data (emacsconf-mail-parse-submission body)))
    (when (string-match "<\\(.*\\)>" from)
      (setq from (match-string 1 from)))
        (find-file emacsconf-org-file)
      ;;  go to the submissions entry
      (goto-char (org-find-property "CUSTOM_ID" "submissions"))
      (when (org-find-property "CUSTOM_ID" slug)
        (error "Duplicate talk ID")))
    (find-file emacsconf-org-file)
    (insert " " (or (plist-get data :title) "") "\n")
    (org-todo "TO_REVIEW")
    (org-entry-put (point) "CUSTOM_ID" slug)
    (org-entry-put (point) "SLUG" slug)
    (org-entry-put (point) "TRACK" "General")
    (org-entry-put (point) "EMAIL" from)
    (org-entry-put (point) "DATE_SUBMITTED" date)
    (org-entry-put (point) "DATE_TO_NOTIFY" to-notify)
    (when (plist-get data :time)
      (org-entry-put (point) "TIME" (plist-get data :time)))
    (when (plist-get data :availability)
      (org-entry-put (point) "AVAILABILITY"
                     (replace-regexp-in-string "\n+" " "
                                               (plist-get data :availability))))
    (when (plist-get data :public)
      (org-entry-put (point) "PUBLIC_CONTACT"
                     (replace-regexp-in-string "\n+" " "
                                               (plist-get data :public))))
    (when (plist-get data :private)
      (org-entry-put (point) "EMERGENCY"
                     (replace-regexp-in-string "\n+" " "
                                               (plist-get data :private))))
    (when (plist-get data :q-and-a)
      (org-entry-put (point) "Q_AND_A"
                     (replace-regexp-in-string "\n+" " "
                                               (plist-get data :q-and-a))))
      (insert (plist-get data :body)))
    (re-search-backward org-drawer-regexp)
    (org-fold-hide-drawer-toggle 'off)

emacsconf-mail-parse-submission: Extract data from EmacsConf 2023 submissions in BODY.
(defun emacsconf-mail-parse-submission (body)
  "Extract data from EmacsConf 2023 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"))))
      (insert body)
      (goto-char (point-min))
      ;; Try to parse it
      (while fields
        ;; skip the field title
        (when (and (or (looking-at (cadar fields))
                       (re-search-forward (cadar fields) nil t))
                   (re-search-forward "\\(:[ \t\n]+\\|\n\n\\)" nil t))
          ;; get the text between this and the next field
          (setq data (plist-put data (caar fields)
                                (buffer-substring (point)
                                                   (when (and (cdr fields)
                                                              (re-search-forward (cadr (cadr fields)) nil t))
                                                     (goto-char (match-beginning 0))
        (setq fields (cdr fields)))
      (if (string-match "[0-9]+" (or (plist-get data :format) ""))
          (plist-put data :time (match-string 0 (or (plist-get data :format) ""))))

The functions above are in the emacsconf-el repository. When I call emacsconf-mail-parse-submission and give it the talk ID I want to use, it makes the Org entry.

Figure 2: Creating the entry

We store structured data in Org Mode properties such as NAME, EMAIL and EMERGENCY. I tend to make mistakes when typing, so I have a short function that sets an Org property based on a region. This is the code from my personal config:

my-org-set-property: In the current entry, set PROPERTY to VALUE.
(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))

I've bound it to C-c C-x p. This is what it looks like when I use it:

Figure 3: Setting Org properties from the region

That helps me reduce errors in entering data. I sometimes forget details, so I ask other people to double-check my work, especially when it comes to speaker availability. That's how I copy the submission e-mails into our Org file.

2023-09-04 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!

How I keep track of new Emacs packages

| emacs

One of the things I like about preparing Emacs News is seeing the new packages that people have added to Emacs. It's pretty awesome! The package archives don't seem to include the date that a new package has been added, but that's easy to work around by saving the data and then comparing new archive contents with the old list. The code for this is somewhere in the very long index.org in the Emacs News repository, so I thought I'd add comments to it and post it as a blog post as well.

The overall function that prepares a draft of Emacs News is my-prepare-emacs-news, and the code specifically related to packages is:

(my-update-package-list date)

Loading saved package data

Here, we load a list of packages and dates, and we compare them with the packages loaded from archive-contents.

(defvar my-package-list-file "~/sync/emacs-news/package-list.el")

(defun my-read-sexp-from-file (filename)
  (with-temp-buffer (insert-file-contents filename)
                    (goto-char (point-min)) (read (current-buffer))))

(defun my-update-package-list (&optional date)
  "Update the list of packages. Mark new packages with DATE."
  (interactive (list (format-time-string "%Y-%m-%d")))
  (setq date (or date (format-time-string "%Y-%m-%d")))
  (let* ((archives (my-get-current-packages date))
         (old-list (my-read-sexp-from-file my-package-list-file)))
    (mapc (lambda (o)
            (let* ((old-entry (assoc-default (car o) old-list))
                     (mapcar 'cadr (cdr o))
                     (mapcar 'car (car (assoc-default (car o) old-list))))))
               ((null (assoc (car o) old-list))
                ;; new package, add it to the list
                (setq old-list
                      (cons (list (car o)
                                   (lambda (entry) (cons (cadr entry) date))
                                   (cdr o)))
                ;; existing package added to a different repository
                (setf old-entry
                       (mapcar (lambda (archive) (cons archive date))
    ;; Save to file, one package per line
    (with-temp-file my-package-list-file
      (insert "("
              (mapconcat #'prin1-to-string

The function above loads a list of packages and dates from my-package-list-file, which is package-list.el in my repository. It's a list of lists storing the package name, the repositories it's available from, and the dates I noticed it was in the repository. Each entry looks something like this:

(vhdl-ts-mode (("melpa" . "2023-08-21")))

Reading archive contents

(defun my-get-current-packages (date)
  "Return a list of package symbols with the package archive and DATE.
Example entry: `(ack (ack \"gnu\" \"2023-09-03\"))`"
  (seq-group-by 'car
                (seq-mapcat (lambda (f)
                              (let ((base (file-name-base f)))
                                 (lambda (entry)
                                   (list (car entry) base date))
                                     (expand-file-name "archive-contents" f))
                                    (goto-char (point-min))
                                    (read (current-buffer)))))))
                             (expand-file-name "archives" package-user-dir) t

Getting new packages

Then I can filter the list to get only the new packages.

(defun my-packages-between (from-date &optional to-date)
   (lambda (o)
      (or (not from-date) (not (string< (cdar (cadr o)) from-date)))
      (or (not to-date) (string< (cdar (cadr o)) to-date))))
   (my-read-sexp-from-file my-package-list-file)))

(defun my-list-new-packages (&optional date)
  (let ((packages
           (lambda (o)
             (seq-remove (lambda (archive) (string< (cdr archive) date))
                         (cadr o)))
           (my-read-sexp-from-file my-package-list-file)))))
    (if (called-interactively-p 'any)
        (insert packages)

Formatting new package entries

Formatting the entry in Emacs News is mostly a matter of grabbing the package description.

(defun my-describe-packages (list)
  "Return an Org list of package descriptions for LIST."
   (lambda (entry)
     (let* ((symbol (car entry))
            (package-desc (assoc symbol package-archive-contents)))
       (if package-desc
           (format "  - %s: %s (%s)"
                   (org-link-make-string (concat "package:" (symbol-name symbol))
                                         (symbol-name symbol))
                   (package-desc-summary (cadr package-desc))
                    (lambda (archive)
                      (pcase (car archive)
                        ("gnu" "GNU ELPA")
                        ("nongnu" "NonGNU ELPA")
                        ("melpa" "MELPA")))
                    (cadr entry)
                    ", "))

I want package links to call describe-package when I'm exploring them inside Emacs, and I want them to export as links to the appropriate repository page when I publish Emacs News as HTML or ASCII. This is handled by a custom link.

  (defun my-org-package-open (package-name)
    (interactive "MPackage name: ")
    (describe-package (intern package-name)))

  (defun my-org-package-export (link description format)
    (let* ((package-info (car (assoc-default (intern link) package-archive-contents)))
           (package-source (package-desc-archive package-info))
           (path (format
                   ((string= package-source "gnu") "https://elpa.gnu.org/packages/%s.html")
                   ((string= package-source "nongnu") "https://elpa.nongnu.org/nongnu/%s.html")
                   ((string= package-source "melpa") "https://melpa.org/#/%s")
                   (t (throw 'unknown-source)))
           (desc (or description link)))
       ((eq format '11ty) (format "<a target=\"_blank\" href=\"%s\">%s</a>" path desc))
       ((eq format 'html) (format "<a target=\"_blank\" href=\"%s\">%s</a>" path desc))
       ((eq format 'wp) (format "<a target=\"_blank\" href=\"%s\">%s</a>" path desc))
       ((eq format 'ascii) (format "%s <%s>" desc path))
       (t path))))

  (org-link-set-parameters "package" :follow 'my-org-package-open :export 'my-org-package-export)

  (ert-deftest my-org-package-export ()
      (my-org-package-export "transcribe" "transcribe" 'html)
      "<a target=\"_blank\" href=\"https://elpa.gnu.org/packages/transcribe.html\">transcribe</a>"
      (my-org-package-export "fireplace" "fireplace" 'html)
      "<a target=\"_blank\" href=\"https://melpa.org/#/fireplace\">fireplace</a>"

Announcing new GNU ELPA packages by e-mail

I announce new GNU ELPA packages on the info-gnu-emacs@gnu.org mailing list. I decided to leave this as partially automated instead of fully automating it, since it involves e-mails that go out to lots of people.

Whenever I prepare Emacs News, I look at the list of new packages for ones in the GNU ELPA repository. Then I use this function to draft the e-mail that announces it. To reduce the risk of errors, I default to the symbol at point, and I use only ELPA packages for completion.

(defun my-announce-elpa-package (package-name)
  "Compose an announcement for PACKAGE-NAME for info-gnu-emacs."
  (interactive (let* ((guess (or (function-called-at-point)
                 (require 'finder-inf nil t)
                 ;; Load the package list if necessary (but don't activate them).
                 (unless package--initialized
                   (package-initialize t))
                 (let ((packages
                        (mapcar #'car
                                 (lambda (p)
                                   (seq-find (lambda (entry)
                                               (string= (package-desc-archive entry)
                                             (cdr p)))
                   (unless (memq guess packages)
                     (setq guess nil))
                   (setq packages (mapcar #'symbol-name packages))
                   (let ((val
                          (completing-read (format-prompt "Describe package" guess)
                                           packages nil t nil nil (when guess
                                                                    (symbol-name guess)))))
                     (list (and (> (length val) 0) (intern val)))))))
  (let ((package (car (assoc-default package-name package-archive-contents))))
    (compose-mail "info-gnu-emacs@gnu.org"
                  (format "New GNU ELPA package: %s - %s"
                          (package-desc-name package)
                          (package-desc-summary package)))
    (describe-package-1 package-name)
    (delete-region (point)
                   (progn (re-search-forward " *Summary:") (match-beginning 0)))
      (goto-char (point-max))
      (insert "\n\n---------\nYou are receiving this message via the info-gnu-emacs@gnu.org mailing list.\nList info/preferences: https://lists.gnu.org/mailman/listinfo/info-gnu-emacs")
      (goto-char (point-min))
      (when (re-search-forward "Maintainer: \\(.+\\)" nil t)
        (message-add-header (concat "Reply-To: " user-mail-address ", " (match-string 1))
                            (concat "Mail-Followup-To: " user-mail-address ", " (match-string 1)))))))

Ideas for future stuff

It might be nice to use this information to publish an RSS feed of new packages, look up the package homepages for screenshots/GIFs/videos, or cross-reference blog posts and community discussions.

Anyway, that's how I keep track of new Emacs packages!