;;; emacsconf.el --- Core functions and variables for EmacsConf -*- lexical-binding: t; -*- ;; Copyright (C) 2021 Sacha Chua ;; Author: Sacha Chua ;; Keywords: multimedia ;; This program is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see . ;;; Commentary: ;; ;;; Code: (defgroup emacsconf nil "EmacsConf" :group 'multimedia) (defcustom emacsconf-id "emacsconf" "ID of conference" :group 'emacsconf :type 'string) (defcustom emacsconf-name "EmacsConf" "Name of conference" :group 'emacsconf :type 'string) (defcustom emacsconf-year "2023" "Conference year. String for easy inclusion." :group 'emacsconf :type 'string) (defcustom emacsconf-cfp-deadline "2023-09-14" "Deadline for proposals." :group 'emacsconf :type 'string) (defcustom emacsconf-date "2023-12-03" "Starting date of EmacsConf." :group 'emacsconf :type 'string) (defcustom emacsconf-video-target-date "2023-11-04" "Target date for receiving talk videos from the speakers." :group 'emacsconf :type 'string) (defcustom emacsconf-schedule-announcement-date "2023-10-25" "Date for publishing the schedule." :group 'emacsconf :type 'string) (defcustom emacsconf-directory "~/vendor/emacsconf-wiki" "Directory where the wiki files are." :group 'emacsconf :type 'directory) (defcustom emacsconf-ansible-directory nil "Directory where the Ansible repository is." :group 'emacsconf :type 'directory) (defcustom emacsconf-timezone "US/Eastern" "Main timezone." :group 'emacsconf :type 'string) (defcustom emacsconf-timezones '("US/Eastern" "US/Central" "US/Mountain" "US/Pacific" "UTC" "Europe/Paris" "Europe/Athens" "Asia/Kolkata" "Asia/Singapore" "Asia/Tokyo") "List of timezones." :group 'emacsconf :type '(repeat string)) (defcustom emacsconf-base-url "https://emacsconf.org/" "Includes trailing slash" :group 'emacsconf :type 'string) (defcustom emacsconf-publishing-phase 'program "Controls what information to include. 'program - don't include times 'schedule - include times; use this leading up to the emacsconference 'resources - after EmacsConf, don't need status" :group 'emacsconf :type '(choice (const :tag "CFP: include invitation" cfp) (const :tag "Program: Don't include times" program) (const :tag "Schedule: Include detailed times" schedule) (const :tag "Conference: Show IRC and watching info" conference) (const :tag "Resources: Don't include status" resources))) (defcustom emacsconf-org-file nil "Path to the Org file with emacsconference information." :type 'file :group 'emacsconf) (defcustom emacsconf-upcoming-file nil "Path to the Org file with upcoming talks." :type 'file :group 'emacsconf) (defcustom emacsconf-emergency-contact nil "Emergency contact information." :type 'string :group 'emacsconf) (defcustom emacsconf-review-days 7 "Number of days for review for early acceptance." :type 'natnum :group 'emacsconf) (defvar emacsconf-stream-base "https://live0.emacsconf.org/") (defvar emacsconf-chat-base "https://chat.emacsconf.org/") (defvar emacsconf-backstage-dir "/ssh:orga@media.emacsconf.org:/var/www/media.emacsconf.org/2022/backstage") (defvar emacsconf-upload-dir "/ssh:orga@media.emacsconf.org:/srv/upload") (defvar emacsconf-res-dir (format "/ssh:orga@res.emacsconf.org:/data/emacsconf/%s" emacsconf-year)) (defvar emacsconf-media-extensions '("webm" "mkv" "mp4" "webm" "avi" "ts" "ogv" "wav" "ogg" "mp3")) (defvar emacsconf-ftp-upload-dir "/ssh:orga@media.emacsconf.org:/srv/ftp/anon/upload-here") (defvar emacsconf-backstage-user "emacsconf") (defvar emacsconf-backstage-password nil "Password for backstage area") (defvar emacsconf-notebook (expand-file-name "index.org" (expand-file-name "organizers-notebook" (expand-file-name emacsconf-year emacsconf-directory)))) (defun emacsconf-prep-agenda () (interactive) (let* ((org-agenda-custom-commands `(("a" "Agenda" ((tags-todo "-PRIORITY=\"C\"-SCHEDULED={.}-nextyear" ((org-agenda-files (list ,emacsconf-notebook)) (org-agenda-sorting-strategy '(priority-down effort-up)))) (agenda "" ((org-agenda-files (list ,emacsconf-notebook)) (org-agenda-span 7))) ))))) (org-agenda nil "a"))) (defun emacsconf-talk-agenda ()) (defun emacsconf-notebook-goto-custom-id (id) (interactive "MID: ") (find-file-other-window emacsconf-notebook) (goto-char (org-find-property "CUSTOM_ID" id))) (defun emacsconf-ftp-upload-dired () (interactive) (dired emacsconf-ftp-upload-dir "-tl")) (defun emacsconf-upload-dired () (interactive) (dired emacsconf-upload-dir "-tl")) (defun emacsconf-backstage-dired () (interactive) (dired emacsconf-backstage-dir "-tl")) (defun emacsconf-res-dired () (interactive) (dired emacsconf-res-dir "-tl")) (defun emacsconf-media-dired () (interactive) (dired emacsconf-public-media-directory "-tl")) (defun emacsconf-cache-dired () (interactive) (dired emacsconf-cache-dir "-tl")) (defun emacsconf-slugify (s) (replace-regexp-in-string " +" "-" (replace-regexp-in-string "[^a-z0-9 ]" "" (downcase s)))) (defun emacsconf-video-slug (talk) (concat "emacsconf-" emacsconf-year "-" (plist-get talk :slug) "--" (emacsconf-slugify (plist-get talk :title)) (if (plist-get talk :speakers) (concat"--" (emacsconf-slugify (plist-get talk :speakers))) ""))) (defun emacsconf-set-video-slug-if-needed (o) (interactive (list (emacsconf-complete-talk-info))) (unless (plist-get o :video-slug) (let ((video-slug (read-string "Set video slug: " (emacsconf-video-slug o)))) (save-window-excursion (emacsconf-with-talk-heading (plist-get o :slug) (org-entry-put (point) "VIDEO_SLUG" video-slug))) (plist-put o :video-slug video-slug))) (plist-get o :video-slug)) (defun emacsconf-set-video-slugs () (interactive) (org-map-entries (lambda () (org-entry-put (point) "VIDEO_SLUG" (emacsconf-video-slug (emacsconf-get-talk-info-for-subtree)))) "SLUG={.}-VIDEO_SLUG={.}")) (defun emacsconf-upload-to-backstage () (interactive) (mapc (lambda (file) (copy-file file (expand-file-name (file-name-nondirectory file) emacsconf-backstage-dir) t) (when (and emacsconf-cache-dir (not (string= (expand-file-name (file-name-nondirectory file) default-directory) (expand-file-name (file-name-nondirectory file) emacsconf-cache-dir)))) (copy-file file (expand-file-name (file-name-nondirectory file) emacsconf-cache-dir) t))) (or (dired-get-marked-files) (list (buffer-file-name))))) (defun emacsconf-get-srv2-and-upload-to-backstage (talk) (interactive (list (emacsconf-complete-talk-info (seq-filter (lambda (o) (plist-get o :youtube-url)) (emacsconf-get-talk-info))))) (let ((filename (make-temp-file nil nil "srv2")) (buf (get-buffer-create "*test*"))) (when (plist-get talk :youtube-url) (call-process "yt-dlp" nil buf t "--write-sub" "--write-auto-sub" "--no-warnings" "--sub-lang" "en" "--skip-download" "--sub-format" "srv2" "-o" filename (plist-get talk :youtube-url)) (with-current-buffer (find-file-noselect (concat filename ".en.srv2")) (emacsconf-upload-to-backstage-and-rename talk "main--srt"))))) (defun emacsconf-upload-to-backstage-and-rename (talk &optional filename) (interactive (list (emacsconf-complete-talk-info))) (mapc (lambda (file) (let ((new-file (or filename (read-string (format "Filename (%s): " (file-name-base file)))))) (copy-file file (expand-file-name (concat (plist-get talk :video-slug) (if (string= new-file "") "" (concat "--" new-file)) "." (file-name-extension file)) emacsconf-backstage-dir) t))) (or (dired-get-marked-files) (list (buffer-file-name))))) (defun emacsconf-upload-copy-from-json (talk key filename) (interactive (let-alist (json-parse-string (buffer-string) :object-type 'alist) (list (emacsconf-complete-talk-info) .metadata.key (read-string (format "Filename: "))))) (let ((new-filename (concat (plist-get talk :video-slug) (if (string= filename "") filename (concat "--" filename)) "." (let-alist (json-parse-string (buffer-string) :object-type 'alist) (file-name-extension .metadata.name))))) (copy-file key (expand-file-name new-filename emacsconf-backstage-dir) t) ;; (copy-file key (expand-file-name new-filename emacsconf-cache-dir)) ;; (unless (file-directory-p (expand-file-name (plist-get talk :slug) emacsconf-res-dir)) ;; (make-directory (expand-file-name (plist-get talk :slug) emacsconf-res-dir))) ;; (copy-file (expand-file-name new-filename emacsconf-cache-dir) ;; (expand-file-name new-filename (expand-file-name (plist-get talk :slug) emacsconf-res-dir))) )) (defcustom emacsconf-download-directory "~/Downloads" "Directory to check for downloaded files." :type 'directory :group 'emacsconf) (defun emacsconf-latest-file (path &optional filter) "Return the newest file in PATH. Optionally filter by FILTER." (car (sort (seq-remove #'file-directory-p (directory-files path 'full filter t)) #'file-newer-than-file-p))) (defun emacsconf-find-captions-from-slug (search) "Edit captions file." (interactive (list (emacsconf-complete-talk))) (emacsconf-with-talk-heading search (emacsconf-subed-find-captions))) (defun emacsconf-edit-wiki-page (search) "Open the wiki page for the talk matching SEARCH." (interactive (list (emacsconf-complete-talk))) (setq search (if (stringp search) (emacsconf-get-slug-from-string search) (plist-get search :slug))) (find-file (expand-file-name (concat search ".md") (expand-file-name "talks" (expand-file-name emacsconf-year emacsconf-directory))))) (defun emacsconf-find-caption-directives-from-slug (search) (interactive (list (emacsconf-complete-talk))) (setq search (emacsconf-get-slug-from-string search)) (find-file (expand-file-name (concat search ".md") (expand-file-name "captions" (expand-file-name emacsconf-year emacsconf-directory))))) (defun emacsconf-browse-wiki-page (search) (interactive (list (emacsconf-complete-talk))) (setq search (emacsconf-get-slug-from-string search)) (browse-url (concat emacsconf-base-url emacsconf-year "/talks/" search "/"))) (defun emacsconf-set-property-from-slug (search prop value) (interactive (list (emacsconf-complete-talk) nil nil)) (save-window-excursion (emacsconf-with-talk-heading search (setq prop (or prop (org-read-property-name))) (setq value (or value (org-read-property-value prop))) (org-entry-put (point) prop value)))) (defun emacsconf-complete-slug () (emacsconf-get-slug-from-string (emacsconf-complete-talk))) (defun emacsconf-export-slug (link description format _) (let* ((path (format "https://emacsconf.org/%s/talks/%s" emacsconf-year link)) (talk (emacsconf-resolve-talk link)) (desc (or description link))) (pcase format (`html (format "%s" path (plist-get talk :title) desc)) (`ascii (format "%s (%s)" desc path)) (`md (format "[%s](%s \"%s\")" desc path (plist-get talk :title))) (_ path)))) (with-eval-after-load 'org (org-link-set-parameters "emacsconf" :follow #'emacsconf-go-to-talk :complete (lambda () (concat "emacsconf:" (emacsconf-complete-slug))) :export #'emacsconf-export-slug)) (defvar emacsconf-complete-talk-cache nil) ;; (setq emacsconf-complete-talk-cache (mapcar (lambda (o) (string-join (delq nil (mapcar (lambda (f) (plist-get o f)) '(:slug :title :speakers :irc))) " - ")) (emacsconf-get-talk-info))) (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) emacsconf-complete-talk-cache (mapcar (lambda (o) (string-join (delq nil (mapcar (lambda (f) (plist-get o f)) '(:slug :title :speakers :irc))) " - ")) (or info (emacsconf-get-talk-info)))))) (completing-read "Talk: " (lambda (string predicate action) (if (eq action 'metadata) '(metadata (category . emacsconf)) (complete-with-action action choices string predicate)))))) (defun emacsconf-complete-talk-info (&optional info) (emacsconf-search-talk-info (emacsconf-complete-talk info) info)) (defun emacsconf-get-slug-from-string (search) (when (listp search) (setq search (car search))) (cond ((and search (stringp search) (string-match "\\(.*?\\) - " search)) (match-string 1 search)) ((and (stringp search) (string-match (concat emacsconf-id "-" emacsconf-year "-\\(.+?\\)--") search)) (match-string 1 search)) (t search))) (defun emacsconf-go-to-talk (search) "Jump to the talk heading matching SEARCH." (interactive (list (emacsconf-complete-talk))) (find-file emacsconf-org-file) (widen) (cond ((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)))) (t (goto-char (catch 'found (org-map-entries (lambda () (when (string-match search (cons (concat (org-entry-get (point) "SLUG") " - " (org-entry-get (point) "ITEM") " - " (org-entry-get (point) "NAME") " - " (org-entry-get (point) "EMAIL")) (point))) (throw 'found (point)))) "SLUG={.}"))))) (org-reveal)) (defmacro emacsconf-for-each-talk (&rest body) (declare (indent 0) (debug t)) `(org-map-entries (lambda () ,@body) "SLUG={.}")) (defmacro emacsconf-with-talk-heading (search &rest body) (declare (indent 1) (debug t)) `(progn (emacsconf-go-to-talk ,search) ,@body)) (defvar emacsconf-status-types '(("WAITING_FOR_PREREC" . "Waiting for video from speaker") ("TO_PROCESS" . "Processing uploaded video") ("PROCESSING" . "Processing uploaded video") ("TO_AUTOCAP" . "Processing uploaded video") ("TO_ASSIGN" . "Waiting for a caption volunteer") ("TO_CAPTION" . "Processing uploaded video") ("TO_STREAM" . "Ready to stream") ("PLAYING" . "Now playing on the conference livestream") ("CLOSED_Q" . "Q&A starting (not yet open for joining)") ("OPEN_Q" . "Q&A open for participation") ("UNSTREAMED_Q" . "Q&A continues off the stream") ("TO_ARCHIVE" . "Q&A finished, IRC and pad will be archived on this page") ("TO_EXTRACT" . "Q&A to be extracted from the room recordings") ("DONE" . "All done") ("CANCELLED" . "Sorry, this talk has been cancelled"))) (defun emacsconf-get-talk-categories (o) (org-narrow-to-subtree) (let (list) (while (re-search-forward "Category[^ \t\n]+" nil t) (setq list (cons (match-string-no-properties 0) list))) (plist-put o :categories (reverse list)))) (defun emacsconf-get-talk-info-from-properties (o) (let ((heading (org-heading-components)) (field-props '( ;; Initial creation (:track "TRACK") (:slug "SLUG") (:speakers "NAME") (:speakers-short "NAME_SHORT") (:email "EMAIL") (:public-email "PUBLIC_EMAIL") (:emergency "EMERGENCY") (:buffer "BUFFER") (:min-time "MIN_TIME") (:max-time "MAX_TIME") (:availability "AVAILABILITY") (:q-and-a "Q_AND_A") (:timezone "TIMEZONE") (:irc "IRC") (:pronunciation "PRONUNCIATION") (:pronouns "PRONOUNS") (:date-submitted "DATE_SUBMITTED") (:date-to-notify "DATE_TO_NOTIFY") (:email-notes "EMAIL_NOTES") ;; Scheduling (:scheduled "SCHEDULED") (:time "TIME") (:fixed-time "FIXED_TIME") ;; Coordination (:prerec-info "PREREC_INFO") ;; Prep (:bbb-room "ROOM") ;; Processing (:video-slug "VIDEO_SLUG") (:video-file "VIDEO_FILE") (:video-time "VIDEO_TIME") (:video-file-size "VIDEO_FILE_SIZE") (:video-duration "VIDEO_DURATION") (:stream-files "STREAM_FILES") (:youtube-url "YOUTUBE_URL") (:toobnix-url "TOOBNIX_URL") (:intro-time "INTRO_TIME") ;; Captioning (:captioner "CAPTIONER") (:caption-note "CAPTION_NOTE") (:captions-edited "CAPTIONS_EDITED") ;; Conference (:check-in "CHECK_IN") (:public "PUBLIC") (:intro-note "INTRO_NOTE") (:hyperlist-note "HYPERLIST_NOTE") ;; Extraction (:qa-youtube "QA_YOUTUBE") (:qa-toobnix "QA_TOOBNIX") (:bbb-playback "BBB_PLAYBACK") ;; Old (:alternate-apac "ALTERNATE_APAC") (:extra-live-time "EXTRA_LIVE_TIME") (:present "PRESENT") (:talk-id "TALK_ID") ; use slug instead (:qa-public "QA_PUBLIC") ; now tracked by the OPEN_Q and UNSTREAMED_Q status (:uuid "UUID") ; Pentabarf export ))) (apply 'append o (list :point (point) :title (org-entry-get (point) "ITEM") :year emacsconf-year :conf-year emacsconf-year :type (if (org-entry-get (point) "SLUG") 'talk 'headline) :status (elt heading 2) :level (car heading) :url (concat emacsconf-year "/talks/" (org-entry-get (point) "SLUG")) :schedule-group (org-entry-get-with-inheritance "SCHEDULE_GROUP") :wiki-file-path (expand-file-name (concat (org-entry-get (point) "SLUG") ".md") (expand-file-name "captions" (expand-file-name emacsconf-year emacsconf-directory))) :start-time (when (org-entry-get (point) "SCHEDULED") (date-to-time (concat (format-time-string "%Y-%m-%dT%H:%M:%S" (org-timestamp-to-time (org-timestamp-split-range (org-timestamp-from-string (org-entry-get (point) "SCHEDULED"))))) emacsconf-timezone-offset))) :end-time (when (org-entry-get (point) "SCHEDULED") (date-to-time (concat (format-time-string "%Y-%m-%dT%H:%M:%S" (org-timestamp-to-time (org-timestamp-split-range (org-timestamp-from-string (org-entry-get (point) "SCHEDULED")) t))) emacsconf-timezone-offset)))) (mapcar (lambda (prop) (list (or (car (rassoc (list (car prop)) field-props)) (intern (concat ":" (replace-regexp-in-string "_" "-" (downcase (car prop)))))) (cdr prop))) (org-entry-properties))))) (defvar emacsconf-abstract-heading-regexp "abstract" "Regexp matching heading for talk abstract.") (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)))) (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") "")))) (defun emacsconf-get-talk-comments-from-subtree (o) (setq o (plist-put o :comments (apply 'append (org-map-entries (lambda () (org-end-of-meta-data) (mapcar (lambda (item) (string-trim (replace-regexp-in-string " *\n *" " " (buffer-substring-no-properties (+ (car item) (length (elt item 2))) (min (point-max) (elt item 6)))))) (org-element-property :structure (org-element-at-point))) ) "ITEM={comments}" 'tree)))) (plist-put o :acceptance-comment (car (delq nil (mapcar (lambda (o) (when (string-match "For the [^ ]+ speakers?: " o) (replace-match "" t t o))) (plist-get o :comments)))))) (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))) (defun emacsconf-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 emacsconf-add-timezone-conversions (o) (plist-put o :scheduled-tzs (concat (org-timestamp-format (plist-get o :start-time) "%a %b %e %l:%M%p Toronto time (") (emacsconf-summarize-times (plist-get o :start-time) emacsconf-timezones) ")"))) (defun emacsconf-get-abstract-from-wiki (o) (plist-put o :markdown (emacsconf-talk-markdown-from-wiki (plist-get o :slug)))) (defun emacsconf-add-talk-status (o) (plist-put o :status-label (or (assoc-default (plist-get o :status) emacsconf-status-types 'string= "") (plist-get o :status))) (if (or (member (plist-get o :status) (split-string "PLAYING CLOSED_Q OPEN_Q UNSTREAMED_Q TO_ARCHIVE TO_EXTRACT TO_FOLLOW_UP DONE")) (time-less-p (plist-get o :start-time) (current-time))) (plist-put o :public t)) o) (defun emacsconf-talk-live-p (talk) "Return non-nil if TALK is ready to be published." (plist-get talk :public)) (defun emacsconf-test-public-states () (let ((states (split-string (replace-regexp-in-string "(.*?)" "" "TODO(t) TO_REVIEW TO_ACCEPT TO_CONFIRM WAITING_FOR_PREREC(w) TO_PROCESS(p) TO_AUTOCAP(y) TO_ASSIGN(a) TO_CAPTION(c) TO_STREAM(s) PLAYING(m) CLOSED_Q(q) OPEN_Q(o) UNSTREAMED_Q(u) TO_ARCHIVE TO_EXTRACT TO_FOLLOW_UP")))) (mapc (lambda (state) (assert (null (plist-get (emacsconf-add-talk-status (list :status state)) :public)))) (subseq states 0 (seq-position states "PLAYING"))) (mapc (lambda (state) (assert (plist-get (emacsconf-add-talk-status (list :status state)) :public))) (subseq states (seq-position states "PLAYING"))))) ;; https://stackoverflow.com/questions/55855621/org-mode-getting-logbook-notes (defun emacsconf-get-logbook-notes () (save-excursion (unless (org-at-heading-p) (outline-previous-heading)) (when (re-search-forward ":LOGBOOK:" (save-excursion (outline-next-heading) (point)) t) (let* ((elt (org-element-property-drawer-parser nil)) (beg (org-element-property :contents-begin elt)) (end (org-element-property :contents-end elt))) (buffer-substring-no-properties beg end))))) (defun emacsconf-get-talk-logbook (o) (plist-put o :logbook (emacsconf-get-logbook-notes))) (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.") (defun emacsconf-add-speakers-with-pronouns (o) (plist-put o :speakers-with-pronouns (cond ((null (plist-get o :pronouns)) (plist-get o :speakers)) ((string= (plist-get o :pronouns) "nil") (plist-get o :speakers)) ((string-match "(" (plist-get o :pronouns)) (plist-get o :pronouns)) (t (format "%s (%s)" (plist-get o :speakers) (plist-get o :pronouns))))) o) (defun emacsconf-add-checkin-time (o) (unless (or (null (plist-get o :status)) (null (plist-get o :email)) (string= (plist-get o :status) "CANCELLED") (string-match "after" (or (plist-get o :q-and-a) ""))) (if (null (plist-get o :video-file)) (progn (plist-put o :live-time (plist-get o :start-time)) (plist-put o :qa-time (plist-get o :live-time)) (plist-put o :checkin-label "1 hour before the scheduled start of your talk, since you don't have a pre-recorded video") (plist-put o :checkin-time (seconds-to-time (time-subtract (plist-get o :start-time) (seconds-to-time 3600))))) (plist-put o :live-time (seconds-to-time (+ (time-to-seconds (plist-get o :start-time)) (* 60 (string-to-number (or (plist-get o :video-time) "0"))) (* 60 (string-to-number (or (plist-get o :intro-time) "0"))) ))) (unless (string-match "none\\|after" (or (plist-get o :q-and-a) "none")) (plist-put o :qa-time (plist-get o :live-time))) (plist-put o :checkin-label "30 minutes before the scheduled start of your Q&A, since you have a pre-recorded video") (when (plist-get o :video-time) (plist-put o :checkin-time (seconds-to-time (time-subtract (time-add (plist-get o :start-time) (seconds-to-time (* 60 (string-to-number (plist-get o :video-time))))) (seconds-to-time (/ 3600 2)))))))) o) (require 'emacsconf-pad) (defun emacsconf-add-live-info (o) (plist-put o :absolute-url (concat emacsconf-base-url (plist-get o :url))) (plist-put o :in-between-url (format "%s%s/in-between/%s.png" emacsconf-media-base-url emacsconf-year (plist-get o :slug))) (plist-put o :qa-slide-url (format "%s%s/in-between/%s.png" emacsconf-media-base-url emacsconf-year (plist-get o :slug))) (plist-put o :intro-expanded (emacsconf-pad-expand-intro o)) (let ((track (seq-find (lambda (track) (string= (plist-get o :track) (plist-get track :name))) emacsconf-tracks))) (when track (plist-put o :watch-url (concat emacsconf-base-url emacsconf-year "/watch/" (plist-get track :id))) (plist-put o :webchat-url (concat emacsconf-chat-base "?join=emacsconf" (if (eq emacsconf-publishing-phase 'conference) (concat "," (replace-regexp-in-string "#" "" (plist-get track :channel))) ""))) (plist-put o :track-id (plist-get track :id))) (plist-put o :channel (if (eq emacsconf-publishing-phase 'conference) (plist-get track :channel) "emacsconf")) (plist-put o :bbb-backstage (concat emacsconf-media-base-url emacsconf-year "/backstage/current/room/" (plist-get o :slug))) (cond ((string= (or (plist-get o :q-and-a) "") "") (plist-put o :qa-info "none") (plist-put o :qa-link "none")) ((string-match "live" (plist-get o :q-and-a)) (plist-put o :bbb-redirect (format "https://emacsconf.org/current/%s/room/" (plist-get o :slug))) (plist-put o :qa-info (plist-get o :bbb-redirect)) (plist-put o :qa-link (format "BBB" (plist-get o :bbb-redirect)))) ((string-match "IRC" (plist-get o :q-and-a)) (plist-put o :qa-info (concat "#" (plist-get o :channel) (emacsconf-surround ", speaker nick: " (plist-get o :irc) ""))) (plist-put o :qa-link (format "%s" (plist-get o :webchat-url) (plist-get o :qa-info)))) ((string-match "Mumble" (plist-get o :q-and-a)) (plist-put o :qa-info "Moderated via Mumble, ask questions via pad or IRC") (plist-put o :qa-link (format "%s" (plist-get o :webchat-url) (plist-get o :qa-info)))) ((string-match "pad" (plist-get o :q-and-a)) (plist-put o :qa-info "Etherpad") (plist-put o :qa-link (if (plist-get o :pad-url) (format "%s" (plist-get o :pad-url) (plist-get o :qa-info))))) (t (plist-put o :qa-info "none") (plist-put o :qa-link "none"))) (plist-put o :pad-url (format "https://pad.emacsconf.org/%s-%s" emacsconf-year (plist-get o :slug))) (plist-put o :recorded-intro (let ((filename (expand-file-name (concat (plist-get o :slug) ".webm") (expand-file-name "intros" emacsconf-stream-asset-dir)))) (and (file-exists-p filename) filename))) o)) (defun emacsconf-search-talk-info (search &optional info) (setq info (or info (emacsconf-get-talk-info))) (or (seq-find (lambda (o) (string= (plist-get o :slug) (emacsconf-get-slug-from-string search))) info) (seq-find (lambda (o) (string-match search (format "%s - %s - %s - %s" (plist-get o :slug) (plist-get o :title) (plist-get o :speakers) (plist-get o :email)))) info))) (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)) (defun emacsconf-sort-by-scheduled (a b) (let ((time-a (plist-get a :start-time)) (time-b (plist-get b :start-time))) (cond ((null time-b) t) ((null time-a) nil) ((time-less-p time-a time-b) t) ((time-less-p time-b time-a) nil) (t (< (or (plist-get a :point) 0) (or (plist-get b :point) 0)))))) (defun emacsconf-get-talk-info () (with-current-buffer (find-file-noselect emacsconf-org-file) (save-excursion (save-restriction (widen) (let (results) (org-map-entries (lambda () (when (or (org-entry-get (point) "TIME") (org-entry-get (point) "SLUG") (org-entry-get (point) "INCLUDE_IN_INFO")) (setq results (cons (emacsconf-get-talk-info-for-subtree) results))))) (nreverse results)))))) (defun emacsconf-filter-talks (list) "Return only talk info in LIST." (seq-filter (lambda (talk) (eq (plist-get talk :type) 'talk)) list)) (defun emacsconf-collect-field-for-status (status field &optional info) (seq-map (lambda (o) (plist-get o field)) (seq-filter (lambda (o) (if (listp status) (member (plist-get o :status) status) (string= status (plist-get o :status)))) (emacsconf-filter-talks (or info (emacsconf-get-talk-info)))))) (defun emacsconf-get-talk-info-from-file (&optional filename) (with-temp-buffer (insert-file-contents (or filename "conf.org")) (org-mode) (org-show-all) (goto-char (point-min)) (goto-char (org-find-property "ID" "talks")) (emacsconf-get-talk-info 'wiki))) (defun emacsconf-include-next-talks (info number) (let* ((info (emacsconf-prepare-for-display info)) (cur-list info)) ;; add links to the next talks (while cur-list (plist-put (pop cur-list) :next-talks (seq-take cur-list number))) info)) (defun emacsconf-previous-talk (talk &optional info) (setq info (emacsconf-prepare-for-display (or info (emacsconf-get-talk-info)))) (let* ((pos (seq-position info talk)) (prev (and pos (> pos 0) (elt info (1- pos))))) (and prev (string= (format-time-string "%Y-%m-%d" (plist-get prev :start-time) emacsconf-timezone) (format-time-string "%Y-%m-%d" (plist-get talk :start-time) emacsconf-timezone)) prev))) ;; (emacsconf-previous-talk (emacsconf-resolve-talk "lspbridge")) (defun emacsconf-resolve-talk (talk &optional info) "Return the plist for TALK." (if (stringp talk) (emacsconf-find-talk-info talk info) talk)) (defun emacsconf-find-talk-info (filter &optional info) (setq info (or info (emacsconf-filter-talks (emacsconf-get-talk-info)))) (when (stringp filter) (setq filter (list filter))) (or (seq-find (lambda (o) (string= (plist-get o :slug) (car filter))) info) (seq-find (lambda (o) (let ((case-fold-search t) (all (mapconcat (lambda (f) (plist-get o f)) '(:title :speakers :slug) " "))) (null (seq-contains-p (mapcar (lambda (condition) (string-match condition all)) filter) nil)))) info))) (defun emacsconf-combine-plist (list-of-talks separator) (let (result entry) (while list-of-talks (setq entry (car list-of-talks)) (while entry (unless (equal (plist-get result (car entry)) (cadr entry)) (setq result (plist-put result (car entry) (cons (cadr entry) (or (plist-get result (car entry))))))) (setq entry (cddr entry))) (setq list-of-talks (cdr list-of-talks))) result)) (defun emacsconf-goto-talk-id (id) (goto-char (org-find-property "TALK_ID" id))) (defun emacsconf-goto-slug (slug) (goto-char (org-find-property "SLUG" id))) (defun emacsconf-talk-markdown-from-wiki (slug) "Return the markdown from SLUG." (when (file-exists-p (expand-file-name (format "%s/talks/%s.md" emacsconf-year slug) emacsconf-directory)) (with-temp-buffer (insert-file-contents (expand-file-name (format "%s/talks/%s.md" emacsconf-year slug) emacsconf-directory)) (goto-char (point-min)) (while (re-search-forward "" nil t) (delete-region start (match-end 0))))) (goto-char (point-min)) (while (re-search-forward "\\[\\[![^]]+\\]\\]" nil t) (replace-match "")) (string-trim (buffer-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)) (defun emacsconf-public-talks (info) (seq-filter (lambda (f) (plist-get f :public)) info)) (defun emacsconf-format-short-time (string &optional omit-end-time) (if (stringp string) (setq string (org-timestamp-from-string string))) (downcase (concat (format-time-string "~%l:%M%p" (org-timestamp-to-time (org-timestamp-split-range string))) (if omit-end-time "" (format-time-string "-%l:%M%p" (org-timestamp-to-time (org-timestamp-split-range string t))))))) (defvar emacsconf-focus 'time "'time or 'status") ;;; Embark (defun emacsconf-embark-finder () "Identify when we're on a talk subtree." (cond ((and (derived-mode-p 'org-mode) (let ((context (org-element-context))) (and (org-element-type-p context 'link) (string= (org-element-property :type context) "emacsconf")))) (cons 'emacsconf (org-element-property :path (org-element-context)))) ((and (derived-mode-p 'org-mode) (org-entry-get-with-inheritance "SLUG")) (cons 'emacsconf (org-entry-get-with-inheritance "SLUG"))) ((emacsconf-resolve-talk (symbol-name (symbol-at-point))) (cons 'emacsconf (symbol-name (symbol-at-point)))))) (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))) (defun emacsconf-message-talk-info (talk prop) "Briefly display info for TALK" (interactive (list (emacsconf-complete-talk-info) (completing-read "Property: " '("pronouns" "availability")))) (message "%s" (plist-get (emacsconf-resolve-talk talk) (intern (concat ":" (downcase prop)))))) (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 "I" #'emacsconf-message-talk-info "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 "l" #'emacsconf-add-to-talk-logbook "M" #'emacsconf-mail-insert-info "n" #'emacsconf-mail-notmuch-search-for-talk "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 (defun emacsconf-status-update () (interactive) (let ((emacsconf-info (emacsconf-get-talk-info))) (kill-new (format "%d captioned (%d minutes), %d received and waiting to be captioned (%d minutes)" (length (emacsconf-collect-field-for-status "CAPTIONED" :title)) (apply '+ (seq-map 'string-to-number (conf-collect-field-for-status "CAPTIONED" :time))) (length (emacsconf-collect-field-for-status "PREREC_RECEIVED" :title)) (apply '+ (seq-map 'string-to-number (conf-collect-field-for-status "PREREC_RECEIVED" :time))))))) ;; Timezones (defvar emacsconf-timezone-offset (format-time-string "%z" (date-to-time emacsconf-date) emacsconf-timezone) "Timezone offset for `emacsconf-timezone' on `emacsconf-date'.") (defun emacsconf-timezone-string (o tz) (let* ((timestamp (org-timestamp-from-string (plist-get o :scheduled))) (start (org-timestamp-to-time (org-timestamp-split-range timestamp))) (end (org-timestamp-to-time (org-timestamp-split-range timestamp t)))) (if (string= tz "UTC") (format "%s - %s " (format-time-string "%A, %b %-e %Y, ~%-l:%M %p" start tz) (format-time-string "%-l:%M %p %Z" end tz)) (format "%s - %s (%s)" (format-time-string "%A, %b %-e %Y, ~%-l:%M %p" start tz) (format-time-string "%-l:%M %p %Z" end tz) tz)))) (defun emacsconf-timezone-strings (o &optional timezones) (mapcar (lambda (tz) (emacsconf-timezone-string o tz)) (or timezones emacsconf-timezones))) ;;;###autoload (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)) (time (date-to-time (concat emacsconf-date "T" (string-pad time 5 ?0 t) ":00.000" from-offset)))) (message "%s = %s" (format-time-string "%b %d %H:%M %z" time timezone) (format-time-string "%b %d %H:%M %z" time emacsconf-timezone)))) (defun emacsconf-timezone-set (timezone) "Set the timezone for the current Org entry." (interactive (list (progn (require 'tzc) (completing-read "Timezone: " tzc-time-zones)))) (org-entry-put (point) "TIMEZONE" timezone)) ;;; Etherpad (defvar emacsconf-review-comments-heading "Comments") (defun emacsconf-import-comments-from-etherpad-text (filename) (interactive "FEtherpad text export: ") (with-temp-buffer (insert-file-contents filename) (goto-char (point-min)) (while (re-search-forward "^[\t ]+Comments for \\([^:]+\\)" nil t) (let ((slug (match-string 1)) comments) (forward-line 1) (setq comments (split-string (replace-regexp-in-string "\t\t\\*[ \t]*" "" (buffer-substring-no-properties (point) (if (re-search-forward "^[^\t]" nil t) (match-beginning 0) (point-max)))) "\n")) (save-window-excursion (emacsconf-with-talk-heading slug ;; Do we already have a heading for comments? (if (re-search-forward (concat "^\\(\\*+\\) +" emacsconf-review-comments-heading) (save-excursion (org-end-of-subtree)) t) (org-end-of-meta-data) (org-end-of-subtree) (org-insert-heading-after-current) (insert emacsconf-review-comments-heading "\n")) ;; Are these comments already included? (save-restriction (org-narrow-to-subtree) (mapc (lambda (o) (goto-char (point-min)) (unless (re-search-forward (regexp-quote o) nil t) (goto-char (point-max)) (unless (bolp) (insert "\n")) (insert "- " o "\n"))) comments)))))))) ;;; Validation (defun emacsconf-validate-all-talks-have-comments-for-speakers () (interactive) (emacsconf-for-each-talk (unless (re-search-forward "^\\(- \\)?For \\(the \\)?[^ ]+ speaker" (save-excursion (org-end-of-subtree) (point)) t) (error "Could not find comment for %s" (org-entry-get (point) "SLUG")))) nil) (defun emacsconf-validate-all-talks-have-field (field) (emacsconf-for-each-talk (when (string= (or (org-entry-get (point) field) "") "") (error "%s is missing %s" (org-entry-get (point) "SLUG") field))) nil) (defvar emacsconf-time-constraints '(("saturday closing remarks" "16:30" "17:30") ("sunday closing remarks" "16:30" "17:30"))) (defun emacsconf-validate-no-overlaps (&optional info) (let (results)) (while (cdr info) (when (and (plist-get (car info) :slug) (time-less-p (plist-get (cadr info) :start-time) (plist-get (car info) :end-time))) (setq results (cons "%s overlaps with %s (ends %s, starts %s)" (or (plist-get (car info) :slug) (plist-get (car info) :title)) (or (plist-get (cadr info) :slug) (plist-get (cadr info) :title)) (format-time-string "%H:%M" (plist-get (car info) :end-time)) (format-time-string "%H:%M" (plist-get (cadr info) :start-time))))) (setq info (cdr info)))) (defun emacsconf-active-talks (list) "Remove CANCELLED talks from the list." (seq-remove (lambda (o) (string= (plist-get o :status) "CANCELLED")) list)) (defun emacsconf-validate-talk-subtree () "Report an error if required properties are missing." (interactive) (let* ((props (org-entry-properties)) (missing (seq-remove (lambda (o) (assoc-default (symbol-name o) props)) '(CUSTOM_ID SLUG NAME NAME_SHORT EMAIL AVAILABILITY Q_AND_A TRACK MAX_TIME)))) (when missing (if (called-interactively-p 'any) (message "Missing %s" (mapconcat #'symbol-name missing ", ")) (format "Missing %s" (mapconcat #'symbol-name missing ", ")))))) ;;; Ansible support (defun emacsconf-ansible-export-talks () (interactive) (when emacsconf-ansible-directory (with-temp-file (expand-file-name "talks.json" emacsconf-ansible-directory) (insert (json-encode (list :talks (mapcar (lambda (o) (apply 'list (cons :start-time (format-time-string "%FT%T%z" (plist-get o :start-time) t)) (cons :end-time (format-time-string "%FT%T%z" (plist-get o :end-time) t)) (mapcar (lambda (field) (cons field (plist-get o field))) '(:slug :title :speakers :pronouns :pronunciation :url :track :video-slug))) ) (emacsconf-filter-talks (emacsconf-get-talk-info))))))))) (defun emacsconf-ansible-load-vars (file) (interactive (list (read-file-name "File: " emacsconf-ansible-directory))) (with-temp-buffer (insert-file-contents file) (let ((vars (yaml-parse-string (buffer-string)))) (mapc (lambda (var) (set (car var) (gethash (cdr var) vars)) ) '((emacsconf-pad-api-key . etherpad_api_key) (emacsconf-pad-base . etherpad_url) (emacsconf-backstage-password . emacsconf_backstage_password)))))) ;; (emacsconf-ansible-load-vars (expand-file-name "prod-vars.yml" emacsconf-ansible-directory)) ;;; Tracks (defvar emacsconf-tracks `((:name "General" :color "peachpuff" :id "gen" :channel "emacsconf-gen" :watch "https://live.emacsconf.org/2022/watch/gen/" :tramp "/ssh:emacsconf-gen@res.emacsconf.org#46668:" :webchat-url "https://chat.emacsconf.org/?join=emacsconf,emacsconf-org,emacsconf-accessible,emacsconf-dev,emacsconf-gen" :stream ,(concat emacsconf-stream-base "gen.webm") :480p ,(concat emacsconf-stream-base "gen-480p.webm") :youtube-url "https://www.youtube.com/watch?v=UEJ88c7MJq0" :youtube-studio-url "https://studio.youtube.com/video/UEJ88c7MJq0/livestreaming" :toobnix-url "https://toobnix.org/w/7t9X8eXuSby8YpyEKTb4aj" :start "09:00" :end "17:00" :vnc-display ":5" :vnc-port "5905" :status "offline") (:name "Development" :color "skyblue" :id "dev" :channel "emacsconf-dev" :watch "https://live.emacsconf.org/2022/watch/dev/" :webchat-url "https://chat.emacsconf.org/?join=emacsconf,emacsconf-org,emacsconf-accessible,emacsconf-gen,emacsconf-dev" :tramp "/ssh:emacsconf-dev@res.emacsconf.org#46668:" :toobnix-url "https://toobnix.org/w/w6K77y3bNMo8xsNuqQeCcD" :youtube-url "https://www.youtube.com/watch?v=PMaoF-xa1b4" :youtube-studio-url "https://studio.youtube.com/video/PMaoF-xa1b4/livestreaming" :stream ,(concat emacsconf-stream-base "dev.webm") :480p ,(concat emacsconf-stream-base "dev-480p.webm") :start "10:00" :end "17:00" :vnc-display ":6" :vnc-port "5906" :status "offline"))) (defun emacsconf-get-track (name) "Get the track for NAME. NAME could be a track name, a talk name, or a list." (when (and (listp name) (plist-get name :track)) (setq name (plist-get name :track))) (if (stringp name) (or (seq-find (lambda (track) (or (string= name (plist-get track :name)) (string= name (plist-get track :id)))) emacsconf-tracks) (let ((talk (emacsconf-resolve-talk name))) (seq-find (lambda (track) (or (string= (plist-get talk :track) (plist-get track :name)) (string= (plist-get talk :track) (plist-get track :id)))) emacsconf-tracks)) name) name)) (defun emacsconf-by-track (info) (mapcar (lambda (track) (seq-filter (lambda (talk) (string= (plist-get talk :track) (plist-get track :name))) info)) emacsconf-tracks)) (defun emacsconf-complete-track (&optional prompt tracks) (emacsconf-get-track (completing-read (or prompt "Track: ") (mapcar (lambda (o) (plist-get o :name)) (or tracks emacsconf-tracks))))) (defun emacsconf-by-day (info) (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))) info) #'emacsconf-sort-by-scheduled))) (defun emacsconf-filter-talks-by-track (track info) (when (stringp track) (setq track (emacsconf-get-track track))) (seq-filter (lambda (o) (string= (plist-get o :track) (plist-get track :name))) info)) (defun emacsconf-filter-talks-by-slugs (slugs &optional info) (setq info (or info (emacsconf-get-talk-info))) (if slugs (seq-filter (lambda (o) (member (plist-get o :slug) slugs)) info) info)) (defun emacsconf-filter-talks-by-logbook (text &optional info) (setq info (or info (emacsconf-get-talk-info))) (if text (seq-remove (lambda (o) (and (plist-get o :logbook) (string-match (regexp-quote text) (plist-get o :logbook)))) info) info)) (defvar emacsconf-shifts (list (list :id "sat-am-gen" :track "General" :start "2022-12-03T08:00:00-0500" :end "2022-12-03T12:00:00-0500" :host "zaeph" :streamer "sachac" :checkin "corwin" :irc "dto" :pad "publicvoit" :coord "sachac") (list :id "sat-pm-gen" :track "General" :start "2022-12-03T13:00:00-0500" :end "2022-12-03T18:00:00-0500" :host "zaeph" :streamer "sachac" :checkin "FlowyCoder" :irc "bandali" :pad "publicvoit" :coord "sachac") (list :id "sat-am-dev" :track "Development" :start "2022-12-03T08:00:00-0500" :end "2022-12-03T12:00:00-0500" :host "bandali" :streamer "sachac" :checkin "corwin" :irc "dto" :coord "sachac") (list :id "sat-pm-dev" :track "Development" :start "2022-12-03T13:00:00-0500" :end "2022-12-03T18:00:00-0500" :host "bandali" :streamer "sachac" :checkin "FlowyCoder" :irc "bandali" :coord "sachac") (list :id "sun-am-gen" :track "General" :start "2022-12-04T08:00:00-0500" :end "2022-12-04T12:00:00-0500" :host "zaeph" :streamer "sachac" :checkin "corwin" :irc "dto" :pad "publicvoit" :coord "sachac") (list :id "sun-pm-gen" :track "General" :start "2022-12-04T13:00:00-0500" :end "2022-12-04T18:00:00-0500" :host "zaeph" :streamer "jman" :checkin "FlowyCoder" :irc "bandali" :pad "publicvoit" :coord "sachac") (list :id "sun-am-dev" :track "Development" :start "2022-12-04T08:00:00-0500" :end "2022-12-04T12:00:00-0500" :host "bandali" :streamer "sachac" :checkin "corwin" :irc "dto" :coord "sachac") (list :id "sun-pm-dev" :track "Development" :start "2022-12-04T13:00:00-0500" :end "2022-12-04T18:00:00-0500" :host "bandali" :streamer "sachac" :checkin "FlowyCoder" :irc "bandali" :coord "sachac"))) (defun emacsconf-filter-talks-by-time (start-time end-time info) "Return talks that are between START-TIME and END-TIME (inclusive) in INFO." (when (stringp start-time) (setq start-time (date-to-time start-time))) (when (stringp end-time) (setq end-time (date-to-time end-time))) (seq-filter (lambda (o) (and (plist-get o :start-time) (time-less-p (plist-get o :start-time) end-time) (time-less-p start-time (plist-get o :end-time)))) info)) (defun emacsconf-get-shift (time) "Return the shift that TIME is in." (unless (stringp time) (setq time (format-time-string "%Y-%m-%dT%H:%M:%S%z" time emacsconf-timezone))) (seq-find (lambda (shift) (and (not (string> time (plist-get shift :end))) (not (string> (plist-get shift :start) time)))) emacsconf-shifts)) (defun emacsconf-filter-talks-by-shift (time track info) "Return a list of talks that are in the shift specified by TIME. Filter by TRACK if given. Use INFO as the list of talks." (let* ((shift (emacsconf-get-shift time)) (list (emacsconf-filter-talks-by-time (plist-get shift :start) (plist-get shift :end) info))) (if track (emacsconf-filter-talks-by-track track info) list))) (defun emacsconf-talk-all-done-p (talk) (member (plist-get talk :status) (split-string "TO_ARCHIVE TO_EXTRACT TO_FOLLOW_UP DONE"))) (defun emacsconf-bbb-status (talk) (let ((states '((open . "OPEN_Q UNSTREAMED_Q") (before . "TODO TO_REVIEW TO_ACCEPT WAITING_FOR_PREREC TO_PROCESS PROCESSING TO_AUTOCAP TO_ASSIGN TO_CAPTION TO_STREAM PLAYING CLOSED_Q") (after . "TO_ARCHIVE TO_EXTRACT TO_REVIEW_QA TO_INDEX_QA TO_CAPTION_QA TO_FOLLOW_UP DONE") (cancelled . "CANCELLED")))) (if (string-match "live" (or (plist-get talk :q-and-a) "")) (or (car (seq-find (lambda (state) (member (plist-get talk :status) (split-string (cdr state)))) states)) (throw 'error "Unknown talk BBB state")) 'irc))) (defun emacsconf-captions-edited-p (filename) "Return non-nil if FILENAME has been edited and is okay for inclusion." (and filename (file-exists-p filename) (with-temp-buffer (insert-file-contents filename) (goto-char (point-min)) (re-search-forward "captioned by" (line-end-position) t)))) (defvar emacsconf-bbb-base-url "https://bbb.emacsverse.org/" "Include trailing slash.") (defun emacsconf-bbb-room-title-list (&optional info) (delq nil (mapcar (lambda (o) (when (car o) (concat "ec" (substring emacsconf-year 2) "-" (plist-get (emacsconf-get-shift (plist-get (cadr o) :start-time)) :id) "-" (plist-get (emacsconf-get-track (plist-get (cadr o) :track)) :id) " " (car o) " (" (mapconcat (lambda (talk) (plist-get talk :slug)) (cdr o) ", ") ")"))) (seq-group-by (lambda (o) (plist-get o :speakers)) (or info (emacsconf-active-talks (emacsconf-filter-talks (emacsconf-get-talk-info)))))))) (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)) ;;; Volunteer management (defun emacsconf-get-volunteer-info () (with-current-buffer (find-file-noselect emacsconf-org-file) (org-map-entries (lambda () (org-entry-properties)) "volunteer+EMAIL={.}"))) (defun emacsconf-volunteer-emails-for-completion (&optional info) (mapcar (lambda (o) (emacsconf-surround (if (assoc-default "ITEM" o) (concat (assoc-default "ITEM" o) " <") "<") (assoc-default "EMAIL" o) ">" "")) (or info (emacsconf-get-volunteer-info)))) (defun emacsconf-complete-volunteer (&optional info) (setq info (or info (emacsconf-get-volunteer-info))) (let* ((choices (emacsconf-volunteer-emails-for-completion)) (choice (completing-read "Volunteer: " (lambda (string predicate action) (if (eq action 'metadata) '(metadata (category . volunteer)) (complete-with-action action choices string predicate)))))) (elt info (seq-position choices choice)))) ;;; Reflowing (defun emacsconf-reflow () "Help reflow text files." (interactive) (let (input last-input (case-fold-search t)) (while (not (string= "" (setq input (read-string "Word: ")))) (when (string= input "!") (delete-backward-char 1) (insert " ") (end-of-line) (re-search-forward (regexp-quote last-input) nil t) (setq input last-input)) (if (string= input "'") (progn (end-of-line) (unless (looking-back " ") (insert " ")) (delete-char 1)) (forward-word) (cond ((string= input ",") (re-search-forward ", " nil t) (goto-char (match-end 0))) ((string= input ".") (re-search-forward "\\. " nil t) (goto-char (match-end 0))) (t (re-search-forward (concat "\\<" (regexp-quote input)) nil t) (goto-char (match-beginning 0)))) (insert "\n") (when (< (+ (- (line-end-position) (point)) (save-excursion (- (line-end-position) (line-beginning-position))) 1) fill-column) (save-excursion (goto-char (line-end-position)) (insert " ") (delete-char 1))) (setq last-input input) (recenter) (undo-boundary))))) (defmacro emacsconf-with-todo-hooks (&rest body) "Run BODY with the Emacsconf todo hooks." `(with-current-buffer (find-file-noselect emacsconf-org-file) (let ((org-after-todo-state-change-hook '(emacsconf-org-after-todo-state-change))) ,@body))) (defun emacsconf-add-org-after-todo-state-change-hook () "Add FUNC to `org-after-todo-stage-change-hook'." (interactive) (with-current-buffer (find-buffer-visiting emacsconf-org-file) (add-hook 'org-after-todo-state-change-hook #'emacsconf-org-after-todo-state-change nil t))) (defun emacsconf-remove-org-after-todo-state-change-hook () "Remove FUNC from `org-after-todo-stage-change-hook'." (interactive) (with-current-buffer (find-buffer-visiting emacsconf-org-file) (remove-hook 'org-after-todo-state-change-hook #'emacsconf-org-after-todo-state-change t))) (defvar emacsconf-todo-hooks '((emacsconf-stream-play-talk-on-change "gen" "dev") (emacsconf-stream-open-qa-windows-on-change "gen" "dev") emacsconf-erc-announce-on-change ;; announce via ERC emacsconf-publish-bbb-redirect emacsconf-stream-update-talk-info-on-change emacsconf-publish-media-files-on-change ;; emacsconf-publish-update-talk ;; skipping this for now, I'll do this locally emacsconf-publish-backstage-org-on-state-change ;; update the backstage index ;; write to the talk text ) "Functions to run when the todo state changes. They will be called with TALK.") (defun emacsconf-org-after-todo-state-change () "Run all the hooks in `emacsconf-todo-hooks'. If an `emacsconf-todo-hooks' entry is a list, run it only for the tracks with the ID in the cdr of that list." (let* ((talk (emacsconf-get-talk-info-for-subtree)) (track (emacsconf-get-track (plist-get talk :track)))) (mapc (lambda (hook-entry) (cond ((symbolp hook-entry) (funcall hook-entry talk)) ((member (plist-get track :id) (cdr hook-entry)) (funcall (car hook-entry) talk)))) emacsconf-todo-hooks))) (defun emacsconf-broadcast (message) (interactive "MMessage: ") (when (not (string= (or message "") "")) (erc-cmd-BROADCAST message)) (emacsconf-stream-broadcast message)) (defun emacsconf-agenda () (interactive) (let ((org-agenda-files (list emacsconf-org-file))) (org-agenda-list nil emacsconf-date 2))) (defun emacsconf-track-agenda (track) (interactive (list (emacsconf-complete-track))) (when (stringp track) (setq track (emacsconf-get-track track))) (let ((org-agenda-files (list emacsconf-org-file)) (org-agenda-category-filter-preset (list (concat "+" (plist-get track :id))))) (org-agenda-list nil emacsconf-date 2))) (defun emacsconf-update-talk-status-with-hooks (slug from-states to-state) (interactive (list (emacsconf-complete-talk) "." (completing-read "To: " (mapcar 'car emacsconf-status-types)))) (emacsconf-with-todo-hooks (emacsconf-update-talk-status slug from-states to-state))) (defun emacsconf-update-talk-status (slug from-states to-state) (interactive (list (emacsconf-complete-talk) "." (completing-read "To: " (mapcar 'car emacsconf-status-types)))) (emacsconf-with-talk-heading slug (when (string-match from-states (org-entry-get (point) "TODO")) (org-todo to-state) (save-buffer)))) ;; copied from org-ascii--indent-string (defun emacsconf-indent-string (s width) "Indent S by WIDTH spaces." (replace-regexp-in-string "\\(^\\)[ \t]*\\S-" (make-string width ?\s) s nil nil 1)) (defun emacsconf-add-to-logbook (note) "Add NOTE as a logbook entry for the current subtree." (move-marker org-log-note-return-to (point)) (move-marker org-log-note-marker (point)) (with-temp-buffer (insert note) (let ((org-log-note-purpose 'note)) (org-store-log-note)))) (defun emacsconf-add-to-talk-logbook (talk note) "Add NOTE as a logbook entry for TALK." (interactive (list (emacsconf-complete-talk) (read-string "Note: "))) (save-excursion (emacsconf-with-talk-heading talk (emacsconf-add-to-logbook note)))) (defun emacsconf-reload () "Reload the emacsconf-el modules." (interactive) (mapc #'load-library '("emacsconf" "emacsconf-erc" "emacsconf-publish" "emacsconf-stream" "emacsconf-pad"))) (defun emacsconf-find-talk-file-in-cache (talk filename) (interactive (let ((talk (emacsconf-complete-talk-info))) (list talk (completing-read "File: " (directory-files emacsconf-cache-dir t (plist-get talk :video-slug)))))) (find-file filename)) (defun emacsconf-cache-find-file (filename) (interactive (list (read-file-name "File: " (expand-file-name "./" emacsconf-cache-dir) nil t))) (find-file (expand-file-name filename emacsconf-cache-dir))) (defun emacsconf-format-seconds (seconds) (concat (format-seconds "%.2m:%.2s" (floor seconds)) "." (format "%03d" (% (floor (* 1000 seconds)) 1000)))) (defun emacsconf-insert-time-for-speaker (talk) (interactive (list (emacsconf-complete-talk-info))) (insert (format-time-string "%-I:%M %P %Z" (plist-get talk :start-time) emacsconf-timezone) " (" emacsconf-timezone ")" (if (string= (format-time-string "%z" (plist-get talk :start-time) (plist-get talk :timezone)) emacsconf-timezone-offset) "" (concat " which should be the same as " (format-time-string "%-I:%M %P %Z" (plist-get talk :start-time) (plist-get talk :timezone)) " in " (plist-get talk :timezone))))) (defun emacsconf-collect-prop (prop list) (mapcar (lambda (o) (plist-get o prop)) list)) (defun emacsconf-talk-file (talk suffix &optional always source) (let ((wiki-filename (expand-file-name (concat (plist-get talk :video-slug) suffix) (expand-file-name "captions" (expand-file-name (plist-get talk :year) emacsconf-directory)))) (cache-filename (expand-file-name (concat (plist-get talk :video-slug) suffix) emacsconf-cache-dir))) (cond ((and (file-exists-p wiki-filename) (not (eq source 'cache))) wiki-filename) ((and (file-exists-p cache-filename) (not (eq source 'wiki-captions))) cache-filename) (always cache-filename)))) (with-eval-after-load 'org (defun emacsconf-el-complete () "Complete a file from the Emacsconf Elisp library." (concat "emacsconf-el:" (file-name-base (read-file-name "File: " (file-name-directory (locate-library "emacsconf.el")))))) (defun emacsconf-el-open (link _) "Visit a file from the Emacsconf Elisp library." (find-file (expand-file-name link (file-name-directory (locate-library "emacsconf.el"))))) (defun emacsconf-el-export (link description format _) "Export link to emacsconf-el file." (format "%s" (file-name-nondirectory link) (or description link))) (org-link-set-parameters "emacsconf-el" :complete #'emacsconf-el-complete :export #'emacsconf-el-export :follow #'emacsconf-el-open) (defun emacsconf-ansible-complete () "Complete a file from the Emacsconf Elisp library." (concat "emacsconf-el:" (file-name-base (read-file-name "File: " emacsconf-ansible-directory)))) (defun emacsconf-ansible-open (link _) "Visit a file from the Emacsconf Elisp library." (find-file (expand-file-name link emacsconf-ansible-directory))) (defun emacsconf-ansible-export (link description format _) "Export link to emacsconf-el file." (format "%s" link (or description link))) (org-link-set-parameters "emacsconf-ansible" :complete #'emacsconf-ansible-complete :export #'emacsconf-ansible-export :follow #'emacsconf-ansible-open)) (defun emacsconf-end-of-week (date) "Useful for analyzing data. Assumes week ends Sunday." (let ((d (decode-time (date-to-time date)))) (format-time-string "%Y-%m-%d" (encode-time (decoded-time-add d (make-decoded-time :day (% (- 7 (decoded-time-weekday d)) 7))))))) (defun emacsconf-count-submissions-by-week (&optional info cfp-deadline) "Count submissions in INFO by distance to CFP-DEADLINE." (setq cfp-deadline (or cfp-deadline emacsconf-cfp-deadline)) (setq info (or info (emacsconf-get-talk-info))) (cons '("Weeks to CFP end date" "Count" "Hours") (mapcar (lambda (entry) (list (car entry) (length (cdr entry)) (apply '+ (mapcar 'cdr (cdr entry))))) (seq-group-by 'car (sort (seq-keep (lambda (o) (and (emacsconf-publish-talk-p o) (plist-get o :date-submitted) (cons (floor (/ (days-between (plist-get o :date-submitted) cfp-deadline) 7.0)) (string-to-number (or (plist-get o :video-duration) (plist-get o :time) "0"))))) info) (lambda (a b) (< (car a) (car b)))))))) (provide 'emacsconf) ;;; emacsconf.el ends here