diff --git a/static/js/upcoming.js b/static/js/upcoming.js index f031b4d..dde5f1f 100644 --- a/static/js/upcoming.js +++ b/static/js/upcoming.js @@ -1,5 +1,96 @@ 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; +} + +/** + * 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. * @@ -18,18 +109,27 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { const events = []; - for (const component of calendar.getAllSubcomponents("vevent")) { - const event = new ICAL.Event(component); + 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; } - // ICAL.Event does not expose the URL property, so read it from the component. - const url = component.getFirstPropertyValue("url") ?? ""; - if (event.isRecurring()) { const iterator = event.iterator(); + const iterateUntil = iterationEnd(event, end); while (true) { const occurrence = iterator.next(); @@ -40,19 +140,22 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { // Recurrences are chronological, so we're done // once we pass the end of our search window. - if (occurrence.toJSDate() > end) { + if (occurrence.toJSDate() > iterateUntil) { break; } - // Details resolve the times of occurrences overridden by RECURRENCE-ID. + // 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. - if (details.endDate.toJSDate() > now) { + // 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: details.startDate.toJSDate(), - name: event.summary ?? "", - url, + name: details.item.summary ?? "", + url: eventUrl(details.item), }); } } @@ -63,7 +166,7 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) { events.push({ start, name: event.summary ?? "", - url, + url: eventUrl(event), }); } }