Move what the two calendar views share into one module
Both views read the same file and ask it the same question, only the answer is presented differently: the start page lists the next few occurrences, the calendar page marks the occurrences of one month. Since they were taught to read a calendar properly they also carry the same code for it, twice and word for word, some 130 lines of `eventUrl()`, `exceptionsByUid()`, `iterationEnd()`, the fetch with its check of the response and the walk over the events of the calendar. Every fix so far had to be written twice, and the next one that is only written once leaves the two views disagreeing about the same calendar. Put it into `assets/js/events.js`, which offers what both need: - `loadCalendar()` fetches and parses `/calendars/all.ics` - `occurrencesBetween()` walks the occurrences that touch a window, expanding recurring events and resolving the ones that were modified on their own - `eventUrl()` reads the URL of an event `upcoming.js` and `calendar.js` keep what is really theirs, the shape of their entries and how they are drawn, and both lose their own import of ical.js: it is the concern of the module that reads the calendar now. The two of them shrink from 259 and 549 lines to 105 and 406. Hugo bundles the module into both scripts, so no shortcode changes and no second request. The walk keeps an occurrence when it starts at or before the end of the window and ends after its start. That is the condition the start page used; the calendar page compared against the start of the month with "<" instead of "<=". Its window reaches two days past the month, so the day this can differ on is nowhere near it. No behaviour changes with this: the upcoming list over 120 days and every day of the month view from 2025 to 2028, taken from https://berlin.ccc.de/calendars/all.ics with the name, URL, start, end and all day flag of each entry, are identical before and after, and so are the results of the tests for moved occurrences, for modifications between two series and for all day events in Berlin, Tokyo, Los Angeles and Kiritimati. Assisted-by: Claude:claude-opus-5 Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
This commit is contained in:
parent
75dd9ca997
commit
6782da49d4
3 changed files with 215 additions and 318 deletions
|
|
@ -1,6 +1,4 @@
|
||||||
import ICAL from "./vendor/ical.js";
|
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
|
||||||
|
|
||||||
const icsUrl = "/calendars/all.ics";
|
|
||||||
|
|
||||||
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
|
// 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.
|
// no matter which time zone the browser of the visitor is set to.
|
||||||
|
|
@ -116,37 +114,6 @@ function daysCovered(start, end) {
|
||||||
return days;
|
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.
|
* 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<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* 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");
|
for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) {
|
||||||
const exceptions = exceptionsByUid(components);
|
add(toOccurrence(event, startDate, endDate));
|
||||||
|
|
||||||
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 occurrences of Object.values(byDate)) {
|
for (const occurrences of Object.values(byDate)) {
|
||||||
|
|
@ -531,18 +397,9 @@ document.addEventListener("DOMContentLoaded", function() {
|
||||||
currentMonth = today.getMonth();
|
currentMonth = today.getMonth();
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
|
|
||||||
fetch(icsUrl)
|
loadCalendar()
|
||||||
.then(response => {
|
.then(loaded => {
|
||||||
// Without this an error page would be handed to the parser below, which
|
calendar = loaded;
|
||||||
// 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));
|
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
})
|
})
|
||||||
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
|
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
|
||||||
|
|
|
||||||
194
assets/js/events.js
Normal file
194
assets/js/events.js
Normal file
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ICAL.Component>} 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,68 +1,4 @@
|
||||||
import ICAL from "./vendor/ical.js";
|
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.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.
|
* 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
|
* @param {ICAL.Component} calendar The parsed calendar
|
||||||
* 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 {Date} now Events must still be running at this date
|
||||||
* @param {number} maxEvents Maximum number of events to return
|
* @param {number} maxEvents Maximum number of events to return
|
||||||
* @param {number} maxDays Maximum number of days into the future
|
* @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
|
* @returns {{start: Date, allDay: boolean, name: string, url: string}[]} url is empty when the event has no URL
|
||||||
*/
|
*/
|
||||||
function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
|
function getUpcomingEvents(calendar, now, maxEvents, maxDays) {
|
||||||
const jcal = ICAL.parse(icsText);
|
|
||||||
const calendar = new ICAL.Component(jcal);
|
|
||||||
|
|
||||||
const end = new Date(now.getTime());
|
const end = new Date(now.getTime());
|
||||||
end.setDate(end.getDate() + maxDays);
|
end.setDate(end.getDate() + maxDays);
|
||||||
|
|
||||||
const events = [];
|
const events = [];
|
||||||
|
|
||||||
const components = calendar.getAllSubcomponents("vevent");
|
// A running event stays listed until it is over, so the window starts at now
|
||||||
const exceptions = exceptionsByUid(components);
|
// and the walk keeps everything that has not ended yet.
|
||||||
|
for (const { event, startDate } of occurrencesBetween(calendar, now, end)) {
|
||||||
for (const component of components) {
|
events.push({
|
||||||
// Occurrences modified via RECURRENCE-ID are reached through the event they
|
start: startOf(startDate),
|
||||||
// belong to, listing them here as well would show them twice.
|
allDay: startDate.isDate,
|
||||||
if (component.hasProperty("recurrence-id")) {
|
name: event.summary ?? "",
|
||||||
continue;
|
url: eventUrl(event),
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// We have occurrences from multiple events, so sort them
|
||||||
|
|
@ -198,25 +54,15 @@ function getUpcomingEvents(icsText, now, maxEvents, maxDays) {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const ics = "/calendars/all.ics";
|
|
||||||
const max_days = 20;
|
const max_days = 20;
|
||||||
const max_items = 5;
|
const max_items = 5;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const table = document.getElementById("upcoming");
|
const table = document.getElementById("upcoming");
|
||||||
fetch(ics)
|
loadCalendar()
|
||||||
.then(response => {
|
.then(calendar => {
|
||||||
// Without this an error page would be handed to the parser below, which
|
getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => {
|
||||||
// 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 row = document.createElement("tr");
|
||||||
|
|
||||||
const colBegin = document.createElement("td");
|
const colBegin = document.createElement("td");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue