Emacs calendar

Upcoming events

  • Emacs Berlin (hybrid, in English) https://emacs-berlin.org/ Wed Jun 25 0930 America/Vancouver - 1130 America/Chicago - 1230 America/Toronto - 1630 Etc/GMT - 1830 Europe/Berlin - 2200 Asia/Kolkata – Thu Jun 26 0030 Asia/Singapore
  • Emacs APAC: Emacs APAC meetup (virtual) https://emacs-apac.gitlab.io/announcements/ Sat Jun 28 0130 America/Vancouver - 0330 America/Chicago - 0430 America/Toronto - 0830 Etc/GMT - 1030 Europe/Berlin - 1400 Asia/Kolkata - 1630 Asia/Singapore
  • EmacsATX: Emacs Social https://www.meetup.com/emacsatx/events/307923518/ Wed Jul 2 1600 America/Vancouver - 1800 America/Chicago - 1900 America/Toronto - 2300 Etc/GMT – Thu Jul 3 0100 Europe/Berlin - 0430 Asia/Kolkata - 0700 Asia/Singapore
  • M-x Research: TBA https://m-x-research.github.io/ Fri Jul 4 0800 America/Vancouver - 1000 America/Chicago - 1100 America/Toronto - 1500 Etc/GMT - 1700 Europe/Berlin - 2030 Asia/Kolkata - 2300 Asia/Singapore
  • Emacs.si (in person): Emacs.si meetup #7 2025 (v #živo) https://dogodki.kompot.si/events/0fb2dc00-8cea-4365-a6ca-ab1a3e76f0ee Tue Jul 8 1900 CET
  • OrgMeetup (virtual) https://orgmode.org/worg/orgmeetup.html Wed Jul 9 0900 America/Vancouver - 1100 America/Chicago - 1200 America/Toronto - 1600 Etc/GMT - 1800 Europe/Berlin - 2130 Asia/Kolkata – Thu Jul 10 0000 Asia/Singapore
  • Atelier Emacs Montpellier (in person) https://lebib.org/date/atelier-emacs Fri Jul 11 1800 Europe/Paris
  • M-x Research: TBA https://m-x-research.github.io/ Wed Jul 16 0800 America/Vancouver - 1000 America/Chicago - 1100 America/Toronto - 1500 Etc/GMT - 1700 Europe/Berlin - 2030 Asia/Kolkata - 2300 Asia/Singapore
  • Emacs APAC: Emacs APAC meetup (virtual) https://emacs-apac.gitlab.io/announcements/ Sat Jul 26 0130 America/Vancouver - 0330 America/Chicago - 0430 America/Toronto - 0830 Etc/GMT - 1030 Europe/Berlin - 1400 Asia/Kolkata - 1630 Asia/Singapore
  • Emacs Berlin (hybrid, in English) https://emacs-berlin.org/ Wed Jul 30 0930 America/Vancouver - 1130 America/Chicago - 1230 America/Toronto - 1630 Etc/GMT - 1830 Europe/Berlin - 2200 Asia/Kolkata – Thu Jul 31 0030 Asia/Singapore

Introduction

This calendar is maintained by sacha@sachachua.com. You can find it at https://emacslife.com/calendar/

You can find a list of upcoming events and other meet-ups at https://www.emacswiki.org/emacs/Usergroups.

Or you can add this iCal to your calendar program:

https://emacslife.com/calendar/emacs-calendar.ics

Or you can view the following HTML calendars:

(America/Vancouver America/Chicago America/Toronto Etc/GMT Europe/Berlin Asia/Kolkata Asia/Singapore)

(Let me know if you want me to add your timezone! - mailto:sacha@sachachua.com)

Or you can periodically download and include one of these files in your Org agenda files:

Enjoy!

Code I use to run it

Timezones

  • America/Vancouver
  • America/Chicago
  • America/Toronto
  • Etc/GMT
  • Europe/Berlin
  • Asia/Kolkata
  • Asia/Singapore

Download and parse the iCal file with Python

