@sandsoftwaresolutions/ics-lite 0.1.0 → 0.1.1

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 CHANGED
@@ -10,6 +10,8 @@ Make calendar files (`.ics`) from your own data, and read them back. For rosters
10
10
  npm install @sandsoftwaresolutions/ics-lite
11
11
  ```
12
12
 
13
+ Works with `import` and `require()`, and ships TypeScript types for both.
14
+
13
15
  ## Write
14
16
 
15
17
  ```js
package/dist/index.cjs ADDED
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ const { createHash } = require("node:crypto");
3
+
4
+ // RFC 5545 TEXT: backslash, semicolon and comma get a backslash, a line break becomes \\n.
5
+ const escapeText = (s) => String(s).replace(/\\/g, "\\\\").replace(/;/g, "\\" + ";").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
6
+ const unescapeText = (s) => s.replace(/\\([nN,;\\])/g, (_, c) => (c === "n" || c === "N" ? "\n" : c));
7
+ const pad = (n, w = 2) => String(n).padStart(w, "0");
8
+
9
+ function utc(date) {
10
+ return `${pad(date.getUTCFullYear(), 4)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
11
+ }
12
+ function day(date) {
13
+ return `${pad(date.getUTCFullYear(), 4)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`;
14
+ }
15
+
16
+ /** Lines may be at most 75 octets; longer ones are continued on the next line that starts with a space. Never splits a character. */
17
+ function fold(line) {
18
+ const encoder = new TextEncoder();
19
+ if (encoder.encode(line).length <= 75) return line;
20
+ const parts = [];
21
+ let current = "";
22
+ let size = 0;
23
+ for (const ch of line) {
24
+ const bytes = encoder.encode(ch).length;
25
+ const limit = parts.length === 0 ? 75 : 74;
26
+ if (size + bytes > limit) {
27
+ parts.push(current);
28
+ current = "";
29
+ size = 0;
30
+ }
31
+ current += ch;
32
+ size += bytes;
33
+ }
34
+ parts.push(current);
35
+ return parts.join("\r\n ");
36
+ }
37
+
38
+ const toDate = (value, name) => {
39
+ if (!(value instanceof Date) && typeof value !== "string" && typeof value !== "number") throw new TypeError(`createCalendar: ${name} must be a Date, a date text or a timestamp`); // new Date(null) would be 1970
40
+ const date = value instanceof Date ? value : new Date(value);
41
+ if (Number.isNaN(date.getTime())) throw new TypeError(`createCalendar: ${name} is not a valid date`);
42
+ if (date.getUTCFullYear() < 0 || date.getUTCFullYear() > 9999) throw new RangeError(`createCalendar: ${name} is outside the years 0000 to 9999`);
43
+ return date;
44
+ };
45
+
46
+ // Values that are written as they are (UID, URL, PRODID, STATUS) must not contain a line break or other control character, or they
47
+ // could add properties (an attendee, an alarm) to the file.
48
+ const plain = (value, name) => {
49
+ const text = String(value);
50
+ if (/[\u0000-\u001f\u007f]/.test(text)) throw new TypeError(`createCalendar: ${name} contains a control character or line break`);
51
+ return text;
52
+ };
53
+ const STATUSES = new Set(["CONFIRMED", "TENTATIVE", "CANCELLED"]);
54
+
55
+ /**
56
+ * Builds an .ics calendar text. Events: `{ title, start, end?, allDay?, description?, location?, url?, uid?, status? }`.
57
+ * Times are written in UTC, so every calendar app shows them at the right local time. An all-day event uses dates only
58
+ * (`end` is the last day, inclusive). Without `uid`, a stable one is made from the content, so importing the same file twice
59
+ * updates the events instead of duplicating them. `now` fixes the DTSTAMP, for tests.
60
+ */
61
+ function createCalendar({ name, events = [], prodId = "-//Sand Software Solutions//ics-lite//EN", now = new Date() } = {}) {
62
+ const stamp = toDate(now, "now");
63
+ const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", `PRODID:${plain(prodId, "prodId")}`, "CALSCALE:GREGORIAN", "METHOD:PUBLISH"];
64
+ if (name) lines.push(`X-WR-CALNAME:${escapeText(name)}`);
65
+ for (const [i, event] of events.entries()) {
66
+ if (!event || typeof event.title !== "string" || event.title === "") throw new TypeError(`createCalendar: event ${i} needs a title`);
67
+ const start = toDate(event.start, `event ${i} start`);
68
+ let end = event.end === undefined ? undefined : toDate(event.end, `event ${i} end`);
69
+ if (end && end < start) throw new RangeError(`createCalendar: event ${i} ends before it starts`);
70
+ const uid = event.uid ?? `${createHash("sha256").update([event.title, start.toISOString(), end?.toISOString() ?? "", event.location ?? "", event.allDay ? "d" : ""].join("\n")).digest("hex").slice(0, 32)}@ics-lite`;
71
+ lines.push("BEGIN:VEVENT", `UID:${plain(uid, `event ${i} uid`)}`, `DTSTAMP:${utc(stamp)}`);
72
+ if (event.allDay) {
73
+ const last = end ?? start;
74
+ lines.push(`DTSTART;VALUE=DATE:${day(start)}`, `DTEND;VALUE=DATE:${day(new Date(Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), last.getUTCDate() + 1)))}`);
75
+ } else {
76
+ lines.push(`DTSTART:${utc(start)}`);
77
+ if (end) lines.push(`DTEND:${utc(end)}`);
78
+ }
79
+ lines.push(`SUMMARY:${escapeText(event.title)}`);
80
+ if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`);
81
+ if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`);
82
+ if (event.url) lines.push(`URL:${plain(event.url, `event ${i} url`)}`);
83
+ if (event.status) {
84
+ const status = String(event.status).toUpperCase();
85
+ if (!STATUSES.has(status)) throw new RangeError(`createCalendar: event ${i} status must be confirmed, tentative or cancelled`);
86
+ lines.push(`STATUS:${status}`);
87
+ }
88
+ lines.push("END:VEVENT");
89
+ }
90
+ lines.push("END:VCALENDAR");
91
+ return `${lines.map(fold).join("\r\n")}\r\n`;
92
+ }
93
+
94
+ const DAY_MS = 86_400_000;
95
+
96
+ function parseWhen(value, params) {
97
+ if (/VALUE=DATE(?!-)/i.test(params) || /^\d{8}$/.test(value)) {
98
+ const m = /^(\d{4})(\d{2})(\d{2})$/.exec(value);
99
+ return m && validDate(+m[1], +m[2], +m[3]) ? { date: new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])), allDay: true } : null;
100
+ }
101
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value);
102
+ if (!match) return null;
103
+ const [, y, mo, d, h, mi, s, z] = match;
104
+ if (!validDate(+y, +mo, +d) || +h > 23 || +mi > 59 || +s > 60) return null; // 20261340T250000 would roll over into another month
105
+ // Without Z the time is "floating" (or belongs to a TZID we do not resolve); it is read as UTC and flagged.
106
+ return { date: new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)), allDay: false, floating: z !== "Z" };
107
+ }
108
+
109
+ function validDate(year, month, day) {
110
+ if (month < 1 || month > 12 || day < 1) return false;
111
+ const probe = new Date(0);
112
+ probe.setUTCFullYear(year, month, 0); // day 0 of the next month is the last day of this one
113
+ return day <= probe.getUTCDate();
114
+ }
115
+
116
+ // P1W, P1DT12H, PT30M ... in milliseconds, or null.
117
+ function parseDurationValue(value) {
118
+ const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i.exec(value.trim());
119
+ if (!m || m.slice(2).every((v) => v === undefined)) return null;
120
+ const [, sign, w = 0, d = 0, h = 0, mi = 0, s = 0] = m;
121
+ const ms = (((Number(w) * 7 + Number(d)) * 24 + Number(h)) * 60 + Number(mi)) * 60_000 + Number(s) * 1000;
122
+ return sign === "-" ? -ms : ms;
123
+ }
124
+
125
+ // The first colon that is not inside a quoted parameter value: DESCRIPTION;ALTREP="http://x":text.
126
+ function splitProperty(line) {
127
+ let quoted = false;
128
+ for (let i = 0; i < line.length; i++) {
129
+ if (line[i] === '"') quoted = !quoted;
130
+ else if (line[i] === ":" && !quoted) return [line.slice(0, i), line.slice(i + 1)];
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Reads the events from .ics text: title, start, end, allDay, description, location, url, uid, status. Folded lines and
137
+ * escapes are handled. Times with a TZID or without Z are read as UTC and marked `floating: true` (time zones are not resolved).
138
+ * A DURATION without DTEND becomes `end`. Alarms and other components inside an event are skipped, so a reminder's description
139
+ * does not replace the event's. Recurrence rules are not expanded; the raw rule is returned as `rrule`.
140
+ */
141
+ function parseIcs(text) {
142
+ if (typeof text !== "string") throw new TypeError("parseIcs: text must be a string");
143
+ const raw = text.replace(/\r\n[ \t]/g, "").replace(/\n[ \t]/g, "").replace(/\r[ \t]/g, "").split(/\r\n|\n|\r/);
144
+ const events = [];
145
+ let current = null;
146
+ let duration = null;
147
+ let nested = 0; // depth of components inside the event (VALARM...)
148
+ for (const line of raw) {
149
+ const upper = line.toUpperCase();
150
+ if (!current) {
151
+ if (upper === "BEGIN:VEVENT") {
152
+ current = {};
153
+ duration = null;
154
+ nested = 0;
155
+ }
156
+ continue;
157
+ }
158
+ if (nested === 0 && upper === "END:VEVENT") {
159
+ if (duration !== null && current.start && !current.end) {
160
+ current.end = current.allDay ? new Date(current.start.getTime() + Math.max(duration - DAY_MS, 0)) : new Date(current.start.getTime() + duration);
161
+ }
162
+ events.push(current);
163
+ current = null;
164
+ } else if (upper.startsWith("BEGIN:")) nested++;
165
+ else if (upper.startsWith("END:")) nested = Math.max(0, nested - 1);
166
+ else if (nested === 0) {
167
+ const parts = splitProperty(line);
168
+ if (!parts) continue;
169
+ const [name, ...paramParts] = parts[0].split(";");
170
+ const params = paramParts.join(";");
171
+ const value = parts[1];
172
+ switch (name.toUpperCase()) {
173
+ case "SUMMARY": current.title = unescapeText(value); break;
174
+ case "DESCRIPTION": current.description = unescapeText(value); break;
175
+ case "LOCATION": current.location = unescapeText(value); break;
176
+ case "URL": current.url = value; break;
177
+ case "UID": current.uid = value; break;
178
+ case "STATUS": current.status = value.toLowerCase(); break;
179
+ case "RRULE": current.rrule = value; break;
180
+ case "DURATION": duration = parseDurationValue(value); break;
181
+ case "DTSTART": {
182
+ const w = parseWhen(value, params);
183
+ if (w) {
184
+ current.start = w.date;
185
+ current.allDay = w.allDay;
186
+ if (w.floating || /TZID=/i.test(params)) current.floating = true;
187
+ }
188
+ break;
189
+ }
190
+ case "DTEND": {
191
+ const w = parseWhen(value, params);
192
+ if (w) current.end = w.allDay ? new Date(w.date.getTime() - DAY_MS) : w.date; // all-day DTEND is exclusive
193
+ break;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ return events;
199
+ }
200
+
201
+ exports.createCalendar = createCalendar;
202
+ exports.parseIcs = parseIcs;
@@ -0,0 +1,39 @@
1
+ export interface CalendarEvent {
2
+ title: string;
3
+ start: Date | string | number;
4
+ /** For an all-day event: the last day, inclusive. */
5
+ end?: Date | string | number;
6
+ allDay?: boolean;
7
+ description?: string;
8
+ location?: string;
9
+ url?: string;
10
+ uid?: string;
11
+ status?: "confirmed" | "tentative" | "cancelled";
12
+ }
13
+
14
+ export interface CalendarOptions {
15
+ name?: string;
16
+ events?: CalendarEvent[];
17
+ prodId?: string;
18
+ /** Fixes DTSTAMP; for tests. */
19
+ now?: Date;
20
+ }
21
+
22
+ export interface ParsedEvent {
23
+ title?: string;
24
+ start?: Date;
25
+ end?: Date;
26
+ allDay?: boolean;
27
+ description?: string;
28
+ location?: string;
29
+ url?: string;
30
+ uid?: string;
31
+ status?: string;
32
+ /** Raw recurrence rule; not expanded. */
33
+ rrule?: string;
34
+ /** The time had no Z or a TZID and was read as UTC. */
35
+ floating?: boolean;
36
+ }
37
+
38
+ export function createCalendar(options?: CalendarOptions): string;
39
+ export function parseIcs(text: string): ParsedEvent[];
package/package.json CHANGED
@@ -1,16 +1,25 @@
1
1
  {
2
2
  "name": "@sandsoftwaresolutions/ics-lite",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Make and read calendar files (.ics) for rosters, bookings and deadlines.",
5
5
  "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "types": "./src/index.d.ts",
6
8
  "exports": {
7
9
  ".": {
8
- "types": "./src/index.d.ts",
9
- "import": "./src/index.js"
10
+ "import": {
11
+ "types": "./src/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.cts",
16
+ "default": "./dist/index.cjs"
17
+ }
10
18
  }
11
19
  },
12
20
  "files": [
13
21
  "src",
22
+ "dist",
14
23
  "README.md",
15
24
  "LICENSE"
16
25
  ],
@@ -19,8 +28,10 @@
19
28
  "node": ">=20"
20
29
  },
21
30
  "scripts": {
22
- "test": "node --test",
23
- "pack:check": "npm pack --dry-run"
31
+ "test": "node --test && node scripts/build-cjs.mjs --check",
32
+ "pack:check": "npm pack --dry-run",
33
+ "build": "node scripts/build-cjs.mjs",
34
+ "prepack": "node scripts/build-cjs.mjs"
24
35
  },
25
36
  "keywords": [
26
37
  "ics",