@svar-ui/calendar-ical 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -6,6 +6,16 @@ interface CalendarEvent {
6
6
  end: Date;
7
7
  allDay?: boolean;
8
8
  text?: string;
9
+ /** iCal RRULE of a recurring master */
10
+ rrule?: string;
11
+ /** occurrences removed from the series */
12
+ exdates?: Date[];
13
+ /** set on an exception: the master it belongs to */
14
+ masterEventId?: EventID;
15
+ /** set on an exception: the occurrence it replaces */
16
+ originalDate?: Date;
17
+ /** single instance duration in ms, set by the store on a master */
18
+ duration?: number;
9
19
  [key: string]: any;
10
20
  }
11
21
  //#endregion
package/dist/index.mjs CHANGED
@@ -1,80 +1,157 @@
1
1
  //#region src/parse.ts
2
+ const DURATION_RE = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
2
3
  function unfold(ics) {
3
4
  return ics.replace(/\r?\n[ \t]/g, "");
4
5
  }
5
6
  function parseDate(value) {
6
- if (/^\d{8}$/.test(value)) {
7
- const y = +value.slice(0, 4);
8
- const m = +value.slice(4, 6) - 1;
9
- const d = +value.slice(6, 8);
10
- return {
11
- date: new Date(y, m, d),
12
- allDay: true
13
- };
14
- }
15
- if (value.endsWith("Z")) {
16
- const y = +value.slice(0, 4);
17
- const mo = +value.slice(4, 6) - 1;
18
- const d = +value.slice(6, 8);
19
- const h = +value.slice(9, 11);
20
- const mi = +value.slice(11, 13);
21
- const s = +value.slice(13, 15);
22
- return {
23
- date: new Date(Date.UTC(y, mo, d, h, mi, s)),
24
- allDay: false
25
- };
26
- }
27
7
  const y = +value.slice(0, 4);
28
8
  const mo = +value.slice(4, 6) - 1;
29
9
  const d = +value.slice(6, 8);
10
+ if (/^\d{8}$/.test(value)) return {
11
+ date: new Date(y, mo, d),
12
+ allDay: true
13
+ };
30
14
  const h = +value.slice(9, 11);
31
15
  const mi = +value.slice(11, 13);
32
16
  const s = +value.slice(13, 15);
17
+ if (value.endsWith("Z")) return {
18
+ date: new Date(Date.UTC(y, mo, d, h, mi, s)),
19
+ allDay: false
20
+ };
33
21
  return {
34
22
  date: new Date(y, mo, d, h, mi, s),
35
23
  allDay: false
36
24
  };
37
25
  }
