PageSourceSearch

https://smartcone.ai/src/shell/common/ics.js

js smartcone.ai collected 2026-10-05 15:07:58 UTC 34,675 bytes, 805 lines download raw bytes

1// /src/shell/common/ics.js
2// iCalendar (RFC 5545) READER — turn the bytes of an .ics file into plain event
3// records. It parses; it does not store, render or decide. Storing is
4// icsImport.js, rendering is the calendar, and both of those are one import
5// away from any shell that wants them. Plain ES module, no dependencies, no
6// build step — it runs in the browser exactly as it is written here.
7//
8// ── WHY A PARSER AND NOT A REGEX ─────────────────────────────────────────────
9// Every invite that matters here comes out of Outlook, Webex or Google, and all
10// three emit the parts of the format that a regex gets wrong:
11//
12//   • FOLDING. A long DESCRIPTION is split across lines with a leading space or
13//     tab on the continuation. Read line-by-line, a URL comes back with spaces
14//     in it — a join link that silently does not work.
15//   • VTIMEZONE. It is a component full of DTSTART and RRULE lines that belong
16//     to a timezone rule, not to a meeting. A parser that scans for "DTSTART:"
17//     picks up the DST rule's 1601 date and files the meeting under the year
18//     1601 — a wrong answer that looks like data, which is the failure mode
19//     this codebase treats as worse than a crash.
20//   • ALL-DAY DTEND IS EXCLUSIVE. A one-day event is DTSTART=20260818,
21//     DTEND=20260819. Rendering that end date is an off-by-one that shows every
22//     holiday as two days and is invisible until someone books against it.
23//   • PARAMS CAN CONTAIN A COLON. ORGANIZER;CN="Rainero, Alex":mailto:… — split
24//     on the first colon and the name eats the value.
25//
26// ── TIMES ARE RESOLVED, NOT GUESSED ──────────────────────────────────────────
27// A DTSTART carries one of three time bases and they are NOT interchangeable:
28//   …Z            an instant, in UTC.
29//   …;TZID=X      wall clock in zone X.
30//   …             floating: whatever the wall clock says wherever it is read.
31// TZID is resolved in this order, and the order matters:
32//   1. Intl, when the TZID is an IANA name ("America/Los_Angeles"). Exact, and
33//      the browser owns the DST history.
34//   2. The file's own VTIMEZONE, when it is not. Outlook writes
35//      TZID="Pacific Standard Time", which Intl rejects — but the same file
36//      carries the STANDARD/DAYLIGHT offsets and the changeover rule, so the
37//      answer is in the message and does not have to be looked up.
38//   3. Floating, WITH A WARNING NAMING THE TZID. Never a silent local-time
39//      fallback: a meeting at the wrong hour and a meeting at the right hour
40//      look identical on a calendar.
41// The one approximation left is the changeover hour itself — a transition rule
42// is written in the offset it is leaving, and this compares it in the wall
43// clock it is entering. An event inside that one ambiguous hour twice a year
44// can land an hour out. Everything outside it is exact.
45//
46// ── RECURRENCE IS BOUNDED, AND SAYS SO ───────────────────────────────────────
47// "FREQ=WEEKLY" with no COUNT and no UNTIL is an infinite series. Expansion
48// stops at MAX_OCCURRENCES or the horizon, whichever comes first, and the stop
49// is REPORTED — a series quietly truncated at 400 reads as a series that ended.
50
51// ── Limits ─────────────────────────────────────────────────────────────────
52const MAX_OCCURRENCES = 400;    // per series — a bound, and a reported one
53const HORIZON_YEARS   = 3;      // how far an open-ended series is expanded
54const MAX_SPAN_DAYS   = 370;    // longest single event a day-by-day view will draw
55const WEEKDAY_CODES   = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
56
57// ── Line handling ──────────────────────────────────────────────────────────
58
59/**
60 * Undo RFC 5545 line folding and normalise line endings.
61 * A folded line is CRLF followed by ONE space or tab; both the break and that
62 * one whitespace character are part of the folding, not of the value.
63 */
64function unfold(text) {
65  return String(text || "")
66    .replace(/\r\n/g, "\n")
67    .replace(/\r/g, "\n")
68    .replace(/\n[ \t]/g, "");
69}
70
71/** Split on a delimiter, ignoring delimiters inside a DQUOTEd param value. */
72function splitUnquoted(s, delim) {
73  const out = [];
74  let cur = "", inQuote = false;
75  for (const ch of s) {
76    if (ch === '"') { inQuote = !inQuote; cur += ch; continue; }
77    if (ch === delim && !inQuote) { out.push(cur); cur = ""; continue; }
78    cur += ch;
79  }
80  out.push(cur);
81  return out;
82}
83
84/**
85 * Parse one content line into { name, params, value }.
86 * The name/params section ends at the first colon OUTSIDE quotes — see the
87 * ORGANIZER;CN="Last, First":mailto:… case in the header.
88 */
89function parseContentLine(line) {
90  if (!line) return null;
91  let i = 0, inQuote = false;
92  for (; i < line.length; i++) {
93    const ch = line[i];
94    if (ch === '"') { inQuote = !inQuote; continue; }
95    if (ch === ":" && !inQuote) break;
96  }
97  if (i >= line.length) return null;          // no colon: not a content line
98  const head  = line.slice(0, i);
99  const value = line.slice(i + 1);
100  const segs  = splitUnquoted(head, ";");
101  const name  = segs[0].trim().toUpperCase();
102  if (!name) return null;
103  const params = {};
104  for (const seg of segs.slice(1)) {
105    const eq = seg.indexOf("=");
106    if (eq < 0) continue;
107    const k = seg.slice(0, eq).trim().toUpperCase();
108    let v = seg.slice(eq + 1).trim();
109    if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
110    params[k] = v;
111  }
112  return { name, params, value };
113}
114
115/** Unescape a TEXT value: \n and \N are newlines; \\ \, \; are literals. */
116function unescapeText(v) {
117  const s = String(v == null ? "" : v);
118  let out = "";
119  for (let i = 0; i < s.length; i++) {
120    const c = s[i];
121    if (c !== "\\" || i + 1 >= s.length) { out += c; continue; }
122    const n = s[++i];
123    if (n === "n" || n === "N") out += "\n";
124    else out += n;                            // \\ \, \; and anything unknown
125  }
126  return out;
127}
128
129// ── Component tree ─────────────────────────────────────────────────────────
130
131/**
132 * Build the component tree with a BEGIN/END stack, so a VTIMEZONE's DTSTART
133 * and RRULE stay inside the VTIMEZONE where they belong.
134 */
135function parseComponents(text) {
136  const root  = { name: "ROOT", props: [], subs: [] };
137  const stack = [root];
138  for (const raw of unfold(text).split("\n")) {
139    const line = raw.trim();
140    if (!line) continue;
141    const cl = parseContentLine(line);
142    if (!cl) continue;
143    if (cl.name === "BEGIN") {
144      const child = { name: cl.value.trim().toUpperCase(), props: [], subs: [] };
145      stack[stack.length - 1].subs.push(child);
146      stack.push(child);
147      continue;
148    }
149    if (cl.name === "END") {
150      if (stack.length > 1) stack.pop();
151      continue;
152    }
153    stack[stack.length - 1].props.push(cl);
154  }
155  return root;
156}
157
158const prop   = (comp, name) => comp.props.find(p => p.name === name) || null;
159const props  = (comp, name) => comp.props.filter(p => p.name === name);
160const textOf = (comp, name) => { const p = prop(comp, name); return p ? unescapeText(p.value).trim() : ""; };
161
162// ── The join link ──────────────────────────────────────────────────────────
163// The link is the thing a reader actually wants from an invite, and no two
164// vendors put it in the same place. Explicit properties are taken as given.
165// A link scraped out of the DESCRIPTION is trusted only when its HOST is a
166// known conferencing host -- that text is mostly boilerplate ("Learn more",
167// "Meeting options", privacy policies), and a Join button that opens a privacy
168// policy is worse than no Join button at all.
169//
170// ⛔ HOST ALONE IS NOT ENOUGH. Measured on a real Webex REQUEST (Samsung, 2026-08-18):
171// the description carries FIVE https URLs and FOUR of them are on webex.com --
172// the join link, the same link again, a global-call-in page, a meeting-info
173// page and help.webex.com. "First URL on a known host" gets the right answer
174// there only because of the order Webex happens to write them in. So the first
175// pass also requires a JOIN-shaped PATH, and host-only is the fallback.
176const CONFERENCE_HOSTS = [
177  { host: /(?:^|\.)teams\.microsoft\.com$|(?:^|\.)teams\.live\.com$/i, label: "Microsoft Teams", join: /\/l\/meetup-join|\/meet\//i },
178  { host: /(?:^|\.)webex\.com$/i,                  label: "Webex",        join: /\/j\.php|\/meet\/|\/join\/|\/wbxmjs\//i },
179  { host: /(?:^|\.)zoom\.us$|(?:^|\.)zoom\.com$/i, label: "Zoom",         join: /\/(?:j|w|s)\//i },
180  { host: /^meet\.google\.com$/i,                  label: "Google Meet",  join: /^\/[a-z]{3}-[a-z]{4}-[a-z]{3}/i },
181  { host: /(?:^|\.)gotomeeting\.com$/i,            label: "GoToMeeting",  join: /\/join\//i },
182  { host: /(?:^|\.)gotomeet\.me$/i,                label: "GoToMeeting",  join: /^\/./ },
183  { host: /(?:^|\.)whereby\.com$/i,                label: "Whereby",      join: /^\/./ },
184  { host: /(?:^|\.)chime\.aws$/i,                  label: "Amazon Chime", join: /^\/./ },
185  { host: /(?:^|\.)bluejeans\.com$/i,              label: "BlueJeans",    join: /^\/\d|\/s\//i },
186  { host: /^meet\.jit\.si$/i,                      label: "Jitsi",        join: /^\/./ },
187];
188
189// An ALLOW-list of schemes, never a block-list: an invite is untrusted input and
190// this href ends up on a link the reader clicks. Angle brackets are stripped
191// because Outlook renders a hyperlink into plain text as `Join meeting<url>`.
192function httpUrl(v) {
193  const s = String(v == null ? "" : v).trim().replace(/^<+|>+$/g, "").replace(/[.,;:]+$/, "");
194  if (!/^https?:\/\//i.test(s)) return null;
195  try { return new URL(s); } catch { return null; }
196}
197
198const conferenceHost = (u) => CONFERENCE_HOSTS.find(h => h.host.test(u.hostname)) || null;
199
200function deriveConference(ve) {
201  // Properties that MEAN "this is the meeting", most specific first.
202  const explicit = [
203    ["X-MICROSOFT-SKYPETEAMSMEETINGURL", "the Teams meeting property"],
204    ["X-GOOGLE-CONFERENCE",              "the Google conference property"],
205    ["CONFERENCE",                       "the CONFERENCE property"],
206    ["URL",                              "the URL property"],
207    ["LOCATION",                         "the location"],
208  ];
209  for (const [name, via] of explicit) {
210    const u = httpUrl(textOf(ve, name));
211    if (u) {
212      const known = conferenceHost(u);
213      return { url: u.href, label: known ? known.label : u.hostname, via };
214    }
215  }
216
217  // Then the invite text: HTML alternative last, since it repeats the same links.
218  const body = `${textOf(ve, "DESCRIPTION")}\n${textOf(ve, "X-ALT-DESC")}`;
219  const found = [...body.matchAll(/https?:\/\/[^\s<>"')\]]+/gi)]
220    .map(m => httpUrl(m[0]))
221    .filter(Boolean)
222    .map(u => ({ u, known: conferenceHost(u) }))
223    .filter(x => x.known);
224  const hit = found.find(x => x.known.join.test(x.u.pathname)) || found[0];
225  return hit
226    ? { url: hit.u.href, label: hit.known.label, via: "a link in the invite text" }
227    : { url: "", label: "", via: "" };
228}
229
230function findComponents(comp, name, out = []) {
231  for (const s of comp.subs) {
232    if (s.name === name) out.push(s);
233    findComponents(s, name, out);
234  }
235  return out;
236}
237
238// ── Time zones ─────────────────────────────────────────────────────────────
239
240const dtfCache = new Map();
241
242function getDtf(tz) {
243  if (dtfCache.has(tz)) return dtfCache.get(tz);
244  let dtf = null;
245  try {
246    dtf = new Intl.DateTimeFormat("en-US", {
247      timeZone: tz, hourCycle: "h23",
248      year: "numeric", month: "2-digit", day: "2-digit",
249      hour: "2-digit", minute: "2-digit", second: "2-digit",
250    });
251  } catch { dtf = null; }                     // not an IANA zone this browser knows
252  dtfCache.set(tz, dtf);
253  return dtf;
254}
255
256/** Offset of an IANA zone from UTC, in ms, at the instant ts. */
257function ianaOffsetMs(tz, ts) {
258  const dtf = getDtf(tz);
259  if (!dtf) return null;
260  const p = {};
261  for (const part of dtf.formatToParts(new Date(ts))) p[part.type] = part.value;
262  const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
263  return asUtc - ts;
264}
265
266/**
267 * Wall clock in an IANA zone -> epoch ms.
268 * Two passes: the first offset is looked up at the wrong instant by definition
269 * (we do not know the instant yet), the second corrects it. One correction is
270 * enough for every real zone; only a same-hour double transition would need a
271 * third, and none exists.
272 */
273function ianaWallToEpoch(tz, wallUtcMs) {
274  const first = ianaOffsetMs(tz, wallUtcMs);
275  if (first == null) return null;
276  const second = ianaOffsetMs(tz, wallUtcMs - first);
277  return wallUtcMs - (second == null ? first : second);
278}
279
280/** nth weekday of a month; n<0 counts back from the end. null if it does not exist. */
281function nthWeekdayOfMonth(year, month, dow, n) {
282  const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
283  if (n > 0) {
284    const firstDow = new Date(Date.UTC(year, month - 1, 1)).getUTCDay();
285    const day = 1 + ((dow - firstDow + 7) % 7) + (n - 1) * 7;
286    return day > daysInMonth ? null : day;
287  }
288  if (n < 0) {
289    const lastDow = new Date(Date.UTC(year, month - 1, daysInMonth)).getUTCDay();
290    const day = daysInMonth - ((lastDow - dow + 7) % 7) + (n + 1) * 7;
291    return day < 1 ? null : day;
292  }
293  return null;
294}
295
296/** "+0530" / "-0800" / "+053000" -> minutes east of UTC. */
297function parseUtcOffset(v) {
298  const m = /^([+-])(\d{2})(\d{2})(\d{2})?$/.exec(String(v || "").trim());
299  if (!m) return null;
300  const sign = m[1] === "-" ? -1 : 1;
301  return sign * (+m[2] * 60 + +m[3]);
302}
303
304/**
305 * Index the file's own VTIMEZONE components, which is how a non-IANA TZID
306 * ("Pacific Standard Time") is resolved without a lookup table.
307 */
308function buildVtzIndex(root) {
309  const index = new Map();
310  for (const vtz of findComponents(root, "VTIMEZONE")) {
311    const tzid = textOf(vtz, "TZID");
312    if (!tzid) continue;
313    const subs = [];
314    for (const sub of vtz.subs) {
315      if (sub.name !== "STANDARD" && sub.name !== "DAYLIGHT") continue;
316      const offsetTo = parseUtcOffset(textOf(sub, "TZOFFSETTO"));
317      if (offsetTo == null) continue;
318      const dts  = prop(sub, "DTSTART");
319      const dtsM = dts ? /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/.exec(dts.value.trim()) : null;
320      const rr   = prop(sub, "RRULE");
321      const rule = {};
322      if (rr) for (const kv of rr.value.split(";")) {
323        const eq = kv.indexOf("=");
324        if (eq > 0) rule[kv.slice(0, eq).trim().toUpperCase()] = kv.slice(eq + 1).trim();
325      }
326      const byday = /^(-?\d)?([A-Z]{2})$/.exec(String(rule.BYDAY || "").toUpperCase());
327      subs.push({
328        kind:        sub.name,
329        offsetToMin: offsetTo,
330        month:       rule.BYMONTH ? +rule.BYMONTH : (dtsM ? +dtsM[2] : null),
331        nth:         byday && byday[1] ? +byday[1] : (rule.BYDAY ? 1 : null),
332        dow:         byday ? WEEKDAY_CODES[byday[2]] : null,
333        monthDay:    rule.BYMONTHDAY ? +rule.BYMONTHDAY : (rule.BYDAY ? null : (dtsM ? +dtsM[3] : null)),
334        hour:        dtsM ? +dtsM[4] : 2,
335        minute:      dtsM ? +dtsM[5] : 0,
336        second:      dtsM ? +dtsM[6] : 0,
337        fixedWallMs: dtsM && !rr
338          ? Date.UTC(+dtsM[1], +dtsM[2] - 1, +dtsM[3], +dtsM[4], +dtsM[5], +dtsM[6])
339          : null,
340      });
341    }
342    if (subs.length) index.set(tzid, subs);
343  }
344  return index;
345}
346
347/** The wall-clock instant this rule switches over, in the given year. */
348function subTransitionWallMs(sub, year) {
349  if (sub.month && sub.dow != null) {
350    const day = nthWeekdayOfMonth(year, sub.month, sub.dow, sub.nth || 1);
351    if (day == null) return null;
352    return Date.UTC(year, sub.month - 1, day, sub.hour, sub.minute, sub.second);
353  }
354  if (sub.month && sub.monthDay) {
355    return Date.UTC(year, sub.month - 1, sub.monthDay, sub.hour, sub.minute, sub.second);
356  }
357  return sub.fixedWallMs;
358}
359
360/** Offset (minutes) that a VTIMEZONE says applies to a given wall clock. */
361function vtzOffsetForWall(subs, wallUtcMs) {
362  if (subs.length === 1) return subs[0].offsetToMin;
363  const year = new Date(wallUtcMs).getUTCFullYear();
364  let best = null, bestAt = -Infinity;
365  for (const sub of subs) {
366    for (const y of [year - 1, year]) {
367      const at = subTransitionWallMs(sub, y);
368      if (at == null || at > wallUtcMs) continue;
369      if (at > bestAt) { bestAt = at; best = sub; }
370    }
371  }
372  if (best) return best.offsetToMin;
373  // Before every transition this file describes: the STANDARD offset is the
374  // only defensible answer, and it is what the zone was on before DST existed.
375  const std = subs.find(s => s.kind === "STANDARD");
376  return (std || subs[0]).offsetToMin;
377}
378
379/** A floating time is the reader's own wall clock — literally, local time. */
380function floatingToEpoch(wallUtcMs) {
381  const d = new Date(wallUtcMs);
382  return new Date(
383    d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(),
384    d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()
385  ).getTime();
386}
387
388/**
389 * Build a wall-clock -> epoch resolver for one TZID.
390 * @returns {{ toEpoch:(wallUtcMs:number)=>number, resolved:boolean, via:string, tzid:string }}
391 */
392function makeZoneResolver(tzid, vtzIndex) {
393  if (!tzid) {
394    return { via: "floating", resolved: true, tzid: "", toEpoch: floatingToEpoch };
395  }
396  if (getDtf(tzid)) {
397    return { via: "iana", resolved: true, tzid, toEpoch: (wall) => ianaWallToEpoch(tzid, wall) };
398  }
399  const subs = vtzIndex.get(tzid);
400  if (subs && subs.length) {
401    return {
402      via: "vtimezone", resolved: true, tzid,
403      toEpoch: (wall) => wall - vtzOffsetForWall(subs, wall) * 60000,
404    };
405  }
406  return { via: "floating", resolved: false, tzid, toEpoch: floatingToEpoch };
407}
408
409// ── Value parsing ──────────────────────────────────────────────────────────
410
411/**
412 * Parse a DATE or DATE-TIME property into wall components plus a resolver.
413 * Returns null when the value is not a date at all — the caller reports that
414 * rather than substituting "now", which would file the event under today.
415 */
416function parseDateProp(cl, vtzIndex) {
417  const raw    = String(cl.value || "").trim();
418  const isDate = String(cl.params.VALUE || "").toUpperCase() === "DATE" || /^\d{8}$/.test(raw);
419
420  if (isDate) {
421    const m = /^(\d{4})(\d{2})(\d{2})$/.exec(raw);
422    if (!m) return null;
423    return {
424      allDay: true,
425      wallMs: Date.UTC(+m[1], +m[2] - 1, +m[3]),
426      zone: { via: "date", resolved: true, tzid: "", toEpoch: floatingToEpoch },
427    };
428  }
429
430  const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(raw);
431  if (!m) return null;
432  const wallMs = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
433  if (m[7]) {
434    return { allDay: false, wallMs, zone: { via: "utc", resolved: true, tzid: "UTC", toEpoch: (w) => w } };
435  }
436  return { allDay: false, wallMs, zone: makeZoneResolver(cl.params.TZID || "", vtzIndex) };
437}
438
439/** ISO 8601 duration as used by RFC 5545: [-]P[nW][nD][T[nH][nM][nS]] -> ms. */
440function parseDuration(v) {
441  const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(String(v || "").trim());
442  if (!m) return null;
443  const sign = m[1] === "-" ? -1 : 1;
444  const ms = (+(m[2] || 0) * 604800 + +(m[3] || 0) * 86400 +
445              +(m[4] || 0) * 3600   + +(m[5] || 0) * 60 + +(m[6] || 0)) * 1000;
446  return sign * ms;
447}
448
449// ── Recurrence ─────────────────────────────────────────────────────────────
450
451function parseRrule(value) {
452  const rule = {};
453  for (const kv of String(value || "").split(";")) {
454    const eq = kv.indexOf("=");
455    if (eq <= 0) continue;
456    rule[kv.slice(0, eq).trim().toUpperCase()] = kv.slice(eq + 1).trim();
457  }
458  return rule;
459}
460
461function bydayList(v) {
462  return String(v || "").toUpperCase().split(",")
463    .map(tok => /^(-?\d{1,2})?([A-Z]{2})$/.exec(tok.trim()))
464    .filter(m => m && WEEKDAY_CODES[m[2]] != null)
465    .map(m => ({ nth: m[1] ? +m[1] : 0, dow: WEEKDAY_CODES[m[2]] }));
466}
467
468/**
469 * Expand an RRULE into wall-clock instants (Date.UTC-encoded wall components).
470 * Recurrence repeats on the WALL CLOCK, not on the instant — a 09:00 meeting is
471 * still 09:00 after a DST change — so the whole expansion happens in wall terms
472 * and each occurrence is resolved to an epoch afterwards.
473 *
474 * @returns {{ occurrences:number[], truncated:boolean, unsupported:string }}
475 */
476function expandRrule(rule, startWallMs, opts = {}) {
477  const freq     = String(rule.FREQ || "").toUpperCase();
478  const interval = Math.max(1, parseInt(rule.INTERVAL, 10) || 1);
479  const count    = rule.COUNT ? parseInt(rule.COUNT, 10) : null;
480  const maxOccurrences = opts.maxOccurrences || MAX_OCCURRENCES;
481
482  if (!["DAILY", "WEEKLY", "MONTHLY", "YEARLY"].includes(freq)) {
483    return { occurrences: [startWallMs], truncated: false, unsupported: freq || "(no FREQ)" };
484  }
485
486  let untilWallMs = null;
487  if (rule.UNTIL) {
488    const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2}))?/.exec(rule.UNTIL.trim());
489    if (m) untilWallMs = Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] || 23), +(m[5] || 59), +(m[6] || 59));
490  }
491  const start = new Date(startWallMs);
492  const horizonMs = Date.UTC(
493    start.getUTCFullYear() + HORIZON_YEARS, start.getUTCMonth(), start.getUTCDate()
494  );
495  const limitMs = untilWallMs != null ? Math.min(untilWallMs, horizonMs) : horizonMs;
496
497  const hh = start.getUTCHours(), mi = start.getUTCMinutes(), ss = start.getUTCSeconds();
498  const byDay      = bydayList(rule.BYDAY);
499  const byMonthDay = String(rule.BYMONTHDAY || "").split(",").map(n => parseInt(n, 10)).filter(Number.isFinite);
500  const byMonth    = String(rule.BYMONTH || "").split(",").map(n => parseInt(n, 10)).filter(Number.isFinite);
501
502  const out = [];
503  const cap = count ? Math.min(count, maxOccurrences) : maxOccurrences;
504  const full = () => out.length >= cap;
505  const push = (ms) => {
506    if (ms < startWallMs || ms > limitMs) return;
507    if (!out.includes(ms)) out.push(ms);
508  };
509
510  if (freq === "DAILY") {
511    for (let ms = startWallMs; ms <= limitMs && !full(); ms += interval * 86400000) push(ms);
512  } else if (freq === "WEEKLY") {
513    const days = byDay.length ? byDay.map(b => b.dow) : [start.getUTCDay()];
514    // Anchor on the Sunday of the first week so INTERVAL counts whole weeks.
515    let weekStart = startWallMs - start.getUTCDay() * 86400000;
516    while (weekStart <= limitMs && !full()) {
517      for (const dow of days) {
518        if (full()) break;
519        push(weekStart + dow * 86400000);
520      }
521      weekStart += interval * 7 * 86400000;
522    }
523  } else if (freq === "MONTHLY") {
524    let y = start.getUTCFullYear(), mo = start.getUTCMonth() + 1;
525    for (let step = 0; step < 12 * HORIZON_YEARS + 12 && !full(); step++) {
526      if (Date.UTC(y, mo - 1, 1) > limitMs) break;
527      const daysInMonth = new Date(Date.UTC(y, mo, 0)).getUTCDate();
528      let days = [];
529      if (byDay.length) {
530        for (const b of byDay) {
531          if (b.nth) {
532            const day = nthWeekdayOfMonth(y, mo, b.dow, b.nth);
533            if (day) days.push(day);
534          } else {
535            for (let d = 1; d <= daysInMonth; d++) {
536              if (new Date(Date.UTC(y, mo - 1, d)).getUTCDay() === b.dow) days.push(d);
537            }
538          }
539        }
540      } else if (byMonthDay.length) {
541        days = byMonthDay.map(d => (d > 0 ? d : daysInMonth + d + 1));
542      } else {
543        days = [start.getUTCDate()];
544      }
545      for (const d of days.sort((a, b) => a - b)) {
546        if (d < 1 || d > daysInMonth) continue;   // the 31st of a 30-day month is skipped, per RFC
547        if (full()) break;
548        push(Date.UTC(y, mo - 1, d, hh, mi, ss));
549      }
550      mo += interval;
551      while (mo > 12) { mo -= 12; y += 1; }
552    }
553  } else if (freq === "YEARLY") {
554    const months = byMonth.length ? byMonth : [start.getUTCMonth() + 1];
555    for (let y = start.getUTCFullYear(); !full(); y += interval) {
556      if (Date.UTC(y, 0, 1) > limitMs) break;
557      for (const mo of months) {
558        if (full()) break;
559        const daysInMonth = new Date(Date.UTC(y, mo, 0)).getUTCDate();
560        let days = [];
561        if (byDay.length) {
562          for (const b of byDay) {
563            const day = nthWeekdayOfMonth(y, mo, b.dow, b.nth || 1);
564            if (day) days.push(day);
565          }
566        } else if (byMonthDay.length) {
567          days = byMonthDay.map(d => (d > 0 ? d : daysInMonth + d + 1));
568        } else {
569          days = [start.getUTCDate()];
570        }
571        for (const d of days) {
572          if (d < 1 || d > daysInMonth) continue;
573          if (full()) break;
574          push(Date.UTC(y, mo - 1, d, hh, mi, ss));
575        }
576      }
577    }
578  }
579
580  out.sort((a, b) => a - b);
581  // Hitting the cap is only a truncation if the series was still running.
582  const truncated = count ? out.length < count : out.length >= maxOccurrences;
583  return { occurrences: out, truncated, unsupported: "" };
584}
585
586// ── Date keys ──────────────────────────────────────────────────────────────
587
588/** Local "YYYY-MM-DD" for an instant — the key every calendar view is drawn on. */
589export function dateKeyOf(ms) {
590  const d = new Date(ms);
591  const mm = String(d.getMonth() + 1).padStart(2, "0");
592  const dd = String(d.getDate()).padStart(2, "0");
593  return `${d.getFullYear()}-${mm}-${dd}`;
594}
595
596/** Local "HH:MM" for an instant. */
597export function timeOf(ms) {
598  const d = new Date(ms);
599  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
600}
601
602/** Every local date key from `fromKey` to `toKey` inclusive, bounded. */
603export function dateKeysBetween(fromKey, toKey, maxDays = MAX_SPAN_DAYS) {
604  const keys = [];
605  const [fy, fm, fd] = String(fromKey || "").split("-").map(Number);
606  const [ty, tm, td] = String(toKey || "").split("-").map(Number);
607  if (!fy || !ty) return keys;
608  let cur = new Date(fy, fm - 1, fd);
609  const end = new Date(ty, tm - 1, td);
610  while (cur <= end && keys.length < maxDays) {
611    keys.push(dateKeyOf(cur.getTime()));
612    cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
613  }
614  return keys;
615}
616
617// ── The reader ─────────────────────────────────────────────────────────────
618
619/**
620 * Does this text look like an iCalendar document? Used to decide whether a mail
621 * part is worth parsing, before spending anything on it.
622 */
623export function looksLikeIcs(text) {
624  return /BEGIN:VCALENDAR/i.test(String(text || ""));
625}
626
627/**
628 * Parse an .ics document.
629 *
630 * @param {string} text  The file, as text.
631 * @returns {{
632 *   ok: boolean,
633 *   prodId: string, calendarName: string, method: string,
634 *   events: object[],          // one entry per OCCURRENCE, times already resolved
635 *   warnings: string[],        // things a person has to be told about the import
636 *   error: string              // set only when nothing could be read at all
637 * }}
638 */
639export function parseIcs(text) {
640  const raw = String(text || "");
641  const result = {
642    ok: false, prodId: "", calendarName: "", method: "",
643    events: [], warnings: [], error: "",
644  };
645
646  if (!raw.trim()) { result.error = "that file is empty"; return result; }
647  if (!looksLikeIcs(raw)) {
648    result.error = "that is not an iCalendar file — it has no BEGIN:VCALENDAR line";
649    return result;
650  }
651
652  const root     = parseComponents(raw);
653  const vtzIndex = buildVtzIndex(root);
654  const cal      = findComponents(root, "VCALENDAR")[0];
655  if (cal) {
656    result.prodId       = textOf(cal, "PRODID");
657    result.method       = textOf(cal, "METHOD").toUpperCase();
658    result.calendarName = textOf(cal, "X-WR-CALNAME");
659  }
660
661  const vevents = findComponents(root, "VEVENT");
662  if (!vevents.length) {
663    // A VTODO/VJOURNAL-only file is valid iCalendar with no meetings in it.
664    result.error = "that file has no events in it (no VEVENT)";
665    return result;
666  }
667
668  const unresolvedZones = new Set();
669
670  for (const ve of vevents) {
671    const uid     = textOf(ve, "UID");
672    const summary = textOf(ve, "SUMMARY");
673    const label   = summary || uid || "(untitled event)";
674
675    const dtStartProp = prop(ve, "DTSTART");
676    if (!dtStartProp) {
677      result.warnings.push(`skipped "${label}" — it has no start time (no DTSTART)`);
678      continue;
679    }
680    const startSpec = parseDateProp(dtStartProp, vtzIndex);
681    if (!startSpec) {
682      result.warnings.push(`skipped "${label}" — its start time "${dtStartProp.value}" is not a date this reader understands`);
683      continue;
684    }
685    if (!startSpec.zone.resolved && startSpec.zone.tzid) unresolvedZones.add(startSpec.zone.tzid);
686
687    // End: DTEND wins, then DURATION, then the RFC defaults — one day for an
688    // all-day event, zero length for a timed one.
689    let durationMs = null;
690    const dtEndProp = prop(ve, "DTEND");
691    if (dtEndProp) {
692      const endSpec = parseDateProp(dtEndProp, vtzIndex);
693      if (endSpec) durationMs = endSpec.wallMs - startSpec.wallMs;
694      else result.warnings.push(`"${label}" has an end time this reader could not read ("${dtEndProp.value}
694") — its length was taken from the start alone`);
695    }
696    if (durationMs == null) {
697      const durProp = prop(ve, "DURATION");
698      if (durProp) durationMs = parseDuration(durProp.value);
699    }
700    if (durationMs == null) durationMs = startSpec.allDay ? 86400000 : 0;
701    if (durationMs < 0) durationMs = 0;
702
703    // Excluded instances, resolved the same way as the start.
704    const exdates = new Set();
705    for (const ex of props(ve, "EXDATE")) {
706      for (const one of ex.value.split(",")) {
707        const spec = parseDateProp({ name: "EXDATE", params: ex.params, value: one }, vtzIndex);
708        if (spec) exdates.add(spec.wallMs);
709      }
710    }
711
712    // Occurrences, in WALL terms.
713    let occurrences = [startSpec.wallMs];
714    let truncated = false;
715    const rruleProp = prop(ve, "RRULE");
716    if (rruleProp) {
717      const exp = expandRrule(parseRrule(rruleProp.value), startSpec.wallMs);
718      occurrences = exp.occurrences;
719      truncated   = exp.truncated;
720      if (exp.unsupported) {
721        result.warnings.push(`"${label}" repeats with a rule this reader does not expand (FREQ=${exp.unsupported}) — only the first occurrence was imported`);
722      } else if (truncated && occurrences.length) {
723        const last = occurrences[occurrences.length - 1];
724        result.warnings.push(`"${label}" repeats without a stated end — ${occurrences.length} occurrences were imported, up to ${dateKeyOf(startSpec.zone.toEpoch(last))}. Later ones were not.`);
725      }
726    }
727    for (const rd of props(ve, "RDATE")) {
728      for (const one of rd.value.split(",")) {
729        const spec = parseDateProp({ name: "RDATE", params: rd.params, value: one }, vtzIndex);
730        if (spec && !occurrences.includes(spec.wallMs)) occurrences.push(spec.wallMs);
731      }
732    }
733    occurrences = occurrences.filter(ms => !exdates.has(ms)).sort((a, b) => a - b);
734
735    const recurrenceIdProp = prop(ve, "RECURRENCE-ID");
736    const recurrenceSpec   = recurrenceIdProp ? parseDateProp(recurrenceIdProp, vtzIndex) : null;
737
738    const attendees = props(ve, "ATTENDEE").map(a => ({
739      name:     unescapeText(a.params.CN || "").trim(),
740      email:    String(a.value || "").replace(/^mailto:/i, "").trim(),
741      role:     (a.params.ROLE || "").trim(),
742      partstat: (a.params.PARTSTAT || "").trim(),
743    })).filter(a => a.email || a.name);
744
745    const conference = deriveConference(ve);
746
747    const organizerProp = prop(ve, "ORGANIZER");
748    const organizer = organizerProp ? {
749      name:  unescapeText(organizerProp.params.CN || "").trim(),
750      email: String(organizerProp.value || "").replace(/^mailto:/i, "").trim(),
751    } : null;
752
753    const status   = textOf(ve, "STATUS").toUpperCase();
754    const sequence = parseInt(textOf(ve, "SEQUENCE"), 10) || 0;
755
756    for (const wallMs of occurrences) {
757      const startMs  = startSpec.zone.toEpoch(wallMs);
758      const endMs    = startSpec.zone.toEpoch(wallMs + durationMs);
759      const startKey = dateKeyOf(startMs);
760      // An all-day DTEND is EXCLUSIVE and a timed one lands ON the boundary, so
761      // both step back before the day is read off. Without this every all-day
762      // event is a day too long and every meeting ending at midnight spills
763      // into the next day.
764      const endKey = endMs > startMs ? dateKeyOf(endMs - 1) : startKey;
765
766      result.events.push({
767        uid,
768        recurrenceId: recurrenceSpec ? dateKeyOf(startSpec.zone.toEpoch(recurrenceSpec.wallMs)) : "",
769        sequence,
770        summary,
771        description: textOf(ve, "DESCRIPTION"),
772        location:    textOf(ve, "LOCATION"),
773        url:         textOf(ve, "URL"),
774        joinUrl:     conference.url,
775        joinLabel:   conference.label,
776        joinVia:     conference.via,
777        status,
778        method:      result.method,
779        organizer,
780        attendees,
781        allDay:      startSpec.allDay,
782        startMs, endMs,
783        startKey,    endKey,
784        startTime:   startSpec.allDay ? "" : timeOf(startMs),
785        endTime:     startSpec.allDay ? "" : timeOf(endMs),
786        dateKeys:    dateKeysBetween(startKey, endKey),
787        tzid:        startSpec.zone.tzid,
788        tzVia:       startSpec.zone.via,
789        recurring:   !!rruleProp,
790        seriesTruncated: truncated,
791      });
792    }
793  }
794
795  for (const tz of unresolvedZones) {
796    result.warnings.push(`the time zone "${tz}" is not one this browser knows and the file did not describe it — those times were read as local time and may be hours out`);
797  }
798
799  if (!result.events.length) {
800    result.error = "no event in that file had a start time that could be read";
801    return result;
802  }
803  result.ok = true;
804  return result;
805}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.