pip3 install icalevents recurring_ical_events pypandoc requests pytz
import requests
import http.cookiejar as cookielib
import os
cj = cookielib.MozillaCookieJar(os.path.expanduser('~/.cache/meetup-cookies.txt'))
cj.load()
data = requests.get('https://www.meetup.com/emacs-sf/events/ical', cookies=cj)
print(data)

<Response [200]>

import requests
import http.cookiejar as cookielib
import os
from urllib.request import urlopen
import icalendar
import datetime
from dateutil.relativedelta import *
import recurring_ical_events
import pytz
import re
import pypandoc
import subprocess
import sys
import csv
import requests

INCLUDE_MEETUPS = True
INCLUDE_OTHER_ICALS = True

cj = cookielib.MozillaCookieJar(os.path.expanduser('~/.cache/meetup-cookies.txt'))
cj.load()

#                 'Singapore': 'Emacs-SG',
other_meetups = {'EmacsNYC': 'New-York-Emacs-Meetup',
                 'EmacsSF (in person)': 'Emacs-SF',
                 'EmacsATX': 'EmacsATX',
                 'Pelotas, Brazil': 'Pelotas-Emacs-Meetup',
                 'Emacs FFM': 'emacs-ffm',
                 'London Emacs (in person)': 'London-Emacs-Hacking',
                 'London Emacs Lisp': 'London-Emacs-Lisp-Meetup',
                 # 'Finland': 'Finland-Emacs-User-Group',
                 'Delhi': 'Emacs-Delhi'}
other_icals = [ {'name': 'Atelier Emacs (in French)',
                 'source': 'https://mobilizon.fr/@communaute_emacs_francophone/feed/ics'},
                {'name': 'Emacs APAC',
                 'source': 'https://emacs-apac.gitlab.io/announcements/index.ics'},
                {'name': 'Emacs Paris',
                 'source': 'https://emacs-doctor.com/emacs-paris-meetups.ics',
                 'url':'https://emacs-doctor.com/'},
                {'name': 'M-x Research',
                'url': 'https://m-x-research.github.io/',
                'source': 'https://calendar.google.com/calendar/ical/o0tiadljp5dq7lkb51mnvnrh04%40group.calendar.google.com/public/basic.ics',
                'summary_re': r'^M-x Research - '},
                {'name': 'Emacs.si (in person)',
                'url': 'https://dogodki.kompot.si/@emacssi',
                'source': 'https://dogodki.kompot.si/@emacssi/feed/ics'}
               ]
# https://www.meetup.com/Emacs-SF/events/ical/',
all_timezones = []

def summarized_event(e, timezones):
  s = ''
  times = None
  if re.search('in person|v #živo|au Bib', e['SUMMARY']):
    m = re.search("Event time: (.*?)[<\n]", e.get('description') or '')
    if m:
      s += '- ' + m.group(1)
    elif e['DTSTART'].dt.tzinfo:
      tz = e['DTSTART'].dt.tzinfo
      times = [[e['DTSTART'].dt.astimezone(tz), tz.zone, e['DTSTART'].dt.astimezone(tz).utcoffset()]]
    else:
      s += '- see event for details'
  else:
    times = [[e['DTSTART'].dt.astimezone(pytz.timezone(t)), t, e['DTSTART'].dt.astimezone(pytz.timezone(t)).utcoffset()] for t in timezones]
    times.sort(key=lambda x: x[2])
  if times:
    for i, t in enumerate(times):
      if i == 0 or t[0].day != times[i - 1][0].day:
         if i > 0:
           s += " -- "
         s += t[0].strftime('%a %b %-d %H%M') + " " + t[1]
      else:
         s += " - " + t[0].strftime('%H%M') + " " + t[1]
  if e['LOCATION']:
    return "- %s %s %s" % (e['SUMMARY'], e['LOCATION'], s)
  else:
    return "- %s %s" % (e['SUMMARY'], s)

