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.