Group the admin and calendar modules, and stop calling screenshots docs

web/src/lib had grown to 85 flat modules -- 42% of the web source, about
12,800 lines -- with one subdirectory (smime/) to its name. The tell was
that a naming prefix had taken over a directory's job: eight adminX.ts
files sat adjacent because alphabetical order put them there, not because
anything said they belonged together.

  lib/admin/     adminAccess, adminDashboard, adminDirectory, adminDomains,
                 adminGroups, adminLists, adminRoles, adminTenants
  lib/calendar/  appointment, availabilityWindow, eventDrag, ics, recurrence

Tests move with their modules into lib/admin/__tests__ and
lib/calendar/__tests__, which is what views/ already does. describeRules
stays in lib/__tests__: it checks that sieve's describeRule and
recurrence's agree, so it belongs to neither.

recurrence.ts joins the calendar group and archiveDate.ts does not, which
is the opposite of the first guess from the filenames. archiveDate picks
the Archive/2026/09 mailbox for a message -- mail, not calendar --
while recurrence reads JSCalendarRecurrenceRule. schedule.ts is scheduled
*send*, so it stays put too. birthdays.ts is left alone deliberately: it
is read off the contact cards and only rendered by the calendar, so it
belongs to whichever of the two you ask.

docs/ held no documentation. It held ten JPEGs and the two scripts that
capture them, while the actual documentation is a separate site in the
ihasmail.org repository -- so anyone opening docs/ expecting prose found
a headless-Chrome driver. The images are now screenshots/, and the two
capture scripts join the other .mjs tooling in scripts/, which is where a
generator belongs. Renaming docs/ to screenshots/ wholesale would have
produced screenshots/screenshots/inbox-dark.jpg.

