Merge pull request #229 from Coffey-Labs/fix/export-vtimezone
Define the zones an export names, instead of only naming them
This commit is contained in:
@@ -17,7 +17,16 @@ const base: JSCalendarEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const lines = (e: JSCalendarEvent[], name?: string) => toIcs(e, name).split("\r\n");
|
const lines = (e: JSCalendarEvent[], name?: string) => toIcs(e, name).split("\r\n");
|
||||||
const find = (e: JSCalendarEvent[], prefix: string) => lines(e).filter((l) => l.startsWith(prefix));
|
/*
|
||||||
|
* 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];
|
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
|
||||||
|
|
||||||
describe("the document around the events", () => {
|
describe("the document around the events", () => {
|
||||||
@@ -209,3 +218,88 @@ describe("what comes back out of the parser", () => {
|
|||||||
expect(back.events[0]!.summary).toBe("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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+179
-12
@@ -281,29 +281,51 @@ function finish(e: Partial<IcsEvent> & { dtend?: Date; duration?: number }): Ics
|
|||||||
*
|
*
|
||||||
* What is deliberately not here, stated rather than discovered:
|
* What is deliberately not here, stated rather than discovered:
|
||||||
*
|
*
|
||||||
* - **No VTIMEZONE components.** A `TZID` is emitted with the IANA name the
|
* - **Overrides are applied at the top level only.** (See below.)
|
||||||
* server holds -- "Europe/Berlin" -- and no definition of that zone beside
|
* A recurrence override is a JSON patch, and a patch addressing
|
||||||
* it. Generating one means shipping a zone database to the browser to
|
* `locations/x/name` is not something this flattens; those paths are left on
|
||||||
* describe rules the reader's own system already knows. Every client that
|
* the master's value. Plain overridden properties -- a moved time, a changed
|
||||||
* matters resolves IANA names; a strict validator will complain, and the
|
* title -- come across.
|
||||||
* alternative -- converting everything to UTC -- would be worse, because a
|
|
||||||
* weekly 09:00 that becomes 08:00 for half the year is a wrong calendar
|
|
||||||
* rather than a pedantic one.
|
|
||||||
* - **Overrides are applied at the top level only.** 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
|
* - **No localizations, no relatedTo, no per-participant delegation.** Nothing
|
||||||
* in ihasmail sets them.
|
* in ihasmail sets them.
|
||||||
*/
|
*/
|
||||||
export function toIcs(events: JSCalendarEvent[], calendarName?: string): string {
|
export function toIcs(events: JSCalendarEvent[], calendarName?: string): string {
|
||||||
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//ihasmail//EN", "CALSCALE:GREGORIAN"];
|
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//ihasmail//EN", "CALSCALE:GREGORIAN"];
|
||||||
if (calendarName) lines.push(`X-WR-CALNAME:${escText(calendarName)}`);
|
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));
|
for (const e of events) lines.push(...vevent(e));
|
||||||
lines.push("END:VCALENDAR");
|
lines.push("END:VCALENDAR");
|
||||||
return lines.map(foldLine).join("\r\n") + "\r\n";
|
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. */
|
/** RFC 5545 escaping. A comma and a semicolon separate values, so both go. */
|
||||||
function escText(s: string): string {
|
function escText(s: string): string {
|
||||||
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
||||||
@@ -480,3 +502,148 @@ function rrule(r: JSCalendarRecurrenceRule, allDay: boolean): string {
|
|||||||
if (r.firstDayOfWeek) parts.push(`WKST=${DAYS[r.firstDayOfWeek] ?? r.firstDayOfWeek.toUpperCase()}`);
|
if (r.firstDayOfWeek) parts.push(`WKST=${DAYS[r.firstDayOfWeek] ?? r.firstDayOfWeek.toUpperCase()}`);
|
||||||
return parts.join(";");
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user