GeekSocket Plug in and be Geekified

Writing Emacs Lisp to convert ICS to Org mode list

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.

From where the ICS came?

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.

An example entry from the ICS file

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.

The Elisp code

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-:.

Parsing the fields

(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.

Storing the field values

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)))

Calculating duration of the talk

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.

Creating the list item string

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.

Org mode list single

Looping over all the entries

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.

Saving results to a buffer

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:

FOSDEM final buffer with all theentries

The complete function definition

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

comments powered by Disqus