link = "https://calendar.google.com/calendar/ical/c_rkq3fc6u8k1nem23qegqc90l6c%40group.calendar.google.com/public/basic.ics"
f = urlopen(link)
cal = icalendar.Calendar.from_ical(f.read())
start_date = datetime.date(datetime.date.today().year, datetime.date.today().month, 1)

end_date = datetime.date(datetime.date.today().year + 1, datetime.date.today().month, 1)

for event in cal.walk():
  if event.name == 'VEVENT' and not '(ignore)' in event.name:
    event['UID'] = str(event['DTSTART'].dt.timestamp()) + '-' + event['UID']
    if event.get('location') == '':
      match = re.search(r'href="([^"]+)"', event.get('description'))
      if not match:
        match = re.search('^(http.*?)(&nbsp;|<br>|\n)', event.get('description'))
      if match:
        event['location'] = match.group(1)
      else:
        print(event.get('description'))
  if event.name == 'VTIMEZONE' and event['TZID'] not in all_timezones:
    all_timezones.append(event['TZID'])

def merge_cal(main_cal, name, url, start_date, end_date, info=None):
  # print('Requesting cal ', url)
  try:
    if 'meetup.com' in url:
      data = requests.get(url, cookies=cj)
    else:
      data = requests.get(url)
    meetup_cal = icalendar.Calendar.from_ical(data.content)
  except Exception as e:
    print("Error with url: %s" % url)
    print(repr(e))
    return
  # Copy the timezone components
  for tz in meetup_cal.walk():
    if tz.name == 'VTIMEZONE' and tz['TZID'] not in all_timezones:
      all_timezones.append(tz['TZID'])
      main_cal.add_component(tz)
  meetup_events = recurring_ical_events.of(meetup_cal).between(start_date, end_date)
  for event in meetup_events:
    low = event['SUMMARY'].lower()
    if info and 'summary_re' in info:
      event['SUMMARY'] = re.sub(info['summary_re'], '', event['SUMMARY'])
    if re.search(r'virtual|hybrid|online', low):
      name = re.sub(r' \(in person\)', '', name)
    event['SUMMARY'] = name + ': ' + event['SUMMARY']
    event['UID'] = str(event['DTSTART'].dt.timestamp()) + '-' + event['UID']
    event['LOCATION'] = ('URL' in event and event['URL']) or (info and ('url' in info) and info['url'])
    main_cal.add_component(event)

def merge_meetup_events(cal, start_date, end_date):
  global other_meetups
  for name, identifier in other_meetups.items():
    url = "https://www.meetup.com/%s/events/ical/" % (identifier)
    merge_cal(cal, name, url, start_date, end_date)

if INCLUDE_MEETUPS:
  merge_meetup_events(cal, start_date, end_date)
if INCLUDE_OTHER_ICALS:
  for item in other_icals:
    merge_cal(cal, item['name'], item['source'], start_date, end_date, item)

def convert_events_to_utc(cal):
  # Convert everything to UTC?
  utc = pytz.timezone('UTC')
  for event in cal.walk():
    if event.name == 'VEVENT' in event.name:
      for attr in ['DTSTART', 'DTEND', 'DTSTAMP', 'RECURRENCE-ID']:
        if attr in event:
          event[attr].dt = event[attr].dt.astimezone(utc)
          event[attr].params.clear()



f = open('emacs-calendar.ics', 'wb')

f.write(cal.to_ical())
f.close()
events = recurring_ical_events.of(cal).between(start_date, end_date)
events.sort(key=lambda x: x['DTSTART'].dt)
files = {}
org_date = "%Y-%m-%d %a %H:%M" # 2006-11-01 Wed 19:15
# Prepare string for copying
highlight_start = datetime.datetime.now(pytz.UTC)
highlight_end = datetime.datetime.now(pytz.UTC) + relativedelta(weeks=+6)
all_end = datetime.datetime.now(pytz.UTC) + relativedelta(weeks=+1)

org_timezones = [x for x in timezones]
for offset in range(-12, 14):
  if offset < 0:
    org_timezones.append('Etc/GMT+%s' % abs(offset))
  else:
    org_timezones.append('Etc/GMT-%s' % abs(offset))

