diff --git a/assets/js/calendar.js b/assets/js/calendar.js index 596eca0..b12529c 100644 --- a/assets/js/calendar.js +++ b/assets/js/calendar.js @@ -1,6 +1,4 @@ -import ICAL from "./vendor/ical.js"; - -const icsUrl = "/calendars/all.ics"; +import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js"; // The club is in Berlin, so the calendar shows Berlin days and Berlin times, // no matter which time zone the browser of the visitor is set to. @@ -116,37 +114,6 @@ function daysCovered(start, end) { return days; } -/** - * 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 ""; - } -} - /** * Reduce one occurrence of an event to what the calendar displays. * @@ -169,66 +136,6 @@ function toOccurrence(event, startDate, endDate) { }; } -/** - * 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 month. 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); -} - /** * Collect everything that takes place in the given month, keyed by day. * @@ -268,49 +175,8 @@ function occurrencesOfMonth(year, month) { } }; - 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 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, to); - - while (true) { - const occurrence = iterator.next(); - - // Recurrences are chronological, so we are done once one starts after - // the month, 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); - - if (details.endDate.toJSDate() > from) { - add(toOccurrence(details.item, details.startDate, details.endDate)); - } - } - } else if (event.startDate.toJSDate() < to && event.endDate.toJSDate() > from) { - add(toOccurrence(event, event.startDate, event.endDate)); - } + for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) { + add(toOccurrence(event, startDate, endDate)); } for (const occurrences of Object.values(byDate)) { @@ -531,18 +397,9 @@ document.addEventListener("DOMContentLoaded", function() { currentMonth = today.getMonth(); updateEventsForMonth(currentYear, currentMonth); - fetch(icsUrl) - .then(response => { - // 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 response.text(); - }) - .then(icsText => { - calendar = new ICAL.Component(ICAL.parse(icsText)); + loadCalendar() + .then(loaded => { + calendar = loaded; updateEventsForMonth(currentYear, currentMonth); }) .catch(err => console.error("Fehler beim Laden der ICS-Datei:", err)); diff --git a/assets/js/events.js b/assets/js/events.js new file mode 100644 index 0000000..0941e26 --- /dev/null +++ b/assets/js/events.js @@ -0,0 +1,194 @@ +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, + }; + } + } + } +} diff --git a/assets/js/upcoming.js b/assets/js/upcoming.js index c55a2e3..7f6d63f 100644 --- a/assets/js/upcoming.js +++ b/assets/js/upcoming.js @@ -1,68 +1,4 @@ -import ICAL from "./vendor/ical.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; -} +import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js"; /** * When an occurrence starts, as a point in time. @@ -85,109 +21,29 @@ function startOf(time) { } /** - * How far the recurrences of an event have to be iterated. + * The upcoming occurrences of a calendar. * - * 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 {ICAL.Component} calendar The parsed calendar * @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); - +function getUpcomingEvents(calendar, now, maxEvents, maxDays) { 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")) ?? [], + // A running event stays listed until it is over, so the window starts at now + // and the walk keeps everything that has not ended yet. + for (const { event, startDate } of occurrencesBetween(calendar, now, end)) { + events.push({ + start: startOf(startDate), + allDay: startDate.isDate, + name: event.summary ?? "", + url: eventUrl(event), }); - - 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 @@ -198,25 +54,15 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { } 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 => { - // 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(`${ics}: ${response.status} ${response.statusText}`); - } - - return response.text(); - }) - .then(icsText => { - getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => { + loadCalendar() + .then(calendar => { + getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => { const row = document.createElement("tr"); const colBegin = document.createElement("td");