Merge pull request #211 from Coffey-Labs/feat/ical-subscriptions

Subscribe to a calendar published at a URL
This commit is contained in:
Coffey Labs
2026-09-02 00:54:55 -07:00
committed by GitHub
11 changed files with 885 additions and 25 deletions
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/ics";
const cal = (body: string) => `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${body}\r\nEND:VCALENDAR\r\n`;
const event = (props: string) => `BEGIN:VEVENT\r\n${props}\r\nEND:VEVENT`;
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
describe("unfold", () => {
it("joins a continuation with nothing between, per the RFC", () => {
expect(unfold("SUMMARY:A very\r\n long title")).toEqual(["SUMMARY:A very long title"]);
expect(unfold("SUMMARY:A\r\n\tB")).toEqual(["SUMMARY:AB"]);
});
it("handles all three line endings", () => {
expect(unfold("A\r\nB\nC\rD")).toEqual(["A", "B", "C", "D"]);
});
it("does not treat a leading space on the first line as a continuation", () => {
expect(unfold(" oops")).toEqual([" oops"]);
});
});
describe("parseLine", () => {
it("splits a plain property", () => {
expect(parseLine("SUMMARY:Standup")).toEqual({ name: "SUMMARY", params: {}, value: "Standup" });
});
it("reads parameters", () => {
expect(parseLine("DTSTART;VALUE=DATE:20260904")).toEqual({
name: "DTSTART",
params: { VALUE: "DATE" },
value: "20260904",
});
});
it("ignores a colon inside a quoted parameter, which is a real shape", () => {
// A naive indexOf(":") reads this as a property called DTSTART;TZID="GMT+01
const line = parseLine('DTSTART;TZID="GMT+01:00":20260904T140000');
expect(line?.name).toBe("DTSTART");
expect(line?.value).toBe("20260904T140000");
expect(line?.params.TZID).toBe("GMT+01:00");
});
it("uppercases the name, since the RFC does not require any particular case", () => {
expect(parseLine("summary:x")?.name).toBe("SUMMARY");
});
it("says nothing about a line with no colon", () => {
expect(parseLine("NONSENSE")).toBeNull();
expect(parseLine("")).toBeNull();
});
});
describe("unescapeText", () => {
it("undoes the four escapes and leaves everything else", () => {
expect(unescapeText("a\\nb")).toBe("a\nb");
expect(unescapeText("a\\Nb")).toBe("a\nb");
expect(unescapeText("a\\,b\\;c")).toBe("a,b;c");
expect(unescapeText("a\\\\b")).toBe("a\\b");
expect(unescapeText("100% \\real")).toBe("100% \\real");
});
});
describe("parseDateValue", () => {
it("reads a date as all-day in local time, not UTC midnight", () => {
// UTC midnight lands on the day before for anyone west of Greenwich.
const out = parseDateValue("20260904");
expect(out?.allDay).toBe(true);
expect(ymd(out!.date)).toBe("2026-09-04");
expect(hhmm(out!.date)).toBe("00:00");
});
it("respects VALUE=DATE even on a longer string", () => {
expect(parseDateValue("20260904", { VALUE: "DATE" })?.allDay).toBe(true);
});
it("reads a UTC instant", () => {
const out = parseDateValue("20260904T140000Z");
expect(out?.allDay).toBe(false);
expect(out?.date.toISOString()).toBe("2026-09-04T14:00:00.000Z");
});
it("reads a floating wall clock as local time", () => {
const out = parseDateValue("20260904T140000");
expect(out?.allDay).toBe(false);
expect(hhmm(out!.date)).toBe("14:00");
expect(ymd(out!.date)).toBe("2026-09-04");
});
it("says nothing about a value it cannot read", () => {
expect(parseDateValue("not a date")).toBeNull();
expect(parseDateValue("")).toBeNull();
});
});
describe("parseIcsDuration", () => {
it("reads the forms a DTEND substitute uses", () => {
expect(parseIcsDuration("PT1H")).toBe(3600);
expect(parseIcsDuration("PT30M")).toBe(1800);
expect(parseIcsDuration("P1D")).toBe(86400);
expect(parseIcsDuration("P1W")).toBe(604800);
expect(parseIcsDuration("P1DT2H30M")).toBe(95400);
expect(parseIcsDuration("-PT1H")).toBe(-3600);
});
it("says nothing about nonsense", () => {
expect(parseIcsDuration("1 hour")).toBeNull();
expect(parseIcsDuration("")).toBeNull();
});
});
describe("looksLikeCalendar", () => {
it("recognises a calendar and rejects an error page", () => {
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
});
});
describe("parseIcs", () => {
it("reads a timed event with a summary and an end", () => {
const { events } = parseIcs(cal(event("UID:a@x\r\nSUMMARY:Standup\r\nDTSTART:20260904T090000Z\r\nDTEND:20260904T091500Z")));
expect(events).toHaveLength(1);
expect(events[0]!.summary).toBe("Standup");
expect(events[0]!.uid).toBe("a@x");
expect(events[0]!.allDay).toBe(false);
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(15 * 60_000);
});
it("reads an all-day event", () => {
const { events } = parseIcs(cal(event("UID:b@x\r\nSUMMARY:Holiday\r\nDTSTART;VALUE=DATE:20260904")));
expect(events[0]!.allDay).toBe(true);
expect(ymd(events[0]!.start)).toBe("2026-09-04");
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(86400_000);
});
it("takes DURATION when there is no DTEND", () => {
const { events } = parseIcs(cal(event("UID:c@x\r\nDTSTART:20260904T090000Z\r\nDURATION:PT90M")));
expect(events[0]!.end.getTime() - events[0]!.start.getTime()).toBe(90 * 60_000);
});
it("reads the calendar's own name where it gives one", () => {
expect(parseIcs(cal(`X-WR-CALNAME:Team calendar\r\n${event("UID:d\r\nDTSTART:20260904T090000Z")}`)).name).toBe("Team calendar");
});
it("unfolds a long summary before reading it", () => {
const { events } = parseIcs(cal("BEGIN:VEVENT\r\nUID:e\r\nDTSTART:20260904T090000Z\r\nSUMMARY:A very\r\n long title\r\nEND:VEVENT"));
expect(events[0]!.summary).toBe("A very long title");
});
it("steps over components that are not events", () => {
const doc = cal(`BEGIN:VTIMEZONE\r\nTZID:Europe/London\r\nBEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n${event("UID:f\r\nSUMMARY:Real\r\nDTSTART:20260904T090000Z")}\r\nBEGIN:VTODO\r\nSUMMARY:Not an event\r\nEND:VTODO`);
const { events } = parseIcs(doc);
expect(events.map((e) => e.summary)).toEqual(["Real"]);
});
it("counts a recurring event once and does not expand it", () => {
// Showing the wrong dates would be worse than showing the first and saying so.
const { events, recurringCount } = parseIcs(cal(event("UID:g\r\nSUMMARY:Weekly\r\nDTSTART:20260904T090000Z\r\nRRULE:FREQ=WEEKLY;COUNT=10")));
expect(events).toHaveLength(1);
expect(events[0]!.recurring).toBe(true);
expect(recurringCount).toBe(1);
});
it("drops an event with no usable start rather than inventing a time", () => {
const { events } = parseIcs(cal(event("UID:h\r\nSUMMARY:When?")));
expect(events).toEqual([]);
});
it("repairs an end that is before its start", () => {
const { events } = parseIcs(cal(event("UID:i\r\nDTSTART:20260904T100000Z\r\nDTEND:20260904T090000Z")));
expect(events[0]!.end.getTime()).toBeGreaterThanOrEqual(events[0]!.start.getTime());
});
it("gives an event with no UID one of its own, so keys stay unique", () => {
const { events } = parseIcs(cal(`${event("SUMMARY:One\r\nDTSTART:20260904T090000Z")}\r\n${event("SUMMARY:Two\r\nDTSTART:20260905T090000Z")}`));
expect(events).toHaveLength(2);
expect(events[0]!.uid).not.toBe(events[1]!.uid);
});
it("reads several events, and survives an empty document", () => {
const many = cal([1, 2, 3].map((n) => event(`UID:m${n}\r\nSUMMARY:E${n}\r\nDTSTART:2026090${n}T090000Z`)).join("\r\n"));
expect(parseIcs(many).events.map((e) => e.summary)).toEqual(["E1", "E2", "E3"]);
expect(parseIcs("").events).toEqual([]);
expect(parseIcs("<!doctype html>").events).toEqual([]);
});
});
+261
View File
@@ -0,0 +1,261 @@
/**
* Reading an iCalendar document (RFC 5545), enough of one to draw it.
*
* This is a *subscription* parser, not an importer. A subscribed calendar is
* read-only and redrawn from scratch on every refresh, so nothing here has to
* round-trip, survive an edit, or preserve a property it does not understand —
* which is most of what makes a full iCalendar implementation large. What it
* has to do is never mis-state a time, and never hang on a document somebody
* else wrote.
*
* Recurrence is deliberately not expanded. `RRULE` is a small language with a
* lot of edge cases, and a subscription that quietly showed the wrong dates
* would be worse than one that shows the first occurrence and says so.
*/
export interface IcsEvent {
uid: string;
summary: string;
start: Date;
end: Date;
allDay: boolean;
location?: string;
description?: string;
/** True when the source carried an RRULE that has not been expanded. */
recurring: boolean;
}
/**
* Undo the line folding RFC 5545 requires: a continuation is any line starting
* with a space or a tab, and it joins the one before with nothing between.
*/
export function unfold(text: string): string[] {
const out: string[] = [];
for (const raw of text.split(/\r\n|\n|\r/)) {
if ((raw.startsWith(" ") || raw.startsWith("\t")) && out.length) out[out.length - 1] += raw.slice(1);
else out.push(raw);
}
return out;
}
interface Line {
name: string;
params: Record<string, string>;
value: string;
}
/**
* One content line, as `NAME;PARAM=VALUE:the value`.
*
* The colon that ends the name is the first one *outside* a quoted parameter,
* because a parameter may legally contain one — `DTSTART;TZID="GMT+01:00":…`
* is a real thing that a naive `indexOf(":")` reads as a property called
* `DTSTART;TZID="GMT+01`.
*/
export function parseLine(line: string): Line | null {
let quoted = false;
let colon = -1;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') quoted = !quoted;
else if (ch === ":" && !quoted) {
colon = i;
break;
}
}
if (colon < 0) return null;
const head = line.slice(0, colon);
const value = line.slice(colon + 1);
const parts: string[] = [];
let current = "";
quoted = false;
for (const ch of head) {
if (ch === '"') quoted = !quoted;
if (ch === ";" && !quoted) {
parts.push(current);
current = "";
} else current += ch;
}
parts.push(current);
const name = (parts.shift() ?? "").toUpperCase();
if (!name) return null;
const params: Record<string, string> = {};
for (const p of parts) {
const eq = p.indexOf("=");
if (eq < 0) continue;
params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, "");
}
return { name, params, value };
}
/** `\n`, `\,`, `\;` and `\\` are escapes in a TEXT value; nothing else is. */
export function unescapeText(value: string): string {
return value.replace(/\\([nN,;\\])/g, (_, ch: string) => (ch === "n" || ch === "N" ? "\n" : ch));
}
/**
* A DATE or DATE-TIME value.
*
* Three forms, and the difference between them is the whole of why calendars
* are hard:
*
* - `20260904` — a date. All-day, and it means that date wherever the reader
* is, so it is built in local time rather than at UTC midnight, which would
* land on the day before for anyone west of Greenwich.
* - `20260904T140000Z` — an instant, in UTC.
* - `20260904T140000` — a wall clock, with a `TZID` naming where. Without a
* library this cannot be converted exactly, so it is read as local time:
* right for the overwhelmingly common case of a calendar published in the
* reader's own zone, and wrong by the offset otherwise. That limit is
* stated rather than hidden.
*/
export function parseDateValue(value: string, params: Record<string, string> = {}): { date: Date; allDay: boolean } | null {
const v = value.trim();
const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(v);
if (dateOnly || params.VALUE === "DATE") {
const m = dateOnly ?? /^(\d{4})(\d{2})(\d{2})/.exec(v);
if (!m) return null;
return { date: new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])), allDay: true };
}
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v);
if (!m) return null;
const [, y, mo, d, h, mi, se, z] = m;
if (z) {
return { date: new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se))), allDay: false };
}
return { date: new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se)), allDay: false };
}
/** An RFC 5545 DURATION, as seconds. Only the forms a DTEND substitute uses. */
export function parseIcsDuration(value: string): number | null {
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value.trim());
if (!m) return null;
const [, sign, w, d, h, mi, s] = m;
const total = (Number(w ?? 0) * 604800) + (Number(d ?? 0) * 86400) + (Number(h ?? 0) * 3600) + (Number(mi ?? 0) * 60) + Number(s ?? 0);
return sign === "-" ? -total : total;
}
/** Whether a document is plausibly a calendar, rather than an error page. */
export function looksLikeCalendar(text: string): boolean {
return /^\s*BEGIN:VCALENDAR/im.test(text);
}
export interface ParseResult {
events: IcsEvent[];
/** The calendar's own name, where it gave one. */
name: string | null;
/** Events skipped because they carried a recurrence rule. */
recurringCount: number;
}
/**
* Every VEVENT in the document.
*
* VTODO, VJOURNAL, VFREEBUSY and VTIMEZONE are stepped over rather than
* half-read. An event with no usable start is dropped: there is nowhere to
* draw it, and inventing a time is the one thing worse than leaving it out.
*/
export function parseIcs(text: string): ParseResult {
const events: IcsEvent[] = [];
let name: string | null = null;
let recurringCount = 0;
let current: Partial<IcsEvent> & { dtend?: Date; duration?: number; endAllDay?: boolean } | null = null;
/** Depth of any component that is not a VEVENT, so its properties are ignored. */
let skipping = 0;
for (const raw of unfold(text)) {
const line = parseLine(raw);
if (!line) continue;
const { name: prop, params, value } = line;
if (prop === "BEGIN") {
const kind = value.trim().toUpperCase();
if (kind === "VEVENT" && !skipping) current = { recurring: false };
else if (kind !== "VCALENDAR") skipping++;
continue;
}
if (prop === "END") {
const kind = value.trim().toUpperCase();
if (kind === "VEVENT" && current) {
const finished = finish(current);
if (finished) {
if (finished.recurring) recurringCount++;
events.push(finished);
}
current = null;
} else if (kind !== "VCALENDAR" && skipping) skipping--;
continue;
}
if (skipping) continue;
if (!current) {
// Calendar-level properties. X-WR-CALNAME is not in the RFC but is what
// every publisher actually uses to name a calendar.
if (prop === "X-WR-CALNAME") name = unescapeText(value).trim() || null;
continue;
}
switch (prop) {
case "UID":
current.uid = value.trim();
break;
case "SUMMARY":
current.summary = unescapeText(value).trim();
break;
case "LOCATION":
current.location = unescapeText(value).trim();
break;
case "DESCRIPTION":
current.description = unescapeText(value).trim();
break;
case "RRULE":
current.recurring = true;
break;
case "DTSTART": {
const parsed = parseDateValue(value, params);
if (parsed) {
current.start = parsed.date;
current.allDay = parsed.allDay;
}
break;
}
case "DTEND": {
const parsed = parseDateValue(value, params);
if (parsed) {
current.dtend = parsed.date;
current.endAllDay = parsed.allDay;
}
break;
}
case "DURATION":
current.duration = parseIcsDuration(value) ?? undefined;
break;
default:
break;
}
}
return { events, name, recurringCount };
}
function finish(e: Partial<IcsEvent> & { dtend?: Date; duration?: number }): IcsEvent | null {
if (!e.start || Number.isNaN(e.start.getTime())) return null;
const allDay = Boolean(e.allDay);
let end: Date;
if (e.dtend && !Number.isNaN(e.dtend.getTime())) end = e.dtend;
else if (typeof e.duration === "number") end = new Date(e.start.getTime() + e.duration * 1000);
// No end and no duration: a date is the whole day, an instant is a moment.
else end = allDay ? new Date(e.start.getTime() + 86400_000) : new Date(e.start.getTime());
// An end at or before the start is a document being wrong about itself.
if (end.getTime() < e.start.getTime()) end = new Date(e.start.getTime() + (allDay ? 86400_000 : 0));
return {
uid: e.uid || `${e.start.getTime()}-${e.summary ?? ""}`,
summary: e.summary || "(untitled)",
start: e.start,
end,
allDay,
location: e.location,
description: e.description,
recurring: Boolean(e.recurring),
};
}