for t in org_timezones:
  stub = "emacs-calendar-" + re.sub('^.*?/', '', t).lower()
  if 'gmt-' in stub:
    stub = re.sub(r'gmt-', 'gmt+', stub)
  elif 'gmt+' in stub:
    stub = re.sub(r'gmt\+', 'gmt-', stub)
  ical_args = ["ical2html", "-l", "-f", "Times are in " + t, "-z", t, datetime.datetime.today().strftime("%Y%m01"), "P8W", "emacs-calendar.ics"]
  output = subprocess.check_output(ical_args).decode(sys.stdout.encoding)
  changed = re.sub(r'<span class=summary>([^<]+)</span>\n<pre><b class=location>([^<]+)</b></pre>',
                   r'<span class="summary"><a href="\2">\1</a></span>', output)
  f = open(stub + '.html', 'wb')
  f.write(changed.encode(sys.stdout.encoding))
  f.close()
  files[t] = open(stub + '.org', "w")

with open('events.csv', 'w', newline='') as csvfile:
  fieldnames = ['DTSTART', 'DTEND', 'LOCATION', 'SUMMARY', 'TEXT', 'TZID']
  writer = csv.DictWriter(csvfile, fieldnames=fieldnames, extrasaction='ignore')
  writer.writeheader()
  for e in events:
    writer.writerow({**e,
                     'DTSTART': e['DTSTART'].dt.isoformat(),
                     'DTEND': e['DTEND'].dt.isoformat(),
                     'TEXT': summarized_event(e, timezones)
                     })


for e in events:
  if 'DESCRIPTION' not in e:
    e['DESCRIPTION'] = ''
  desc = pypandoc.convert_text(e['DESCRIPTION'], 'org', format='html').replace('\\\\', '')
  utc = datetime.datetime.fromtimestamp(e['DTSTART'].dt.timestamp(), datetime.UTC)
  if utc >= highlight_start and utc <= highlight_end:
    if 'Mastering Emacs' in e['DESCRIPTION'] and utc > all_end:
      pass # do nothing
    else:
      print(summarized_event(e, timezones))
  for t in org_timezones:
    zone = pytz.timezone(t)
    start = e['DTSTART'].dt.astimezone(zone)
    end = e['DTEND'].dt.astimezone(zone)
    files[t].write("""* %s
:PROPERTIES:
:LOCATION: %s
:END:
<%s>--<%s>

%s

""" % (e['SUMMARY'], e['LOCATION'], start.strftime(org_date), end.strftime(org_date), desc))
  • Emacs Berlin (virtual, in English) https://emacs-berlin.org/ Wed Dec 25 0930 America/Vancouver - 1130 America/Chicago - 1230 America/Toronto - 1730 Etc/GMT - 1830 Europe/Berlin - 2300 Asia/Kolkata – Thu Dec 26 0130 Asia/Singapore
  • Emacs APAC (virtual) https://emacs-apac.gitlab.io/ Sat Dec 28 0030 America/Vancouver - 0230 America/Chicago - 0330 America/Toronto - 0830 Etc/GMT - 0930 Europe/Berlin - 1400 Asia/Kolkata - 1630 Asia/Singapore
  • M-x Research: TBA https://m-x-research.github.io/ Fri Jan 3 0800 America/Vancouver - 1000 America/Chicago - 1100 America/Toronto - 1600 Etc/GMT - 1700 Europe/Berlin - 2130 Asia/Kolkata – Sat Jan 4 0000 Asia/Singapore
  • Atelier Emacs Montpellier (in person) https://lebib.org/date/atelier-emacs Fri Jan 10 1800 Europe/Paris
  • M-x Research: TBA https://m-x-research.github.io/ Wed Jan 15 0800 America/Vancouver - 1000 America/Chicago - 1100 America/Toronto - 1600 Etc/GMT - 1700 Europe/Berlin - 2130 Asia/Kolkata – Thu Jan 16 0000 Asia/Singapore
  • Emacs APAC (virtual) https://emacs-apac.gitlab.io/ Sat Jan 25 0030 America/Vancouver - 0230 America/Chicago - 0330 America/Toronto - 0830 Etc/GMT - 0930 Europe/Berlin - 1400 Asia/Kolkata - 1630 Asia/Singapore
  • Emacs Berlin (virtual, in English) https://emacs-berlin.org/ Wed Jan 29 0930 America/Vancouver - 1130 America/Chicago - 1230 America/Toronto - 1730 Etc/GMT - 1830 Europe/Berlin - 2300 Asia/Kolkata – Thu Jan 30 0130 Asia/Singapore

