import ICAL from "./vendor/ical.js"; // The calendar is published next to the site and is not built from this // repository, see the README. const icsUrl = "/calendars/all.ics"; /** * 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) */ export 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; } /** * 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} to End of the window * @returns {Date} The recurrence time to stop at */ function iterationEnd(event, to) { let last = to.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, to.getTime() + movedBy); } } return new Date(last); } /** * Whether an occurrence touches a window. * * @param {ICAL.Time} startDate Start of the occurrence * @param {ICAL.Time} endDate End of the occurrence * @param {Date} from Start of the window * @param {Date} to End of the window * @returns {boolean} True when the two overlap */ function touches(startDate, endDate, from, to) { return startDate.toJSDate() <= to && endDate.toJSDate() > from; } /** * Load and parse the calendar of the club. * * @returns {Promise} The calendar */ export async function loadCalendar() { const response = await fetch(icsUrl); // Without this an error page would be handed to the parser below, which then // fails with a confusing complaint about the calendar syntax. if (!response.ok) { throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`); } return new ICAL.Component(ICAL.parse(await response.text())); } /** * Every occurrence of the calendar that touches the given window. * * A recurring event is expanded, and an occurrence that was modified on its own * is reported with the time, the name and the URL of that modification. * * @param {ICAL.Component} calendar The parsed calendar * @param {Date} from An occurrence has to still be running at this time * @param {Date} to An occurrence has to have started by this time * @yields {{event: ICAL.Event, startDate: ICAL.Time, endDate: ICAL.Time}} */ export function* occurrencesBetween(calendar, from, to) { 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, handling them here as well would report 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()) { if (touches(event.startDate, event.endDate, from, to)) { yield { event, startDate: event.startDate, endDate: event.endDate }; } continue; } const iterator = event.iterator(); const iterateUntil = iterationEnd(event, to); while (true) { const occurrence = iterator.next(); // Recurrences are chronological, so we are done once one starts after the // window, and after the occurrences a modification can still move back // into it. if (!occurrence || 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 modification may have moved the occurrence out of the window, so // judge it by the time it really takes place at. if (touches(details.startDate, details.endDate, from, to)) { yield { event: details.item, startDate: details.startDate, endDate: details.endDate, }; } } } }