The response of the fetch went to the parser without ever looking at it. When
the calendar could not be loaded the error page of the web server was parsed as
a calendar, which threw inside a promise nobody was waiting on. The result was
an empty table, an unhandled rejection in the console and an error message
about broken calendar syntax that says nothing about the actual problem, a
calendar that is not there.
Refuse a response that is not ok, naming the status, and log failures in the
chain, like the calendar page already does.
before: Uncaught (in promise) Error: invalid line (no token ";" or ":")
"<html>404 Not Found</html>"
after: Fehler beim Laden der Termine:
Error: /calendars/all.ics: 404 Not Found
The table stays empty either way, there is nothing to show without a calendar.
Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
259 lines
8.1 KiB
JavaScript
259 lines
8.1 KiB
JavaScript
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<string, ICAL.Component[]>} 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 => {
|
|
// 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 => {
|
|
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);
|
|
});
|
|
})
|
|
.catch(err => console.error("Fehler beim Laden der Termine:", err));
|
|
});
|