26
+ function addDuration$1(start, value) {
27
+ const match = value.trim().match(DURATION_RE);
28
+ if (!match) return null;
29
+ const [, w, d, h, mi, s] = match;
30
+ const days = +(w || 0) * 7 + +(d || 0);
31
+ const ms = +(h || 0) * 36e5 + +(mi || 0) * 6e4 + +(s || 0) * 1e3;
32
+ if (!days && !ms) return null;
33
+ const end = new Date(start);
34
+ if (days) end.setDate(end.getDate() + days);
35
+ return ms ? new Date(end.getTime() + ms) : end;
36
+ }
38
37
  function unescapeText(value) {
39
38
  return value.replace(/\\n/g, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");
40
39
  }
40
+ function parseLine(line) {
41
+ const colon = line.indexOf(":");
42
+ if (colon === -1) return null;
43
+ const [key, ...paramParts] = line.slice(0, colon).split(";");
44
+ const params = {};
45
+ for (const part of paramParts) {
46
+ const eq = part.indexOf("=");
47
+ if (eq > 0) params[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1);
48
+ }
49
+ return {
50
+ key: key.toUpperCase(),
51
+ prop: {
52
+ value: line.slice(colon + 1),
53
+ params
54
+ }
55
+ };
56
+ }
57
+ function first(props, key) {
58
+ return props[key]?.[0]?.value;
59
+ }
60
+ function occurrenceKey(date) {
61
+ const pad = (value) => String(value).padStart(2, "0");
62
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
63
+ }
64
+ function toEventId(uid) {
65
+ return isNaN(Number(uid)) ? uid : Number(uid);
66
+ }
67
+ function buildEntry(props) {
68
+ const startRaw = first(props, "DTSTART");
69
+ if (!startRaw) return null;
70
+ const { date: start, allDay } = parseDate(startRaw);
71
+ const endRaw = first(props, "DTEND");
72
+ const durationRaw = first(props, "DURATION");
73
+ let end = start;
74
+ if (endRaw) end = parseDate(endRaw).date;
75
+ else if (durationRaw) end = addDuration$1(start, durationRaw) ?? start;
76
+ const uid = first(props, "UID") ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
77
+ const event = {
78
+ id: toEventId(uid),
79
+ start,
80
+ end
81
+ };
82
+ if (allDay) event.allDay = true;
83
+ const summary = first(props, "SUMMARY");
84
+ if (summary) event.text = unescapeText(summary);
85
+ const description = first(props, "DESCRIPTION");
86
+ if (description) event.description = unescapeText(description);
87
+ const rrule = first(props, "RRULE");
88
+ if (rrule) event.rrule = rrule;
89
+ const exdates = [];
90
+ for (const prop of props["EXDATE"] ?? []) for (const part of prop.value.split(",")) if (part) exdates.push(parseDate(part).date);
91
+ if (exdates.length) event.exdates = exdates;
92
+ const recurrenceRaw = first(props, "RECURRENCE-ID");
93
+ if (!recurrenceRaw) return {
94
+ uid,
95
+ event
96
+ };
97
+ const recurrenceId = parseDate(recurrenceRaw).date;
98
+ return {
99
+ uid,
100
+ event,
101
+ recurrenceId,
102
+ recurrenceKey: occurrenceKey(recurrenceId)
103
+ };
104
+ }
41
105
  function parseICal(ics) {
42
106
  const lines = unfold(ics).split(/\r?\n/);
43
- const events = [];
44
- let current = null;
45
- for (const line of lines) if (line === "BEGIN:VEVENT") current = {};
46
- else if (line === "END:VEVENT" && current) {
47
- const uid = current["UID"];
48
- const startRaw = current["DTSTART"] ?? "";
49
- const endRaw = current["DTEND"] ?? "";
50
- if (startRaw) {
51
- const { date: start, allDay } = parseDate(startRaw);
52
- const { date: end } = endRaw ? parseDate(endRaw) : { date: start };
53
- const event = {
54
- id: uid ? isNaN(Number(uid)) ? uid : Number(uid) : `${Date.now()}-${Math.random().toString(36).slice(2)}`,
55
- start,
56
- end
57
- };
58
- if (allDay) event.allDay = true;
59
- if (current["SUMMARY"]) event.text = unescapeText(current["SUMMARY"]);
60
- if (current["DESCRIPTION"]) event.description = unescapeText(current["DESCRIPTION"]);
61
- events.push(event);
107
+ const entries = [];
108
+ let props = null;
109
+ let nested = 0;
110
+ for (const line of lines) {
111
+ if (props === null) {
112
+ if (line === "BEGIN:VEVENT") {
113
+ props = {};
114
+ nested = 0;
115
+ }
116
+ continue;
117
+ }
118
+ if (nested === 0 && line === "END:VEVENT") {
119
+ const entry = buildEntry(props);
120
+ if (entry) entries.push(entry);
121
+ props = null;
122
+ continue;
123
+ }
124
+ if (line.startsWith("BEGIN:")) {
125
+ nested++;
126
+ continue;
62
127
  }
63
- current = null;
64
- } else if (current !== null) {
65
- const colonIdx = line.indexOf(":");
66
- if (colonIdx === -1) continue;
67
- const baseKey = line.slice(0, colonIdx).split(";")[0];
68
- const value = line.slice(colonIdx + 1);
69
- current[baseKey] = value;
128
+ if (line.startsWith("END:")) {
129
+ if (nested > 0) nested--;
130
+ continue;
131
+ }
132
+ if (nested > 0) continue;
133
+ const parsed = parseLine(line);
134
+ if (parsed) (props[parsed.key] ??= []).push(parsed.prop);
135
+ }
136
+ const masters = /* @__PURE__ */ new Map();
137
+ for (const entry of entries) if (!entry.recurrenceId && !masters.has(entry.uid)) masters.set(entry.uid, entry);
138
+ for (const entry of entries) {
139
+ const master = entry.recurrenceId && masters.get(entry.uid);
140
+ if (!master) continue;
141
+ entry.event.id = `${entry.uid}-${entry.recurrenceKey}`;
142
+ entry.event.masterEventId = master.event.id;
143
+ entry.event.originalDate = entry.recurrenceId;
70
144
  }
71
- return events;
145
+ return entries.map((entry) => entry.event);
72
146
  }
73
147
  //#endregion
74
148
  //#region src/serialize.ts
149
+ function pad(value) {
150
+ return String(value).padStart(2, "0");
151
+ }
75
152
  function formatDate(date, allDay) {
76
- if (allDay) return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
77
- return `${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, "0")}${String(date.getUTCDate()).padStart(2, "0")}T${String(date.getUTCHours()).padStart(2, "0")}${String(date.getUTCMinutes()).padStart(2, "0")}${String(date.getUTCSeconds()).padStart(2, "0")}Z`;
153
+ if (allDay) return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
154
+ return `${`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`}T${`${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`}Z`;
78
155
  }
79
156
  function escapeText(value) {
80
157
  return value.replace(/\\/g, "\\\\").replace(/,/g, "\\,").replace(/;/g, "\\;").replace(/\n/g, "\\n");
@@ -89,6 +166,20 @@ function foldLine(line) {
89
166
  }
90
167
  return chunks.join("\r\n");
91
168
  }
169
+ function addDuration(start, duration, allDay) {
170
+ if (!allDay) return new Date(start.getTime() + duration);
171
+ const days = Math.max(1, Math.round(duration / 864e5));
172
+ const end = new Date(start);
173
+ end.setDate(end.getDate() + days);
174
+ return end;
175
+ }
176
+ function instanceEnd(event, allDay) {
177
+ const duration = Number(event.duration);
178
+ return event.rrule && Number.isFinite(duration) ? addDuration(event.start, duration, allDay) : event.end;
179
+ }
180
+ function isException(event) {
181
+ return event.masterEventId != null && event.originalDate instanceof Date;
182
+ }
92
183
  function serializeICal(events) {
93
184
  const now = formatDate(/* @__PURE__ */ new Date(), false);
94
185
  const lines = [
@@ -96,17 +187,25 @@ function serializeICal(events) {
96
187
  "VERSION:2.0",
97
188
  "PRODID:-//wx//calendar-ical//EN"
98
189
  ];
190
+ const masters = new Map(events.map((ev) => [ev.id, ev]));
99
191
  for (const ev of events) {
100
192
  const allDay = ev.allDay ?? false;
193
+ const dateParam = allDay ? ";VALUE=DATE" : "";
101
194
  lines.push("BEGIN:VEVENT");
102
- lines.push(`UID:${ev.id}`);
195
+ lines.push(`UID:${isException(ev) ? ev.masterEventId : ev.id}`);
103
196
  lines.push(`DTSTAMP:${now}`);
104
- if (allDay) {
105
- lines.push(`DTSTART;VALUE=DATE:${formatDate(ev.start, true)}`);
106
- lines.push(`DTEND;VALUE=DATE:${formatDate(ev.end, true)}`);
107
- } else {
108
- lines.push(`DTSTART:${formatDate(ev.start, false)}`);
109
- lines.push(`DTEND:${formatDate(ev.end, false)}`);
197
+ if (isException(ev)) {
198
+ const master = masters.get(ev.masterEventId);
199
+ const idAllDay = (master ? master.allDay : ev.allDay) ?? false;
200
+ const idParam = idAllDay ? ";VALUE=DATE" : "";
201
+ lines.push(`RECURRENCE-ID${idParam}:${formatDate(ev.originalDate, idAllDay)}`);
202
+ }
203
+ lines.push(`DTSTART${dateParam}:${formatDate(ev.start, allDay)}`);
204
+ lines.push(`DTEND${dateParam}:${formatDate(instanceEnd(ev, allDay), allDay)}`);
205
+ if (ev.rrule) lines.push(`RRULE:${ev.rrule}`);
206
+ if (ev.exdates?.length) {
207
+ const dates = ev.exdates.map((d) => formatDate(d, allDay)).join(",");
208
+ lines.push(`EXDATE${dateParam}:${dates}`);
110
209
  }
111
210
  if (ev.text) lines.push(`SUMMARY:${escapeText(ev.text)}`);
112
211
  if (ev.description) lines.push(`DESCRIPTION:${escapeText(String(ev.description))}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svar-ui/calendar-ical",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "iCal import/export for SVAR Calendar",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "scripts": {
24
24
  "build": "vp pack",
25
- "watch": "vp pack --watch",
25
+ "dev": "vp pack --watch",
26
26
  "test": "vp test",
27
27
  "check": "vp check"
28
28
  }