import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js"; /** * Read the URL of an event. * * ICAL.Event does not expose the URL property, so read it from the component. * * The value ends up in the href of a link, and the calendar is exported from a * CalDAV server, so whoever may write to it decides what that value is. A * "javascript:" URL there would run on our page as soon as a visitor clicks * the event, so hand on nothing but http and https. * * @param {ICAL.Event} event The event to read the URL of * @returns {string} The URL, empty when the event has none or it is not http(s) */ function eventUrl(event) { const url = event.component.getFirstPropertyValue("url") ?? ""; if (!url) { return ""; } try { // A relative URL is resolved against the page and keeps its scheme. const { protocol } = new URL(url, document.baseURI); return protocol === "http:" || protocol === "https:" ? url : ""; } catch { // Not a URL at all. return ""; } } /** * Group the occurrences that were modified on their own by the UID of the event * they belong to. * * Unless it is told which exceptions belong to an event, ICAL.Event relates * every VEVENT with a RECURRENCE-ID in the file to every recurring event, and * it keys them by the recurrence id alone. Two series that meet at the same * time would therefore take over each other's modifications. * * @param {ICAL.Component[]} components The VEVENTs of the calendar * @returns {Map} The exceptions per UID */ function exceptionsByUid(components) { const exceptions = new Map(); for (const component of components) { if (!component.hasProperty("recurrence-id")) { continue; } const uid = component.getFirstPropertyValue("uid"); const ofEvent = exceptions.get(uid); if (ofEvent) { ofEvent.push(component); } else { exceptions.set(uid, [component]); } } return exceptions; } /** * When an occurrence starts, as a point in time. * * A date has no time and no zone, its digits are the day itself. toJSDate() * reads them as midnight in the zone of the browser, which moves an all day * event by the offset that zone has to Berlin and, far enough east or west, * onto the day before or after. Keep the digits and read them as UTC instead, * the table prints an all day event in UTC as well. * * @param {ICAL.Time} time Start of the occurrence * @returns {Date} The point in time to sort and print by */ function startOf(time) { if (time.isDate) { return new Date(Date.UTC(time.year, time.month - 1, time.day)); } return time.toJSDate(); } /** * How far the recurrences of an event have to be iterated. * * The iteration walks the unmodified recurrence times, so an occurrence that * was moved to an earlier time is only reached through the time it originally * had, which can lie past the end of the window. Keep going for as long as the * largest move towards the past can still carry an occurrence into it. * * @param {ICAL.Event} event The event whose recurrences are iterated * @param {Date} end End of the window * @returns {Date} The recurrence time to stop at */ function iterationEnd(event, end) { let last = end.getTime(); for (const exception of Object.values(event.exceptions)) { const movedBy = exception.recurrenceId.toJSDate().getTime() - exception.startDate.toJSDate().getTime(); if (movedBy > 0) { last = Math.max(last, end.getTime() + movedBy); } } return new Date(last); } /** * Parse an ICS calendar and return upcoming event occurrences. * * @param {string} icsText The contents of the .ics file * @param {Date} now Events must still be running at this date * @param {number} maxEvents Maximum number of events to return * @param {number} maxDays Maximum number of days into the future * @returns {{start: Date, allDay: boolean, name: string, url: string}[]} url is empty when the event has no URL */ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { const jcal = ICAL.parse(icsText); const calendar = new ICAL.Component(jcal); const end = new Date(now.getTime()); end.setDate(end.getDate() + maxDays); const events = []; const components = calendar.getAllSubcomponents("vevent"); const exceptions = exceptionsByUid(components); for (const component of components) { // Occurrences modified via RECURRENCE-ID are reached through the event they // belong to, listing them here as well would show them twice. if (component.hasProperty("recurrence-id")) { continue; } const event = new ICAL.Event(component, { exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [], }); if (!event.startDate) { continue; } if (event.isRecurring()) { const iterator = event.iterator(); const iterateUntil = iterationEnd(event, end); while (true) { const occurrence = iterator.next(); if (!occurrence) { break; } // Recurrences are chronological, so we're done // once we pass the end of our search window. if (occurrence.toJSDate() > iterateUntil) { break; } // Details resolve time, name and URL of an occurrence that was // modified via RECURRENCE-ID. const details = event.getOccurrenceDetails(occurrence); // A running event stays listed until it is over, so filter on its end. // A modification may have moved the occurrence out of the window, so // judge it by the time it really takes place at. if (details.startDate.toJSDate() <= end && details.endDate.toJSDate() > now) { events.push({ start: startOf(details.startDate), allDay: details.startDate.isDate, name: details.item.summary ?? "", url: eventUrl(details.item), }); } } } else if (event.startDate.toJSDate() <= end && event.endDate.toJSDate() > now) { events.push({ start: startOf(event.startDate), allDay: event.startDate.isDate, name: event.summary ?? "", url: eventUrl(event), }); } } // We have occurrences from multiple events, so sort them // before applying the maximum event count. events.sort((a, b) => a.start - b.start); return events.slice(0, maxEvents); } document.addEventListener("DOMContentLoaded", () => { const ics = "/calendars/all.ics"; const max_days = 20; const max_items = 5; const now = new Date(); const table = document.getElementById("upcoming"); fetch(ics) .then(response => response.text()) .then(icsText => { getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => { const row = document.createElement("tr"); const colBegin = document.createElement("td"); // The events take place in Berlin, so name their time in Berlin time // instead of in the time zone the visitor happens to be in. An all day // event has no time of day and carries its date in UTC, see startOf(). const whenFormat = event.allDay ? { timeZone: "UTC" } : { timeZone: "Europe/Berlin", hour: "2-digit", minute: "2-digit" }; const formattedStart = event.start.toLocaleString("de-DE", { weekday: "long", day: "2-digit", month: "2-digit", ...whenFormat, }); colBegin.innerText = event.allDay ? formattedStart : `${formattedStart} Uhr`; row.appendChild(colBegin); const colName = document.createElement("td"); if (event.url) { const a = document.createElement("a"); a.href = event.url; a.text = event.name; colName.appendChild(a); } else { colName.innerText = event.name; } row.appendChild(colName); table.appendChild(row); }); }); });