No behavior changes: every import was already on the @/ alias, so this is
path rewrites and nothing else.
This commit is contained in:
2026-09-15 22:44:53 -07:00
parent 0bde2df69d
commit f7712b1c1e
72 changed files with 108 additions and 108 deletions
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { appointmentDraft, nextHalfHour } from "@/lib/calendar/appointment";
import type { Email, EmailBodyPart } from "@/jmap/types";
/**
* A reminder made out of a mail: the subject becomes the title and the body
* becomes the description, and the reader supplies the one thing the message
* cannot — when it happens. What these pin is that the copy is faithful and
* bounded, because everything else about the event is the editor's job.
*/
function part(partId: string, type: string): EmailBodyPart {
return { partId, type } as EmailBodyPart;
}
function email(parts: Partial<Email>): Email {
return { id: "m1", subject: null, ...parts } as Email;
}
function body(subject: string, type: "text/plain" | "text/html", value: string): Email {
const key = type === "text/plain" ? "textBody" : "htmlBody";
return email({ subject, [key]: [part("1", type)], bodyValues: { 1: { value, isEncodingProblem: false, isTruncated: false } } });
}
const text = (value: string) => body("Water bill", "text/plain", value);
describe("the time an appointment starts", () => {
it("rounds up to the next half hour", () => {
expect(nextHalfHour(new Date("2026-08-31T09:12:40")).toTimeString().slice(0, 5)).toBe("09:30");
expect(nextHalfHour(new Date("2026-08-31T09:41:00")).toTimeString().slice(0, 5)).toBe("10:00");
});
it("moves on from a time already on the boundary, rather than starting now", () => {
expect(nextHalfHour(new Date("2026-08-31T09:30:00")).toTimeString().slice(0, 5)).toBe("10:00");
});
it("runs for an hour", () => {
const d = appointmentDraft(text("anything"), new Date("2026-08-31T09:12:00"));
expect(d.end.getTime() - d.start.getTime()).toBe(3600_000);
expect(d.allDay).toBe(false);
});
});
describe("what is copied from the message", () => {
it("takes the subject as the title and the body as the description", () => {
const d = appointmentDraft(text("Due on the 14th.\nAccount 4471.\n"));
expect(d.title).toBe("Water bill");
expect(d.description).toBe("Due on the 14th.\nAccount 4471.");
});
it("reads an HTML-only message as text, so the description is not markup", () => {
const d = appointmentDraft(body("Renewal", "text/html", "<p>Renews <b>Friday</b></p>"));
expect(d.description).toBe("Renews Friday");
});
it("leaves the title empty when there is no subject, for the editor to prompt for", () => {
expect(appointmentDraft(email({ subject: null })).title).toBe("");
});
/*
* A newsletter is a message too. The whole body would be stored on the
* event, synced everywhere, and shown in a three-row box, so the tail is
* dropped — visibly, so a truncated bill is not read as the whole of it.
*/
it("truncates a body too long to be a description", () => {
const d = appointmentDraft(text("x".repeat(9000)));
expect(d.description).toHaveLength(5001);
expect(d.description.endsWith("…")).toBe(true);
});
});
const between = (parts: Partial<Email>) => email({ subject: "Kickoff", ...parts });
const addr = (email: string, name: string | null = null) => ({ name, email });
describe("who is invited", () => {
it("carries the sender and everyone it was addressed to", () => {
const d = appointmentDraft(
between({ from: [addr("[email protected]", "Grace")], to: [addr("[email protected]"), addr("[email protected]")], cc: [addr("[email protected]")] }),
new Date(),
["[email protected]"],
);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]", "[email protected]", "[email protected]"]);
expect(d.attendees[0]?.name).toBe("Grace");
});
it("leaves the reader out, whatever case their address was written in", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
});
it("counts someone once, however many headers they appear in", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], cc: [addr("[email protected]")] }));
expect(d.attendees).toHaveLength(1);
});
/*
* On a message the reader sent, a blind copy is still a recipient — and
* putting one on a guest list shows them to every other guest. Turning a
* hidden copy into a visible one is not something a menu item may do.
*/
it("never turns a blind copy into a guest", () => {
const d = appointmentDraft(between({ from: [addr("[email protected]")], to: [addr("[email protected]")], bcc: [addr("[email protected]")] }), new Date(), ["[email protected]"]);
expect(d.attendees.map((a) => a.email)).toEqual(["[email protected]"]);
});
it("invites nobody when the message has no addresses at all", () => {
expect(appointmentDraft(between({})).attendees).toEqual([]);
});
});
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import { availabilityWindow } from "@/lib/calendar/availabilityWindow";
const at = (s: string) => new Date(s);
const hours = (w: { ticks: { time: Date }[] }) => w.ticks.map((t) => `${t.time.getDate()}@${t.time.getHours()}`);
describe("the span an availability bar covers", () => {
it("covers the whole day for an event inside one", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:30:00"));
expect(w.start.getHours()).toBe(0);
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
expect(w.end.getHours()).toBe(0);
});
it("stretches to cover an event running over several days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
expect(w.days).toBe(3);
expect(w.start.getDate()).toBe(2);
expect(w.end.getDate()).toBe(5);
});
it("ends an event on the day it ends on, not the midnight it stops at", () => {
// An all-day event on the 2nd runs to midnight starting the 3rd; it does
// not touch the 3rd and the bar should not show it.
const w = availabilityWindow(at("2026-09-02T00:00:00"), at("2026-09-03T00:00:00"));
expect(w.days).toBe(1);
expect(w.end.getDate()).toBe(3);
});
it("never collapses to nothing, even when start and end are the same moment", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T09:00:00"));
expect(w.days).toBe(1);
expect(w.span).toBeGreaterThan(0);
});
it("marks a single day every three hours, labeling every six", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
expect(w.ticks.filter((t) => t.major).map((t) => t.time.getHours())).toEqual([0, 6, 12, 18]);
});
it("thins the marks out to every six hours across two days", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-03T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@6", "2@12", "2@18", "3@0", "3@6", "3@12", "3@18"]);
});
it("marks day boundaries once there are more than two", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-05T10:00:00"));
expect(w.scale).toBe("days");
expect(hours(w)).toEqual(["2@0", "3@0", "4@0", "5@0"]);
expect(w.ticks.every((t) => t.major)).toBe(true);
});
it("puts every mark at its true fraction of the span", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.ticks[0]!.at).toBe(0);
expect(w.ticks[4]!.at).toBeCloseTo(0.5, 5); // noon
expect(w.ticks.every((t) => t.at >= 0 && t.at < 1)).toBe(true);
});
it("stops at a week and says how much it left out", () => {
const w = availabilityWindow(at("2026-09-01T09:00:00"), at("2026-09-30T17:00:00"));
expect(w.days).toBe(7);
expect(w.daysHidden).toBe(23);
});
it("hides nothing when the event fits", () => {
expect(availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00")).daysHidden).toBe(0);
});
it("lands on real midnights, and measures the span between them", () => {
/*
* The span is what every position is a fraction of, so it has to be the
* distance between the two boundaries rather than a count of 24-hour days:
* on the day a clock changes those differ by an hour, which would end the
* bar early and put every block after the change in the wrong place. This
* asserts the relationship; whether the run happens to sit in a zone with
* DST is not something a test should depend on.
*/
for (const day of ["2026-03-29", "2026-10-25", "2026-09-02"]) {
const w = availabilityWindow(at(`${day}T09:00:00`), at(`${day}T10:00:00`));
expect(w.start.getHours(), day).toBe(0);
expect(w.end.getHours(), day).toBe(0);
expect(w.span, day).toBe(w.end.getTime() - w.start.getTime());
}
});
});
describe("looking around the event without changing it", () => {
it("slides the whole window forward, keeping its width", () => {
const here = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"));
const later = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-04T17:00:00"), { offsetDays: 3 });
expect(later.days).toBe(here.days);
expect(later.start.getDate()).toBe(5);
expect(later.end.getDate()).toBe(8);
});
it("slides backwards, across the end of a month", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: -3 });
expect(w.start.getMonth()).toBe(7); // August
expect(w.start.getDate()).toBe(30);
expect(w.days).toBe(1);
});
it("keeps the marks in step with where the window moved to", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"), { offsetDays: 1 });
expect(w.ticks[0]!.time.getDate()).toBe(3);
expect(w.ticks[0]!.at).toBe(0);
});
});
@@ -0,0 +1,230 @@
import { describe, expect, it } from "vitest";
import {
canDragEvent,
formatDuration,
MIN_DURATION_MINUTES,
movedBy,
movedToDay,
pixelsToMinutes,
resizedBy,
snap,
movePatch,
moveByDaysPatch,
dayDelta,
resizePatch,
SNAP_MINUTES,
} from "@/lib/calendar/eventDrag";
import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
import type { CalendarEvent } from "@/jmap/types";
const at = (h: number, m = 0, d = 4) => new Date(2026, 8, d, h, m, 0, 0);
const span = (from: Date, to: Date) => ({ start: from, end: to });
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
describe("snap", () => {
it("rounds to the nearest quarter hour", () => {
expect(snap(0)).toBe(0);
expect(snap(7)).toBe(0);
expect(snap(8)).toBe(15);
expect(snap(22)).toBe(15);
expect(snap(23)).toBe(30);
expect(snap(-8)).toBe(-15);
});
it("takes another slot when asked", () => {
expect(snap(20, 30)).toBe(30);
expect(snap(14, 30)).toBe(0);
});
});
describe("movedBy", () => {
it("moves both ends, so the length does not change", () => {
const out = movedBy(span(at(14), at(15)), 30);
expect(hhmm(out.start)).toBe("14:30");
expect(hhmm(out.end)).toBe("15:30");
});
it("snaps the drag rather than taking it literally", () => {
const out = movedBy(span(at(14), at(15)), 7);
expect(hhmm(out.start)).toBe("14:00");
});
it("moves backwards too", () => {
const out = movedBy(span(at(14), at(15)), -60);
expect(hhmm(out.start)).toBe("13:00");
expect(hhmm(out.end)).toBe("14:00");
});
it("carries an event across midnight without losing its length", () => {
const out = movedBy(span(at(23, 30), at(23, 45)), 60);
expect(ymd(out.start)).toBe("2026-09-05");
expect(hhmm(out.start)).toBe("00:30");
expect(out.end.getTime() - out.start.getTime()).toBe(15 * 60_000);
});
});
describe("movedToDay", () => {
it("keeps the time of day, which is what the month grid is not asking about", () => {
// Dragged from Friday to Monday: still at two o'clock.
const out = movedToDay(span(at(14), at(15, 30)), new Date(2026, 8, 7));
expect(ymd(out.start)).toBe("2026-09-07");
expect(hhmm(out.start)).toBe("14:00");
expect(hhmm(out.end)).toBe("15:30");
});
it("keeps a length that spans days", () => {
const out = movedToDay(span(at(14, 0, 4), at(10, 0, 6)), new Date(2026, 8, 20));
expect(ymd(out.start)).toBe("2026-09-20");
expect(ymd(out.end)).toBe("2026-09-22");
});
it("moves across a month boundary", () => {
const out = movedToDay(span(at(9), at(10)), new Date(2026, 9, 1));
expect(ymd(out.start)).toBe("2026-10-01");
expect(hhmm(out.start)).toBe("09:00");
});
});
describe("resizedBy", () => {
it("moves the end and leaves the start alone", () => {
const out = resizedBy(span(at(14), at(15)), 30);
expect(hhmm(out.start)).toBe("14:00");
expect(hhmm(out.end)).toBe("15:30");
});
it("clamps at one slot rather than refusing the drag", () => {
// A drag that goes too far is still a drag; stopping is what the reader
// sees happening while they do it.
const out = resizedBy(span(at(14), at(15)), -600);
expect(out.end.getTime() - out.start.getTime()).toBe(MIN_DURATION_MINUTES * 60_000);
expect(hhmm(out.end)).toBe("14:15");
});
it("never lets the end cross the start", () => {
for (const delta of [-60, -120, -1000]) {
const out = resizedBy(span(at(9), at(9, 30)), delta);
expect(out.end.getTime()).toBeGreaterThan(out.start.getTime());
}
});
});
describe("formatDuration", () => {
it("writes the shapes the wire expects", () => {
expect(formatDuration(3600)).toBe("PT1H");
expect(formatDuration(5400)).toBe("PT1H30M");
expect(formatDuration(900)).toBe("PT15M");
expect(formatDuration(86400)).toBe("P1D");
expect(formatDuration(90000)).toBe("P1DT1H");
expect(formatDuration(0)).toBe("PT0S");
expect(formatDuration(45)).toBe("PT45S");
});
});
describe("the patch a drag sends, computed in the event's own frame", () => {
/*
* The bug this shape exists to prevent: working the new time out from the
* reader's local hours and then re-expressing it in the event's zone
* converts twice, and the two do not cancel. An event two hours from the
* reader jumped two hours the first time it was dragged and then sat still.
* None of these functions touches a zone at all.
*/
it("moves the stored start by the snapped delta", () => {
expect(movePatch("2026-09-04T14:00:00", 30)).toEqual({ start: "2026-09-04T14:30:00" });
expect(movePatch("2026-09-04T14:00:00", -60)).toEqual({ start: "2026-09-04T13:00:00" });
expect(movePatch("2026-09-04T14:00:00", 7)).toEqual({ start: "2026-09-04T14:00:00" });
});
it("carries a move across midnight and across a month", () => {
expect(movePatch("2026-09-30T23:30:00", 60)).toEqual({ start: "2026-10-01T00:30:00" });
});
it("never sends a duration for a move, so the length is left alone", () => {
expect(movePatch("2026-09-04T14:00:00", 30).duration).toBeUndefined();
});
it("keeps the time of day when moving by whole days", () => {
expect(moveByDaysPatch("2026-09-04T14:30:00", 6)).toEqual({ start: "2026-09-10T14:30:00" });
expect(moveByDaysPatch("2026-09-04T14:30:00", -3)).toEqual({ start: "2026-09-01T14:30:00" });
});
it("moves by the delta the hand made, not to the date that was dropped on", () => {
/*
* The month grid's cells are local days; the stored date is in the event's
* own zone. Writing the dropped-on date put a Tokyo event dropped on the
* 11th onto the 10th, because 15:00 in Tokyo is the previous evening in
* Phoenix — it went where its own calendar said, not where the pointer did.
*/
const storedTokyo = "2026-09-04T15:00:00"; // shown to a Phoenix reader on the 3rd
const shownOn = new Date(2026, 8, 3);
const droppedOn = new Date(2026, 8, 11);
const patch = moveByDaysPatch(storedTokyo, dayDelta(shownOn, droppedOn));
// Eight days later in its own frame, so eight days later on screen too.
expect(patch).toEqual({ start: "2026-09-12T15:00:00" });
});
it("counts whole local days, ignoring the time on either side", () => {
expect(dayDelta(new Date(2026, 8, 3, 23, 30), new Date(2026, 8, 4, 0, 30))).toBe(1);
expect(dayDelta(new Date(2026, 8, 4), new Date(2026, 8, 4))).toBe(0);
expect(dayDelta(new Date(2026, 8, 11), new Date(2026, 8, 3))).toBe(-8);
expect(dayDelta(new Date(2026, 8, 30), new Date(2026, 9, 2))).toBe(2);
});
it("never sends a start for a resize, so the zone question does not arise", () => {
const patch = resizePatch(3600, 60);
expect(patch).toEqual({ duration: "PT2H" });
expect(patch.start).toBeUndefined();
});
it("clamps a resize at one slot", () => {
expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" });
});
it("says nothing at all about a start it cannot read", () => {
expect(movePatch("not a date", 30)).toEqual({});
expect(moveByDaysPatch("", 3)).toEqual({});
expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({});
});
});
describe("canDragEvent", () => {
const writable = { myRights: { mayWriteAll: true } };
const readonly = { myRights: { mayWriteAll: false, mayWriteOwn: false } };
const event = { id: "e1" } as CalendarEvent;
it("allows a normal event on a calendar you can write to", () => {
expect(canDragEvent(event, writable)).toBe(true);
expect(canDragEvent(event, { myRights: { mayWriteOwn: true } })).toBe(true);
});
it("refuses a birthday, which is derived and has nothing to move", () => {
expect(canDragEvent({ id: `${BIRTHDAY_ID_PREFIX}c1:2026` } as CalendarEvent, writable)).toBe(false);
});
it("refuses a calendar you cannot write to, and one that is not there", () => {
expect(canDragEvent(event, readonly)).toBe(false);
expect(canDragEvent(event, undefined)).toBe(false);
});
it("refuses nothing at all", () => {
expect(canDragEvent(null, writable)).toBe(false);
});
});
describe("pixelsToMinutes", () => {
it("converts against the grid's own scale", () => {
expect(pixelsToMinutes(48, 48)).toBe(60);
expect(pixelsToMinutes(24, 48)).toBe(30);
expect(pixelsToMinutes(-48, 48)).toBe(-60);
});
it("says nothing rather than dividing by zero before the grid is measured", () => {
expect(pixelsToMinutes(100, 0)).toBe(0);
});
it("round-trips through snap to the slot the pointer is over", () => {
expect(snap(pixelsToMinutes(10, 48))).toBe(15);
expect(snap(pixelsToMinutes(2, 48))).toBe(0);
expect(SNAP_MINUTES).toBe(15);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { describe, expect, it } from "vitest";
import { looksLikeCalendar, parseIcs, parseIcsDuration, parseDateValue, parseLine, unescapeText, unfold } from "@/lib/calendar/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("recognizes 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,305 @@
import { describe, expect, it } from "vitest";
import { toIcs, parseIcs } from "@/lib/calendar/ics";
import type { JSCalendarEvent } from "@/jmap/types";
/*
* Writing iCalendar out of the server's RFC 8984 objects.
*
* The properties worth pinning are the ones where the two formats disagree, or
* where getting it wrong shows up as a wrong time rather than as an error: how
* a zone is said, what UNTIL is measured in, and where a changed occurrence
* goes.
*/
const base: JSCalendarEvent = {
"@type": "Event", uid: "[email protected]", title: "Kickoff",
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Europe/Berlin",
};
const lines = (e: JSCalendarEvent[], name?: string) => toIcs(e, name).split("\r\n");
/*
* From the first event onwards. The zone definitions above carry DTSTART and
* TZNAME of their own, and a test asking "what is this event's DTSTART" must
* not be answered by a transition rule.
*/
const eventLines = (e: JSCalendarEvent[]) => {
const all = lines(e);
return all.slice(all.indexOf("BEGIN:VEVENT"));
};
const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l) => l.startsWith(prefix));
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
describe("the document around the events", () => {
it("is a calendar a reader will recognize", () => {
const l = lines([base]);
expect(l[0]).toBe("BEGIN:VCALENDAR");
expect(l).toContain("VERSION:2.0");
expect(l).toContain("END:VCALENDAR");
expect(l.some((x) => x.startsWith("PRODID:"))).toBe(true);
});
it("carries the calendar's name where a reader will look for it", () => {
expect(lines([base], "Work")).toContain("X-WR-CALNAME:Work");
});
it("ends every line the way the format requires", () => {
expect(toIcs([base]).endsWith("\r\n")).toBe(true);
expect(toIcs([base]).includes("\n\n")).toBe(false);
});
});
describe("times and zones", () => {
it("names the zone rather than converting, so a series survives a DST change", () => {
expect(one(base, "DTSTART")).toBe("DTSTART;TZID=Europe/Berlin:20260902T090000");
});
it("writes UTC as UTC", () => {
expect(one({ ...base, timeZone: "Etc/UTC" }, "DTSTART")).toBe("DTSTART:20260902T090000Z");
});
it("leaves a floating time floating, with no zone at all", () => {
// No zone means "whatever clock the reader is on", which is a real and
// different thing from UTC -- a 09:00 alarm clock, not an instant.
expect(one({ ...base, timeZone: null }, "DTSTART")).toBe("DTSTART:20260902T090000");
});
it("writes an all-day event as a date, not as midnight", () => {
const e = { ...base, showWithoutTime: true, duration: "P1D" };
expect(one(e, "DTSTART")).toBe("DTSTART;VALUE=DATE:20260902");
});
it("keeps the duration rather than working out an end", () => {
expect(one(base, "DURATION")).toBe("DURATION:PT1H");
});
it("says nothing about duration when the event has none", () => {
expect(find([{ ...base, duration: undefined }], "DURATION")).toEqual([]);
});
});
describe("recurrence", () => {
const weekly = { ...base, recurrenceRule: { frequency: "weekly" as const, byDay: [{ day: "we" as const }] } };
it("writes the rule rather than expanding it into a year of events", () => {
expect(one(weekly, "RRULE")).toBe("RRULE:FREQ=WEEKLY;BYDAY=WE");
expect(find([weekly], "BEGIN:VEVENT")).toHaveLength(1);
});
it("reads the array form as well as the single rule Stalwart stores", () => {
const e = { ...base, recurrenceRules: [{ frequency: "monthly" as const, interval: 2, count: 5 }] };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=5");
});
it("measures UNTIL in UTC, so a series does not stop a day early elsewhere", () => {
const e = { ...base, recurrenceRule: { frequency: "weekly" as const, until: "2026-12-30T09:00:00" } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=WEEKLY;UNTIL=20261230T090000Z");
});
it("measures UNTIL as a date when the series is all-day", () => {
const e = { ...base, showWithoutTime: true, recurrenceRule: { frequency: "daily" as const, until: "2026-12-30T00:00:00" } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=DAILY;UNTIL=20261230");
});
it("keeps the nth-weekday form that BYDAY carries a number for", () => {
const e = { ...base, recurrenceRule: { frequency: "monthly" as const, byDay: [{ day: "th" as const, nthOfPeriod: -1 }] } };
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
});
it("turns a canceled occurrence into an EXDATE", () => {
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
});
it("treats an override marked excluded the same way", () => {
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
});
it("gives a changed occurrence its own event, sharing the uid", () => {
/*
* Which is how iCalendar has always said it: the same UID, plus the
* RECURRENCE-ID of the slot being replaced. The master keeps its rule and
* the override must not.
*/
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Kickoff (moved)" } } };
const l = lines([e]);
expect(l.filter((x) => x === "BEGIN:VEVENT")).toHaveLength(2);
expect(l.filter((x) => x === "UID:[email protected]")).toHaveLength(2);
expect(l).toContain("RECURRENCE-ID;TZID=Europe/Berlin:20260909T090000");
expect(l).toContain("SUMMARY:Kickoff (moved)");
// One RRULE in the file, on the master.
expect(l.filter((x) => x.startsWith("RRULE:"))).toHaveLength(1);
});
});
describe("the rest of an event", () => {
it("escapes what the format uses as punctuation", () => {
const e = { ...base, title: "Budget; Q4, final", description: "line one\nline two" };
// Both escapes doubled here for JS's sake: what reaches the file is one
// backslash before each of the two characters the format reserves.
expect(one(e, "SUMMARY")).toBe("SUMMARY:Budget\\; Q4\\, final");
expect(one(e, "DESCRIPTION")).toBe("DESCRIPTION:line one\\nline two");
});
it("folds a long line rather than writing it past the limit", () => {
const e = { ...base, title: "x".repeat(200) };
for (const l of lines([e])) expect(l.length).toBeLessThanOrEqual(75);
});
it("puts a room in LOCATION and a video link in URL", () => {
// A meeting URL where a room name goes is what makes a printed agenda
// useless, and they are different fields in both formats.
const e = {
...base,
locations: { l1: { name: "Room 3" } },
virtualLocations: { v1: { uri: "https://meet.example.org/abc" } },
} as JSCalendarEvent;
expect(one(e, "LOCATION")).toBe("LOCATION:Room 3");
expect(one(e, "URL")).toBe("URL:https://meet.example.org/abc");
});
it("maps the words the two formats spell differently", () => {
const e = { ...base, status: "tentative" as const, privacy: "secret" as const, freeBusyStatus: "free" as const };
expect(one(e, "STATUS")).toBe("STATUS:TENTATIVE");
expect(one(e, "CLASS")).toBe("CLASS:CONFIDENTIAL");
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
});
it("writes the organizer and the guests, with what each answered", () => {
const e = {
...base,
organizerCalendarAddress: "mailto:[email protected]",
participants: {
p1: { roles: { attendee: true }, name: "Ada", calendarAddress: "mailto:[email protected]", participationStatus: "accepted" as const, expectReply: true },
p2: { roles: { optional: true }, sendTo: { imip: "mailto:[email protected]" }, participationStatus: "needs-action" as const },
},
} as JSCalendarEvent;
expect(one(e, "ORGANIZER")).toBe("ORGANIZER:mailto:[email protected]");
const att = find([e], "ATTENDEE");
expect(att[0]).toBe("ATTENDEE;CN=Ada;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:[email protected]");
expect(att[1]).toBe("ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT:mailto:[email protected]");
});
it("skips a participant with no address at all rather than writing a broken line", () => {
const e = { ...base, participants: { p1: { roles: { attendee: true }, name: "Nobody" } } } as JSCalendarEvent;
expect(find([e], "ATTENDEE")).toEqual([]);
});
it("nests an alarm inside the event it belongs to", () => {
const e = { ...base, alerts: { a1: { trigger: { offset: "-PT15M" } } } } as JSCalendarEvent;
const l = lines([e]);
expect(l).toContain("BEGIN:VALARM");
expect(l).toContain("TRIGGER:-PT15M");
expect(l).toContain("ACTION:DISPLAY");
expect(l.indexOf("BEGIN:VALARM")).toBeLessThan(l.indexOf("END:VEVENT"));
});
it("says when an alarm hangs off the end rather than the start", () => {
const e = { ...base, alerts: { a1: { trigger: { offset: "PT5M", relativeTo: "end" as const } } } } as JSCalendarEvent;
expect(one(e, "TRIGGER")).toBe("TRIGGER;RELATED=END:PT5M");
});
});
describe("what comes back out of the parser", () => {
/*
* Not a full round trip -- the reader is a subscription parser and keeps far
* less than the writer emits -- but what it does read should be what went in.
*/
it("reads back the events it wrote", () => {
const two = [base, { ...base, uid: "[email protected]", title: "Retro", start: "2026-09-09T14:00:00" }];
const back = parseIcs(toIcs(two));
expect(back.events.map((e) => e.uid)).toEqual(["[email protected]", "[email protected]"]);
expect(back.events.map((e) => e.summary)).toEqual(["Kickoff", "Retro"]);
});
it("reads back a title that needed escaping, unescaped", () => {
const back = parseIcs(toIcs([{ ...base, title: "Budget; Q4, final" }]));
expect(back.events[0]!.summary).toBe("Budget; Q4, final");
});
});
/*
* Time zone definitions.
*
* These exist because leaving them out was wrong, and measurably: ical.js --
* Mozilla's library, the one Thunderbird's calendar uses -- reads a TZID with
* nothing defining it as *floating*, so a 09:00 in Phoenix opened anywhere else
* reads as 09:00 there. Seven hours out, silently, on every timed event.
*/
describe("the zones an export names", () => {
const inZone = (uid: string, tz: string, start = "2026-09-02T09:00:00") =>
({ ...base, uid, timeZone: tz, start }) as JSCalendarEvent;
it("defines every zone its events refer to", () => {
const l = lines([inZone("a", "America/Phoenix"), inZone("b", "Asia/Tokyo")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(2);
expect(l).toContain("TZID:America/Phoenix");
expect(l).toContain("TZID:Asia/Tokyo");
});
it("defines a zone once however many events use it", () => {
const l = lines([inZone("a", "Europe/Berlin"), inZone("b", "Europe/Berlin"), inZone("c", "Europe/Berlin")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(1);
});
it("says nothing about UTC, which needs no definition", () => {
expect(lines([inZone("a", "Etc/UTC")]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
});
it("says nothing about an all-day event, which has no zone to define", () => {
const e = { ...base, showWithoutTime: true, timeZone: "Europe/Berlin" } as JSCalendarEvent;
expect(lines([e]).filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
});
it("writes a zone that never changes as one standing rule", () => {
// Phoenix keeps MST all year: one sub-component, and the two offsets equal.
const l = lines([inZone("a", "America/Phoenix")]);
expect(l.filter((x) => x === "BEGIN:DAYLIGHT")).toHaveLength(0);
expect(l.filter((x) => x === "BEGIN:STANDARD")).toHaveLength(1);
expect(l).toContain("TZOFFSETFROM:-0700");
expect(l).toContain("TZOFFSETTO:-0700");
expect(l).toContain("TZNAME:MST");
});
it("finds the transitions of a zone that does change", () => {
const l = lines([inZone("a", "Europe/Berlin")]);
// Both directions, and at the hours the EU actually changes at.
expect(l).toContain("DTSTART:20260329T020000");
expect(l).toContain("DTSTART:20261025T030000");
const spring = l.indexOf("DTSTART:20260329T020000");
expect(l[spring - 1]).toBe("BEGIN:DAYLIGHT");
expect(l[spring + 1]).toBe("TZOFFSETFROM:+0100");
expect(l[spring + 2]).toBe("TZOFFSETTO:+0200");
});
it("covers years around the events rather than only the year they fall in", () => {
// An open-ended weekly meeting outlives the year it was created in, so a
// definition that stopped at that year would leave later occurrences
// undefined.
const l = lines([inZone("a", "Europe/Berlin")]);
const years = new Set(l.filter((x) => x.startsWith("DTSTART:")).map((x) => x.slice(8, 12)));
expect(years.size).toBeGreaterThan(5);
expect([...years].some((y) => Number(y) > 2030)).toBe(true);
});
it("leaves out a zone name that only repeats the offset", () => {
// Intl answers "GMT+9" for Tokyo, which says nothing TZOFFSETTO has not.
const l = lines([inZone("a", "Asia/Tokyo")]);
expect(l.some((x) => x.startsWith("TZNAME:GMT"))).toBe(false);
expect(l).toContain("TZOFFSETTO:+0900");
});
it("says nothing at all about a zone the browser does not know", () => {
// Rather than writing a definition made up out of nothing. The TZID stays
// on the event, which is where it was before any of this.
const l = lines([inZone("a", "Mars/Olympus_Mons")]);
expect(l.filter((x) => x === "BEGIN:VTIMEZONE")).toHaveLength(0);
expect(l).toContain("DTSTART;TZID=Mars/Olympus_Mons:20260902T090000");
});
it("puts the definitions before the events that use them", () => {
const l = lines([inZone("a", "Europe/Berlin")]);
expect(l.indexOf("BEGIN:VTIMEZONE")).toBeLessThan(l.indexOf("BEGIN:VEVENT"));
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { Email, EmailAddress } from "@/jmap/types";
import { useCalendar, type EventDraft } from "@/store/calendar";
import { useMail } from "@/store/mail";
import { uniqueAddresses } from "../address";
import { toLocalDateOnly } from "../dates";
import { htmlToText } from "../text";
/**
* How much of a message body is copied into an event description.
*
* The reader is making a reminder out of a mail, and a newsletter is a mail
* too: whole bodies run to hundreds of kilobytes, which would be stored on the
* event, synced to every device, and shown in a three-row textarea. What is
* worth keeping is near the top -- the amount owed, the date, the address --
* so the tail is what gets dropped, and visibly, so nobody reads a truncated
* bill as the whole of it.
*/
const MAX_DESCRIPTION = 5000;
/**
* The next half-hour, which is when an appointment made now can start.
*
* Always forward, never the current instant: the reader still has a form to
* fill in, and a start time that is already in the past by the time they press
* Create is one they have to fix by hand.
*/
export function nextHalfHour(now: Date = new Date()): Date {
const d = new Date(now);
d.setSeconds(0, 0);
d.setMinutes(d.getMinutes() + (30 - (d.getMinutes() % 30)));
return d;
}
/** The message's body as plain text, however it was sent. */
function bodyText(email: Email): string {
const textPart = email.textBody?.[0];
const text = textPart?.partId ? (email.bodyValues?.[textPart.partId]?.value ?? "") : "";
if (text.trim()) return text;
const htmlPart = email.htmlBody?.[0];
const html = htmlPart?.partId ? (email.bodyValues?.[htmlPart.partId]?.value ?? "") : "";
return html ? htmlToText(html) : "";
}
/**
* An event seeded from a message: its subject, its body, and a time to fix.
*
* Deliberately nothing clever. The date is the one thing the message cannot
* supply -- "the 14th" in a bill is not a due date the parser could trust --
* so the editor opens with the reader's cursor on a form they finish, rather
* than a guess they have to check.
*/
/**
* Everyone the message was between, as guests: the sender and the people it
* was addressed to.
*
* The reader's own addresses come out -- they are the organizer, and an
* organizer listed among their own guests is an event that invites you to your
* own appointment. Bcc stays out too, on a message the reader sent themselves:
* a blind recipient added to a guest list is visible to every other guest, and
* turning a hidden copy into a public one is not something a menu item should
* do quietly.
*/
function guests(email: Email, ownEmails: string[]): EmailAddress[] {
const own = new Set(ownEmails.map((e) => e.toLowerCase()));
return uniqueAddresses([...(email.from ?? []), ...(email.to ?? []), ...(email.cc ?? [])]).filter((a) => !own.has(a.email.trim().toLowerCase()));
}
export function appointmentDraft(email: Email, now: Date = new Date(), ownEmails: string[] = []): EventDraft {
const start = nextHalfHour(now);
const body = bodyText(email).trim();
return {
title: email.subject?.trim() ?? "",
description: body.length > MAX_DESCRIPTION ? `${body.slice(0, MAX_DESCRIPTION).trimEnd()}` : body,
start,
end: new Date(start.getTime() + 3600_000),
allDay: false,
attendees: guests(email, ownEmails),
};
}
/**
* Open the calendar's event editor on a draft made from this message.
*
* The list holds a message without its body -- only a preview -- so the full
* one is fetched first; `getEmails` serves it from the cache when the message
* has already been read.
*/
export async function startAppointment(email: Email, navigate: (to: string) => void): Promise<void> {
const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email;
// Which addresses are the reader's own decides who is a guest, so they are
// worth a round trip when the session has not loaded them yet.
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
const draft = appointmentDraft(full, new Date(), identities.map((i) => i.email));
useCalendar.getState().setDraft(draft);
navigate(`/calendar/day/${toLocalDateOnly(draft.start)}`);
}
@@ -0,0 +1,95 @@
import { DAY_MS } from "@/lib/dates";
/**
* The span an availability bar covers, and the marks along it.
*
* The bar used to be a day wide whatever it was showing: it began at midnight
* on the event's start day and stopped 24 hours later, so an event running over
* two days showed availability for the first of them and gave no sign that
* there was more. It also carried no marks at all, which left "is this the
* whole day or only working hours" unanswerable without dragging the event
* around to see where its own outline moved. That is issue #172, parts 1 and 2.
*
* Whole days, always: a bar that started at the event's own start time would
* move under the reader every time they adjusted it, and "busy from about a
* third of the way along" is not a time anybody can read.
*/
export interface AvailabilityWindow {
/** Midnight at the start of the first day shown. */
start: Date;
/** Midnight at the end of the last day shown. */
end: Date;
/** Milliseconds between the two, which a DST change makes not a multiple of a day. */
span: number;
/** Days actually shown. */
days: number;
/**
* Marks along the bar. `at` is a fraction of the span, so a caller positions
* one with a percentage and never does date arithmetic of its own. Only
* `major` marks are worth a label; the rest are there to read a block against.
*/
ticks: { at: number; time: Date; major: boolean }[];
/** Whether marks fall on hours or on days, which decides how to label them. */
scale: "hours" | "days";
/**
* Days the event covers that the bar does not. An event long enough to need
* this is not one anybody is checking for a free slot, and drawing a month at
* eight pixels a day would say nothing; saying how much was left out is more
* use than showing it.
*/
daysHidden: number;
}
/** Midnight starting the day `d` falls in, in local time. */
function startOfDay(d: Date): Date {
const out = new Date(d);
out.setHours(0, 0, 0, 0);
return out;
}
/**
* `n` days on from `d`, by the calendar rather than by arithmetic: a day is 23
* or 25 hours twice a year, and adding 24 of them lands an hour off.
*/
function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
out.setHours(0, 0, 0, 0);
return out;
}
/** How far apart the marks go, in hours, and which of them get a label. */
function spacing(days: number): { every: number; label: number } {
if (days <= 1) return { every: 3, label: 6 };
if (days <= 2) return { every: 6, label: 12 };
return { every: 24, label: 24 };
}
export function availabilityWindow(start: Date, end: Date, opts: { maxDays?: number; offsetDays?: number } = {}): AvailabilityWindow {
const maxDays = opts.maxDays ?? 7;
/*
* Days moved from where the event sits, for looking around it without
* changing it. The whole window slides rather than growing: keeping the span
* fixed means what you compare when you step forward is the same width as
* what you were looking at, which is the point of stepping.
*/
const from = addDays(startOfDay(start), opts.offsetDays ?? 0);
// The last day is the one the event ends *on*. An event ending exactly at
// midnight ends on the day before, not at the start of a day it never
// touches -- that is the whole of what all-day events do.
const lastDay = addDays(startOfDay(new Date(Math.max(end.getTime() - 1, start.getTime()))), opts.offsetDays ?? 0);
const total = Math.max(1, Math.round((lastDay.getTime() - from.getTime()) / DAY_MS) + 1);
const days = Math.min(total, maxDays);
const to = addDays(from, days);
const span = to.getTime() - from.getTime();
const { every, label } = spacing(days);
const ticks: AvailabilityWindow["ticks"] = [];
for (let hour = 0; ; hour += every) {
const time = new Date(from.getTime() + hour * 3600_000);
if (time.getTime() >= to.getTime()) break;
ticks.push({ at: (time.getTime() - from.getTime()) / span, time, major: hour % label === 0 });
}
return { start: from, end: to, span, days, ticks, scale: days <= 2 ? "hours" : "days", daysHidden: total - days };
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Moving and resizing an event by dragging it.
*
* The arithmetic lives here, away from the grids and under test, for the same
* reason the swipe thresholds do: the numbers are the whole thing, and a
* mistake in them moves somebody's meeting to the wrong hour rather than
* merely looking wrong.
*
* Nothing here talks to the server or knows what a scope is. It answers one
* question — given an event and a gesture, what are the new start and end —
* and the caller decides whether it is allowed to save that.
*/
import { addMinutes } from "../dates";
import { isBirthdayEvent } from "../birthdays";
import type { CalendarEvent } from "@/jmap/types";
/**
* Fifteen minutes, which is the smallest slot anybody schedules against and
* the largest that still lands where the pointer looks like it is.
*/
export const SNAP_MINUTES = 15;
/** An event has to keep some length; dragging its end past its start is not a request. */
export const MIN_DURATION_MINUTES = 15;
/** Round a count of minutes to the nearest slot, away from zero on a tie. */
export function snap(minutes: number, slot: number = SNAP_MINUTES): number {
return Math.round(minutes / slot) * slot;
}
export interface Span {
start: Date;
end: Date;
}
/**
* Moved by a number of minutes, keeping its length.
*
* Both ends move together: dragging the middle of an event is asking for it to
* happen at another time, not to become a different length.
*/
export function movedBy(span: Span, deltaMinutes: number): Span {
const delta = snap(deltaMinutes);
return { start: addMinutes(span.start, delta), end: addMinutes(span.end, delta) };
}
/**
* Moved to another day, keeping its time of day and its length.
*
* This is the month grid, where a cell is a day and nothing finer. An event
* dragged from Tuesday to Friday should still be at two o'clock; changing the
* hour as well would be answering a question nobody asked.
*/
export function movedToDay(span: Span, day: Date): Span {
const length = span.end.getTime() - span.start.getTime();
const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), span.start.getHours(), span.start.getMinutes(), 0, 0);
return { start, end: new Date(start.getTime() + length) };
}
/**
* Resized from its end, never shorter than one slot.
*
* The floor is a clamp rather than a refusal: a drag that goes too far is
* still a drag, and stopping at fifteen minutes is what the reader sees
* happening while they do it.
*/
export function resizedBy(span: Span, deltaMinutes: number): Span {
const end = addMinutes(span.end, snap(deltaMinutes));
const minimum = addMinutes(span.start, MIN_DURATION_MINUTES);
return { start: span.start, end: end.getTime() < minimum.getTime() ? minimum : end };
}
/** Seconds, as an ISO 8601 duration — the shape `duration` takes on the wire. */
export function formatDuration(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (!total) return "PT0S";
const time = [hours && `${hours}H`, minutes && `${minutes}M`, secs && `${secs}S`].filter(Boolean).join("");
return `P${days ? `${days}D` : ""}${time ? `T${time}` : ""}`;
}
/**
* The patch a move or a resize sends.
*
* **Computed in the event's own frame, never through an instant.** An event
* carries a wall-clock `start` and a `timeZone`, and the grid draws it at the
* reader's local time. Working out a new time from those local hours and then
* re-expressing it in the event's zone converts twice, and the two conversions
* do not cancel: an event in a zone two hours from the reader's moved two
* hours the first time it was dragged, and then sat still, because after that
* its stored time and the reader's happened to agree.
*
* Parsing the stored string into its parts and adding minutes to those parts
* touches no zone at all, so there is nothing to get wrong. The zone itself is
* left exactly as it was: dragging an event is not a claim about where it
* happens.
*/
function parseStored(start: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(start ?? "");
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6] ?? 0), 0);
}
function formatStored(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
export interface DragPatch {
start?: string;
duration?: string;
}
/** Moved by a number of minutes, in the event's own frame. */
export function movePatch(storedStart: string, deltaMinutes: number): DragPatch {
const base = parseStored(storedStart);
if (!base) return {};
return { start: formatStored(addMinutes(base, snap(deltaMinutes))) };
}
/**
* Moved by a whole number of days, keeping the time of day it already had.
*
* A day *delta*, not a target date, and the difference matters whenever the
* event's zone is not the reader's. The month grid's cells are local days; the
* event's stored date is in its own zone. Rewriting the stored date to the day
* that was dropped on put a Tokyo event dropped on the 11th onto the 10th,
* because 15:00 in Tokyo on the 11th is 23:00 in Phoenix on the 10th — the
* event went where its own calendar said, not where the pointer did.
*
* Shifting by the difference between the two local days moves it exactly as
* far as the hand did, and adding whole days to a wall clock leaves the time
* of day alone without touching the zone.
*/
export function moveByDaysPatch(storedStart: string, days: number): DragPatch {
const base = parseStored(storedStart);
if (!base || !Number.isFinite(days)) return {};
const moved = new Date(base.getFullYear(), base.getMonth(), base.getDate() + Math.round(days), base.getHours(), base.getMinutes(), base.getSeconds(), 0);
return { start: formatStored(moved) };
}
/** Whole days between two local dates, ignoring the time of day on each. */
export function dayDelta(from: Date, to: Date): number {
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
const b = new Date(to.getFullYear(), to.getMonth(), to.getDate()).getTime();
return Math.round((b - a) / 86400_000);
}
/**
* Resized from its end. Only the duration moves, so the start -- and with it
* the whole question of zones -- is not touched at all.
*/
export function resizePatch(currentSeconds: number, deltaMinutes: number): DragPatch {
const seconds = Math.max(MIN_DURATION_MINUTES * 60, currentSeconds + snap(deltaMinutes) * 60);
return { duration: formatDuration(seconds) };
}
/**
* Whether this event can be dragged at all.
*
* Three separate reasons it might not be, and they are checked here so no grid
* has to remember all three:
*
* - **A birthday is derived**, not stored. There is nothing on the server to
* move, and the date belongs to a contact rather than to a calendar.
* - **The calendar may be read-only** — someone else's, shared without write
* rights. This is the same question the popover asks before offering Edit.
* - **An event with no calendar** has nowhere to be saved.
*/
export function canDragEvent(event: CalendarEvent | null | undefined, calendar: { myRights?: { mayWriteAll?: boolean; mayWriteOwn?: boolean } } | undefined): boolean {
if (!event || isBirthdayEvent(event.id)) return false;
if (!calendar) return false;
return Boolean(calendar.myRights?.mayWriteAll || calendar.myRights?.mayWriteOwn);
}
/** How far the pointer moved, in minutes, given a grid's pixels-per-hour. */
export function pixelsToMinutes(deltaPixels: number, hourHeight: number): number {
if (!hourHeight) return 0;
return (deltaPixels / hourHeight) * 60;
}
+649
View File
@@ -0,0 +1,649 @@
/**
* Reading an iCalendar document (RFC 5545), enough of one to draw it -- and,
* from `toIcs` at the foot of the file, writing one back out.
*
* The two halves are not symmetrical and are not meant to be. Reading serves
* subscriptions; writing serves export, and starts from the server's RFC 8984
* objects rather than from anything this parser produced.
*
* 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.
*/
import type { JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule } from "@/jmap/types";
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),
};
}
/* ------------------------------------------------------------------ */
/* Writing */
/* ------------------------------------------------------------------ */
/**
* JSCalendar out to iCalendar.
*
* The reverse of everything above, and a narrower job than it looks: the events
* come from the server as RFC 8984 objects, and RFC 8984 was written as a
* restatement of RFC 5545, so most of this is renaming. Where the two disagree
* the comments say which way it went and why.
*
* What is deliberately not here, stated rather than discovered:
*
* - **Overrides are applied at the top level only.** (See below.)
* A recurrence override is a JSON patch, and a patch addressing
* `locations/x/name` is not something this flattens; those paths are left on
* the master's value. Plain overridden properties -- a moved time, a changed
* title -- come across.
* - **No localizations, no relatedTo, no per-participant delegation.** Nothing
* in ihasmail sets them.
*/
export function toIcs(events: JSCalendarEvent[], calendarName?: string): string {
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//ihasmail//EN", "CALSCALE:GREGORIAN"];
if (calendarName) lines.push(`X-WR-CALNAME:${escText(calendarName)}`);
for (const zone of zonesUsed(events)) lines.push(...vtimezone(zone, ...windowFor(events)));
for (const e of events) lines.push(...vevent(e));
lines.push("END:VCALENDAR");
return lines.map(foldLine).join("\r\n") + "\r\n";
}
/** Every named zone the events refer to; UTC needs no definition. */
function zonesUsed(events: JSCalendarEvent[]): string[] {
const zones = new Set<string>();
for (const e of events) {
if (e.showWithoutTime) continue;
const tz = e.timeZone;
if (tz && tz !== "Etc/UTC" && tz !== "UTC") zones.add(tz);
}
return [...zones].sort();
}
/**
* The years a definition has to cover.
*
* A zone's rules are not a fact, they are a decision somebody makes and
* changes, so a VTIMEZONE states them for a span rather than for ever. From the
* year before the earliest event -- an event can be moved earlier by an
* override -- to ten years past the latest, which covers an open-ended weekly
* meeting for as long as anyone plans around one.
*/
function windowFor(events: JSCalendarEvent[]): [number, number] {
const years = events.map((e) => Number(e.start.slice(0, 4))).filter((y) => Number.isFinite(y) && y > 1000);
const now = new Date().getUTCFullYear();
const first = years.length ? Math.min(...years) : now;
const last = Math.max(now, years.length ? Math.max(...years) : now);
return [first - 1, last + 10];
}
/** RFC 5545 escaping. A comma and a semicolon separate values, so both go. */
function escText(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
}
/** 75 octets is the limit; a continuation begins with one space. */
function foldLine(line: string): string {
if (line.length <= 75) return line;
const out: string[] = [];
let i = 0;
while (i < line.length) {
out.push((i ? " " : "") + line.slice(i, i + 74));
i += 74;
}
return out.join("\r\n");
}
/** "2026-09-02T09:00:00" -> "20260902T090000"; the date half alone for all-day. */
function stamp(local: string, dateOnly = false): string {
const compact = local.replace(/[-:]/g, "").replace(/\.\d+/, "");
return dateOnly ? compact.slice(0, 8) : compact.slice(0, 15);
}
/** A UTC instant as iCalendar spells it. */
function utcStamp(iso: string): string {
return `${iso.replace(/[-:]/g, "").replace(/\.\d+/, "").slice(0, 15)}Z`;
}
/**
* A date-time property with its zone said the way the zone requires.
*
* Three shapes, and the difference matters: a floating time carries no zone and
* means "whatever clock the reader is on", UTC carries the Z, and everything
* else names an IANA zone in TZID.
*/
function dateProp(name: string, local: string, timeZone: string | null | undefined, allDay: boolean): string {
if (allDay) return `${name};VALUE=DATE:${stamp(local, true)}`;
if (!timeZone) return `${name}:${stamp(local)}`;
if (timeZone === "Etc/UTC" || timeZone === "UTC") return `${name}:${stamp(local)}Z`;
return `${name};TZID=${timeZone}:${stamp(local)}`;
}
const STATUS: Record<string, string> = { confirmed: "CONFIRMED", cancelled: "CANCELLED", tentative: "TENTATIVE" };
const CLASS: Record<string, string> = { public: "PUBLIC", private: "PRIVATE", secret: "CONFIDENTIAL" };
const PARTSTAT: Record<string, string> = {
"needs-action": "NEEDS-ACTION", accepted: "ACCEPTED", declined: "DECLINED",
tentative: "TENTATIVE", delegated: "DELEGATED",
};
/** A participant's address, wherever this server keeps it. */
function participantAddress(p: JSCalendarParticipant): string | null {
return p.calendarAddress ?? p.sendTo?.imip ?? (p.email ? `mailto:${p.email}` : null) ?? null;
}
function vevent(e: JSCalendarEvent, recurrenceId?: { local: string; timeZone: string | null | undefined; allDay: boolean }): string[] {
const allDay = Boolean(e.showWithoutTime);
const tz = allDay ? null : e.timeZone;
const out = ["BEGIN:VEVENT", `UID:${e.uid}`];
/* DTSTAMP is required and means "when this description was made", which for
an export is the last time the event changed. */
out.push(`DTSTAMP:${utcStamp(e.updated ?? e.created ?? new Date().toISOString())}`);
out.push(dateProp("DTSTART", e.start, tz, allDay));
/* DURATION rather than DTEND, because that is what JSCalendar holds and
converting would mean doing the zone arithmetic here to no purpose. */
if (e.duration && e.duration !== "PT0S") out.push(`DURATION:${e.duration}`);
if (recurrenceId) out.push(dateProp("RECURRENCE-ID", recurrenceId.local, recurrenceId.timeZone, recurrenceId.allDay));
if (e.title) out.push(`SUMMARY:${escText(e.title)}`);
if (e.description) out.push(`DESCRIPTION:${escText(e.description)}`);
const location = Object.values(e.locations ?? {}).map((l) => l.name).filter(Boolean)[0];
if (location) out.push(`LOCATION:${escText(location)}`);
/* A virtual location is a URL and belongs in URL, not LOCATION: putting a
video link where a room name goes is what makes an agenda unreadable. */
const virtual = Object.values(e.virtualLocations ?? {}).map((v) => v.uri).filter(Boolean)[0];
const link = Object.values(e.links ?? {}).map((l) => l.href).filter(Boolean)[0];
if (virtual ?? link) out.push(`URL:${virtual ?? link}`);
const categories = [...Object.keys(e.keywords ?? {}), ...Object.keys(e.categories ?? {})];
if (categories.length) out.push(`CATEGORIES:${categories.map(escText).join(",")}`);
if (e.status && STATUS[e.status]) out.push(`STATUS:${STATUS[e.status]}`);
if (e.privacy && CLASS[e.privacy]) out.push(`CLASS:${CLASS[e.privacy]}`);
/* TRANSP is about whether the time is busy, which is the same question
freeBusyStatus answers and the opposite word for it. */
if (e.freeBusyStatus) out.push(`TRANSP:${e.freeBusyStatus === "free" ? "TRANSPARENT" : "OPAQUE"}`);
if (e.priority != null) out.push(`PRIORITY:${e.priority}`);
if (e.sequence != null) out.push(`SEQUENCE:${e.sequence}`);
if (e.created) out.push(`CREATED:${utcStamp(e.created)}`);
if (e.updated) out.push(`LAST-MODIFIED:${utcStamp(e.updated)}`);
if (e.color) out.push(`COLOR:${e.color}`);
const organizer = e.organizerCalendarAddress ?? e.replyTo?.imip;
if (organizer) out.push(`ORGANIZER:${organizer}`);
for (const p of Object.values(e.participants ?? {})) {
const address = participantAddress(p);
if (!address) continue;
const params = [
p.name ? `CN=${escText(p.name)}` : "",
p.participationStatus && PARTSTAT[p.participationStatus] ? `PARTSTAT=${PARTSTAT[p.participationStatus]}` : "",
p.roles?.chair ? "ROLE=CHAIR" : p.roles?.optional ? "ROLE=OPT-PARTICIPANT" : "",
p.expectReply ? "RSVP=TRUE" : "",
].filter(Boolean);
out.push(`ATTENDEE${params.length ? `;${params.join(";")}` : ""}:${address}`);
}
/* Stalwart 0.16 names a single rule `recurrenceRule`; RFC 8984 says
`recurrenceRules`. Both are read, because both turn up. */
for (const rule of [...(e.recurrenceRules ?? []), ...(e.recurrenceRule ? [e.recurrenceRule] : [])]) {
out.push(`RRULE:${rrule(rule, allDay)}`);
}
const excluded: string[] = [];
const modified: Array<[string, Record<string, unknown>]> = [];
for (const [when, patch] of Object.entries(e.recurrenceOverrides ?? {})) {
if (patch === null || (patch as Record<string, unknown>).excluded === true) excluded.push(when);
else modified.push([when, patch as Record<string, unknown>]);
}
if (excluded.length) {
out.push(allDay
? `EXDATE;VALUE=DATE:${excluded.map((d) => stamp(d, true)).join(",")}`
: tz
? `EXDATE;TZID=${tz}:${excluded.map((d) => stamp(d)).join(",")}`
: `EXDATE:${excluded.map((d) => stamp(d)).join(",")}`);
}
/*
* An alarm is a component, not a property, so it nests inside the event. Only
* DISPLAY and EMAIL are written because they are the only two JSCalendar
* names, and an acknowledged alert is still exported -- whether it has fired
* is this reader's business, not the file's.
*/
for (const a of Object.values(e.alerts ?? {})) {
const trigger = "offset" in a.trigger
? `TRIGGER${a.trigger.relativeTo === "end" ? ";RELATED=END" : ""}:${a.trigger.offset}`
: `TRIGGER;VALUE=DATE-TIME:${utcStamp(a.trigger.when)}`;
out.push("BEGIN:VALARM", trigger, `ACTION:${a.action === "email" ? "EMAIL" : "DISPLAY"}`, `DESCRIPTION:${escText(e.title ?? "")}`, "END:VALARM");
}
out.push("END:VEVENT");
/* A changed occurrence is its own VEVENT carrying the same UID and the
RECURRENCE-ID of the slot it replaces -- which is how iCalendar has always
said it, and why these come after the master rather than inside it. */
for (const [when, patch] of modified) {
const merged = { ...e, ...patch } as JSCalendarEvent;
delete merged.recurrenceRules;
delete merged.recurrenceRule;
delete merged.recurrenceOverrides;
out.push(...vevent(merged, { local: when, timeZone: tz, allDay }));
}
return out;
}
const FREQ: Record<string, string> = {
yearly: "YEARLY", monthly: "MONTHLY", weekly: "WEEKLY", daily: "DAILY",
hourly: "HOURLY", minutely: "MINUTELY", secondly: "SECONDLY",
};
const DAYS: Record<string, string> = { mo: "MO", tu: "TU", we: "WE", th: "TH", fr: "FR", sa: "SA", su: "SU" };
function rrule(r: JSCalendarRecurrenceRule, allDay: boolean): string {
const parts = [`FREQ=${FREQ[r.frequency] ?? r.frequency.toUpperCase()}`];
if (r.interval && r.interval !== 1) parts.push(`INTERVAL=${r.interval}`);
if (r.count != null) parts.push(`COUNT=${r.count}`);
/* UNTIL has to match DTSTART's kind: a date for an all-day series, and a UTC
instant otherwise. Sending a local time here is the classic way to make a
series stop on the wrong day in another zone. */
if (r.until) parts.push(`UNTIL=${allDay ? stamp(r.until, true) : `${stamp(r.until)}Z`}`);
if (r.byDay?.length) parts.push(`BYDAY=${r.byDay.map((d) => `${d.nthOfPeriod ?? ""}${DAYS[d.day] ?? d.day.toUpperCase()}`).join(",")}`);
if (r.byMonthDay?.length) parts.push(`BYMONTHDAY=${r.byMonthDay.join(",")}`);
if (r.byMonth?.length) parts.push(`BYMONTH=${r.byMonth.join(",")}`);
if (r.byYearDay?.length) parts.push(`BYYEARDAY=${r.byYearDay.join(",")}`);
if (r.byWeekNo?.length) parts.push(`BYWEEKNO=${r.byWeekNo.join(",")}`);
if (r.byHour?.length) parts.push(`BYHOUR=${r.byHour.join(",")}`);
if (r.byMinute?.length) parts.push(`BYMINUTE=${r.byMinute.join(",")}`);
if (r.bySecond?.length) parts.push(`BYSECOND=${r.bySecond.join(",")}`);
if (r.bySetPosition?.length) parts.push(`BYSETPOS=${r.bySetPosition.join(",")}`);
if (r.firstDayOfWeek) parts.push(`WKST=${DAYS[r.firstDayOfWeek] ?? r.firstDayOfWeek.toUpperCase()}`);
return parts.join(";");
}
/* ------------------------------------------------------------------ */
/* Time zones */
/* ------------------------------------------------------------------ */
/**
* A zone's definition, worked out from the one the browser already has.
*
* This exists because leaving it out was wrong, and provably so. A `TZID`
* naming an IANA zone with nothing defining it is not resolved by ical.js --
* Mozilla's own iCalendar library, and the one Thunderbird's calendar uses --
* which falls back to *floating* time. A 09:00 in Phoenix then reads as 09:00
* wherever the file is opened: seven hours out, silently, on every timed event.
* Measured, not assumed.
*
* The reason it was left out -- that generating one means shipping a zone
* database -- was also wrong. The browser has the IANA database already, behind
* `Intl`, and an offset for an instant is a formatting question. Transitions
* are then found by looking for the months where the answer changes and
* bisecting inside them, rather than by knowing any rules.
*
* Each transition is written as its own dated sub-component instead of as an
* RRULE. It is more lines and no cleverness: a rule has to be *derived*, and a
* derived rule that is subtly wrong moves somebody's meeting, while a list of
* dates can only be incomplete at the ends -- which is what the window is for.
*/
export function vtimezone(tzid: string, fromYear: number, toYear: number): string[] {
let offsetAt: (d: Date) => number;
try {
offsetAt = offsetFinder(tzid);
} catch {
/* A zone `Intl` does not know: say nothing rather than say something wrong.
The TZID stays on the events, which is where it was before this. */
return [];
}
const start = Date.UTC(fromYear, 0, 1);
const end = Date.UTC(toYear, 11, 31);
const MONTH = 30 * 24 * 3600 * 1000;
const transitions: Array<{ at: number; from: number; to: number }> = [];
let prev = offsetAt(new Date(start));
const firstOffset = prev;
for (let t = start; t < end; t += MONTH) {
const next = Math.min(t + MONTH, end);
const here = offsetAt(new Date(next));
if (here === prev) continue;
// Somewhere in this month. Bisect to the minute, which is finer than any
// transition anybody has ever scheduled.
let lo = t;
let hi = next;
// All the way down, rather than to the nearest second and rounded: rounding
// the wrong way writes a 02:00 change as 02:00:01, and thirty more halvings
// of a range that is already one month is nothing.
while (hi - lo > 1) {
const mid = lo + Math.floor((hi - lo) / 2);
if (offsetAt(new Date(mid)) === prev) lo = mid;
else hi = mid;
}
transitions.push({ at: hi, from: prev, to: here });
prev = here;
}
const out = ["BEGIN:VTIMEZONE", `TZID:${tzid}`];
if (!transitions.length) {
/* A zone that does not change -- Phoenix, Tokyo, UTC+X -- is one standing
rule, and RFC 5545 still wants a sub-component to hang it on. */
out.push("BEGIN:STANDARD", `DTSTART:${localStamp(new Date(start), firstOffset)}`,
`TZOFFSETFROM:${offsetText(firstOffset)}`, `TZOFFSETTO:${offsetText(firstOffset)}`,
...tzNameLine(tzid, new Date(start)), "END:STANDARD");
} else {
for (const tr of transitions) {
/* Daylight is the side with the larger offset from UTC; the names are
only labels, but a reader that shows them should not show them
backwards. */
const kind = tr.to > tr.from ? "DAYLIGHT" : "STANDARD";
out.push(`BEGIN:${kind}`,
/* DTSTART is local time read in the *old* offset, which is what
TZOFFSETFROM is there to say. */
`DTSTART:${localStamp(new Date(tr.at), tr.from)}`,
`TZOFFSETFROM:${offsetText(tr.from)}`,
`TZOFFSETTO:${offsetText(tr.to)}`,
...tzNameLine(tzid, new Date(tr.at + 60_000)),
`END:${kind}`);
}
}
out.push("END:VTIMEZONE");
return out;
}
/**
* Minutes east of UTC at an instant, from the zone database `Intl` carries.
*
* Formatting the instant into the zone and reading the clock back is the
* portable way to ask this: `timeZoneName: "longOffset"` is newer than some
* browsers this has to run in, and the difference between the two readings is
* the offset by definition.
*/
function offsetFinder(tzid: string): (d: Date) => number {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone: tzid, hourCycle: "h23",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
// Throws RangeError here, on construction, if the zone is not known.
dtf.format(new Date());
return (d: Date) => {
const p: Record<string, string> = {};
for (const part of dtf.formatToParts(d)) p[part.type] = part.value;
const asUTC = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute), Number(p.second));
return Math.round((asUTC - d.getTime()) / 60_000);
};
}
/** TZNAME, or nothing at all where there is no name worth writing. */
function tzNameLine(tzid: string, at: Date): string[] {
const name = zoneName(tzid, at);
return name ? [`TZNAME:${name}`] : [];
}
/** The zone's short label at an instant -- "MST", "CEST" -- or "" if it has none. */
function zoneName(tzid: string, at: Date): string {
try {
const parts = new Intl.DateTimeFormat("en-US", { timeZone: tzid, timeZoneName: "short" }).formatToParts(at);
const name = parts.find((p) => p.type === "timeZoneName")?.value.replace(/[^A-Za-z0-9+-]/g, "") ?? "";
/* Where a zone has no abbreviation in common use, `Intl` answers "GMT+9",
which repeats the offset beside it and reads as a mistake. */
return /^(GMT|UTC)[+-]?/.test(name) ? "" : name;
} catch {
return tzid;
}
}
/** "+0200" / "-0700", which is how iCalendar writes an offset. */
function offsetText(minutes: number): string {
const sign = minutes < 0 ? "-" : "+";
const abs = Math.abs(minutes);
return `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}${String(abs % 60).padStart(2, "0")}`;
}
/** An instant written as the wall clock it shows at a given offset. */
function localStamp(at: Date, offsetMinutes: number): string {
const shifted = new Date(at.getTime() + offsetMinutes * 60_000);
return shifted.toISOString().replace(/[-:]/g, "").replace(/\.\d+/, "").slice(0, 15);
}
+162
View File
@@ -0,0 +1,162 @@
import type { JSCalendarRecurrenceRule, JSCalendarNDay } from "@/jmap/types";
import { formatList, weekdayName, weekdayNames } from "../datetime";
import { plural, t } from "@/lib/i18n";
/**
* The seven days, Monday first, named in the reader's locale.
*
* This was a table of English strings carrying `label: "Monday"` and
* `short: "M"`, rendered straight into the picker. The long names could have
* become catalog entries; the short ones could not, because "T" is both
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalog
* cannot hold two translations under one key. Intl knows all of them.
*/
export const WEEKDAY_KEYS: Array<JSCalendarNDay["day"]> = ["mo", "tu", "we", "th", "fr", "sa", "su"];
export function weekdayOptions(): Array<{ key: JSCalendarNDay["day"]; label: string; short: string }> {
return weekdayNames("long").map(({ key, name }) => ({
key: key as JSCalendarNDay["day"],
label: name,
short: weekdayName(key, "narrow"),
}));
}
export type RecurrencePreset = "none" | "daily" | "weekly" | "weekdays" | "monthly" | "yearly" | "custom";
export function presetFor(rule: JSCalendarRecurrenceRule | undefined): RecurrencePreset {
if (!rule) return "none";
const simple = !rule.count && !rule.until && (rule.interval ?? 1) === 1;
if (rule.frequency === "daily" && simple && !rule.byDay) return "daily";
if (rule.frequency === "weekly" && simple) {
if (!rule.byDay) return "weekly";
const days = rule.byDay.map((d) => d.day).sort().join(",");
if (days === ["mo", "tu", "we", "th", "fr"].sort().join(",")) return "weekdays";
if (rule.byDay.length === 1) return "weekly";
}
if (rule.frequency === "monthly" && simple && !rule.byDay && (!rule.byMonthDay || rule.byMonthDay.length === 1)) return "monthly";
if (rule.frequency === "yearly" && simple && !rule.byDay && !rule.byMonth) return "yearly";
return "custom";
}
export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalendarRecurrenceRule | undefined {
const dow = WEEKDAY_KEYS[(start.getDay() + 6) % 7]!;
switch (preset) {
case "daily":
return { "@type": "RecurrenceRule", frequency: "daily" };
case "weekly":
return { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: dow }] };
case "weekdays":
return { "@type": "RecurrenceRule", frequency: "weekly", byDay: ["mo", "tu", "we", "th", "fr"].map((d) => ({ "@type": "NDay" as const, day: d as JSCalendarNDay["day"] })) };
case "monthly":
return { "@type": "RecurrenceRule", frequency: "monthly", byMonthDay: [start.getDate()] };
case "yearly":
return { "@type": "RecurrenceRule", frequency: "yearly" };
default:
return undefined;
}
}
/**
* A recurrence rule as a sentence.
*
* Built as whole sentences with placeholders rather than by concatenation.
* The old version appended fragments -- `base += " on " + names` -- which is
* untranslatable however complete the catalog is: German puts the weekday
* list somewhere else in the clause, and a translator handed " on " alone
* cannot move it. Every branch below is one key a translator can rewrite in
* full, including the word order.
*/
export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string {
if (!rule) return t("Does not repeat");
const n = rule.interval ?? 1;
const every = n !== 1;
let base: string;
switch (rule.frequency) {
case "daily":
base = every ? plural(n, { one: "Every {n} day", other: "Every {n} days" }) : t("Daily");
break;
case "weekly": {
const days = rule.byDay?.length ? rule.byDay.map((d) => d.day) : [];
const weekdaysOnly =
days.length === 5 && ["mo", "tu", "we", "th", "fr"].every((d) => days.includes(d as JSCalendarNDay["day"]));
if (weekdaysOnly && !every) {
base = t("Every weekday");
} else if (days.length) {
const list = formatList(days.map((d) => weekdayName(d as never)));
base = every
? plural(n, { one: "Every {n} week on {days}", other: "Every {n} weeks on {days}" }, { days: list })
: t("Weekly on {days}", { days: list });
} else {
base = every ? plural(n, { one: "Every {n} week", other: "Every {n} weeks" }) : t("Weekly");
}
break;
}
case "monthly": {
if (rule.byMonthDay?.length) {
const list = formatList(rule.byMonthDay.map(String));
base = every
? plural(n, { one: "Every {n} month on day {days}", other: "Every {n} months on day {days}" }, { days: list })
: t("Monthly on day {days}", { days: list });
} else if (rule.byDay?.length) {
const d = rule.byDay[0]!;
const weekday = weekdayName(d.day as never);
if (d.nthOfPeriod) {
const ord = ordinal(d.nthOfPeriod);
base = every
? plural(n, { one: "Every {n} month on the {ordinal} {weekday}", other: "Every {n} months on the {ordinal} {weekday}" }, { ordinal: ord, weekday })
: t("Monthly on the {ordinal} {weekday}", { ordinal: ord, weekday });
} else {
base = every
? plural(n, { one: "Every {n} month on {weekday}", other: "Every {n} months on {weekday}" }, { weekday })
: t("Monthly on {weekday}", { weekday });
}
} else {
base = every ? plural(n, { one: "Every {n} month", other: "Every {n} months" }) : t("Monthly");
}
break;
}
case "yearly":
base = every ? plural(n, { one: "Every {n} year", other: "Every {n} years" }) : t("Yearly");
break;
default:
// An RFC frequency this build has no sentence for. The frequency word
// itself stays as the server sent it rather than being invented.
base = t("Every {n} {frequency}", { n, frequency: rule.frequency });
}
// The tail wraps the sentence rather than being glued to its end, so a
// translator can put "until 3 May" first if that is what the language does.
if (rule.count) {
base = plural(rule.count, { one: "{rule}, {n} time", other: "{rule}, {n} times" }, { rule: base });
}
if (rule.until) {
base = t("{rule}, until {date}", { rule: base, date: rule.until.slice(0, 10) });
}
return base;
}
/**
* "first", "second", "last" -- words, not "1st".
*
* The suffix table this replaced ("st", "nd", "rd", "th") is English spelling
* rules in code: German writes "1.", Japanese "第1", and no catalog can
* reach a suffix chosen by arithmetic. JSCalendar's nthOfPeriod is 1-5 or -1
* in practice, so five words and "last" cover it; anything else falls back to
* the bare number, which is wrong in no language.
*/
function ordinal(n: number): string {
switch (n) {
case -1: return t("last");
case 1: return t("first");
case 2: return t("second");
case 3: return t("third");
case 4: return t("fourth");
case 5: return t("fifth");
default: return String(n);
}
}