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
+28
View File
@@ -529,6 +529,34 @@ nothing for anybody else.
- **iCal import** through `CalendarEvent/parse` (a file of any number of - **iCal import** through `CalendarEvent/parse` (a file of any number of
events), from the calendar's own menu, into that calendar. The events are events), from the calendar's own menu, into that calendar. The events are
filed rather than scheduled: no invitations go out to anyone named in them. filed rather than scheduled: no invitations go out to anyone named in them.
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
Added in Settings Calendar & contacts, read-only, and shown beside your own
with their own colour.
**Nothing is stored.** The document is fetched when you open the calendar and
parsed in the browser; the server keeps no copy, no cache and no schedule,
which is what lets an immutable container serve this at all. There is no
timer either: ihasmail has nowhere to run one, so the honest guarantee is
that a subscription is as current as the last time somebody looked — which is
also when it matters.
The fetch has to happen on the server, because a calendar URL belongs to
whoever published it and almost none of them send CORS headers. That makes it
the second place ihasmail reaches an address a stranger chose, and it goes
through **exactly the same guard as the image proxy** — one implementation,
not two: the name is resolved and every answer must be acceptable, the
connection is pinned to the address that was checked, and each redirect is
re-resolved and re-pinned. `webcal:` is understood, because that is how these
are published, and it is read as `https:` rather than waved past the checks.
Two consequences worth stating. A calendar on a private address — including
one on your own machine — is refused, by design. And **recurring events are
not expanded**: `RRULE` is a small language with a lot of edge cases, and a
subscription quietly showing the wrong dates would be worse than one showing
the first occurrence.
A subscription that cannot be read **says so** in the sidebar rather than
drawing an empty calendar, which looks like a calendar with nothing in it.
- **Birthdays**, as a calendar of its own derived from the birthdays already on - **Birthdays**, as a calendar of its own derived from the birthdays already on
your contacts. Off until switched on in Settings Calendar & contacts, and your contacts. Off until switched on in Settings Calendar & contacts, and
hideable from the calendar's own sidebar without turning it off. hideable from the calendar's own sidebar without turning it off.
+4
View File
@@ -30,6 +30,7 @@ import {
revokeAppPassword, revokeAppPassword,
} from "./account.js"; } from "./account.js";
import { imageProxyHandler } from "./imageproxy.js"; import { imageProxyHandler } from "./imageproxy.js";
import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js"; import { staticHandler } from "./static.js";
type Env = { Variables: { session: LiveSession } }; type Env = { Variables: { session: LiveSession } };
@@ -613,6 +614,9 @@ export function createApp(basePath = config.basePath): Hono<Env> {
// ---------- Remote image privacy proxy ---------- // ---------- Remote image privacy proxy ----------
api.get("/image", requireSession, imageProxyHandler); api.get("/image", requireSession, imageProxyHandler);
// Behind the session for the same reason the image proxy is: an open fetcher
// on someone else's server is a gift to whoever finds it.
api.get("/ics", requireSession, icsProxyHandler);
api.notFound((c) => c.json({ error: "not_found" }, 404)); api.notFound((c) => c.json({ error: "not_found" }, 404));
api.onError((err, c) => { api.onError((err, c) => {
+62
View File
@@ -0,0 +1,62 @@
import { test } from "node:test";
import assert from "node:assert/strict";
process.env.STALWART_URL = "http://127.0.0.1:1";
process.env.APP_SECRET = "test-secret-for-ics-proxy";
const { safeFetch, safeFetchStatus } = await import("./imageproxy.js");
/**
* Subscribing to a calendar makes the server fetch a URL a stranger published,
* which is the second time this app knocks on a door somebody else chose. It
* goes through the same guard as the first — these tests are about that guard
* being reached, and about `webcal:` not being a way around it.
*/
test("a calendar URL is refused before any connection when it points somewhere private", async () => {
for (const url of [
"http://127.0.0.1/calendar.ics",
"http://169.254.169.254/latest/meta-data/", // cloud metadata
"http://[::1]/calendar.ics",
"http://10.0.0.1/c.ics",
"https://192.168.1.1/c.ics",
]) {
const got = await safeFetch(url, 500);
assert.equal(got, "forbidden_target", url);
}
});
test("webcal: is treated as https rather than waved through", async () => {
// Every subscription URL people are given is a webcal: one. It has to be
// understood, and it must not be a way past the address check.
const got = await safeFetch("webcal://127.0.0.1/calendar.ics", 500);
assert.equal(got, "forbidden_target");
});
test("schemes that are not http, https or webcal are refused", async () => {
for (const url of ["file:///etc/passwd", "ftp://example.com/c.ics", "gopher://example.com", "data:text/calendar,BEGIN:VCALENDAR"]) {
const got = await safeFetch(url, 500);
assert.equal(got, "bad_scheme", url);
}
});
test("a URL carrying credentials is refused", async () => {
// Credentials in a subscription URL would be sent by the server on the
// reader's behalf to a host the reader may not have looked at.
assert.equal(await safeFetch("http://user:[email protected]/c.ics", 500), "bad_url");
});
test("nonsense is refused rather than guessed at", async () => {
for (const url of ["", "not a url", "://missing-scheme"]) {
assert.equal(await safeFetch(url, 500), "bad_url", JSON.stringify(url));
}
});
test("each refusal has a status that says which kind it was", () => {
assert.equal(safeFetchStatus("forbidden_target"), 403);
assert.equal(safeFetchStatus("bad_scheme"), 400);
assert.equal(safeFetchStatus("bad_url"), 400);
assert.equal(safeFetchStatus("bad_redirect"), 400);
assert.equal(safeFetchStatus("dns_failure"), 502);
assert.equal(safeFetchStatus("fetch_failed"), 502);
});
+85
View File
@@ -0,0 +1,85 @@
import type { Context } from "hono";
import { safeFetch, safeFetchStatus } from "./imageproxy.js";
/**
* Fetching a calendar somebody has subscribed to.
*
* The browser cannot do this itself: a calendar URL belongs to whoever
* published it and almost none of them send CORS headers, so the request has
* to be made from here. That makes it the second place ihasmail reaches out to
* an address a stranger chose, and it goes through exactly the same guard as
* the first — `safeFetch` resolves the name, refuses private space on every
* answer, pins the connection to the address it checked, and re-checks each
* redirect. There is deliberately no second implementation of that.
*
* **Nothing is stored.** The text goes straight back to the browser, which
* parses it and holds the result in memory for as long as the tab is open. The
* server keeps no copy, no cache and no schedule, which is what lets an
* immutable container serve this at all.
*/
/** Generous for a calendar, small enough that nobody can post a film through it. */
const MAX_ICS_BYTES = 4 * 1024 * 1024;
/**
* Types a calendar is served as in practice. `text/plain` and the octet-stream
* are here because a great many servers get this wrong, and refusing a real
* calendar over a header the publisher chose badly helps nobody -- the parser
* checks the content itself, which is the claim that actually matters.
*/
const ACCEPTABLE = new Set(["text/calendar", "text/plain", "application/octet-stream", "application/ics", ""]);
export async function icsProxyHandler(c: Context) {
const got = await safeFetch(c.req.query("url") ?? "", 20_000);
if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400);
const { res, done } = got;
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
done();
res.resume();
return c.json({ error: "fetch_failed", status: res.statusCode ?? 0 }, 502);
}
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
if (!ACCEPTABLE.has(type)) {
done();
res.resume();
return c.json({ error: "not_calendar", type }, 415);
}
const declared = Number(res.headers["content-length"] ?? "0");
if (declared > MAX_ICS_BYTES) {
done();
res.resume();
return c.json({ error: "too_large" }, 413);
}
// Read it here rather than streaming: the browser needs the whole document
// to parse it, and the cap has to hold whether or not a length was declared.
let total = 0;
const chunks: Buffer[] = [];
try {
await new Promise<void>((resolve, reject) => {
res.on("data", (chunk: Buffer) => {
total += chunk.byteLength;
if (total > MAX_ICS_BYTES) {
res.destroy();
reject(new Error("too_large"));
return;
}
chunks.push(chunk);
});
res.on("end", () => resolve());
res.on("error", reject);
});
} catch (err) {
done();
return c.json({ error: (err as Error).message === "too_large" ? "too_large" : "fetch_failed" }, 502);
}
done();
return c.body(Buffer.concat(chunks).toString("utf8"), 200, {
"Content-Type": "text/calendar; charset=utf-8",
// Never stored on disk, and never held by anything in between either.
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
}
+60 -22
View File
@@ -98,32 +98,50 @@ export function fetchPinned(url: URL, addr: string, signal?: AbortSignal): Promi
* Gmail-style remote content proxy: hides the reader's IP address and * Gmail-style remote content proxy: hides the reader's IP address and
* user-agent from tracking pixels, and blocks SSRF to internal networks. * user-agent from tracking pixels, and blocks SSRF to internal networks.
*/ */
export async function imageProxyHandler(c: Context) { /** Why a guarded fetch refused, in the words the handlers answer with. */
if (!config.imageProxy) return c.json({ error: "disabled" }, 404); export type SafeFetchError = "bad_url" | "bad_scheme" | "forbidden_target" | "dns_failure" | "fetch_failed" | "bad_redirect";
const raw = c.req.query("url") ?? "";
export interface SafeFetchResult {
res: IncomingMessage;
/** The URL actually fetched, which is not the one asked for if it redirected. */
url: URL;
done: () => void;
}
/**
* Fetch a URL nobody here chose, with every check the image proxy has always
* made — and made in one place, because a second copy of an SSRF guard is how
* one of them ends up missing a case.
*
* The name is resolved first and *every* answer has to be acceptable, the
* connection is pinned to the address that was checked, and each redirect hop
* is re-resolved and re-pinned rather than handed to the socket library.
*/
export async function safeFetch(raw: string, timeoutMs = 15_000): Promise<SafeFetchResult | SafeFetchError> {
let url: URL; let url: URL;
try { try {
url = new URL(raw); url = new URL(raw);
} catch { } catch {
return c.json({ error: "bad_url" }, 400); return "bad_url";
} }
if (url.protocol !== "http:" && url.protocol !== "https:") return c.json({ error: "bad_scheme" }, 400); // webcal: is an http URL wearing a different word; nothing else is allowed.
if (url.username || url.password) return c.json({ error: "bad_url" }, 400); if (url.protocol === "webcal:") url = new URL(`https:${raw.slice(raw.indexOf(":") + 1)}`);
if (url.protocol !== "http:" && url.protocol !== "https:") return "bad_scheme";
if (url.username || url.password) return "bad_url";
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000); const timer = setTimeout(() => controller.abort(), timeoutMs);
let res: IncomingMessage; const done = () => clearTimeout(timer);
try { try {
let addr: string; let addr: string;
try { try {
addr = await resolveAllowed(url.hostname); addr = await resolveAllowed(url.hostname);
} catch (err) { } catch (err) {
clearTimeout(timer); done();
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502); return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
} }
res = await fetchPinned(url, addr, controller.signal); let res = await fetchPinned(url, addr, controller.signal);
// Follow a limited number of redirects, re-checking and re-pinning each hop.
let hops = 0; let hops = 0;
while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) { while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) {
const loc = res.headers.location; const loc = res.headers.location;
@@ -131,38 +149,58 @@ export async function imageProxyHandler(c: Context) {
res.resume(); // discard the redirect body res.resume(); // discard the redirect body
const next = new URL(loc, url); const next = new URL(loc, url);
if (next.protocol !== "http:" && next.protocol !== "https:") { if (next.protocol !== "http:" && next.protocol !== "https:") {
clearTimeout(timer); done();
return c.json({ error: "bad_redirect" }, 400); return "bad_redirect";
} }
try { try {
addr = await resolveAllowed(next.hostname); addr = await resolveAllowed(next.hostname);
} catch (err) { } catch (err) {
clearTimeout(timer); done();
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502); return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
} }
url = next; url = next;
res = await fetchPinned(url, addr, controller.signal); res = await fetchPinned(url, addr, controller.signal);
hops++; hops++;
} }
return { res, url, done };
} catch { } catch {
clearTimeout(timer); done();
return c.json({ error: "fetch_failed" }, 502); return "fetch_failed";
} }
}
const SAFE_FETCH_STATUS: Record<SafeFetchError, number> = {
bad_url: 400,
bad_scheme: 400,
bad_redirect: 400,
forbidden_target: 403,
dns_failure: 502,
fetch_failed: 502,
};
export function safeFetchStatus(err: SafeFetchError): number {
return SAFE_FETCH_STATUS[err];
}
export async function imageProxyHandler(c: Context) {
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
const got = await safeFetch(c.req.query("url") ?? "");
if (typeof got === "string") return c.json({ error: got }, safeFetchStatus(got) as 400);
const { res, done } = got;
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
clearTimeout(timer); done();
res.resume(); res.resume();
return c.json({ error: "fetch_failed" }, 502); return c.json({ error: "fetch_failed" }, 502);
} }
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase(); const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
if (!type.startsWith("image/") || type === "image/svg+xml") { if (!type.startsWith("image/") || type === "image/svg+xml") {
clearTimeout(timer); done();
res.resume(); res.resume();
return c.json({ error: "not_image" }, 415); return c.json({ error: "not_image" }, 415);
} }
const len = Number(res.headers["content-length"] ?? "0"); const len = Number(res.headers["content-length"] ?? "0");
if (len > MAX_IMAGE_BYTES) { if (len > MAX_IMAGE_BYTES) {
clearTimeout(timer); done();
res.resume(); res.resume();
return c.json({ error: "too_large" }, 413); return c.json({ error: "too_large" }, 413);
} }
@@ -176,7 +214,7 @@ export async function imageProxyHandler(c: Context) {
else controller2.enqueue(chunk); else controller2.enqueue(chunk);
}, },
}); });
res.on("close", () => clearTimeout(timer)); res.on("close", done);
const headers = new Headers({ const headers = new Headers({
"Content-Type": type, "Content-Type": type,
"Cache-Control": "private, max-age=86400", "Cache-Control": "private, max-age=86400",
+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),
};
}
+107 -2
View File
@@ -5,6 +5,8 @@ import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browser
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { useContacts } from "./contacts"; import { useContacts } from "./contacts";
import { BIRTHDAY_CALENDAR_ID, birthdaysInRange, isBirthdayEvent, type Birthday } from "@/lib/birthdays"; import { BIRTHDAY_CALENDAR_ID, birthdaysInRange, isBirthdayEvent, type Birthday } from "@/lib/birthdays";
import { looksLikeCalendar, parseIcs, type IcsEvent } from "@/lib/ics";
import { withBase } from "@/lib/basePath";
import { settings, useSettings } from "./settings"; import { settings, useSettings } from "./settings";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -261,6 +263,11 @@ interface CalendarState {
error: string | null; error: string | null;
identities: ParticipantIdentity[]; identities: ParticipantIdentity[];
hidden: Record<Id, true>; hidden: Record<Id, true>;
/** Events from each subscribed calendar, by subscription id. Never persisted. */
subscriptionEvents: Record<string, IcsEvent[]>;
/** Why a subscription last failed, if it did. */
subscriptionErrors: Record<string, string>;
subscriptionsLoading: boolean;
/** Waiting to be opened in the editor; see `EventDraft`. */ /** Waiting to be opened in the editor; see `EventDraft`. */
draft: EventDraft | null; draft: EventDraft | null;
@@ -273,6 +280,8 @@ interface CalendarState {
setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise<void>; setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise<void>;
loadRange(start: Date, end: Date, force?: boolean): Promise<void>; loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
instancesIn(start: Date, end: Date): EventInstance[]; instancesIn(start: Date, end: Date): EventInstance[];
/** Re-fetch every subscribed calendar. */
refreshSubscriptions(): Promise<void>;
getEvent(id: Id): Promise<CalendarEvent | null>; getEvent(id: Id): Promise<CalendarEvent | null>;
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>; createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
/** Returns the properties that had to be left to the series, if any. */ /** Returns the properties that had to be left to the series, if any. */
@@ -334,6 +343,9 @@ export const useCalendar = create<CalendarState>((set, get) => ({
error: null, error: null,
identities: [], identities: [],
hidden: {}, hidden: {},
subscriptionEvents: {},
subscriptionErrors: {},
subscriptionsLoading: false,
draft: null, draft: null,
async init() { async init() {
@@ -502,6 +514,42 @@ export const useCalendar = create<CalendarState>((set, get) => ({
} }
}, },
async refreshSubscriptions() {
const subs = settings().icalSubscriptions;
if (!subs.length) {
if (Object.keys(get().subscriptionEvents).length) set({ subscriptionEvents: {}, subscriptionErrors: {} });
return;
}
set({ subscriptionsLoading: true });
const events: Record<string, IcsEvent[]> = {};
const errors: Record<string, string> = {};
/*
* Sequential rather than parallel. These are other people's servers, and a
* reader with a dozen subscriptions opening the calendar should not put a
* dozen simultaneous requests on them from every device they own.
*/
for (const sub of subs) {
try {
const res = await fetch(withBase(`/api/ics?url=${encodeURIComponent(sub.url)}`), { headers: { "X-Requested-With": "ihasmail" } });
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
errors[sub.id] = body.error ?? `HTTP ${res.status}`;
continue;
}
const text = await res.text();
if (!looksLikeCalendar(text)) {
// A login page answering 200 is the usual shape of this.
errors[sub.id] = "not_calendar";
continue;
}
events[sub.id] = parseIcs(text).events;
} catch (err) {
errors[sub.id] = (err as Error).message;
}
}
set({ subscriptionEvents: events, subscriptionErrors: errors, subscriptionsLoading: false });
},
instancesIn(start, end) { instancesIn(start, end) {
const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get(); const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get();
/* /*
@@ -524,6 +572,27 @@ export const useCalendar = create<CalendarState>((set, get) => ({
}); });
} }
} }
/*
* Subscribed calendars, from whatever the last refresh fetched. Same funnel
* as the birthdays and for the same reason: no view has to know they are
* not real calendars, and nothing about them is stored.
*/
for (const sub of settings().icalSubscriptions) {
const calId = subscriptionCalendarId(sub.id);
if (hidden[calId]) continue;
const cal = subscriptionCalendar(sub);
for (const e of get().subscriptionEvents[sub.id] ?? []) {
if (e.end <= start || e.start >= end) continue;
birthdays.push({
key: `${calId}:${e.uid}:${e.start.getTime()}`,
event: synthesiseSubscriptionEvent(sub.id, e),
start: e.start,
end: e.end,
allDay: e.allDay,
calendar: cal,
});
}
}
const ids = new Set<Id>(); const ids = new Set<Id>();
for (const list of Object.values(ranges)) for (const id of list) ids.add(id); for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
const out: EventInstance[] = []; const out: EventInstance[] = [];
@@ -596,7 +665,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
* is the check that makes that true of the store as well, whatever calls * is the check that makes that true of the store as well, whatever calls
* it. * it.
*/ */
if (isBirthdayEvent(event.id)) return []; if (isBirthdayEvent(event.id) || isSubscriptionEvent(event.id)) return [];
const accountId = get().accountId!; const accountId = get().accountId!;
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope); const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
// An occurrence takes less than the series does, and says so about only // An occurrence takes less than the series does, and says so about only
@@ -618,7 +687,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
* is the check that makes that true of the store as well, whatever calls * is the check that makes that true of the store as well, whatever calls
* it. * it.
*/ */
if (isBirthdayEvent(event.id)) return; if (isBirthdayEvent(event.id) || isSubscriptionEvent(event.id)) return;
const accountId = get().accountId!; const accountId = get().accountId!;
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope); const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites }); const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
@@ -844,6 +913,42 @@ function synthesiseBirthdayEvent(b: Birthday): CalendarEvent {
} as unknown as CalendarEvent; } as unknown as CalendarEvent;
} }
/** The virtual calendar id for a subscription; never a JMAP id. */
export function subscriptionCalendarId(subId: string): string {
return `ihm-ics:${subId}`;
}
export function isSubscriptionEvent(id: string | null | undefined): boolean {
return Boolean(id?.startsWith("ihm-ics:"));
}
function subscriptionCalendar(sub: { id: string; name: string; color: string }): Calendar {
return {
id: subscriptionCalendarId(sub.id),
name: sub.name,
color: sub.color,
isSubscribed: true,
isVisible: true,
// Read-only, and honestly so: everything that asks before offering an edit
// reads these rights, so nothing has to know a subscription is special.
myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayAdmin: false, mayDelete: false },
} as unknown as Calendar;
}
function synthesiseSubscriptionEvent(subId: string, e: IcsEvent): CalendarEvent {
const local = `${e.start.getFullYear()}-${String(e.start.getMonth() + 1).padStart(2, "0")}-${String(e.start.getDate()).padStart(2, "0")}T${String(e.start.getHours()).padStart(2, "0")}:${String(e.start.getMinutes()).padStart(2, "0")}:00`;
return {
id: `${subscriptionCalendarId(subId)}:${e.uid}`,
calendarIds: { [subscriptionCalendarId(subId)]: true },
title: e.summary,
start: local,
showWithoutTime: e.allDay,
location: e.location,
description: e.description,
freeBusyStatus: "free",
} as unknown as CalendarEvent;
}
export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null { export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null {
const allDay = Boolean(e.showWithoutTime); const allDay = Boolean(e.showWithoutTime);
let start: Date; let start: Date;
+15
View File
@@ -41,6 +41,14 @@ export interface Label {
visibility?: LabelVisibility; visibility?: LabelVisibility;
} }
/** A calendar subscribed to by URL, read-only and redrawn on every refresh. */
export interface IcalSubscription {
id: string;
url: string;
name: string;
color: string;
}
export interface Template { export interface Template {
id: string; id: string;
name: string; name: string;
@@ -152,6 +160,12 @@ export interface Settings {
* dates nobody put there is a surprise rather than a feature. * dates nobody put there is a surprise rather than a feature.
*/ */
birthdayCalendar: boolean; birthdayCalendar: boolean;
/**
* Calendars subscribed to by URL. The subscription is the setting; the
* events themselves are fetched on demand and never stored, so this follows
* the account the way every other preference does and costs nothing to sync.
*/
icalSubscriptions: IcalSubscription[];
/** What order the message list is in. See lib/listSort.ts. */ /** What order the message list is in. See lib/listSort.ts. */
listSortPreset: SortPreset; listSortPreset: SortPreset;
listSortLevels: SortLevel[]; listSortLevels: SortLevel[];
@@ -267,6 +281,7 @@ export const DEFAULT_SETTINGS: Settings = {
timeZone: null, timeZone: null,
labelsSidebar: true, labelsSidebar: true,
birthdayCalendar: false, birthdayCalendar: false,
icalSubscriptions: [],
listSortPreset: "newest", listSortPreset: "newest",
listSortLevels: [], listSortLevels: [],
listSortScope: "inbox", listSortScope: "inbox",
+33 -1
View File
@@ -1,10 +1,11 @@
import { useMemo, useRef, useState, useEffect } from "react"; import { useMemo, useRef, useState, useEffect } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X } from "lucide-react"; import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X, AlertTriangle } from "lucide-react";
import { useCalendar } from "@/store/calendar"; import { useCalendar } from "@/store/calendar";
import { dateTimeKey, useSettings } from "@/store/settings"; import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates"; import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthdays"; import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthdays";
import { subscriptionCalendarId } from "@/store/calendar";
import { useContacts } from "@/store/contacts"; import { useContacts } from "@/store/contacts";
import { formatMonthYear } from "@/lib/format"; import { formatMonthYear } from "@/lib/format";
import { formatWeekday } from "@/lib/datetime"; import { formatWeekday } from "@/lib/datetime";
@@ -65,6 +66,17 @@ export function CalendarSidebar() {
if (!cal.available) return null; if (!cal.available) return null;
const birthdaysOn = useSettings((st) => st.settings.birthdayCalendar); const birthdaysOn = useSettings((st) => st.settings.birthdayCalendar);
const subscriptions = useSettings((st) => st.settings.icalSubscriptions);
/*
* Refreshed when the calendar is opened, and not on a timer. ihasmail has
* nowhere to run a schedule -- no worker, no server-side state -- so the
* honest guarantee is that a subscription is as current as the last time
* somebody looked, which is also when it matters.
*/
useEffect(() => {
if (subscriptions.length) void useCalendar.getState().refreshSubscriptions();
}, [subscriptions]);
/* /*
* The cards have to be loaded for there to be any birthdays to derive, and * The cards have to be loaded for there to be any birthdays to derive, and
* the calendar is a view somebody can land on directly without ever opening * the calendar is a view somebody can land on directly without ever opening
@@ -109,6 +121,26 @@ export function CalendarSidebar() {
<span className="cal-name">{t("Birthdays")}</span> <span className="cal-name">{t("Birthdays")}</span>
</div> </div>
)} )}
{subscriptions.map((sub) => {
const id = subscriptionCalendarId(sub.id);
const failed = cal.subscriptionErrors[sub.id];
const count = cal.subscriptionEvents[sub.id]?.length ?? 0;
return (
<div
key={id}
className={`cal-list-item ${cal.hidden[id] ? "hidden-cal" : ""}`}
onClick={() => cal.toggleHidden(id)}
title={failed ? t("Could not read this calendar: {reason}", { reason: failed }) : t("Subscribed to {url}", { url: sub.url })}
>
<span className="cal-color" style={{ background: sub.color, borderColor: sub.color }} />
<span className="cal-name">{sub.name}</span>
{/* A subscription that cannot be read says so here rather than
drawing an empty calendar, which looks like a calendar with
nothing in it. */}
{failed ? <AlertTriangle size={12} className="faint" aria-label={t("Could not be read")} /> : count === 0 ? null : null}
</div>
);
})}
{calendars.map((c) => ( {calendars.map((c) => (
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}> <div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} /> <span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
@@ -61,6 +61,49 @@ export function CalendarSettings() {
))} ))}
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> {t("New category")}</button> <button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> {t("New category")}</button>
<h2>{t("Subscribed calendars")}</h2>
<p className="hint" style={{ marginTop: -8 }}>
{t("A calendar published at a URL — a timetable, a rota, a public holiday list. It is read-only, refreshed when you open the calendar, and never stored: the events are fetched and kept only for as long as this tab is open.")}
</p>
{s.icalSubscriptions.map((sub) => (
<div key={sub.id} className="card">
<div className="card-head">
<span className="label-dot" style={{ background: sub.color, width: 14, height: 14 }} />
<h3>{sub.name}</h3>
<button
className="icon-btn sm danger"
aria-label={t("Remove subscription")}
onClick={() => update({ icalSubscriptions: s.icalSubscriptions.filter((x) => x.id !== sub.id) })}
>
<Trash2 size={16} />
</button>
</div>
<div className="hint truncate notranslate" translate="no">{sub.url}</div>
<div style={{ marginTop: 8 }}>
<ColorSwatches value={sub.color} onChange={(c) => update({ icalSubscriptions: s.icalSubscriptions.map((x) => (x.id === sub.id ? { ...x, color: c } : x)) })} />
</div>
</div>
))}
<button
className="btn"
onClick={async () => {
const url = await promptDialog({ title: t("Subscribe to a calendar"), placeholder: "https://example.com/calendar.ics" });
if (!url?.trim()) return;
const name = await promptDialog({ title: t("What is it called?"), defaultValue: t("Subscribed calendar"), placeholder: t("Name") });
if (!name?.trim()) return;
update({
icalSubscriptions: [
...s.icalSubscriptions,
// webcal: is how these are almost always published; it is an
// https URL wearing a different word, and the server treats it so.
{ id: `ics${Date.now()}`, url: url.trim(), name: name.trim(), color: CALENDAR_COLORS[s.icalSubscriptions.length % CALENDAR_COLORS.length]! },
],
});
}}
>
<Plus size={16} /> {t("Subscribe to a calendar")}
</button>
<h2>{t("Birthdays")}</h2> <h2>{t("Birthdays")}</h2>
<Switch <Switch
checked={s.birthdayCalendar} checked={s.birthdayCalendar}