@procedures/ics 0.0.0-stage → 0.2.3
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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/ics.d.ts +73 -0
- package/dist/ics.d.ts.map +1 -0
- package/dist/ics.js +336 -0
- package/dist/ics.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +12 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +15 -0
- package/dist/module.js.map +1 -0
- package/dist/procedures.d.ts +14 -0
- package/dist/procedures.d.ts.map +1 -0
- package/dist/procedures.js +116 -0
- package/dist/procedures.js.map +1 -0
- package/dist/register.d.ts +6 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +8 -0
- package/dist/register.js.map +1 -0
- package/package.json +77 -3
- package/src/ics.ts +403 -0
- package/src/index.ts +4 -0
- package/src/module.ts +18 -0
- package/src/procedures.ts +139 -0
- package/src/register.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package",
|
|
2
3
|
"name": "@procedures/ics",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
4
|
+
"version": "0.2.3",
|
|
5
|
+
"description": "An iCalendar (RFC 5545) reader without dependencies: the events of a calendar and their occurrences in a time window, recurrences included.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"ics",
|
|
8
|
+
"icalendar",
|
|
9
|
+
"calendar",
|
|
10
|
+
"rrule",
|
|
11
|
+
"recurrence",
|
|
12
|
+
"procedures"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://mark1russell7.github.io/client/",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/mark1russell7/client/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/mark1russell7/client.git",
|
|
21
|
+
"directory": "packages/ics"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Mark Russell (https://github.com/mark1russell7)",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./module": {
|
|
34
|
+
"types": "./dist/module.d.ts",
|
|
35
|
+
"import": "./dist/module.js"
|
|
36
|
+
},
|
|
37
|
+
"./register": {
|
|
38
|
+
"types": "./dist/register.d.ts",
|
|
39
|
+
"import": "./dist/register.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"src",
|
|
45
|
+
"!src/**/*.test.ts",
|
|
46
|
+
"!src/**/*.test.tsx",
|
|
47
|
+
"!src/**/__tests__",
|
|
48
|
+
"CHANGELOG.md"
|
|
49
|
+
],
|
|
50
|
+
"sideEffects": [
|
|
51
|
+
"./dist/register.js"
|
|
52
|
+
],
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public",
|
|
58
|
+
"provenance": true
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@procedures/client": "0.2.3",
|
|
62
|
+
"zod": "^4.6.5"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"typescript": "^7.0.2",
|
|
66
|
+
"@types/node": "^22.0.0",
|
|
67
|
+
"@mark1russell7/cue": "^1.0.0",
|
|
68
|
+
"vitest": "^5.0.3"
|
|
69
|
+
},
|
|
70
|
+
"client": {
|
|
71
|
+
"procedures": "./dist/register.js"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "tsc -b",
|
|
75
|
+
"typecheck": "tsc --noEmit",
|
|
76
|
+
"clean": "rm -rf dist .tsbuildinfo",
|
|
77
|
+
"test": "vitest run",
|
|
78
|
+
"test:watch": "vitest"
|
|
79
|
+
}
|
|
6
80
|
}
|
package/src/ics.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An iCalendar (RFC 5545) reader: the events of a calendar, and the occurrences of an event in a
|
|
3
|
+
* time window. It has no dependencies. It reads the properties that a calendar of events needs, and
|
|
4
|
+
* it ignores the others (alarms, attendees, time zone definitions).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One event of a calendar (one VEVENT). */
|
|
8
|
+
export interface IcsEvent {
|
|
9
|
+
readonly uid: string;
|
|
10
|
+
readonly summary: string;
|
|
11
|
+
/** The start, in UTC (`YYYY-MM-DDTHH:MM:SSZ`). An all-day event starts at noon UTC of its date. */
|
|
12
|
+
readonly start: string;
|
|
13
|
+
/** The end, in UTC. Without DTEND, one hour after the start (one day for an all-day event). */
|
|
14
|
+
readonly end: string;
|
|
15
|
+
readonly allDay: boolean;
|
|
16
|
+
/** The recurrence rule (`RRULE`), as the calendar gives it. */
|
|
17
|
+
readonly rrule?: string | undefined;
|
|
18
|
+
readonly location?: string | undefined;
|
|
19
|
+
/** The dates (`YYYY-MM-DD`) that EXDATE removes from the recurrence. */
|
|
20
|
+
readonly exdates?: readonly string[] | undefined;
|
|
21
|
+
/** The TZID of DTSTART. A recurrence keeps the local time of this zone across a DST change. */
|
|
22
|
+
readonly tzid?: string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* The RECURRENCE-ID date (`YYYY-MM-DD`). This event replaces that one occurrence of the series
|
|
25
|
+
* with the same UID. Use `uid` and `recurrenceId` together as the key of an event.
|
|
26
|
+
*/
|
|
27
|
+
readonly recurrenceId?: string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* SEQUENCE and LAST-MODIFIED change only when a person edits the event. Use them to find the
|
|
30
|
+
* newest version of an event. Do not use DTSTAMP: it changes at each export.
|
|
31
|
+
*/
|
|
32
|
+
readonly sequence?: number | undefined;
|
|
33
|
+
readonly lastModified?: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One occurrence of an event in a time window. */
|
|
37
|
+
export interface Occurrence {
|
|
38
|
+
readonly uid: string;
|
|
39
|
+
readonly summary: string;
|
|
40
|
+
readonly start: string;
|
|
41
|
+
readonly end: string;
|
|
42
|
+
readonly allDay: boolean;
|
|
43
|
+
readonly location?: string | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface IcsProp {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly params: ReadonlyMap<string, string>;
|
|
49
|
+
readonly value: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HOUR_MS = 3_600_000;
|
|
53
|
+
const DAY_MS = 86_400_000;
|
|
54
|
+
|
|
55
|
+
/** A recurrence makes at most this number of occurrences. It is a bound for safety. */
|
|
56
|
+
const MAX_ITERATIONS = 20_000;
|
|
57
|
+
|
|
58
|
+
const toIso = (ms: number): string => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
59
|
+
|
|
60
|
+
/** The lines of a calendar, with the folded lines (RFC 5545 3.1) joined. */
|
|
61
|
+
const unfold = (text: string): string[] =>
|
|
62
|
+
text
|
|
63
|
+
.replace(/\r?\n[ \t]/g, "")
|
|
64
|
+
.split(/\r?\n/)
|
|
65
|
+
.filter((line) => line.length > 0);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* This function decodes an RFC 5545 TEXT value. Exporters write both `\n` and `\N` for a new
|
|
69
|
+
* line.
|
|
70
|
+
*/
|
|
71
|
+
export const unescapeText = (value: string): string =>
|
|
72
|
+
value
|
|
73
|
+
.replaceAll("\\n", "\n")
|
|
74
|
+
.replaceAll("\\N", "\n")
|
|
75
|
+
.replaceAll("\\,", ",")
|
|
76
|
+
.replaceAll("\\;", ";")
|
|
77
|
+
.replaceAll("\\\\", "\\");
|
|
78
|
+
|
|
79
|
+
const parseProp = (line: string): IcsProp | undefined => {
|
|
80
|
+
const colon = line.indexOf(":");
|
|
81
|
+
if (colon === -1) return undefined;
|
|
82
|
+
const head = line.slice(0, colon);
|
|
83
|
+
const value = line.slice(colon + 1);
|
|
84
|
+
const [name = "", ...paramParts] = head.split(";");
|
|
85
|
+
const params = new Map<string, string>();
|
|
86
|
+
for (const part of paramParts) {
|
|
87
|
+
const eq = part.indexOf("=");
|
|
88
|
+
if (eq !== -1) params.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
|
|
89
|
+
}
|
|
90
|
+
return { name: name.toUpperCase(), params, value };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const tzOffsetMs = (timeZone: string, date: Date): number => {
|
|
94
|
+
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
95
|
+
timeZone,
|
|
96
|
+
year: "numeric",
|
|
97
|
+
month: "2-digit",
|
|
98
|
+
day: "2-digit",
|
|
99
|
+
hour: "2-digit",
|
|
100
|
+
minute: "2-digit",
|
|
101
|
+
second: "2-digit",
|
|
102
|
+
hour12: false,
|
|
103
|
+
});
|
|
104
|
+
const parts = new Map(dtf.formatToParts(date).map((part) => [part.type, part.value]));
|
|
105
|
+
const hour = parts.get("hour") === "24" ? "00" : (parts.get("hour") ?? "00");
|
|
106
|
+
const local = Date.UTC(
|
|
107
|
+
Number(parts.get("year")),
|
|
108
|
+
Number(parts.get("month")) - 1,
|
|
109
|
+
Number(parts.get("day")),
|
|
110
|
+
Number(hour),
|
|
111
|
+
Number(parts.get("minute")),
|
|
112
|
+
Number(parts.get("second"))
|
|
113
|
+
);
|
|
114
|
+
return local - date.getTime();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** This function gives the UTC time (ISO 8601) of a local time in an IANA time zone. */
|
|
118
|
+
export const zonedToUtc = (y: number, mo: number, d: number, h: number, mi: number, s: number, timeZone: string): string => {
|
|
119
|
+
const asUtc = Date.UTC(y, mo - 1, d, h, mi, s);
|
|
120
|
+
let ts = asUtc - tzOffsetMs(timeZone, new Date(asUtc));
|
|
121
|
+
// A second pass corrects the offset near a DST change
|
|
122
|
+
const second = asUtc - tzOffsetMs(timeZone, new Date(ts));
|
|
123
|
+
if (second !== ts) ts = second;
|
|
124
|
+
return toIso(ts);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const parseIcsDate = (prop: IcsProp): { iso: string; allDay: boolean; tzid?: string } | undefined => {
|
|
128
|
+
const value = prop.value;
|
|
129
|
+
const tzid = prop.params.get("TZID");
|
|
130
|
+
const dateOnly = prop.params.get("VALUE") === "DATE" || /^\d{8}$/.test(value);
|
|
131
|
+
if (dateOnly) {
|
|
132
|
+
const m = /^(\d{4})(\d{2})(\d{2})$/.exec(value);
|
|
133
|
+
if (m === null) return undefined;
|
|
134
|
+
// Noon UTC, not midnight: an all-day date has no zone, and midnight UTC is the day before in
|
|
135
|
+
// each zone west of UTC. Noon UTC is the correct date in each zone from -11 h to +11 h.
|
|
136
|
+
return { iso: `${m[1]}-${m[2]}-${m[3]}T12:00:00Z`, allDay: true };
|
|
137
|
+
}
|
|
138
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value);
|
|
139
|
+
if (m === null) return undefined;
|
|
140
|
+
const [, y, mo, d, h, mi, s, z] = m;
|
|
141
|
+
if (z === "Z" || tzid === undefined) {
|
|
142
|
+
return { iso: `${y}-${mo}-${d}T${h}:${mi}:${s}Z`, allDay: false };
|
|
143
|
+
}
|
|
144
|
+
const nums = [y, mo, d, h, mi, s].map(Number) as [number, number, number, number, number, number];
|
|
145
|
+
return { iso: zonedToUtc(...nums, tzid), allDay: false, tzid };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** The local date, time and weekday of a UTC time in a zone. */
|
|
149
|
+
const localParts = (
|
|
150
|
+
iso: string,
|
|
151
|
+
tz: string
|
|
152
|
+
): { y: number; mo: number; d: number; h: number; mi: number; s: number; weekday: number } => {
|
|
153
|
+
const dtf = new Intl.DateTimeFormat("en-US", {
|
|
154
|
+
timeZone: tz,
|
|
155
|
+
year: "numeric",
|
|
156
|
+
month: "2-digit",
|
|
157
|
+
day: "2-digit",
|
|
158
|
+
hour: "2-digit",
|
|
159
|
+
minute: "2-digit",
|
|
160
|
+
second: "2-digit",
|
|
161
|
+
weekday: "short",
|
|
162
|
+
hour12: false,
|
|
163
|
+
});
|
|
164
|
+
const p = new Map(dtf.formatToParts(new Date(iso)).map((part) => [part.type, part.value]));
|
|
165
|
+
const hour = p.get("hour") === "24" ? "00" : (p.get("hour") ?? "00");
|
|
166
|
+
const weekdays: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
167
|
+
return {
|
|
168
|
+
y: Number(p.get("year")),
|
|
169
|
+
mo: Number(p.get("month")),
|
|
170
|
+
d: Number(p.get("day")),
|
|
171
|
+
h: Number(hour),
|
|
172
|
+
mi: Number(p.get("minute")),
|
|
173
|
+
s: Number(p.get("second")),
|
|
174
|
+
weekday: weekdays[p.get("weekday") ?? "Sun"] ?? 0,
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const dateOf = (value: string): string | undefined => {
|
|
179
|
+
const m = /^(\d{4})(\d{2})(\d{2})/.exec(value.trim());
|
|
180
|
+
return m === null ? undefined : `${m[1]}-${m[2]}-${m[3]}`;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** True when a text is an iCalendar object. A proxy error page is not. */
|
|
184
|
+
export const isCalendar = (text: string): boolean => /^\s*BEGIN:VCALENDAR/i.test(text);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* This function gives the events of a calendar. It skips an event without a UID or a start, and
|
|
188
|
+
* an event with STATUS:CANCELLED.
|
|
189
|
+
*/
|
|
190
|
+
export const parseIcs = (text: string): IcsEvent[] => {
|
|
191
|
+
const events: IcsEvent[] = [];
|
|
192
|
+
let current: Map<string, IcsProp> | undefined;
|
|
193
|
+
// EXDATE can occur more than one time, each with a list of dates: keep them apart from the map
|
|
194
|
+
let exdates: string[] = [];
|
|
195
|
+
for (const line of unfold(text)) {
|
|
196
|
+
if (line === "BEGIN:VEVENT") {
|
|
197
|
+
current = new Map();
|
|
198
|
+
exdates = [];
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (line === "END:VEVENT") {
|
|
202
|
+
if (current !== undefined) {
|
|
203
|
+
const event = eventOf(current, exdates);
|
|
204
|
+
if (event !== undefined) events.push(event);
|
|
205
|
+
}
|
|
206
|
+
current = undefined;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (current === undefined) continue;
|
|
210
|
+
const prop = parseProp(line);
|
|
211
|
+
if (prop === undefined) continue;
|
|
212
|
+
if (prop.name === "EXDATE") {
|
|
213
|
+
for (const value of prop.value.split(",")) {
|
|
214
|
+
const date = dateOf(value);
|
|
215
|
+
if (date !== undefined) exdates.push(date);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
current.set(prop.name, prop);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return events;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const eventOf = (props: ReadonlyMap<string, IcsProp>, exdates: readonly string[]): IcsEvent | undefined => {
|
|
225
|
+
const uid = props.get("UID")?.value;
|
|
226
|
+
const startProp = props.get("DTSTART");
|
|
227
|
+
const start = startProp === undefined ? undefined : parseIcsDate(startProp);
|
|
228
|
+
if (uid === undefined || start === undefined || props.get("STATUS")?.value === "CANCELLED") return undefined;
|
|
229
|
+
const endProp = props.get("DTEND");
|
|
230
|
+
const end = endProp === undefined ? undefined : parseIcsDate(endProp);
|
|
231
|
+
const location = props.get("LOCATION")?.value;
|
|
232
|
+
const recurrenceValue = props.get("RECURRENCE-ID")?.value;
|
|
233
|
+
const recurrenceId = recurrenceValue === undefined ? undefined : dateOf(recurrenceValue);
|
|
234
|
+
const sequenceValue = props.get("SEQUENCE")?.value;
|
|
235
|
+
const sequence = sequenceValue === undefined ? undefined : Number.parseInt(sequenceValue, 10);
|
|
236
|
+
const lastModified = props.get("LAST-MODIFIED")?.value;
|
|
237
|
+
const rrule = props.get("RRULE")?.value;
|
|
238
|
+
return {
|
|
239
|
+
uid,
|
|
240
|
+
summary: unescapeText(props.get("SUMMARY")?.value ?? "(untitled)"),
|
|
241
|
+
start: start.iso,
|
|
242
|
+
end: end?.iso ?? toIso(Date.parse(start.iso) + (start.allDay ? DAY_MS : HOUR_MS)),
|
|
243
|
+
allDay: start.allDay,
|
|
244
|
+
...(rrule !== undefined ? { rrule } : {}),
|
|
245
|
+
...(location !== undefined ? { location: unescapeText(location) } : {}),
|
|
246
|
+
...(exdates.length > 0 ? { exdates: [...exdates] } : {}),
|
|
247
|
+
...(start.tzid !== undefined ? { tzid: start.tzid } : {}),
|
|
248
|
+
...(recurrenceId !== undefined ? { recurrenceId } : {}),
|
|
249
|
+
...(sequence !== undefined && Number.isFinite(sequence) ? { sequence } : {}),
|
|
250
|
+
...(lastModified !== undefined ? { lastModified } : {}),
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const BYDAY_INDEX: Record<string, number> = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
|
|
255
|
+
|
|
256
|
+
/** A time window: the occurrences that touch it are in the result. */
|
|
257
|
+
export interface TimeWindow {
|
|
258
|
+
readonly from: string;
|
|
259
|
+
readonly to: string;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* This function gives the occurrences of an event that touch a time window. It knows FREQ DAILY,
|
|
264
|
+
* WEEKLY (with BYDAY), MONTHLY and YEARLY, with INTERVAL, COUNT, UNTIL and EXDATE. An event with
|
|
265
|
+
* a TZID keeps its local time across a DST change. Other rules give the first occurrence only.
|
|
266
|
+
*/
|
|
267
|
+
export const expandOccurrences = (event: IcsEvent, window: TimeWindow): Occurrence[] => {
|
|
268
|
+
const windowFrom = Date.parse(window.from);
|
|
269
|
+
const windowTo = Date.parse(window.to);
|
|
270
|
+
const startMs = Date.parse(event.start);
|
|
271
|
+
const duration = Date.parse(event.end) - startMs;
|
|
272
|
+
const occurrence = (ms: number): Occurrence => ({
|
|
273
|
+
uid: event.uid,
|
|
274
|
+
summary: event.summary,
|
|
275
|
+
start: toIso(ms),
|
|
276
|
+
end: toIso(ms + duration),
|
|
277
|
+
allDay: event.allDay,
|
|
278
|
+
...(event.location !== undefined ? { location: event.location } : {}),
|
|
279
|
+
});
|
|
280
|
+
const once = (): Occurrence[] => (startMs + duration >= windowFrom && startMs <= windowTo ? [occurrence(startMs)] : []);
|
|
281
|
+
|
|
282
|
+
if (event.rrule === undefined) return once();
|
|
283
|
+
|
|
284
|
+
const rule = new Map<string, string>();
|
|
285
|
+
for (const part of event.rrule.split(";")) {
|
|
286
|
+
const eq = part.indexOf("=");
|
|
287
|
+
if (eq !== -1) rule.set(part.slice(0, eq).toUpperCase(), part.slice(eq + 1));
|
|
288
|
+
}
|
|
289
|
+
const freq = rule.get("FREQ");
|
|
290
|
+
if (freq !== "DAILY" && freq !== "WEEKLY" && freq !== "MONTHLY" && freq !== "YEARLY") return once();
|
|
291
|
+
const interval = Math.max(1, Number(rule.get("INTERVAL") ?? "1") || 1);
|
|
292
|
+
const count = rule.has("COUNT") ? Number(rule.get("COUNT")) : undefined;
|
|
293
|
+
const untilRaw = rule.get("UNTIL");
|
|
294
|
+
const until =
|
|
295
|
+
untilRaw === undefined
|
|
296
|
+
? undefined
|
|
297
|
+
: Date.parse(
|
|
298
|
+
/^\d{8}$/.test(untilRaw)
|
|
299
|
+
? `${untilRaw.slice(0, 4)}-${untilRaw.slice(4, 6)}-${untilRaw.slice(6, 8)}T23:59:59Z`
|
|
300
|
+
: `${untilRaw.slice(0, 4)}-${untilRaw.slice(4, 6)}-${untilRaw.slice(6, 8)}T${untilRaw.slice(9, 11)}:${untilRaw.slice(11, 13)}:${untilRaw.slice(13, 15)}Z`
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// For an event with a TZID, each occurrence keeps the local time of the zone, so a 6 PM event
|
|
304
|
+
// stays at 6 PM after a DST change. The weekday is also the local weekday.
|
|
305
|
+
const tz = event.tzid;
|
|
306
|
+
const local = tz !== undefined ? localParts(event.start, tz) : undefined;
|
|
307
|
+
const startWeekday = local !== undefined ? local.weekday : new Date(startMs).getUTCDay();
|
|
308
|
+
const msAfterDays = (days: number): number => {
|
|
309
|
+
if (tz === undefined || local === undefined) return startMs + days * DAY_MS;
|
|
310
|
+
const civil = new Date(Date.UTC(local.y, local.mo - 1, local.d + days));
|
|
311
|
+
return Date.parse(
|
|
312
|
+
zonedToUtc(civil.getUTCFullYear(), civil.getUTCMonth() + 1, civil.getUTCDate(), local.h, local.mi, local.s, tz)
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
const base =
|
|
316
|
+
local ??
|
|
317
|
+
(() => {
|
|
318
|
+
const utc = new Date(startMs);
|
|
319
|
+
return {
|
|
320
|
+
y: utc.getUTCFullYear(),
|
|
321
|
+
mo: utc.getUTCMonth() + 1,
|
|
322
|
+
d: utc.getUTCDate(),
|
|
323
|
+
h: utc.getUTCHours(),
|
|
324
|
+
mi: utc.getUTCMinutes(),
|
|
325
|
+
s: utc.getUTCSeconds(),
|
|
326
|
+
};
|
|
327
|
+
})();
|
|
328
|
+
// MONTHLY and YEARLY move by calendar months, so the 14th stays the 14th. RFC 5545: a month
|
|
329
|
+
// without the day (a 31st in April) has no occurrence. The date does not move to the next month.
|
|
330
|
+
const msAfterMonths = (months: number): number | undefined => {
|
|
331
|
+
const total = base.mo - 1 + months;
|
|
332
|
+
const y = base.y + Math.floor(total / 12);
|
|
333
|
+
const mo = (((total % 12) + 12) % 12) + 1;
|
|
334
|
+
const daysInMonth = new Date(Date.UTC(y, mo, 0)).getUTCDate();
|
|
335
|
+
if (base.d > daysInMonth) return undefined;
|
|
336
|
+
return tz !== undefined
|
|
337
|
+
? Date.parse(zonedToUtc(y, mo, base.d, base.h, base.mi, base.s, tz))
|
|
338
|
+
: Date.UTC(y, mo - 1, base.d, base.h, base.mi, base.s);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// The loops go through the whole series from its start, and keep only the occurrences in the
|
|
342
|
+
// window. A series that started years ago still reaches the window.
|
|
343
|
+
const starts: number[] = [];
|
|
344
|
+
if (freq === "MONTHLY" || freq === "YEARLY") {
|
|
345
|
+
const step = interval * (freq === "YEARLY" ? 12 : 1);
|
|
346
|
+
let emitted = 0;
|
|
347
|
+
for (let n = 0; n < MAX_ITERATIONS; n += 1) {
|
|
348
|
+
const ms = msAfterMonths(n * step);
|
|
349
|
+
// A month without the day uses an iteration, but it is not counted by COUNT
|
|
350
|
+
if (ms === undefined) continue;
|
|
351
|
+
if ((until !== undefined && ms > until) || (count !== undefined && emitted >= count) || ms > windowTo) break;
|
|
352
|
+
emitted += 1;
|
|
353
|
+
if (ms + duration >= windowFrom) starts.push(ms);
|
|
354
|
+
}
|
|
355
|
+
} else if (freq === "DAILY") {
|
|
356
|
+
for (let n = 0; n < MAX_ITERATIONS; n += 1) {
|
|
357
|
+
const ms = msAfterDays(n * interval);
|
|
358
|
+
if ((until !== undefined && ms > until) || (count !== undefined && n >= count) || ms > windowTo) break;
|
|
359
|
+
if (ms + duration >= windowFrom) starts.push(ms);
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
const byday = (rule.get("BYDAY")?.split(",") ?? []).map((day) => BYDAY_INDEX[day]);
|
|
363
|
+
const days = byday.filter((day): day is number => day !== undefined);
|
|
364
|
+
const anchors = (days.length > 0 ? days : [startWeekday]).sort((a, b) => a - b);
|
|
365
|
+
let emitted = 0;
|
|
366
|
+
let done = false;
|
|
367
|
+
for (let week = 0; week < MAX_ITERATIONS && !done; week += 1) {
|
|
368
|
+
for (const day of anchors) {
|
|
369
|
+
const ms = msAfterDays(week * interval * 7 + day - startWeekday);
|
|
370
|
+
if (ms < startMs) continue;
|
|
371
|
+
if ((until !== undefined && ms > until) || (count !== undefined && emitted >= count) || ms > windowTo) {
|
|
372
|
+
done = true;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
emitted += 1;
|
|
376
|
+
if (ms + duration >= windowFrom) starts.push(ms);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// EXDATE removes an occurrence by its date
|
|
382
|
+
const excluded = new Set(event.exdates ?? []);
|
|
383
|
+
return starts.filter((ms) => !excluded.has(toIso(ms).slice(0, 10))).map(occurrence);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* This function gives the occurrences of all events of a calendar in a time window, sorted by
|
|
388
|
+
* start. An event with a RECURRENCE-ID replaces the occurrence of its series on that date.
|
|
389
|
+
*/
|
|
390
|
+
export const calendarOccurrences = (events: readonly IcsEvent[], window: TimeWindow): Occurrence[] => {
|
|
391
|
+
const replaced = new Set(
|
|
392
|
+
events.filter((event) => event.recurrenceId !== undefined).map((event) => `${event.uid}|${event.recurrenceId}`)
|
|
393
|
+
);
|
|
394
|
+
const out: Occurrence[] = [];
|
|
395
|
+
for (const event of events) {
|
|
396
|
+
for (const occurrence of expandOccurrences(event, window)) {
|
|
397
|
+
const replacedHere =
|
|
398
|
+
event.recurrenceId === undefined && replaced.has(`${event.uid}|${occurrence.start.slice(0, 10)}`);
|
|
399
|
+
if (!replacedHere) out.push(occurrence);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return out.sort((a, b) => a.start.localeCompare(b.start));
|
|
403
|
+
};
|
package/src/index.ts
ADDED
package/src/module.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The procedures of @procedures/ics as data. Importing this file registers nothing.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { defineModule, registerModules, PROCEDURE_REGISTRY, type ProcedureModule, type ProcedureRegistry } from "@procedures/client";
|
|
6
|
+
import { icsProcedures } from "./procedures.js";
|
|
7
|
+
|
|
8
|
+
export const icsModule: ProcedureModule = defineModule("@procedures/ics", icsProcedures);
|
|
9
|
+
|
|
10
|
+
export default icsModule;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* This function registers the procedures of @procedures/ics in a registry. The default is the
|
|
14
|
+
* global registry.
|
|
15
|
+
*/
|
|
16
|
+
export function registerIcsProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY): void {
|
|
17
|
+
registerModules(registry, [icsModule]);
|
|
18
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The procedures of `@procedures/ics`: `ics.parse`, `ics.occurrences` and `ics.fetch`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createProcedure, zodAdapter, type AnyProcedure, type ProcedureContext } from "@procedures/client";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { calendarOccurrences, isCalendar, parseIcs, type IcsEvent, type Occurrence } from "./ics.js";
|
|
8
|
+
|
|
9
|
+
const PURE = { reads: [], writes: [] };
|
|
10
|
+
|
|
11
|
+
const isoTime = z.string().refine((value) => !Number.isNaN(Date.parse(value)), "Write an ISO 8601 time");
|
|
12
|
+
|
|
13
|
+
const eventSchema = z.object({
|
|
14
|
+
uid: z.string(),
|
|
15
|
+
summary: z.string(),
|
|
16
|
+
start: z.string(),
|
|
17
|
+
end: z.string(),
|
|
18
|
+
allDay: z.boolean(),
|
|
19
|
+
rrule: z.string().optional(),
|
|
20
|
+
location: z.string().optional(),
|
|
21
|
+
exdates: z.array(z.string()).optional(),
|
|
22
|
+
tzid: z.string().optional(),
|
|
23
|
+
recurrenceId: z.string().optional(),
|
|
24
|
+
sequence: z.number().optional(),
|
|
25
|
+
lastModified: z.string().optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const occurrenceSchema = z.object({
|
|
29
|
+
uid: z.string(),
|
|
30
|
+
summary: z.string(),
|
|
31
|
+
start: z.string(),
|
|
32
|
+
end: z.string(),
|
|
33
|
+
allDay: z.boolean(),
|
|
34
|
+
location: z.string().optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// ics.parse
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
const ParseInputSchema = z.object({
|
|
42
|
+
text: z.string().describe("The iCalendar text (BEGIN:VCALENDAR ... END:VCALENDAR)"),
|
|
43
|
+
});
|
|
44
|
+
type ParseInput = z.infer<typeof ParseInputSchema>;
|
|
45
|
+
interface EventsOutput {
|
|
46
|
+
events: IcsEvent[];
|
|
47
|
+
}
|
|
48
|
+
const EventsOutputSchema = z.object({ events: z.array(eventSchema) });
|
|
49
|
+
|
|
50
|
+
export const icsParseProcedure: AnyProcedure = createProcedure()
|
|
51
|
+
.path(["ics", "parse"])
|
|
52
|
+
.input(zodAdapter<ParseInput>(ParseInputSchema))
|
|
53
|
+
.output(zodAdapter<EventsOutput>(EventsOutputSchema))
|
|
54
|
+
.meta({
|
|
55
|
+
description: "Read the events of an iCalendar text",
|
|
56
|
+
output: "json",
|
|
57
|
+
effects: PURE,
|
|
58
|
+
})
|
|
59
|
+
.handler(async (input: ParseInput): Promise<EventsOutput> => ({ events: parseIcs(input.text) }))
|
|
60
|
+
.build() as AnyProcedure;
|
|
61
|
+
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// ics.occurrences
|
|
64
|
+
// =============================================================================
|
|
65
|
+
|
|
66
|
+
const OccurrencesInputSchema = z.object({
|
|
67
|
+
text: z.string().describe("The iCalendar text"),
|
|
68
|
+
from: isoTime.describe("The start of the window (ISO 8601)"),
|
|
69
|
+
to: isoTime.describe("The end of the window (ISO 8601)"),
|
|
70
|
+
});
|
|
71
|
+
type OccurrencesInput = z.infer<typeof OccurrencesInputSchema>;
|
|
72
|
+
interface OccurrencesOutput {
|
|
73
|
+
occurrences: Occurrence[];
|
|
74
|
+
}
|
|
75
|
+
const OccurrencesOutputSchema = z.object({ occurrences: z.array(occurrenceSchema) });
|
|
76
|
+
|
|
77
|
+
export const icsOccurrencesProcedure: AnyProcedure = createProcedure()
|
|
78
|
+
.path(["ics", "occurrences"])
|
|
79
|
+
.input(zodAdapter<OccurrencesInput>(OccurrencesInputSchema))
|
|
80
|
+
.output(zodAdapter<OccurrencesOutput>(OccurrencesOutputSchema))
|
|
81
|
+
.meta({
|
|
82
|
+
description: "Give the occurrences of the events of an iCalendar text in a time window, recurrences included",
|
|
83
|
+
output: "json",
|
|
84
|
+
effects: PURE,
|
|
85
|
+
})
|
|
86
|
+
.handler(
|
|
87
|
+
async (input: OccurrencesInput): Promise<OccurrencesOutput> => ({
|
|
88
|
+
occurrences: calendarOccurrences(parseIcs(input.text), { from: input.from, to: input.to }),
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
.build() as AnyProcedure;
|
|
92
|
+
|
|
93
|
+
// =============================================================================
|
|
94
|
+
// ics.fetch
|
|
95
|
+
// =============================================================================
|
|
96
|
+
|
|
97
|
+
const FetchInputSchema = z.object({
|
|
98
|
+
url: z.url().describe("The address of the calendar feed. A private feed address is a secret: no error repeats it."),
|
|
99
|
+
timeoutMs: z.number().int().positive().max(120_000).default(20_000).describe("The time limit, in milliseconds"),
|
|
100
|
+
});
|
|
101
|
+
type FetchInput = z.infer<typeof FetchInputSchema>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* This function gets the text of a calendar feed. The address of a private feed is a secret, so
|
|
105
|
+
* an error never includes it.
|
|
106
|
+
*/
|
|
107
|
+
export async function fetchCalendar(url: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
|
|
108
|
+
const signals = signal ? [signal, AbortSignal.timeout(timeoutMs)] : [AbortSignal.timeout(timeoutMs)];
|
|
109
|
+
let response: Response;
|
|
110
|
+
try {
|
|
111
|
+
response = await fetch(url, { signal: AbortSignal.any(signals), headers: { accept: "text/calendar, */*" } });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const reason = (error as { name?: string }).name === "TimeoutError" ? `no answer in ${timeoutMs} ms` : "the request failed";
|
|
114
|
+
throw new Error(`The calendar feed did not answer: ${reason}`);
|
|
115
|
+
}
|
|
116
|
+
if (!response.ok) throw new Error(`The calendar feed gave HTTP ${response.status}`);
|
|
117
|
+
const text = await response.text();
|
|
118
|
+
// A proxy page or an error page with status 200 is not a calendar. Zero events from it would
|
|
119
|
+
// look like an empty calendar.
|
|
120
|
+
if (!isCalendar(text)) throw new Error("The calendar feed did not give an iCalendar text");
|
|
121
|
+
return text;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const icsFetchProcedure: AnyProcedure = createProcedure()
|
|
125
|
+
.path(["ics", "fetch"])
|
|
126
|
+
.input(zodAdapter<FetchInput>(FetchInputSchema))
|
|
127
|
+
.output(zodAdapter<EventsOutput>(EventsOutputSchema))
|
|
128
|
+
.meta({
|
|
129
|
+
description: "Get a calendar feed and read its events",
|
|
130
|
+
args: ["url"],
|
|
131
|
+
output: "json",
|
|
132
|
+
effects: { reads: ["network"], writes: [] },
|
|
133
|
+
})
|
|
134
|
+
.handler(async (input: FetchInput, ctx?: ProcedureContext): Promise<EventsOutput> => {
|
|
135
|
+
return { events: parseIcs(await fetchCalendar(input.url, input.timeoutMs, ctx?.signal)) };
|
|
136
|
+
})
|
|
137
|
+
.build() as AnyProcedure;
|
|
138
|
+
|
|
139
|
+
export const icsProcedures: AnyProcedure[] = [icsParseProcedure, icsOccurrencesProcedure, icsFetchProcedure];
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importing this file registers the procedures of @procedures/ics in the global registry:
|
|
3
|
+
* `import "@procedures/ics/register"`. The package root registers nothing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { registerIcsProcedures } from "./module.js";
|
|
7
|
+
|
|
8
|
+
export * from "./module.js";
|
|
9
|
+
|
|
10
|
+
registerIcsProcedures();
|