The blog of dlaa.me

"I refuse to join any club that would have me as a member." [A simple Node.js web app to filter iCalendar events]

If you're a member of a club, organization, committee, etc. with regularly-scheduled events, you may want those events to automatically show up on your calendar. Fortunately, there's the Internet Calendaring and Scheduling Core Object Specification (iCalendar) which defines a way to represent calendar entries in a portable manner, and the webcal: URI scheme that makes it possible to subscribe to a dynamically-updating list of calendar events.

But maybe your organization is very active and there are lots of events and you're only interested in some of them. If you're lucky and the organization publishes separate lists for each subgroup, you can subscribe to just the ones you care about. But if there is only one list with everything, subscribing to it will create a bunch of noise on your calendar. It would be nice if you could filter that list down to just the ones that matter to you and then subscribe to the filtered list.

Thanks to the magic of open protocols and the web, this is possible!

The code below is a simple Node.js web application that returns a filtered view of any calendar on the public web. Additionally, it hosts a simple web page that makes it easy to subscribe to the filtered list from any device with a web browser and a calendar app.

Notes:

  • Configure this application for your scenario by editing the three variables at the top of the file. You need to provide a link to the full calendar, a list of words that indicate an event should be EXcluded when filtering, and a list of words that indicate an event should be INcluded when filtering.
  • Abstractly, only an exclude list OR an include list should be necessary, but both are used here because it's a good way to avoid excluding interesting events accidentally. For example, maybe "book" is on your EXclude list because you don't care about book club events, but you love hiking and so "hike" is on your INclude list. If an event came up titled "Book your hike today!", it would be a candidate for EXclusion because of the word "book", but will end up getting INcluded because the word "hike" overrides that.
  • The implementation of exclude/include word lists uses regular expressions, specifically the | disjunction operator. This may seem unusual, but provides a simple way of case-insensitively searching for multiple substrings using a single function call. I'd expect a decent regular expression library to match this pattern efficiently, though performance here is not an issue due to the limited size of the text involved.
  • Producing a filtered list of calendar events works by waiting for a request, fetching the full collection as iCalendar text, parsing it into structured calendar objects, filtering those object summaries per the exclude/include lists, converting back to iCalendar text, and returning that response to the caller. Caching could be added fairly easily, but is not present because it wasn't necessary.
  • Whenever someone makes a request for the filtered list, they get the current/latest list of events. Over time, old events disappear from the full list and new events show up. The user's calendar application handles synchronization and updates their schedule accordingly.
  • For diagnostic and debugging purposes, the processing of each request outputs all the event summaries to the console along with a + or - prefix to indicate whether each event was INcluded or EXcluded in the filtered list. This is handy when setting up and fine-tuning the filters, but unnecessary once that's done and can be commented-out.
  • The companion web page isn't necessary, but I found emailing and messaging webcal: links on iOS often resulted in the operating system "helpfully" downloading content as a file and sending that instead of the link. With the web page, it's easy to send people a normal https: link to the /share path. When they activate that link, it opens in their browser and provides a short message with a link to the subscribe-able webcal: URL via the /calendar.ics path.
  • You could make things a bit more discoverable by changing the helper web page to use the / path instead of /share. However, discoverability was an explicit non-goal for me. I only wanted people who'd been sent the share link to have access to the filtered list (not any random person who stumbled across the hostname). Of course, obscurity is not security, but in this case there's nothing that needs to be secret.
  • This code has a single external dependency, the ical2json npm package which handles the decoding and encoding of iCalendar data. This package worked exactly like I expected it to and helped keep my code simple and concise.
  • The code has rudimentary error checking which worked fine for my scenario, but you may find that something more resilient is necessary for yours.
  • What's below runs as an Azure Web App; minor tweaks may be necessary to run in a different environment (e.g., process.env.WEBSITE_HOSTNAME and PORT).

Code:

import { createServer } from "node:http";
import ical2json from "ical2json";

// Configuration
const calendarIcs = "https://example.com/organization/calendar.ics";
const excludeRe = /boring|dull|sad/i;
const includeRe = /fun|exciting|happy/i;

// Constants
const contentTypeHeader = "Content-Type";
const contentTypeTextHtml = "text/html";
const icsPath = "/calendar.ics";
const sharePath = "/share";

// HTTP request handler
const httpListener = async (req, res) => {
  if ((req.method === "GET") && (req.url === icsPath)) {

    // Fetch the un-filtered ICS data
    let response;
    try {
      response = await fetch(calendarIcs);
      if (!response.ok) {
        throw new Error(`Error fetching ${calendarIcs}: ${response.status} ${response.statusText}`);
      }
    } catch (error) {
      // Return an error
      res.writeHead(500);
      res.end(`SERVER ERROR: ${error.message}`);
      return;
    }
    const responseContentType = response.headers.get(contentTypeHeader);
    const responseText = await response.text();

    // Filter the ICS calendars/events in place
    const responseJson = ical2json.convert(responseText);
    for (const vcalendar of responseJson["VCALENDAR"]) {
      vcalendar["X-WR-CALNAME"] += " (Filtered)";
      console.log(vcalendar["X-WR-CALNAME"]);
      vcalendar["VEVENT"] = vcalendar["VEVENT"].filter((vevent) => {
        const summary = vevent["SUMMARY"];
        const include = !excludeRe.test(summary) || includeRe.test(summary);
        console.log(`${include ? "+" : "-"} ${summary}`);
        return include;
      });
    }

    // Return the filtered ICS data
    const resultText = ical2json.revert(responseJson);
    res.writeHead(200, { [contentTypeHeader]: responseContentType });
    res.end(resultText);

  } else if ((req.method === "GET") && (req.url === sharePath)) {

    // Return a simple HTML page that can be shared via URL
    const host = process.env.WEBSITE_HOSTNAME || "localhost";
    res.writeHead(200, { [contentTypeHeader]: contentTypeTextHtml });
    res.end(
      "<html>" +
        "<head>" +
          "<title>Filtered Calendar</title>" +
          '<meta name="viewport" content="width=device-width, initial-scale=1"/>' +
        "</head>" +
        "<body>" +
          `Subscribe to the filtered calendar by <a href="webcal://${host}${icsPath}">clicking here</a>.` +
        "</body>" +
      "</html>"
    );

  } else {

    // Return an error
    res.writeHead(404);
    res.end("NOT FOUND");

  }
};

// Create an HTTP server on the specified port and listen for requests
const port = process.env.PORT || 3000;
createServer(httpListener).listen(port, () => console.log(`Listening on ${port}`));