@nomideusz/svelte-calendar 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * iCalendar (.ics, RFC 5545) — read and write the format every calendar app
3
+ * speaks: Google Calendar, Apple Calendar / iCloud, Outlook / Microsoft 365,
4
+ * Thunderbird, Proton, Fastmail, Nextcloud. A file import, a subscription URL
5
+ * ("secret address in iCal format", "publish calendar", webcal://) and an
6
+ * export are all this one format.
7
+ *
8
+ * Usage:
9
+ * import { createIcsAdapter, parseIcs, toIcs } from '@nomideusz/svelte-calendar';
10
+ *
11
+ * const adapter = createIcsAdapter(await file.text()); // a dropped .ics file
12
+ * const feed = createIcsAdapter({ url: '/api/google-feed' }); // a subscription
13
+ * const download = toIcs(events, { name: 'Studio' }); // back out
14
+ *
15
+ * Recurrence: RRULE (DAILY, WEEKLY, MONTHLY, YEARLY with INTERVAL, COUNT,
16
+ * UNTIL, BYDAY incl. ordinals, BYMONTHDAY, BYMONTH, BYSETPOS, WKST), RDATE,
17
+ * EXDATE and RECURRENCE-ID overrides. Times: UTC, floating, IANA TZIDs and
18
+ * the VTIMEZONE blocks Outlook writes under Windows zone names.
19
+ */
20
+ import { getTimezoneOffset } from 'date-fns-tz';
21
+ import { CalendarReadOnlyError, EventNotFoundError } from './errors.js';
22
+ /** One content line: NAME;PARAM=a;PARAM="b:c":value */
23
+ function parseLine(line) {
24
+ let i = 0;
25
+ while (i < line.length && line[i] !== ';' && line[i] !== ':')
26
+ i++;
27
+ const name = line.slice(0, i).toUpperCase();
28
+ const params = {};
29
+ while (line[i] === ';') {
30
+ const eq = line.indexOf('=', i);
31
+ if (eq < 0)
32
+ return null;
33
+ const key = line.slice(i + 1, eq).toUpperCase();
34
+ let val = '';
35
+ let quoted = false;
36
+ for (i = eq + 1; i < line.length; i++) {
37
+ const ch = line[i];
38
+ if (ch === '"')
39
+ quoted = !quoted;
40
+ else if (!quoted && (ch === ';' || ch === ':'))
41
+ break;
42
+ else
43
+ val += ch;
44
+ }
45
+ params[key] = val;
46
+ }
47
+ if (line[i] !== ':')
48
+ return null;
49
+ return { name, params, value: line.slice(i + 1) };
50
+ }
51
+ function parseComponents(text) {
52
+ const root = { name: 'ROOT', props: [], children: [] };
53
+ const stack = [root];
54
+ // Unfold: a line starting with a space or tab continues the previous one.
55
+ const lines = text.replace(/\r\n?/g, '\n').replace(/\n[ \t]/g, '').split('\n');
56
+ for (const line of lines) {
57
+ if (!line.trim())
58
+ continue;
59
+ const prop = parseLine(line);
60
+ if (!prop)
61
+ continue;
62
+ const top = stack[stack.length - 1];
63
+ if (prop.name === 'BEGIN') {
64
+ const child = { name: prop.value.trim().toUpperCase(), props: [], children: [] };
65
+ top.children.push(child);
66
+ stack.push(child);
67
+ }
68
+ else if (prop.name === 'END') {
69
+ if (stack.length > 1)
70
+ stack.pop();
71
+ }
72
+ else {
73
+ top.props.push(prop);
74
+ }
75
+ }
76
+ return root;
77
+ }
78
+ const prop = (c, name) => c.props.find((p) => p.name === name);
79
+ const props = (c, name) => c.props.filter((p) => p.name === name);
80
+ function unescapeText(v) {
81
+ return v.replace(/\\([\\;,nN])/g, (_, ch) => (ch === 'n' || ch === 'N' ? '\n' : ch));
82
+ }
83
+ const text = (c, name) => {
84
+ const p = prop(c, name);
85
+ return p ? unescapeText(p.value) : undefined;
86
+ };
87
+ // ── Time ────────────────────────────────────────────────
88
+ // A "wall" is a wall-clock time stored as UTC milliseconds: its UTC getters
89
+ // read the local fields. Day math on walls is plain DAY steps, DST-free; a
90
+ // wall only becomes an instant through its zone.
91
+ const DAY = 86_400_000;
92
+ const UTC_ZONE = (wall) => wall;
93
+ /** A floating time: the browser's own wall clock. */
94
+ const LOCAL_ZONE = (wall) => {
95
+ const d = new Date(wall);
96
+ return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()).getTime();
97
+ };
98
+ function ianaZone(tz) {
99
+ if (Number.isNaN(getTimezoneOffset(tz, new Date(0))))
100
+ return undefined;
101
+ return (wall) => {
102
+ // Two passes: the offset at the guessed instant settles DST edges.
103
+ const guess = wall - getTimezoneOffset(tz, new Date(wall));
104
+ return wall - getTimezoneOffset(tz, new Date(guess));
105
+ };
106
+ }
107
+ /** "20261026" or "20261026T090000" (Z dropped) → wall */
108
+ function parseWall(v) {
109
+ const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?/.exec(v.trim());
110
+ if (!m)
111
+ return undefined;
112
+ return Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0));
113
+ }
114
+ /** "-PT15M", "P1DT2H", "P1W" → ms */
115
+ function parseDuration(v) {
116
+ const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(v.trim());
117
+ if (!m)
118
+ return undefined;
119
+ const ms = (+(m[2] ?? 0) * 7 + +(m[3] ?? 0)) * DAY + (+(m[4] ?? 0) * 3600 + +(m[5] ?? 0) * 60 + +(m[6] ?? 0)) * 1000;
120
+ return m[1] === '-' ? -ms : ms;
121
+ }
122
+ // ── Recurrence ──────────────────────────────────────────
123
+ const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
124
+ function parseRule(v, zoneOf) {
125
+ const parts = {};
126
+ for (const kv of v.split(';')) {
127
+ const [k, val] = kv.split('=');
128
+ if (k && val)
129
+ parts[k.toUpperCase()] = val.toUpperCase();
130
+ }
131
+ const freq = parts.FREQ;
132
+ // ponytail: HOURLY/MINUTELY/SECONDLY and BYHOUR/BYMINUTE/BYYEARDAY/BYWEEKNO are
133
+ // not expanded (calendar apps don't write them); such an event shows its DTSTART only.
134
+ if (freq !== 'DAILY' && freq !== 'WEEKLY' && freq !== 'MONTHLY' && freq !== 'YEARLY')
135
+ return undefined;
136
+ const nums = (s) => s?.split(',').map(Number).filter((n) => Number.isInteger(n) && n !== 0);
137
+ let until;
138
+ if (parts.UNTIL) {
139
+ const wall = parseWall(parts.UNTIL);
140
+ if (wall != null)
141
+ until = { wall, date: !parts.UNTIL.includes('T'), zone: parts.UNTIL.endsWith('Z') ? UTC_ZONE : zoneOf() };
142
+ }
143
+ return {
144
+ freq,
145
+ interval: Math.max(1, Number(parts.INTERVAL) || 1),
146
+ count: parts.COUNT ? Number(parts.COUNT) : undefined,
147
+ until,
148
+ byDay: parts.BYDAY?.split(',').flatMap((s) => {
149
+ const m = /^([+-]?\d+)?([A-Z]{2})$/.exec(s);
150
+ const wd = m ? WEEKDAYS.indexOf(m[2]) : -1;
151
+ return m && wd >= 0 ? [{ n: Number(m[1] ?? 0), wd }] : [];
152
+ }),
153
+ byMonthDay: nums(parts.BYMONTHDAY),
154
+ byMonth: nums(parts.BYMONTH)?.map((m) => m - 1),
155
+ bySetPos: nums(parts.BYSETPOS),
156
+ wkst: Math.max(0, WEEKDAYS.indexOf(parts.WKST ?? 'MO')),
157
+ };
158
+ }
159
+ const daysInMonth = (y, m) => new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
160
+ const weekday = (wall) => new Date(wall).getUTCDay();
161
+ /** Day offsets in a span of `len` days from `start` matching BYDAY (ordinals count within the span). */
162
+ function pickWeekdays(start, len, byDay) {
163
+ const out = [];
164
+ for (const { n, wd } of byDay) {
165
+ const all = [];
166
+ for (let d = (wd - weekday(start) + 7) % 7; d < len; d += 7)
167
+ all.push(d);
168
+ if (!n)
169
+ out.push(...all);
170
+ else {
171
+ const pick = n > 0 ? all[n - 1] : all[all.length + n];
172
+ if (pick != null)
173
+ out.push(pick);
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+ /** Days (walls at midnight) of month y/m the rule selects; `dtDay` when it names none. */
179
+ function monthDays(y, m, r, dtDay) {
180
+ const len = daysInMonth(y, m);
181
+ const start = Date.UTC(y, m, 1);
182
+ let days;
183
+ if (r.byMonthDay?.length) {
184
+ days = r.byMonthDay.map((d) => (d < 0 ? len + d + 1 : d)).filter((d) => d >= 1 && d <= len).map((d) => d - 1);
185
+ }
186
+ if (r.byDay?.length) {
187
+ const picked = pickWeekdays(start, len, r.byDay);
188
+ days = days ? days.filter((d) => picked.includes(d)) : picked;
189
+ }
190
+ // RFC 5545: a date that doesn't exist (31 Feb) is skipped, not clamped.
191
+ days ??= dtDay <= len ? [dtDay - 1] : [];
192
+ return days.map((d) => start + d * DAY);
193
+ }
194
+ /**
195
+ * Occurrence walls of a rule, in order, from DTSTART (always the first) until
196
+ * the rule ends or a period starts after `to`. `from` lets a rule without
197
+ * COUNT skip the periods that end before it.
198
+ */
199
+ function* expandRule(dt, r, from, to) {
200
+ const tod = ((dt % DAY) + DAY) % DAY;
201
+ const dtDate = new Date(dt - tod);
202
+ const dtY = dtDate.getUTCFullYear();
203
+ const dtM = dtDate.getUTCMonth();
204
+ const dtD = dtDate.getUTCDate();
205
+ let emitted = 0;
206
+ const limit = r.count ?? Infinity;
207
+ yield dt;
208
+ if (++emitted >= limit)
209
+ return;
210
+ const inMonth = (w) => !r.byMonth?.length || r.byMonth.includes(new Date(w).getUTCMonth());
211
+ for (let p = 0;; p += r.interval) {
212
+ let periodStart;
213
+ let days;
214
+ switch (r.freq) {
215
+ case 'DAILY': {
216
+ periodStart = dt - tod + p * DAY;
217
+ if (r.count == null && periodStart < from - DAY) {
218
+ p += Math.floor((from - DAY - periodStart) / DAY / r.interval) * r.interval;
219
+ periodStart = dt - tod + p * DAY;
220
+ }
221
+ const d = new Date(periodStart);
222
+ const ok = inMonth(periodStart) &&
223
+ (!r.byDay?.length || r.byDay.some((b) => b.wd === d.getUTCDay())) &&
224
+ (!r.byMonthDay?.length ||
225
+ r.byMonthDay.some((md) => (md < 0 ? daysInMonth(d.getUTCFullYear(), d.getUTCMonth()) + md + 1 : md) === d.getUTCDate()));
226
+ days = ok ? [periodStart] : [];
227
+ break;
228
+ }
229
+ case 'WEEKLY': {
230
+ const week0 = dt - tod - ((weekday(dt) - r.wkst + 7) % 7) * DAY;
231
+ periodStart = week0 + p * 7 * DAY;
232
+ if (r.count == null && periodStart < from - 7 * DAY) {
233
+ p += Math.floor((from - 7 * DAY - periodStart) / (7 * DAY) / r.interval) * r.interval;
234
+ periodStart = week0 + p * 7 * DAY;
235
+ }
236
+ const wds = r.byDay?.length ? r.byDay.map((b) => b.wd) : [weekday(dt)];
237
+ days = wds.map((wd) => periodStart + ((wd - r.wkst + 7) % 7) * DAY).filter(inMonth);
238
+ break;
239
+ }
240
+ case 'MONTHLY': {
241
+ const y = dtY + Math.floor((dtM + p) / 12);
242
+ const m = (dtM + p) % 12;
243
+ periodStart = Date.UTC(y, m, 1);
244
+ days = r.byMonth?.length && !r.byMonth.includes(m) ? [] : monthDays(y, m, r, dtD);
245
+ break;
246
+ }
247
+ case 'YEARLY': {
248
+ const y = dtY + p;
249
+ periodStart = Date.UTC(y, 0, 1);
250
+ if (r.byDay?.length && !r.byMonth?.length && !r.byMonthDay?.length) {
251
+ // BYDAY alone ranges over the whole year (20MO = 20th Monday).
252
+ const len = (Date.UTC(y + 1, 0, 1) - periodStart) / DAY;
253
+ days = pickWeekdays(periodStart, len, r.byDay).map((d) => periodStart + d * DAY);
254
+ }
255
+ else {
256
+ const months = r.byMonth?.length ? r.byMonth : r.byMonthDay?.length ? [...Array(12).keys()] : [dtM];
257
+ days = months.flatMap((m) => monthDays(y, m, r, dtD));
258
+ }
259
+ break;
260
+ }
261
+ }
262
+ if (periodStart > to)
263
+ return;
264
+ if (r.until && periodStart > r.until.wall + DAY)
265
+ return;
266
+ let walls = [...new Set(days)].sort((a, b) => a - b).map((d) => d + tod);
267
+ if (r.bySetPos?.length) {
268
+ const all = walls;
269
+ walls = r.bySetPos.map((i) => all[i > 0 ? i - 1 : all.length + i]).filter((w) => w != null).sort((a, b) => a - b);
270
+ }
271
+ for (const w of walls) {
272
+ if (w <= dt)
273
+ continue;
274
+ yield w;
275
+ if (++emitted >= limit)
276
+ return;
277
+ }
278
+ }
279
+ }
280
+ // ── VTIMEZONE ───────────────────────────────────────────
281
+ const parseOffset = (v) => {
282
+ const m = /^([+-])(\d{2})(\d{2})(\d{2})?$/.exec(v.trim());
283
+ return m ? (m[1] === '-' ? -1 : 1) * ((+m[2] * 60 + +m[3]) * 60 + +(m[4] ?? 0)) * 1000 : 0;
284
+ };
285
+ /** A zone built from a VTIMEZONE's STANDARD/DAYLIGHT onsets (Outlook's Windows-named zones). */
286
+ function vtimezoneZone(tz) {
287
+ const parts = tz.children.flatMap((c) => {
288
+ const start = parseWall(prop(c, 'DTSTART')?.value ?? '');
289
+ const to = prop(c, 'TZOFFSETTO');
290
+ if (start == null || !to)
291
+ return [];
292
+ const rrule = prop(c, 'RRULE');
293
+ return [{
294
+ start,
295
+ to: parseOffset(to.value),
296
+ from: parseOffset(prop(c, 'TZOFFSETFROM')?.value ?? to.value),
297
+ rule: rrule ? parseRule(rrule.value, () => UTC_ZONE) : undefined,
298
+ rdates: props(c, 'RDATE').flatMap((p) => p.value.split(',')).map(parseWall).filter((w) => w != null),
299
+ }];
300
+ });
301
+ if (!parts.length)
302
+ return undefined;
303
+ const byYear = new Map();
304
+ const onsets = (year) => {
305
+ let list = byYear.get(year);
306
+ if (!list) {
307
+ const from = Date.UTC(year - 1, 0, 1);
308
+ const to = Date.UTC(year + 1, 0, 1);
309
+ list = [];
310
+ for (const part of parts) {
311
+ const walls = part.rule ? expandRule(part.start, part.rule, from, to) : [part.start];
312
+ for (const at of walls)
313
+ if (at >= from && at < to)
314
+ list.push({ at, to: part.to });
315
+ for (const at of part.rdates)
316
+ if (at >= from && at < to)
317
+ list.push({ at, to: part.to });
318
+ }
319
+ list.sort((a, b) => a.at - b.at);
320
+ byYear.set(year, list);
321
+ }
322
+ return list;
323
+ };
324
+ const first = parts.reduce((a, b) => (b.start < a.start ? b : a));
325
+ return (wall) => {
326
+ const list = onsets(new Date(wall).getUTCFullYear());
327
+ let offset = first.from;
328
+ for (const o of list)
329
+ if (o.at <= wall)
330
+ offset = o.to;
331
+ return wall - offset;
332
+ };
333
+ }
334
+ const instant = (t) => t.zone(t.wall);
335
+ /** An all-day date is midnight in the browser's zone; a time is its instant. */
336
+ const toDate = (t) => new Date(t.date ? LOCAL_ZONE(t.wall) : instant(t));
337
+ /** UNTIL is inclusive; a date UNTIL keeps that whole day. */
338
+ function pastUntil(t, until) {
339
+ if (!until)
340
+ return false;
341
+ if (until.date)
342
+ return t.wall >= until.wall + DAY;
343
+ return t.date ? t.wall > until.wall : instant(t) > instant(until);
344
+ }
345
+ const STATUS = { CANCELLED: 'cancelled', TENTATIVE: 'tentative', CONFIRMED: 'confirmed' };
346
+ /**
347
+ * Parse an iCalendar text (.ics, .ical, .ifb; a .vcs reads as far as its
348
+ * events go). Recurring events are expanded per range by `events(range)`.
349
+ * A malformed line or event is skipped, never thrown.
350
+ */
351
+ export function parseIcs(input) {
352
+ const root = parseComponents(input);
353
+ const cal = root.children.find((c) => c.name === 'VCALENDAR') ?? root;
354
+ const zones = new Map();
355
+ for (const tz of cal.children.filter((c) => c.name === 'VTIMEZONE')) {
356
+ const id = prop(tz, 'TZID')?.value;
357
+ const zone = id ? vtimezoneZone(tz) : undefined;
358
+ if (id && zone)
359
+ zones.set(id, zone);
360
+ }
361
+ // Floating times read in the calendar's own zone when it names one (Google does).
362
+ const calTz = prop(cal, 'X-WR-TIMEZONE')?.value;
363
+ const floating = (calTz && ianaZone(calTz)) || LOCAL_ZONE;
364
+ const zoneOf = (tzid) => {
365
+ if (!tzid)
366
+ return floating;
367
+ const id = tzid.replace(/^\//, '');
368
+ return ianaZone(id) ?? zones.get(tzid) ?? floating;
369
+ };
370
+ const timeOf = (p) => {
371
+ if (!p)
372
+ return undefined;
373
+ const wall = parseWall(p.value);
374
+ if (wall == null)
375
+ return undefined;
376
+ return {
377
+ wall,
378
+ date: p.params.VALUE === 'DATE' || !p.value.includes('T'),
379
+ zone: p.value.trim().endsWith('Z') ? UTC_ZONE : zoneOf(p.params.TZID),
380
+ };
381
+ };
382
+ const timesOf = (ps) => ps.flatMap((p) => p.value.split(',').map((value) => timeOf({ ...p, value }))).filter((t) => !!t);
383
+ const defs = [];
384
+ for (const ve of cal.children.filter((c) => c.name === 'VEVENT')) {
385
+ const start = timeOf(prop(ve, 'DTSTART'));
386
+ if (!start)
387
+ continue;
388
+ const end = timeOf(prop(ve, 'DTEND'));
389
+ const dur = prop(ve, 'DURATION');
390
+ let duration = end
391
+ ? start.date ? end.wall - start.wall : instant(end) - instant(start)
392
+ : dur ? (parseDuration(dur.value) ?? 0) : start.date ? DAY : 0;
393
+ if (duration < 0)
394
+ duration = 0;
395
+ const rrule = prop(ve, 'RRULE');
396
+ const recurrenceId = timeOf(prop(ve, 'RECURRENCE-ID'));
397
+ const status = prop(ve, 'STATUS')?.value.trim().toUpperCase();
398
+ const data = { readOnly: true };
399
+ const description = text(ve, 'DESCRIPTION');
400
+ if (description)
401
+ data.description = description;
402
+ const url = prop(ve, 'URL')?.value.trim();
403
+ if (url)
404
+ data.url = url;
405
+ defs.push({
406
+ uid: prop(ve, 'UID')?.value.trim() || `ics-${defs.length}`,
407
+ start,
408
+ duration,
409
+ rule: rrule ? parseRule(rrule.value, zoneOf) : undefined,
410
+ rdates: timesOf(props(ve, 'RDATE')),
411
+ exdates: new Set(timesOf(props(ve, 'EXDATE')).map(instant)),
412
+ recurrence: recurrenceId,
413
+ title: text(ve, 'SUMMARY')?.trim() || '(No title)',
414
+ location: text(ve, 'LOCATION')?.trim() || undefined,
415
+ status: status ? STATUS[status] : undefined,
416
+ category: text(ve, 'CATEGORIES')?.split(',')[0]?.trim() || undefined,
417
+ color: prop(ve, 'COLOR')?.value.trim() || undefined,
418
+ data,
419
+ });
420
+ }
421
+ // RECURRENCE-ID rows replace one occurrence of their master.
422
+ const overrides = new Map();
423
+ for (const d of defs) {
424
+ if (!d.recurrence)
425
+ continue;
426
+ let m = overrides.get(d.uid);
427
+ if (!m)
428
+ overrides.set(d.uid, (m = new Map()));
429
+ m.set(instant(d.recurrence), d);
430
+ }
431
+ const masters = defs.filter((d) => !d.recurrence);
432
+ const mastered = new Set(masters.map((d) => d.uid));
433
+ // An override whose master isn't in the file still stands alone.
434
+ const orphans = defs.filter((d) => d.recurrence && !mastered.has(d.uid));
435
+ const calColor = prop(cal, 'X-APPLE-CALENDAR-COLOR')?.value.trim() || prop(cal, 'COLOR')?.value.trim() || undefined;
436
+ const toEvent = (d, start, id) => {
437
+ const s = toDate(start);
438
+ const e = start.date
439
+ ? toDate({ ...start, wall: start.wall + d.duration })
440
+ : new Date(s.getTime() + d.duration);
441
+ const ev = { id, title: d.title, start: s, end: e, externalId: d.uid, data: { ...d.data } };
442
+ if (start.date)
443
+ ev.allDay = true;
444
+ if (d.location)
445
+ ev.location = d.location;
446
+ if (d.status)
447
+ ev.status = d.status;
448
+ if (d.category)
449
+ ev.category = d.category;
450
+ if (d.color)
451
+ ev.color = d.color;
452
+ return ev;
453
+ };
454
+ const key = (t) => new Date(t.wall).toISOString().replace(/[-:]|\.\d+Z$/g, '').replace(t.date ? /T.*$/ : /$/, '');
455
+ return {
456
+ name: text(cal, 'X-WR-CALNAME') ?? text(cal, 'NAME'),
457
+ color: calColor,
458
+ events(range) {
459
+ const out = [];
460
+ const lo = range.start.getTime();
461
+ const hi = range.end.getTime();
462
+ const push = (ev) => {
463
+ const s = ev.start.getTime();
464
+ const e = Math.max(ev.end.getTime(), s + 1); // a zero-length event still sits at its time
465
+ if (s < hi && e > lo)
466
+ out.push(ev);
467
+ };
468
+ for (const d of masters) {
469
+ if (!d.rule && !d.rdates.length) {
470
+ push(toEvent(d, d.start, d.uid));
471
+ continue;
472
+ }
473
+ const own = overrides.get(d.uid);
474
+ const seen = new Set();
475
+ const occurrence = (t) => {
476
+ const at = instant(t);
477
+ if (seen.has(at) || d.exdates.has(at))
478
+ return;
479
+ seen.add(at);
480
+ const id = `${d.uid}--${key(t)}`;
481
+ const over = own?.get(at);
482
+ push(over ? toEvent(over, over.start, id) : toEvent(d, t, id));
483
+ };
484
+ // Walls run ±14 h from instants, and an occurrence can start a
485
+ // duration before the range: widen by both, trim in push().
486
+ const from = lo - d.duration - 2 * DAY + (d.start.wall - instant(d.start));
487
+ const to = hi + 2 * DAY + (d.start.wall - instant(d.start));
488
+ if (d.rule) {
489
+ for (const wall of expandRule(d.start.wall, d.rule, from, to)) {
490
+ const t = { ...d.start, wall };
491
+ if (wall > to || pastUntil(t, d.rule.until))
492
+ break;
493
+ if (wall >= from)
494
+ occurrence(t);
495
+ }
496
+ }
497
+ else {
498
+ occurrence(d.start);
499
+ }
500
+ for (const t of d.rdates)
501
+ occurrence(t);
502
+ // An override moved into the range from an occurrence outside it.
503
+ for (const over of own?.values() ?? []) {
504
+ const at = instant(over.recurrence);
505
+ if (seen.has(at) || d.exdates.has(at))
506
+ continue;
507
+ push(toEvent(over, over.start, `${d.uid}--${key(over.recurrence)}`));
508
+ }
509
+ }
510
+ for (const d of orphans)
511
+ push(toEvent(d, d.start, `${d.uid}--${key(d.recurrence)}`));
512
+ return out;
513
+ },
514
+ };
515
+ }
516
+ /**
517
+ * A read-only CalendarAdapter over iCalendar data: a file's text, a feed URL,
518
+ * or a function that loads the text. Each occurrence carries `data.readOnly`,
519
+ * and a write throws CalendarReadOnlyError, so a composite hands a drag to
520
+ * `oneventmove`.
521
+ *
522
+ * Feeds from Google, Outlook and iCloud send no CORS headers: in a browser,
523
+ * fetch them through your own server route.
524
+ */
525
+ export function createIcsAdapter(source, options = {}) {
526
+ let parsed = typeof source === 'string' ? parseIcs(source) : undefined;
527
+ let loading;
528
+ let loadedAt = 0;
529
+ // Ids this adapter has handed out: a write to one is refused as read-only,
530
+ // any other id is "not mine" (a composite asks its next child).
531
+ const known = new Set();
532
+ const load = () => {
533
+ if (typeof source === 'string')
534
+ return Promise.resolve(parsed);
535
+ const stale = typeof source === 'object' && source.refreshInterval != null && Date.now() - loadedAt > source.refreshInterval;
536
+ if (!loading || (stale && parsed)) {
537
+ loadedAt = Date.now();
538
+ const request = typeof source === 'function'
539
+ ? source()
540
+ : (source.fetch ?? fetch)(source.url.replace(/^webcal:/i, 'https:')).then((r) => {
541
+ if (!r.ok)
542
+ throw new Error(`ICS feed ${source.url}: HTTP ${r.status}`);
543
+ return r.text();
544
+ });
545
+ loading = request.then((t) => (parsed = parseIcs(t)), (e) => {
546
+ loading = undefined; // the next load tries again
547
+ throw e;
548
+ });
549
+ }
550
+ return parsed && !stale ? Promise.resolve(parsed) : loading;
551
+ };
552
+ const paint = (events, cal) => {
553
+ const color = options.color ?? cal.color;
554
+ for (const e of events)
555
+ known.add(e.id);
556
+ return color ? events.map((e) => (e.color ? e : { ...e, color })) : events;
557
+ };
558
+ return {
559
+ fetchEventsSync(range) {
560
+ return parsed && typeof source === 'string' ? paint(parsed.events(range), parsed) : undefined;
561
+ },
562
+ async fetchEvents(range) {
563
+ const cal = await load();
564
+ return paint(cal.events(range), cal);
565
+ },
566
+ async updateEvent(id) {
567
+ if (!known.has(id))
568
+ throw new EventNotFoundError(id);
569
+ throw new CalendarReadOnlyError(`read-only: ${id} comes from an iCalendar source`);
570
+ },
571
+ };
572
+ }
573
+ // ── Export ──────────────────────────────────────────────
574
+ function escapeText(v) {
575
+ return v.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
576
+ }
577
+ /** Fold a content line at 75 octets (RFC 5545 §3.1), never inside a character. */
578
+ function fold(line) {
579
+ const out = [];
580
+ let cur = '';
581
+ let bytes = 0;
582
+ for (const ch of line) {
583
+ const cp = ch.codePointAt(0);
584
+ const n = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
585
+ if (bytes + n > (out.length ? 74 : 75)) {
586
+ out.push(cur);
587
+ cur = '';
588
+ bytes = 0;
589
+ }
590
+ cur += ch;
591
+ bytes += n;
592
+ }
593
+ out.push(cur);
594
+ return out.join('\r\n ');
595
+ }
596
+ const pad = (n) => String(n).padStart(2, '0');
597
+ const utcStamp = (d) => d.toISOString().replace(/[-:]|\.\d+/g, '');
598
+ const localDate = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
599
+ /**
600
+ * Write events as an iCalendar file that Google Calendar, Apple Calendar and
601
+ * Outlook import (or subscribe to, served from a URL). Times go out in UTC;
602
+ * all-day events as dates. UID is `externalId ?? id`. A string
603
+ * `data.description` / `data.url` becomes DESCRIPTION / URL.
604
+ */
605
+ export function toIcs(events, options = {}) {
606
+ const stamp = utcStamp(new Date());
607
+ const lines = [
608
+ 'BEGIN:VCALENDAR',
609
+ 'VERSION:2.0',
610
+ `PRODID:${options.prodId ?? '-//nomideusz//svelte-calendar//EN'}`,
611
+ 'CALSCALE:GREGORIAN',
612
+ 'METHOD:PUBLISH',
613
+ ];
614
+ if (options.name)
615
+ lines.push(`X-WR-CALNAME:${escapeText(options.name)}`);
616
+ for (const ev of events) {
617
+ lines.push('BEGIN:VEVENT', `UID:${escapeText(ev.externalId ?? ev.id)}`, `DTSTAMP:${stamp}`);
618
+ if (ev.allDay) {
619
+ // An all-day end is exclusive: a one-day event ends the next midnight.
620
+ const end = ev.end > ev.start ? ev.end : new Date(ev.start.getFullYear(), ev.start.getMonth(), ev.start.getDate() + 1);
621
+ const endDay = end.getHours() || end.getMinutes() ? new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1) : end;
622
+ lines.push(`DTSTART;VALUE=DATE:${localDate(ev.start)}`, `DTEND;VALUE=DATE:${localDate(endDay)}`);
623
+ }
624
+ else {
625
+ lines.push(`DTSTART:${utcStamp(ev.start)}`, `DTEND:${utcStamp(ev.end)}`);
626
+ }
627
+ lines.push(`SUMMARY:${escapeText(ev.title)}`);
628
+ if (ev.location)
629
+ lines.push(`LOCATION:${escapeText(ev.location)}`);
630
+ const description = ev.data?.description ?? ev.subtitle;
631
+ if (typeof description === 'string' && description)
632
+ lines.push(`DESCRIPTION:${escapeText(description)}`);
633
+ if (typeof ev.data?.url === 'string')
634
+ lines.push(`URL:${ev.data.url}`);
635
+ if (ev.category)
636
+ lines.push(`CATEGORIES:${escapeText(ev.category)}`);
637
+ if (ev.status === 'cancelled' || ev.status === 'tentative')
638
+ lines.push(`STATUS:${ev.status.toUpperCase()}`);
639
+ lines.push('END:VEVENT');
640
+ }
641
+ lines.push('END:VCALENDAR');
642
+ return lines.map(fold).join('\r\n') + '\r\n';
643
+ }
644
+ // ── One-event links ─────────────────────────────────────
645
+ const detailsOf = (ev) => {
646
+ const d = ev.data?.description ?? ev.subtitle;
647
+ return typeof d === 'string' ? d : '';
648
+ };
649
+ /** An "add to Google Calendar" link for one event. */
650
+ export function googleCalendarUrl(ev) {
651
+ const dates = ev.allDay
652
+ ? `${localDate(ev.start)}/${localDate(ev.end > ev.start ? ev.end : new Date(ev.start.getFullYear(), ev.start.getMonth(), ev.start.getDate() + 1))}`
653
+ : `${utcStamp(ev.start)}/${utcStamp(ev.end)}`;
654
+ const q = new URLSearchParams({ action: 'TEMPLATE', text: ev.title, dates });
655
+ if (ev.location)
656
+ q.set('location', ev.location);
657
+ const details = detailsOf(ev);
658
+ if (details)
659
+ q.set('details', details);
660
+ return `https://calendar.google.com/calendar/render?${q}`;
661
+ }
662
+ /**
663
+ * An "add to Outlook" link for one event: outlook.live.com (personal), or
664
+ * `work: true` for outlook.office.com (Microsoft 365).
665
+ */
666
+ export function outlookCalendarUrl(ev, options = {}) {
667
+ const iso = (d) => (ev.allDay ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : d.toISOString());
668
+ const q = new URLSearchParams({
669
+ path: '/calendar/action/compose',
670
+ rru: 'addevent',
671
+ subject: ev.title,
672
+ startdt: iso(ev.start),
673
+ enddt: iso(ev.end),
674
+ allday: String(!!ev.allDay),
675
+ });
676
+ if (ev.location)
677
+ q.set('location', ev.location);
678
+ const details = detailsOf(ev);
679
+ if (details)
680
+ q.set('body', details);
681
+ return `https://${options.work ? 'outlook.office.com' : 'outlook.live.com'}/calendar/0/deeplink/compose?${q}`;
682
+ }
@@ -12,4 +12,6 @@ export { withInitialEvents } from './seeded.js';
12
12
  export type { CompositeAdapterOptions } from './composite.js';
13
13
  export { createJmapAdapter } from './jmap.js';
14
14
  export type { JmapClient, JmapCalendarAdapterOptions } from './jmap.js';
15
+ export { createIcsAdapter, parseIcs, toIcs, googleCalendarUrl, outlookCalendarUrl } from './ics.js';
16
+ export type { IcsCalendar, IcsSource, IcsAdapterOptions, ToIcsOptions } from './ics.js';
15
17
  export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './errors.js';