@nomideusz/svelte-calendar 1.7.1 → 1.8.2
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.
- package/README.md +62 -0
- package/dist/adapters/ics.d.ts +60 -0
- package/dist/adapters/ics.js +682 -0
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.js +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/text-fit.d.ts +4 -0
- package/dist/text-fit.js +38 -9
- package/package.json +1 -1
- package/widget/widget.js +11 -1
package/README.md
CHANGED
|
@@ -585,6 +585,7 @@ The calendar reads its events through an adapter. Which one:
|
|
|
585
585
|
| `createMemoryAdapter(events)` | The events are already in the page (a fixed list, your own state). Writes stay in memory. See [Quick Start](#quick-start). |
|
|
586
586
|
| [`createRecurringAdapter(rules)`](#recurring-schedules) | The schedule repeats by rule: class timetables, office hours. Read-only. |
|
|
587
587
|
| [`createRestAdapter(options)`](#rest-adapter) | The events live behind your own HTTP API, which also stores creates and moves. |
|
|
588
|
+
| [`createIcsAdapter(source)`](#icalendar-ics) | The events come from Google Calendar, Apple Calendar, Outlook or any other calendar app: an imported `.ics` file or a subscription URL. Read-only. |
|
|
588
589
|
| [`createJmapAdapter(client, options)`](#jmap-adapter) | The events are in a JMAP Calendars server (Fastmail, Stalwart, Cyrus). Read-only. |
|
|
589
590
|
| [`createMappedAdapter(records, options)`](#mapped-adapter) | You have records from another system with their own field names: map the fields instead of writing an adapter. |
|
|
590
591
|
| [`createCompositeAdapter(adapters)`](#composite-adapter) | One calendar shows several sources, e.g. a recurring timetable plus one-off events. |
|
|
@@ -640,6 +641,67 @@ const adapter = createJmapAdapter(client, {
|
|
|
640
641
|
|
|
641
642
|
Read-only. Expands recurrences server-side and handles all-day events, ISO-duration ends and per-calendar colors (pass `calendars: [{ id, name, color }]`, or a function returning them).
|
|
642
643
|
|
|
644
|
+
### iCalendar (.ics)
|
|
645
|
+
|
|
646
|
+
Every calendar app speaks iCalendar: Google Calendar, Apple Calendar / iCloud, Outlook / Microsoft 365, Thunderbird, Proton, Fastmail, Nextcloud. One adapter reads all of them, from a file or a feed:
|
|
647
|
+
|
|
648
|
+
```svelte
|
|
649
|
+
<script>
|
|
650
|
+
import { Calendar, createIcsAdapter } from '@nomideusz/svelte-calendar';
|
|
651
|
+
|
|
652
|
+
let adapter = $state(createIcsAdapter(''));
|
|
653
|
+
async function importFile(e) {
|
|
654
|
+
const file = e.currentTarget.files?.[0];
|
|
655
|
+
if (file) adapter = createIcsAdapter(await file.text());
|
|
656
|
+
}
|
|
657
|
+
</script>
|
|
658
|
+
|
|
659
|
+
<input type="file" accept=".ics,.ical,.ifb,text/calendar" onchange={importFile} />
|
|
660
|
+
<Calendar {adapter} />
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
A subscription URL loads on the first fetch (`webcal://` reads as `https://`); `refreshInterval` refetches once the copy is older than that many ms:
|
|
664
|
+
|
|
665
|
+
```ts
|
|
666
|
+
// Google: Settings → your calendar → "Secret address in iCal format"
|
|
667
|
+
// Outlook: Settings → Calendar → Shared calendars → Publish → ICS link
|
|
668
|
+
// iCloud: share the calendar as "Public Calendar" → webcal:// link
|
|
669
|
+
const adapter = createIcsAdapter({ url: '/api/calendar-feed', refreshInterval: 15 * 60_000 });
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
Google, Outlook and iCloud send no CORS headers, so a browser can't fetch their feeds directly: serve them through a route of your own (the feed URL is a secret, so keep it on the server anyway):
|
|
673
|
+
|
|
674
|
+
```ts
|
|
675
|
+
// src/routes/api/calendar-feed/+server.ts
|
|
676
|
+
export const GET = async ({ fetch }) => new Response(await (await fetch(FEED_URL)).text(), {
|
|
677
|
+
headers: { 'content-type': 'text/calendar; charset=utf-8' },
|
|
678
|
+
});
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
- Recurrence is expanded per viewed range: `RRULE` (daily, weekly, monthly, yearly; `INTERVAL`, `COUNT`, `UNTIL`, `BYDAY` with ordinals like `-1FR`, `BYMONTHDAY`, `BYMONTH`, `BYSETPOS`, `WKST`), `RDATE`, `EXDATE`, and moved or edited occurrences (`RECURRENCE-ID`).
|
|
682
|
+
- Times keep their zone: UTC, IANA `TZID`s, and the `VTIMEZONE` blocks Outlook writes under Windows zone names. Floating times read in the calendar's `X-WR-TIMEZONE`, else the browser's. All-day dates are local midnights, end exclusive.
|
|
683
|
+
- `SUMMARY` → `title`, `LOCATION` → `location`, `STATUS` → `status`, the first of `CATEGORIES` → `category`, `COLOR` → `color`, `UID` → `externalId`, `DESCRIPTION` / `URL` → `data.description` / `data.url`. Events without a colour take `options.color`, else the calendar's own (`X-APPLE-CALENDAR-COLOR`).
|
|
684
|
+
- Read-only: each occurrence carries `data.readOnly`, and a write throws `CalendarReadOnlyError`, so in a composite a drag reaches `oneventmove`.
|
|
685
|
+
- The source may also be a function returning the text: `createIcsAdapter(() => loadIt())`.
|
|
686
|
+
|
|
687
|
+
To use the events outside an adapter (copy an import into your own store), parse once and expand any range:
|
|
688
|
+
|
|
689
|
+
```ts
|
|
690
|
+
import { parseIcs } from '@nomideusz/svelte-calendar';
|
|
691
|
+
|
|
692
|
+
const cal = parseIcs(text); // { name, color, events(range) }
|
|
693
|
+
const imported = cal.events({ start, end });
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
**Export.** `toIcs(events, { name })` writes an `.ics` file that Google, Apple and Outlook import, or subscribe to when you serve it from a URL. For a single event, `googleCalendarUrl(event)` and `outlookCalendarUrl(event, { work })` (personal outlook.live.com, or Microsoft 365) open the app's "add event" page:
|
|
697
|
+
|
|
698
|
+
```ts
|
|
699
|
+
import { toIcs, googleCalendarUrl, outlookCalendarUrl } from '@nomideusz/svelte-calendar';
|
|
700
|
+
|
|
701
|
+
const file = new Blob([toIcs(events, { name: 'Studio' })], { type: 'text/calendar' });
|
|
702
|
+
const href = URL.createObjectURL(file); // <a {href} download="studio.ics">
|
|
703
|
+
```
|
|
704
|
+
|
|
643
705
|
### Mapped Adapter
|
|
644
706
|
|
|
645
707
|
Wrap any static array of external records (class schedules, appointments, timetables) without writing a custom adapter. Supply a declarative field mapping (or a `mapEvent` function for full control) and the adapter handles parsing, colors, tags and status:
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { TimelineEvent } from '../core/types.js';
|
|
2
|
+
import type { CalendarAdapter, DateRange } from './types.js';
|
|
3
|
+
export interface IcsCalendar {
|
|
4
|
+
/** The calendar's name (X-WR-CALNAME / NAME), when the file has one */
|
|
5
|
+
name?: string;
|
|
6
|
+
/** The calendar's colour (X-APPLE-CALENDAR-COLOR / COLOR), when the file has one */
|
|
7
|
+
color?: string;
|
|
8
|
+
/** Every event overlapping `range`, recurrences expanded */
|
|
9
|
+
events(range: DateRange): TimelineEvent[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Parse an iCalendar text (.ics, .ical, .ifb; a .vcs reads as far as its
|
|
13
|
+
* events go). Recurring events are expanded per range by `events(range)`.
|
|
14
|
+
* A malformed line or event is skipped, never thrown.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseIcs(input: string): IcsCalendar;
|
|
17
|
+
export type IcsSource = string | {
|
|
18
|
+
/** An https:// or webcal:// feed URL (Google's "secret address in iCal format", Outlook's published ICS, an iCloud public calendar) */
|
|
19
|
+
url: string;
|
|
20
|
+
/** Your own fetch — e.g. one that adds headers or goes through your server */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
/** Refetch on the next load once the copy is older than this many ms (default: never) */
|
|
23
|
+
refreshInterval?: number;
|
|
24
|
+
} | (() => Promise<string>);
|
|
25
|
+
export interface IcsAdapterOptions {
|
|
26
|
+
/** Colour for events that name none (default: the calendar's own colour, else ink) */
|
|
27
|
+
color?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A read-only CalendarAdapter over iCalendar data: a file's text, a feed URL,
|
|
31
|
+
* or a function that loads the text. Each occurrence carries `data.readOnly`,
|
|
32
|
+
* and a write throws CalendarReadOnlyError, so a composite hands a drag to
|
|
33
|
+
* `oneventmove`.
|
|
34
|
+
*
|
|
35
|
+
* Feeds from Google, Outlook and iCloud send no CORS headers: in a browser,
|
|
36
|
+
* fetch them through your own server route.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createIcsAdapter(source: IcsSource, options?: IcsAdapterOptions): CalendarAdapter;
|
|
39
|
+
export interface ToIcsOptions {
|
|
40
|
+
/** Calendar name (X-WR-CALNAME), shown by Google, Apple and Outlook */
|
|
41
|
+
name?: string;
|
|
42
|
+
/** PRODID (default: -//nomideusz//svelte-calendar//EN) */
|
|
43
|
+
prodId?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Write events as an iCalendar file that Google Calendar, Apple Calendar and
|
|
47
|
+
* Outlook import (or subscribe to, served from a URL). Times go out in UTC;
|
|
48
|
+
* all-day events as dates. UID is `externalId ?? id`. A string
|
|
49
|
+
* `data.description` / `data.url` becomes DESCRIPTION / URL.
|
|
50
|
+
*/
|
|
51
|
+
export declare function toIcs(events: TimelineEvent[], options?: ToIcsOptions): string;
|
|
52
|
+
/** An "add to Google Calendar" link for one event. */
|
|
53
|
+
export declare function googleCalendarUrl(ev: TimelineEvent): string;
|
|
54
|
+
/**
|
|
55
|
+
* An "add to Outlook" link for one event: outlook.live.com (personal), or
|
|
56
|
+
* `work: true` for outlook.office.com (Microsoft 365).
|
|
57
|
+
*/
|
|
58
|
+
export declare function outlookCalendarUrl(ev: TimelineEvent, options?: {
|
|
59
|
+
work?: boolean;
|
|
60
|
+
}): string;
|
|
@@ -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
|
+
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/adapters/index.js
CHANGED
|
@@ -5,4 +5,5 @@ export { createMappedAdapter } from './mapped.js';
|
|
|
5
5
|
export { createCompositeAdapter } from './composite.js';
|
|
6
6
|
export { withInitialEvents } from './seeded.js';
|
|
7
7
|
export { createJmapAdapter } from './jmap.js';
|
|
8
|
+
export { createIcsAdapter, parseIcs, toIcs, googleCalendarUrl, outlookCalendarUrl } from './ics.js';
|
|
8
9
|
export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './errors.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -8,8 +8,9 @@ export type { CalendarContext } from './views/shared/context.svelte.js';
|
|
|
8
8
|
export { createEventStore, createViewState, createSelection, createDragState, } from './engine/index.js';
|
|
9
9
|
export type { EventStore, ViewState, ViewStateOptions, CalendarViewId, BuiltInViewId, ViewMode, Selection as CalendarSelection, DragState, DragMode, DragPayload, } from './engine/index.js';
|
|
10
10
|
export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter, withInitialEvents } from './adapters/index.js';
|
|
11
|
+
export { createIcsAdapter, parseIcs, toIcs, googleCalendarUrl, outlookCalendarUrl } from './adapters/index.js';
|
|
11
12
|
export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './adapters/index.js';
|
|
12
|
-
export type { CalendarAdapter, WritableCalendarAdapter, DateRange, MemoryAdapterOptions, RestAdapterOptions, RecurringEvent, RecurringAdapterOptions, FieldMapping, MappedAdapterOptions, MutationHandler, CompositeAdapterOptions, JmapClient, JmapCalendarAdapterOptions, } from './adapters/index.js';
|
|
13
|
+
export type { CalendarAdapter, WritableCalendarAdapter, DateRange, MemoryAdapterOptions, RestAdapterOptions, RecurringEvent, RecurringAdapterOptions, FieldMapping, MappedAdapterOptions, MutationHandler, CompositeAdapterOptions, JmapClient, JmapCalendarAdapterOptions, IcsCalendar, IcsSource, IcsAdapterOptions, ToIcsOptions, } from './adapters/index.js';
|
|
13
14
|
export { createClock, sod, startOfWeek, addDaysMs, diffDays, fmtH, fmtHParts, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
|
|
14
15
|
export type { Clock, TimelineEvent, BlockedSlot, DaySegment, CalendarLabels, EventStatus, } from './core/index.js';
|
|
15
16
|
export { auto, neutral, midnight, presets } from './theme/index.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { useCalendarContext } from './views/shared/context.svelte.js';
|
|
|
11
11
|
export { createEventStore, createViewState, createSelection, createDragState, } from './engine/index.js';
|
|
12
12
|
// ─── Adapters ───────────────────────────────────────────
|
|
13
13
|
export { createMemoryAdapter, createRestAdapter, createRecurringAdapter, createMappedAdapter, createCompositeAdapter, createJmapAdapter, withInitialEvents } from './adapters/index.js';
|
|
14
|
+
export { createIcsAdapter, parseIcs, toIcs, googleCalendarUrl, outlookCalendarUrl } from './adapters/index.js';
|
|
14
15
|
export { CalendarReadOnlyError, EventNotFoundError, isReadOnlyError, isNotFoundError } from './adapters/index.js';
|
|
15
16
|
// ─── Core: clock, time, locale, types ───────────────────
|
|
16
17
|
export { createClock, sod, startOfWeek, addDaysMs, diffDays, fmtH, fmtHParts, fmtTime, fmtDuration, weekdayShort, weekdayLong, monthShort, monthLong, dateShort, dateWithWeekday, fmtDay, fmtWeekRange, setDefaultLocale, getDefaultLocale, is24HourLocale, defaultLabels, setLabels, resetLabels, getLabels, toZonedTime, fromZonedTime, nowInZone, formatInTimeZone, generatePalette, extractAccent, VIVID_PALETTE, isMultiDay, isAllDay, segmentForDay, } from './core/index.js';
|
package/dist/text-fit.d.ts
CHANGED
|
@@ -50,10 +50,14 @@ export declare function fontOf(el: Element): string;
|
|
|
50
50
|
* that does not come from its own text (block, or a flex item with min-width: 0).
|
|
51
51
|
*/
|
|
52
52
|
export declare function fitLabel(candidates: readonly string[], lines?: number): Attachment<HTMLElement>;
|
|
53
|
+
/** The badness of the loosest line but the last, were `lines` justified at `width`. */
|
|
54
|
+
export declare function looseness(lines: readonly string[], font: string, width: number): number;
|
|
53
55
|
export declare function breakLines(text: string, font: string, width: number): string[];
|
|
54
56
|
/**
|
|
55
57
|
* `<p {@attach typeset(text)}>` — sets the paragraph as justified Knuth-Plass
|
|
56
58
|
* lines, one block span per line, re-done on resize and after webfonts load.
|
|
59
|
+
* A paragraph that cannot be justified without gaps (`TOLERANCE`) goes back to
|
|
60
|
+
* the browser as plain text, set however the element sets it (ragged).
|
|
57
61
|
* Progressive: the server-rendered text is what crawlers and no-JS get; if the
|
|
58
62
|
* browser's own measure disagrees with ours (a line overflows), the plain
|
|
59
63
|
* text goes back and the browser breaks it. Pass the text, not the DOM — the
|
package/dist/text-fit.js
CHANGED
|
@@ -96,6 +96,23 @@ export function fitLabel(candidates, lines = 1) {
|
|
|
96
96
|
// (10 + badness)² plus 50² for ending inside a word, plus 10000 when the line
|
|
97
97
|
// above did too. ponytail: no fitness classes, no shrink; O(n·k).
|
|
98
98
|
const SHY = '';
|
|
99
|
+
const badness = (slackPx, spaces, space) => {
|
|
100
|
+
const r = slackPx / (Math.max(spaces, 1) * space * 0.5);
|
|
101
|
+
return Math.min(10000, 100 * r * r * r);
|
|
102
|
+
};
|
|
103
|
+
// TeX's default \tolerance. A narrow column (a phone) sometimes has no set of
|
|
104
|
+
// breaks that keeps every space under ~1.6× its width; justified, those lines
|
|
105
|
+
// read as holes, so typeset leaves that paragraph to the browser, ragged.
|
|
106
|
+
const TOLERANCE = 200;
|
|
107
|
+
/** The badness of the loosest line but the last, were `lines` justified at `width`. */
|
|
108
|
+
export function looseness(lines, font, width) {
|
|
109
|
+
const measure = (s) => measureNaturalWidth(prepareWithSegments(s, font));
|
|
110
|
+
const space = measure('a a') - measure('aa');
|
|
111
|
+
let worst = 0;
|
|
112
|
+
for (const l of lines.slice(0, -1))
|
|
113
|
+
worst = Math.max(worst, badness(width - measure(l), l.split(' ').length - 1, space));
|
|
114
|
+
return worst;
|
|
115
|
+
}
|
|
99
116
|
export function breakLines(text, font, width) {
|
|
100
117
|
const measure = (s) => measureNaturalWidth(prepareWithSegments(s, font));
|
|
101
118
|
const boxes = [];
|
|
@@ -119,10 +136,6 @@ export function breakLines(text, font, width) {
|
|
|
119
136
|
const bw = boxes.map(measure);
|
|
120
137
|
const space = measure('a a') - measure('aa');
|
|
121
138
|
const hyphen = measure('-');
|
|
122
|
-
const badness = (slackPx, spaces) => {
|
|
123
|
-
const r = slackPx / (Math.max(spaces, 1) * space * 0.5);
|
|
124
|
-
return Math.min(10000, 100 * r * r * r);
|
|
125
|
-
};
|
|
126
139
|
// best[h][k]: cheapest way to set boxes[0..k) with a break after, h = 1 when
|
|
127
140
|
// that last line ended inside a word; prev[h][k]: [start of that line, its h].
|
|
128
141
|
const best = [new Array(n + 1).fill(Infinity), new Array(n + 1).fill(Infinity)];
|
|
@@ -144,7 +157,7 @@ export function breakLines(text, font, width) {
|
|
|
144
157
|
break;
|
|
145
158
|
continue; // only the hyphen overflows — try the next box
|
|
146
159
|
}
|
|
147
|
-
const b = last ? 0 : badness(width - lineW, spaces);
|
|
160
|
+
const b = last ? 0 : badness(width - lineW, spaces, space);
|
|
148
161
|
const cost = last ? 0 : (10 + b) ** 2 + (h ? 50 ** 2 : 0) + (h && ph ? 10000 : 0);
|
|
149
162
|
if (best[ph][i] + cost < best[h][j + 1]) {
|
|
150
163
|
best[h][j + 1] = best[ph][i] + cost;
|
|
@@ -177,6 +190,8 @@ export function breakLines(text, font, width) {
|
|
|
177
190
|
/**
|
|
178
191
|
* `<p {@attach typeset(text)}>` — sets the paragraph as justified Knuth-Plass
|
|
179
192
|
* lines, one block span per line, re-done on resize and after webfonts load.
|
|
193
|
+
* A paragraph that cannot be justified without gaps (`TOLERANCE`) goes back to
|
|
194
|
+
* the browser as plain text, set however the element sets it (ragged).
|
|
180
195
|
* Progressive: the server-rendered text is what crawlers and no-JS get; if the
|
|
181
196
|
* browser's own measure disagrees with ours (a line overflows), the plain
|
|
182
197
|
* text goes back and the browser breaks it. Pass the text, not the DOM — the
|
|
@@ -194,19 +209,33 @@ export function typeset(text) {
|
|
|
194
209
|
// hole in the paragraph. Set as nowrap, a misfit is plain overflow, which
|
|
195
210
|
// is measurable: re-break a touch narrower, and only then hand the text
|
|
196
211
|
// back to the browser.
|
|
212
|
+
// The lines are for the eye: their break hyphens ("co-" / "dzienne") are
|
|
213
|
+
// hidden from assistive tech, which reads the text whole from a visually
|
|
214
|
+
// hidden copy. Each line keeps the space it broke at (a trailing space
|
|
215
|
+
// is dropped at a nowrap line's end), so find and copy see words.
|
|
216
|
+
const whole = document.createElement('span');
|
|
217
|
+
whole.textContent = text;
|
|
218
|
+
whole.style.cssText =
|
|
219
|
+
'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap';
|
|
197
220
|
const set = (w) => {
|
|
198
221
|
const lines = breakLines(text, font, w);
|
|
199
|
-
|
|
222
|
+
if (looseness(lines, font, w) > TOLERANCE) {
|
|
223
|
+
el.textContent = text;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
const spans = lines.map((l, i) => {
|
|
200
227
|
const s = document.createElement('span');
|
|
228
|
+
s.setAttribute('aria-hidden', 'true');
|
|
201
229
|
s.style.display = 'block';
|
|
202
230
|
s.style.whiteSpace = 'nowrap';
|
|
203
231
|
s.style.hyphens = 'manual';
|
|
204
232
|
s.style.textAlign = 'justify';
|
|
205
233
|
s.style.textAlignLast = i === lines.length - 1 ? 'auto' : 'justify';
|
|
206
|
-
s.textContent = l;
|
|
234
|
+
s.textContent = i < lines.length - 1 && !l.endsWith('-') ? l + ' ' : l;
|
|
207
235
|
return s;
|
|
208
|
-
})
|
|
209
|
-
|
|
236
|
+
});
|
|
237
|
+
el.replaceChildren(whole, ...spans);
|
|
238
|
+
for (const s of spans)
|
|
210
239
|
if (s.scrollWidth > s.clientWidth + 1)
|
|
211
240
|
return false;
|
|
212
241
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomideusz/svelte-calendar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"description": "A themeable Svelte 5 calendar: day, week, scrolling-week and month views, agenda lists, touch-first mobile layouts, drag-and-drop, timezones, a headless API and an embeddable web component.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
package/widget/widget.js
CHANGED
|
@@ -21056,5 +21056,15 @@ createHTML: (html) => {
|
|
|
21056
21056
|
return props;
|
|
21057
21057
|
}
|
|
21058
21058
|
};
|
|
21059
|
-
if (!customElements.get("day-calendar"))
|
|
21059
|
+
if (!customElements.get("day-calendar")) {
|
|
21060
|
+
customElements.define("day-calendar", DayCalendarElement);
|
|
21061
|
+
const registry = customElements;
|
|
21062
|
+
for (const el of document.querySelectorAll("day-calendar")) {
|
|
21063
|
+
if (el instanceof DayCalendarElement) continue;
|
|
21064
|
+
try {
|
|
21065
|
+
registry.initialize?.(el);
|
|
21066
|
+
} catch {}
|
|
21067
|
+
customElements.upgrade(el);
|
|
21068
|
+
}
|
|
21069
|
+
}
|
|
21060
21070
|
})();
|