Sync

rsync -avze ssh ./ web:/var/www/emacslife.com/calendar/ --exclude=.git

Convert timezones

(defun my-summarize-times (time timezones)
  (let (prev-day)
    (mapconcat
     (lambda (tz)
       (let ((cur-day (format-time-string "%a %b %-e" time tz))
             (cur-time (format-time-string "%H%MH %Z" time tz)))
         (if (equal prev-day cur-day)
             cur-time
           (setq prev-day cur-day)
           (concat cur-day " " cur-time))))
     timezones
     " / ")))

(defun my-org-summarize-event-in-timezones (timezones)
  (interactive (list (or timezones my-timezones)))
  (save-window-excursion
    (save-excursion
      (when (derived-mode-p 'org-agenda-mode) (org-agenda-goto))
      (when (re-search-forward org-element--timestamp-regexp nil (save-excursion (org-end-of-subtree) (point)))
        (goto-char (match-beginning 0))
        (let* ((times (org-element-timestamp-parser))
               (start-time (org-timestamp-to-time (org-timestamp-split-range times)))
               (msg (format "%s - %s - %s"
                            (org-get-heading t t t t)
                            (my-summarize-times start-time timezones)
                            ;; (cond
                            ;;  ((time-less-p (org-timestamp-to-time (org-timestamp-split-range times t)) (current-time))
                            ;;   "(past)")
                            ;;  ((time-less-p (current-time) start-time)
                            ;;   (concat "in " (format-seconds "%D %H %M%Z" (time-subtract start-time (current-time)))))
                            ;;  (t "(ongoing)"))
                            (org-entry-get (point) "LOCATION"))))
          (if (called-interactively-p 'any)
              (progn
                (message "%s" msg)
                (kill-new msg))
            msg))))))

my-org-summarize-event-in-timezones

my-org-summarize-event-in-timezones

Summarize upcoming ones

(defun my-summarize-upcoming-events (limit timezones)
  (interactive (list (org-read-date nil t) my-timezones))
  (let (result)
    (with-current-buffer (find-file-noselect "~/sync/emacs-calendar/emacs-calendar-toronto.org")
      (goto-char (point-min))
      (org-map-entries
       (lambda ()
         (save-excursion
           (when (re-search-forward org-element--timestamp-regexp nil (save-excursion (org-end-of-subtree) (point)))
             (goto-char (match-beginning 0))
             (let ((time (org-timestamp-to-time (org-timestamp-split-range (org-element-timestamp-parser)))))
               (when (and (time-less-p (current-time) time)
                          (time-less-p time limit))
                 (setq result (cons
                               (cons time
                                     (my-org-summarize-event-in-timezones timezones)) result)))))))))
    (setq result (mapconcat
                  (lambda (o) (format "- %s" (cdr  o)))
                  (sort result (lambda (a b)
                                 (time-less-p (car a) (car b))
                                 ))
                  "\n"))
    (if (interactive-p)
        (insert result)
      result)))

my-summarize-upcoming-events

my-summarize-upcoming-events

Announcing Emacs events

(defun my-announce-on-irc (channels message host port)
  (with-temp-buffer
    (insert "PASS " erc-password "\n"
            "USER " erc-nick "\n"
            "NICK " erc-nick "\n"
            (mapconcat (lambda (o)
                         (format "PRIVMSG %s :%s\n" o message))
                       channels "")
            "QUIT\n")
    (call-process-region (point-min) (point-max) "ncat" nil 0 nil
                         "--ssl" host (number-to-string port))))

(defun my-announce-on-irc-and-twitter (time channels message host port)
  (when (< (time-to-seconds (subtract-time (current-time) time)) (* 5 60))
    (shell-command-to-string (format
                              (if my-laptop-p
                                  "zsh -l -c 'rvm use 2.4.1; t update %s'"
                                "bash -l -c 't update %s'")
                              (shell-quote-argument message)))
    (my-announce-on-irc channels message host port)))

(defun my-schedule-announcement (time message)
  (interactive (list (org-read-date t t) (read-string "Message: ")))
  (run-at-time time nil #'my-announce-on-irc-and-twitter time '("#emacs" "#emacsconf") message erc-server erc-port))

(defun my-org-table-as-alist (table)
  "Convert TABLE to an alist. Remember to set :colnames no."
  (let ((headers (seq-map 'intern (car table))))
    (cl-loop for x in (cdr table) collect (-zip headers x))))

(defun my-schedule-announcements-for-upcoming-emacs-meetups ()
  (interactive)
  (cancel-function-timers #'my-announce-on-irc-and-twitter)
  (let ((events (my-org-table-as-alist (pcsv-parse-file "events.csv")))
        (now (current-time))
        (before-limit (time-add (current-time) (seconds-to-time (* 14 24 60 60)))))
    (mapc (lambda (o)
            (unless (string-match "in person\\|workshop" (alist-get 'SUMMARY o))
              (let* ((start-time (encode-time (parse-time-string (alist-get 'DTSTART o))))
                     (fifteen-minutes-before (seconds-to-time (- (time-to-seconds start-time) (* 15 60)))))
                (when (and (time-less-p now fifteen-minutes-before)
                           (time-less-p fifteen-minutes-before before-limit))
                  (my-schedule-announcement fifteen-minutes-before
                                            (format "In 15 minutes: %s - see %s for details"
                                                    (alist-get 'SUMMARY o)
                                                    (alist-get 'LOCATION o))))
                (when (and (time-less-p now start-time)
                           (time-less-p start-time before-limit))
                  (my-schedule-announcement start-time
                                            (format "Starting now: %s - see %s for details"
                                                    (alist-get 'SUMMARY o)
                                                    (alist-get 'LOCATION o)))))))
          events)))

my-schedule-announcements-for-upcoming-emacs-meetups

Update EmacsWiki

(use-package oddmuse
:load-path "~/vendor/oddmuse-el"
:if my-laptop-p
:ensure nil
:config (oddmuse-mode-initialize)
:hook (oddmuse-mode-hook .
          (lambda ()
            (unless (string-match "question" oddmuse-post)
              (when (string-match "EmacsWiki" oddmuse-wiki)
                (setq oddmuse-post (concat "uihnscuskc=1;" oddmuse-post)))
              (when (string-match "OddmuseWiki" oddmuse-wiki)
                (setq oddmuse-post (concat "ham=1;" oddmuse-post)))))))

((lambda nil (unless (string-match question oddmuse-post) (when (string-match EmacsWiki oddmuse-wiki) (setq oddmuse-post (concat uihnscuskc=1; oddmuse-post))) (when (string-match OddmuseWiki oddmuse-wiki) (setq oddmuse-post (concat ham=1; oddmuse-post))))) (lambda nil (if (string-match question oddmuse-post) nil (if (string-match EmacsWiki oddmuse-wiki) (progn (setq oddmuse-post (concat uihnscuskc=1; oddmuse-post)))) (if (string-match OddmuseWiki oddmuse-wiki) (progn (setq oddmuse-post (concat ham=1; oddmuse-post)))))))

nil

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

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

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

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

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

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

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

The code makes entries that look like this:

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

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

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

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

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

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

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

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

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

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

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

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

Back to top | E-mail me