@sandsoftwaresolutions/ics-lite 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +46 -2
- package/package.json +41 -4
- package/src/index.d.ts +39 -0
- package/src/index.js +198 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SandSoftwareSolutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sandsoftwaresolutions/ics-lite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/SandSoftwareSolutions/ss-ics-lite/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Make calendar files (`.ics`) from your own data, and read them back. For rosters, bookings, deadlines: your people click a link and the shifts are in their phone's calendar. No dependencies.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @sandsoftwaresolutions/ics-lite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Write
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { createCalendar } from "@sandsoftwaresolutions/ics-lite";
|
|
17
|
+
|
|
18
|
+
const ics = createCalendar({
|
|
19
|
+
name: "Rooster Strandtent",
|
|
20
|
+
events: [
|
|
21
|
+
{ title: "Avonddienst", start: "2026-10-17T16:00:00Z", end: "2026-10-18T00:30:00Z", location: "Strandtent, Zandvoort" },
|
|
22
|
+
{ title: "Vrij", start: "2026-10-20", allDay: true }
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
// Serve it as text/calendar, or save it as rooster.ics
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- Times are written in UTC, so every calendar app shows them at the right local time.
|
|
29
|
+
- **Stable ids.** Without a `uid`, one is made from the content. Importing the same file twice therefore updates instead of duplicating. Give your own `uid` (such as your shift id) if events can change their title or time and must still count as the same event.
|
|
30
|
+
- All-day events use dates; `end` is the last day, inclusive (the file format itself wants it exclusive, this package does that for you).
|
|
31
|
+
- Text is escaped, lines are folded at 75 bytes without cutting a character in half, and line endings are CRLF as the standard asks.
|
|
32
|
+
- Wrong input fails clearly: no title, an invalid date (`null` is not 1970), an end before the start, a year outside 0000-9999, a `status` other than confirmed, tentative or cancelled, and a line break or other control character in `uid`, `url` or `prodId` (those are written as they are, so they could otherwise add an alarm or attendee to the file). Text fields are escaped (`;` `,` `\` and line breaks).
|
|
33
|
+
- An all-day event uses the **UTC date** of the Date you give. `new Date(2026, 9, 20)` is local midnight, which in Asia/Kolkata is still 19 October in UTC: pass a date text (`"2026-10-20"`) or a UTC Date.
|
|
34
|
+
|
|
35
|
+
## Read
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import { parseIcs } from "@sandsoftwaresolutions/ics-lite";
|
|
39
|
+
|
|
40
|
+
parseIcs(text); // [{ uid, title, start: Date, end: Date, allDay, description, location, url, status }]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Handles folded lines and escapes, and the files other programs make. Limits you should know about:
|
|
44
|
+
|
|
45
|
+
- A time without `Z`, or with a `TZID`, is read as UTC and marked `floating: true`. Time zones are not resolved.
|
|
46
|
+
- Recurring events are not expanded; the raw rule is returned as `rrule`.
|
|
47
|
+
- A `DURATION` without `DTEND` becomes `end`. Alarms (`VALARM`) and other components inside an event are skipped. Dates that do not exist (`20261340`) are ignored instead of rolling over into another month.
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandsoftwaresolutions/ics-lite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Make and read calendar files (.ics) for rosters, bookings and deadlines.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"import": "./src/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test",
|
|
23
|
+
"pack:check": "npm pack --dry-run"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"ics",
|
|
27
|
+
"calendar",
|
|
28
|
+
"ical",
|
|
29
|
+
"roster"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/SandSoftwareSolutions/ss-ics-lite.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/SandSoftwareSolutions/ss-ics-lite/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/SandSoftwareSolutions/ss-ics-lite#readme",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -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/src/index.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// RFC 5545 TEXT: backslash, semicolon and comma get a backslash, a line break becomes \\n.
|
|
4
|
+
const escapeText = (s) => String(s).replace(/\\/g, "\\\\").replace(/;/g, "\\" + ";").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
|
|
5
|
+
const unescapeText = (s) => s.replace(/\\([nN,;\\])/g, (_, c) => (c === "n" || c === "N" ? "\n" : c));
|
|
6
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
7
|
+
|
|
8
|
+
function utc(date) {
|
|
9
|
+
return `${pad(date.getUTCFullYear(), 4)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
|
|
10
|
+
}
|
|
11
|
+
function day(date) {
|
|
12
|
+
return `${pad(date.getUTCFullYear(), 4)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Lines may be at most 75 octets; longer ones are continued on the next line that starts with a space. Never splits a character. */
|
|
16
|
+
function fold(line) {
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
if (encoder.encode(line).length <= 75) return line;
|
|
19
|
+
const parts = [];
|
|
20
|
+
let current = "";
|
|
21
|
+
let size = 0;
|
|
22
|
+
for (const ch of line) {
|
|
23
|
+
const bytes = encoder.encode(ch).length;
|
|
24
|
+
const limit = parts.length === 0 ? 75 : 74;
|
|
25
|
+
if (size + bytes > limit) {
|
|
26
|
+
parts.push(current);
|
|
27
|
+
current = "";
|
|
28
|
+
size = 0;
|
|
29
|
+
}
|
|
30
|
+
current += ch;
|
|
31
|
+
size += bytes;
|
|
32
|
+
}
|
|
33
|
+
parts.push(current);
|
|
34
|
+
return parts.join("\r\n ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const toDate = (value, name) => {
|
|
38
|
+
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
|
|
39
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
40
|
+
if (Number.isNaN(date.getTime())) throw new TypeError(`createCalendar: ${name} is not a valid date`);
|
|
41
|
+
if (date.getUTCFullYear() < 0 || date.getUTCFullYear() > 9999) throw new RangeError(`createCalendar: ${name} is outside the years 0000 to 9999`);
|
|
42
|
+
return date;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Values that are written as they are (UID, URL, PRODID, STATUS) must not contain a line break or other control character, or they
|
|
46
|
+
// could add properties (an attendee, an alarm) to the file.
|
|
47
|
+
const plain = (value, name) => {
|
|
48
|
+
const text = String(value);
|
|
49
|
+
if (/[\u0000-\u001f\u007f]/.test(text)) throw new TypeError(`createCalendar: ${name} contains a control character or line break`);
|
|
50
|
+
return text;
|
|
51
|
+
};
|
|
52
|
+
const STATUSES = new Set(["CONFIRMED", "TENTATIVE", "CANCELLED"]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Builds an .ics calendar text. Events: `{ title, start, end?, allDay?, description?, location?, url?, uid?, status? }`.
|
|
56
|
+
* Times are written in UTC, so every calendar app shows them at the right local time. An all-day event uses dates only
|
|
57
|
+
* (`end` is the last day, inclusive). Without `uid`, a stable one is made from the content, so importing the same file twice
|
|
58
|
+
* updates the events instead of duplicating them. `now` fixes the DTSTAMP, for tests.
|
|
59
|
+
*/
|
|
60
|
+
export function createCalendar({ name, events = [], prodId = "-//Sand Software Solutions//ics-lite//EN", now = new Date() } = {}) {
|
|
61
|
+
const stamp = toDate(now, "now");
|
|
62
|
+
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", `PRODID:${plain(prodId, "prodId")}`, "CALSCALE:GREGORIAN", "METHOD:PUBLISH"];
|
|
63
|
+
if (name) lines.push(`X-WR-CALNAME:${escapeText(name)}`);
|
|
64
|
+
for (const [i, event] of events.entries()) {
|
|
65
|
+
if (!event || typeof event.title !== "string" || event.title === "") throw new TypeError(`createCalendar: event ${i} needs a title`);
|
|
66
|
+
const start = toDate(event.start, `event ${i} start`);
|
|
67
|
+
let end = event.end === undefined ? undefined : toDate(event.end, `event ${i} end`);
|
|
68
|
+
if (end && end < start) throw new RangeError(`createCalendar: event ${i} ends before it starts`);
|
|
69
|
+
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`;
|
|
70
|
+
lines.push("BEGIN:VEVENT", `UID:${plain(uid, `event ${i} uid`)}`, `DTSTAMP:${utc(stamp)}`);
|
|
71
|
+
if (event.allDay) {
|
|
72
|
+
const last = end ?? start;
|
|
73
|
+
lines.push(`DTSTART;VALUE=DATE:${day(start)}`, `DTEND;VALUE=DATE:${day(new Date(Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), last.getUTCDate() + 1)))}`);
|
|
74
|
+
} else {
|
|
75
|
+
lines.push(`DTSTART:${utc(start)}`);
|
|
76
|
+
if (end) lines.push(`DTEND:${utc(end)}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push(`SUMMARY:${escapeText(event.title)}`);
|
|
79
|
+
if (event.description) lines.push(`DESCRIPTION:${escapeText(event.description)}`);
|
|
80
|
+
if (event.location) lines.push(`LOCATION:${escapeText(event.location)}`);
|
|
81
|
+
if (event.url) lines.push(`URL:${plain(event.url, `event ${i} url`)}`);
|
|
82
|
+
if (event.status) {
|
|
83
|
+
const status = String(event.status).toUpperCase();
|
|
84
|
+
if (!STATUSES.has(status)) throw new RangeError(`createCalendar: event ${i} status must be confirmed, tentative or cancelled`);
|
|
85
|
+
lines.push(`STATUS:${status}`);
|
|
86
|
+
}
|
|
87
|
+
lines.push("END:VEVENT");
|
|
88
|
+
}
|
|
89
|
+
lines.push("END:VCALENDAR");
|
|
90
|
+
return `${lines.map(fold).join("\r\n")}\r\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const DAY_MS = 86_400_000;
|
|
94
|
+
|
|
95
|
+
function parseWhen(value, params) {
|
|
96
|
+
if (/VALUE=DATE(?!-)/i.test(params) || /^\d{8}$/.test(value)) {
|
|
97
|
+
const m = /^(\d{4})(\d{2})(\d{2})$/.exec(value);
|
|
98
|
+
return m && validDate(+m[1], +m[2], +m[3]) ? { date: new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])), allDay: true } : null;
|
|
99
|
+
}
|
|
100
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value);
|
|
101
|
+
if (!match) return null;
|
|
102
|
+
const [, y, mo, d, h, mi, s, z] = match;
|
|
103
|
+
if (!validDate(+y, +mo, +d) || +h > 23 || +mi > 59 || +s > 60) return null; // 20261340T250000 would roll over into another month
|
|
104
|
+
// Without Z the time is "floating" (or belongs to a TZID we do not resolve); it is read as UTC and flagged.
|
|
105
|
+
return { date: new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)), allDay: false, floating: z !== "Z" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validDate(year, month, day) {
|
|
109
|
+
if (month < 1 || month > 12 || day < 1) return false;
|
|
110
|
+
const probe = new Date(0);
|
|
111
|
+
probe.setUTCFullYear(year, month, 0); // day 0 of the next month is the last day of this one
|
|
112
|
+
return day <= probe.getUTCDate();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// P1W, P1DT12H, PT30M ... in milliseconds, or null.
|
|
116
|
+
function parseDurationValue(value) {
|
|
117
|
+
const m = /^([+-])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i.exec(value.trim());
|
|
118
|
+
if (!m || m.slice(2).every((v) => v === undefined)) return null;
|
|
119
|
+
const [, sign, w = 0, d = 0, h = 0, mi = 0, s = 0] = m;
|
|
120
|
+
const ms = (((Number(w) * 7 + Number(d)) * 24 + Number(h)) * 60 + Number(mi)) * 60_000 + Number(s) * 1000;
|
|
121
|
+
return sign === "-" ? -ms : ms;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The first colon that is not inside a quoted parameter value: DESCRIPTION;ALTREP="http://x":text.
|
|
125
|
+
function splitProperty(line) {
|
|
126
|
+
let quoted = false;
|
|
127
|
+
for (let i = 0; i < line.length; i++) {
|
|
128
|
+
if (line[i] === '"') quoted = !quoted;
|
|
129
|
+
else if (line[i] === ":" && !quoted) return [line.slice(0, i), line.slice(i + 1)];
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Reads the events from .ics text: title, start, end, allDay, description, location, url, uid, status. Folded lines and
|
|
136
|
+
* escapes are handled. Times with a TZID or without Z are read as UTC and marked `floating: true` (time zones are not resolved).
|
|
137
|
+
* A DURATION without DTEND becomes `end`. Alarms and other components inside an event are skipped, so a reminder's description
|
|
138
|
+
* does not replace the event's. Recurrence rules are not expanded; the raw rule is returned as `rrule`.
|
|
139
|
+
*/
|
|
140
|
+
export function parseIcs(text) {
|
|
141
|
+
if (typeof text !== "string") throw new TypeError("parseIcs: text must be a string");
|
|
142
|
+
const raw = text.replace(/\r\n[ \t]/g, "").replace(/\n[ \t]/g, "").replace(/\r[ \t]/g, "").split(/\r\n|\n|\r/);
|
|
143
|
+
const events = [];
|
|
144
|
+
let current = null;
|
|
145
|
+
let duration = null;
|
|
146
|
+
let nested = 0; // depth of components inside the event (VALARM...)
|
|
147
|
+
for (const line of raw) {
|
|
148
|
+
const upper = line.toUpperCase();
|
|
149
|
+
if (!current) {
|
|
150
|
+
if (upper === "BEGIN:VEVENT") {
|
|
151
|
+
current = {};
|
|
152
|
+
duration = null;
|
|
153
|
+
nested = 0;
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (nested === 0 && upper === "END:VEVENT") {
|
|
158
|
+
if (duration !== null && current.start && !current.end) {
|
|
159
|
+
current.end = current.allDay ? new Date(current.start.getTime() + Math.max(duration - DAY_MS, 0)) : new Date(current.start.getTime() + duration);
|
|
160
|
+
}
|
|
161
|
+
events.push(current);
|
|
162
|
+
current = null;
|
|
163
|
+
} else if (upper.startsWith("BEGIN:")) nested++;
|
|
164
|
+
else if (upper.startsWith("END:")) nested = Math.max(0, nested - 1);
|
|
165
|
+
else if (nested === 0) {
|
|
166
|
+
const parts = splitProperty(line);
|
|
167
|
+
if (!parts) continue;
|
|
168
|
+
const [name, ...paramParts] = parts[0].split(";");
|
|
169
|
+
const params = paramParts.join(";");
|
|
170
|
+
const value = parts[1];
|
|
171
|
+
switch (name.toUpperCase()) {
|
|
172
|
+
case "SUMMARY": current.title = unescapeText(value); break;
|
|
173
|
+
case "DESCRIPTION": current.description = unescapeText(value); break;
|
|
174
|
+
case "LOCATION": current.location = unescapeText(value); break;
|
|
175
|
+
case "URL": current.url = value; break;
|
|
176
|
+
case "UID": current.uid = value; break;
|
|
177
|
+
case "STATUS": current.status = value.toLowerCase(); break;
|
|
178
|
+
case "RRULE": current.rrule = value; break;
|
|
179
|
+
case "DURATION": duration = parseDurationValue(value); break;
|
|
180
|
+
case "DTSTART": {
|
|
181
|
+
const w = parseWhen(value, params);
|
|
182
|
+
if (w) {
|
|
183
|
+
current.start = w.date;
|
|
184
|
+
current.allDay = w.allDay;
|
|
185
|
+
if (w.floating || /TZID=/i.test(params)) current.floating = true;
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "DTEND": {
|
|
190
|
+
const w = parseWhen(value, params);
|
|
191
|
+
if (w) current.end = w.allDay ? new Date(w.date.getTime() - DAY_MS) : w.date; // all-day DTEND is exclusive
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return events;
|
|
198
|
+
}
|