A couple of months back David O’Toole (dto) taught me basics of Emacs Lisp (Elisp). These were things like lists, symbols, functions, debugging and a few more things. After the session I was consciously observing my workflows, so that I can make improvements to them by writing Elisp. We wrote one such fuction during the session, but I had not written anything apart from that. Recently, I wrote an Elisp function to convert event entries from an ICS (iCalendar file) to Org mode list. I will be talking about that code in this post.
Before we take a look at the code, let’s talk about the ICS I wanted to parse.
I use FOSDEM Companion app on my phone to bookmark the talks which I want to watch from the schedule. The application gives notification when a bookmarked talk is live during the conference.
As FOSDEM has a lot of parallel tracks. It is not possible to watch everything which I find interesting during the conference. I watch other talks after the conference. This is where having the bookmarked talks available as a list in my Org entry is useful. It helps me to track my progress.
The FOSDEM Companion app can only export to an ICS file from the bookmarks. Last year I used keyboard macros to convert the event entries from ICS to an Org mode list. But this time I thought, let’s write a function, so that it is easy to do this conversion every year.
This is how the entries from the ICS file look, some fields have been removed for brevity. A buffer with the content of the ICS file is going to be the input.
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
DTSTAMP:20210228T130454Z
DTSTART:20210206T080000Z
DTEND:20210206T082500Z
SUMMARY:Welcome to FOSDEM 2021
DESCRIPTION:FOSDEM welcome and opening talk.
CATEGORIES:FOSDEM
URL:https://fosdem.org/2021/schedule/event/keynotes_welcome/
LOCATION:K.fosdem
END:VEVENT
…
END:VCALENDAR
The fields I’m interested in are the following:
SUMMARY
: The title of the talk.URL
: Link to the talk.DTSTART
, DTEND
: Start and end time for calculating the duration.Now, let’s take a look at the code step by step.
I started with finding above-mentioned fields from the buffer. The ICS file is open, and that’s where we are going to run our code. I was taking reference of the code from EmacsConf organizer’s notebook written by Sacha.
If you already know the basics of Emacs Lisp, jump directly to the complete function definition.
For more information about the functions mentioned in the rest of the
post, run M-x describe-function
(C-h f). This will open
built-in help for the function. To open the manual pages inside Emacs
itself, hover over the links and execute the title text with M-:
.
(defun fosdem-ics-to-org-list ()
(interactive)
(re-search-forward "SUMMARY:\\(.*\\)")
(match-string 1))
The function re-search-forward
does regular expression (regexp)
search in the forward direction only once. The match-string
function
can be used to get the matching group of the regexp. In our example,
it gives us ‘Welcome to FOSDEM 2021’.
I used re-builder on the ICS buffer to interactively write the
regexp. I switched to string
mode (C-c TAB) instead of
the default read
mode. Once I was satisfied with the expression, I
switched to read
mode again. Then used the resulting expression as
it is in the code. More details: re-builder: the Interactive regexp
builder - Mastering
Emacs,
EmacsWiki: Regular
Expression.
To store the values parsed from the fields, I used
let. The scope of these variables is limited to
the let
function call.
(let (start-time end-time summary url duration duration-minutes)
;; …
(re-search-forward "DTSTART:\\(.*\\)")
(setq start-time (match-string 1)))
At this point, I already had start and end time strings in ISO 8601 format.
(setq duration (time-subtract
(parse-iso8601-time-string end-time)
(parse-iso8601-time-string start-time)))
(setq duration-minutes (/ duration 60))
In the above snippet, parse-iso8601-time-string
parses the strings
and converts them to Lisp timestamp
values. time-subtract
finds the
difference, which is in seconds. At the end, the value is converted
into minutes by dividing it by 60.
The time-subtract
function requires a time
value, which is Lisp timestamp
representation. Initially I was using iso8601-parse
function to
parse the time string. This function returns decode-time
structure, which is not suitable for the
subtraction. This resulted in weird values of the
duration-minutes
. Take a look at the following eshell session for a
set of start and end time, the actual difference is 30 minutes here.
eshell> (time-subtract
(iso8601-parse "20210206T120000Z")
(iso8601-parse "20210206T113000Z"))
(-1 65506 1 0)
eshell> (decoded-time-minute '(-1 65506 1 0))
65506
eshell> (time-subtract
(parse-iso8601-time-string "20210206T120000Z")
(parse-iso8601-time-string "20210206T113000Z"))
1800
eshell> (/ 1800 60)
30
Thanks to dale and hmw on #emacs for helping me with this time conversion.
With all the required fields from one entry available, I used format
to create an Org mode list string.
(format "- [ ] [[%s][%s]] (%sm)\n" url summary duration-minutes)
The resulting string looks something like this in Org mode when it is rendered.
The next step was to loop over all the entries from the ICS. Taking reference from organizers-notebook.org, I used a cl-loop while macro along with re-search-forward.
(cl-loop while (re-search-forward "BEGIN:VEVENT" nil t) concat
(let (start-time end-time summary url duration duration-minutes)
;; code parsing all the fields
))
Each event entry begins with the line BEGIN:VEVENT
. The above
snippet keeps iterating till it finds the event line. The third
argument to re-search-forward is NOERROR
. When it is set to t
, a
failed search does not emit an error, and returns nil
. This nil
stops the while loop.
The cl-loop
macro makes it simple to work with loops in
Lisp. In the above case, the iteration
clause while
is used. It is followed
by the accumulation
clause concat
and the action clause
i.e. the loop body. The concat
clause concatenates each execution’s
results into a string.
Now, I had a string with all the entries as Org mode list. I added an Org heading to this string, and added the resulting string to a temporary buffer.
(let (talks-list)
(setq talks-list
(concat
"* FOSDEM talks [/]\n"
(cl-loop while (re-search-forward "BEGIN:VEVENT" nil t) concat
;; …
)))
(with-current-buffer (generate-new-buffer "FOSDEM")
(insert talks-list)
(org-mode)
(org-update-statistics-cookies (point))))
This basically concatenates * FOSDEM talks [/]\n
to the string
returned by the loop, and saves it to talks-list
.
The generate-new-buffer
function generates a unique buffer with
FOSDEM
name prefix. As the name suggests with-current-buffer
runs
the body in the given buffer, in this case it is the FOSDEM buffer.
The value of talks-list
variable is inserted in the buffer. Org mode
is enabled. To update the cookie — the [/]
, I’m running the
org-update-statistics-cookies
function with current position
returned by point
.
The FOSDEM buffer looks like this:
After putting all the above pieces together, this is how the complete function looks.
(defun fosdem-ics-to-org-list ()
"Iterate over the VEVENT entries and create an Org list"
(interactive)
(let (talks-list)
(setq talks-list
(concat
"* FOSDEM talks [/]\n"
(cl-loop while (re-search-forward "BEGIN:VEVENT" nil t) concat
(let (start-time end-time summary url duration duration-minutes)
(re-search-forward "DTSTART:\\(.*\\)")
(setq start-time (match-string 1))
(re-search-forward "DTEND:\\(.*\\)")
(setq end-time (match-string 1))
(re-search-forward "SUMMARY:\\(.*\\)")
(setq summary (match-string 1))
(re-search-forward "URL:\\(.*\\)")
(setq url (match-string 1))
(setq duration (time-subtract
(parse-iso8601-time-string end-time)
(parse-iso8601-time-string start-time)))
(setq duration-minutes (/ duration 60))
(format "- [ ] [[%s][%s]] (%sm)\n" url summary duration-minutes)))))
(with-current-buffer (generate-new-buffer "FOSDEM")
(insert talks-list)
(org-mode)
(org-update-statistics-cookies (point)))))
Comments are not enabled on this site. The old comments might still be displayed. You can reply on one of the platforms listed in ‘Posted on’ list, or email me.