Merge pull request #211 from Coffey-Labs/feat/ical-subscriptions
Subscribe to a calendar published at a URL
This commit is contained in:
+28
@@ -529,6 +529,34 @@ nothing for anybody else.
|
||||
- **iCal import** through `CalendarEvent/parse` (a file of any number of
|
||||
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.
|
||||
- **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
|
||||
your contacts. Off until switched on in Settings › Calendar & contacts, and
|
||||
hideable from the calendar's own sidebar without turning it off.
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
@@ -613,6 +614,9 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
|
||||
// ---------- Remote image privacy proxy ----------
|
||||
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.onError((err, c) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
* user-agent from tracking pixels, and blocks SSRF to internal networks.
|
||||
*/
|
||||
export async function imageProxyHandler(c: Context) {
|
||||
if (!config.imageProxy) return c.json({ error: "disabled" }, 404);
|
||||
const raw = c.req.query("url") ?? "";
|
||||
/** Why a guarded fetch refused, in the words the handlers answer with. */
|
||||
export type SafeFetchError = "bad_url" | "bad_scheme" | "forbidden_target" | "dns_failure" | "fetch_failed" | "bad_redirect";
|
||||
|
||||
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;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} 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);
|
||||
if (url.username || url.password) return c.json({ error: "bad_url" }, 400);
|
||||
// webcal: is an http URL wearing a different word; nothing else is allowed.
|
||||
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 timer = setTimeout(() => controller.abort(), 15_000);
|
||||
let res: IncomingMessage;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const done = () => clearTimeout(timer);
|
||||
try {
|
||||
let addr: string;
|
||||
try {
|
||||
addr = await resolveAllowed(url.hostname);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
done();
|
||||
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;
|
||||
while (res.statusCode && [301, 302, 303, 307, 308].includes(res.statusCode) && hops < 3) {
|
||||
const loc = res.headers.location;
|
||||
@@ -131,38 +149,58 @@ export async function imageProxyHandler(c: Context) {
|
||||
res.resume(); // discard the redirect body
|
||||
const next = new URL(loc, url);
|
||||
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "bad_redirect" }, 400);
|
||||
done();
|
||||
return "bad_redirect";
|
||||
}
|
||||
try {
|
||||
addr = await resolveAllowed(next.hostname);
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
return err instanceof BlockedTarget ? c.json({ error: "forbidden_target" }, 403) : c.json({ error: "dns_failure" }, 502);
|
||||
done();
|
||||
return err instanceof BlockedTarget ? "forbidden_target" : "dns_failure";
|
||||
}
|
||||
url = next;
|
||||
res = await fetchPinned(url, addr, controller.signal);
|
||||
hops++;
|
||||
}
|
||||
return { res, url, done };
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
done();
|
||||
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) {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "fetch_failed" }, 502);
|
||||
}
|
||||
const type = (res.headers["content-type"] ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (!type.startsWith("image/") || type === "image/svg+xml") {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "not_image" }, 415);
|
||||
}
|
||||
const len = Number(res.headers["content-length"] ?? "0");
|
||||
if (len > MAX_IMAGE_BYTES) {
|
||||
clearTimeout(timer);
|
||||
done();
|
||||
res.resume();
|
||||
return c.json({ error: "too_large" }, 413);
|
||||
}
|
||||
@@ -176,7 +214,7 @@ export async function imageProxyHandler(c: Context) {
|
||||
else controller2.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
res.on("close", () => clearTimeout(timer));
|
||||
res.on("close", done);
|
||||
const headers = new Headers({
|
||||
"Content-Type": type,
|
||||
"Cache-Control": "private, max-age=86400",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -5,6 +5,8 @@ import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browser
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useContacts } from "./contacts";
|
||||
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 { useSession } from "./session";
|
||||
|
||||
@@ -261,6 +263,11 @@ interface CalendarState {
|
||||
error: string | null;
|
||||
identities: ParticipantIdentity[];
|
||||
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`. */
|
||||
draft: EventDraft | null;
|
||||
|
||||
@@ -273,6 +280,8 @@ interface CalendarState {
|
||||
setSharedSubscribed(accountId: Id, calendarId: Id, subscribed: boolean): Promise<void>;
|
||||
loadRange(start: Date, end: Date, force?: boolean): Promise<void>;
|
||||
instancesIn(start: Date, end: Date): EventInstance[];
|
||||
/** Re-fetch every subscribed calendar. */
|
||||
refreshSubscriptions(): Promise<void>;
|
||||
getEvent(id: Id): Promise<CalendarEvent | null>;
|
||||
createEvent(event: Partial<CalendarEvent>, calendarId: Id, sendInvites: boolean): Promise<Id>;
|
||||
/** 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,
|
||||
identities: [],
|
||||
hidden: {},
|
||||
subscriptionEvents: {},
|
||||
subscriptionErrors: {},
|
||||
subscriptionsLoading: false,
|
||||
draft: null,
|
||||
|
||||
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) {
|
||||
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>();
|
||||
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
|
||||
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
|
||||
* it.
|
||||
*/
|
||||
if (isBirthdayEvent(event.id)) return [];
|
||||
if (isBirthdayEvent(event.id) || isSubscriptionEvent(event.id)) return [];
|
||||
const accountId = get().accountId!;
|
||||
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
|
||||
// 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
|
||||
* it.
|
||||
*/
|
||||
if (isBirthdayEvent(event.id)) return;
|
||||
if (isBirthdayEvent(event.id) || isSubscriptionEvent(event.id)) return;
|
||||
const accountId = get().accountId!;
|
||||
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
const allDay = Boolean(e.showWithoutTime);
|
||||
let start: Date;
|
||||
|
||||
@@ -41,6 +41,14 @@ export interface Label {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -152,6 +160,12 @@ export interface Settings {
|
||||
* dates nobody put there is a surprise rather than a feature.
|
||||
*/
|
||||
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. */
|
||||
listSortPreset: SortPreset;
|
||||
listSortLevels: SortLevel[];
|
||||
@@ -267,6 +281,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
timeZone: null,
|
||||
labelsSidebar: true,
|
||||
birthdayCalendar: false,
|
||||
icalSubscriptions: [],
|
||||
listSortPreset: "newest",
|
||||
listSortLevels: [],
|
||||
listSortScope: "inbox",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useRef, useState, useEffect } from "react";
|
||||
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 { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthdays";
|
||||
import { subscriptionCalendarId } from "@/store/calendar";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { formatWeekday } from "@/lib/datetime";
|
||||
@@ -65,6 +66,17 @@ export function CalendarSidebar() {
|
||||
|
||||
if (!cal.available) return null;
|
||||
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 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>
|
||||
</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) => (
|
||||
<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)" }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
<Switch
|
||||
checked={s.birthdayCalendar}
|
||||
|
||||
Reference in New Issue
Block a user