Skip to content

Commit be95ed9

Browse files
refactor: remove ical-utils adapter layer, use native node-ical shapes
Replace the wrapDate/wrapDuration/createComponentAdapter compatibility adapters with a flat, native shape returned directly from parseAndExpandEvents: - start/end: Date (was startDate/endDate with .toJSDate()) - duration: number in seconds (was object with .toSeconds()) - isRecurring: boolean (was function returning boolean) - status: string | null (replaces component.getFirstPropertyValue("status")) - ms_busystatus: string | null (replaces component.getFirstPropertyValue("x-microsoft-cdo-busystatus")) node_helper.js updated accordingly; no functional change in behaviour. Add regression tests for STATUS:CANCELLED, X-MICROSOFT-CDO-BUSYSTATUS, and status propagation from parent event to recurring occurrences (13 tests).
1 parent 9378ba6 commit be95ed9

3 files changed

Lines changed: 132 additions & 94 deletions

File tree

lib/ical-utils.js

Lines changed: 36 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -29,45 +29,27 @@ const obfuscateEmail = (email) => {
2929
return `${localPart}@***${topLevel}`;
3030
};
3131

32-
const wrapDate = (date) => ({
33-
toJSDate: () => date
34-
});
35-
36-
const wrapDuration = (seconds) => ({
37-
toSeconds: () => seconds
38-
});
39-
40-
const getEventProperty = (event, propertyName) => {
41-
if (!event || !propertyName) {
32+
/**
33+
* Read a string property from a node-ical event, checking lower/upper-case and
34+
* X-prefix-stripped variants (node-ical stores keys inconsistently).
35+
*/
36+
const eventProp = (event, name) => {
37+
if (!event || !name) {
4238
return null;
4339
}
4440

45-
const lowerCaseName = propertyName.toLowerCase();
46-
const upperCaseName = propertyName.toUpperCase();
47-
const strippedXPrefixUpperCase = upperCaseName.replace(/^X-/u, "");
48-
49-
if (Object.hasOwn(event, lowerCaseName)) {
50-
return event[lowerCaseName];
51-
}
52-
53-
if (Object.hasOwn(event, upperCaseName)) {
54-
return event[upperCaseName];
55-
}
56-
57-
if (Object.hasOwn(event, strippedXPrefixUpperCase)) {
58-
return event[strippedXPrefixUpperCase];
59-
}
41+
const lc = name.toLowerCase();
42+
const uc = name.toUpperCase();
43+
const stripped = uc.replace(/^X-/u, "");
6044

61-
return null;
45+
return (
46+
stringValue(Object.hasOwn(event, lc) ? event[lc] : null) ??
47+
stringValue(Object.hasOwn(event, uc) ? event[uc] : null) ??
48+
stringValue(Object.hasOwn(event, stripped) ? event[stripped] : null) ??
49+
null
50+
);
6251
};
6352

64-
const createComponentAdapter = (event) => ({
65-
getFirstPropertyValue: (propertyName) => {
66-
const value = getEventProperty(event, propertyName);
67-
return stringValue(value);
68-
}
69-
});
70-
7153
const getAllDayDurationSeconds = (startDate, endDate) => {
7254
const rawDayCount = (endDate.getTime() - startDate.getTime()) / 86400000;
7355
const dayCount = Math.max(1, Math.round(rawDayCount));
@@ -148,8 +130,6 @@ const parseAndExpandEvents = (iCalData, startDate, endDate, maxIterations = 1000
148130
const occurrences = [];
149131

150132
for (const event of vevents) {
151-
const component = createComponentAdapter(event);
152-
const attendees = extractAttendees(event);
153133
const isRecurring = Boolean(event.rrule);
154134

155135
if (isRecurring) {
@@ -162,46 +142,39 @@ const parseAndExpandEvents = (iCalData, startDate, endDate, maxIterations = 1000
162142

163143
const limitedOccurrences = expanded.slice(0, Math.max(0, maxIterations));
164144
for (const occurrence of limitedOccurrences) {
165-
const occurrenceComponent = createComponentAdapter(occurrence);
166145
const durationSeconds = getDurationSeconds(occurrence);
167-
const duration = wrapDuration(durationSeconds);
168-
const normalizedOccurrenceEndDate = new Date(
169-
occurrence.start.getTime() + (durationSeconds * 1000)
170-
);
171-
172146
const occurrenceEvent = occurrence.event || {};
173147
occurrences.push({
174-
startDate: wrapDate(occurrence.start),
175-
endDate: wrapDate(normalizedOccurrenceEndDate),
176-
item: {
177-
summary: stringValue(occurrence.summary) || stringValue(occurrenceEvent.summary),
178-
location: stringValue(occurrence.location) || stringValue(occurrenceEvent.location),
179-
description: stringValue(occurrence.description) || stringValue(occurrenceEvent.description),
180-
uid: stringValue(occurrence.uid) || stringValue(occurrenceEvent.uid),
181-
isRecurring: () => true,
182-
duration,
183-
component: occurrenceComponent,
184-
attendees: extractAttendees(occurrenceEvent)
185-
},
186-
component: occurrenceComponent
148+
start: occurrence.start,
149+
end: new Date(occurrence.start.getTime() + (durationSeconds * 1000)),
150+
summary: stringValue(occurrence.summary) || stringValue(occurrenceEvent.summary),
151+
location: stringValue(occurrence.location) || stringValue(occurrenceEvent.location),
152+
description: stringValue(occurrence.description) || stringValue(occurrenceEvent.description),
153+
uid: stringValue(occurrence.uid) || stringValue(occurrenceEvent.uid),
154+
isRecurring: true,
155+
duration: durationSeconds,
156+
status: eventProp(occurrence, "status") || eventProp(occurrenceEvent, "status"),
157+
ms_busystatus: eventProp(occurrence, "x-microsoft-cdo-busystatus") || eventProp(occurrenceEvent, "x-microsoft-cdo-busystatus"),
158+
attendees: extractAttendees(occurrenceEvent)
187159
});
188160
}
189161
} else {
190-
const eventStartDate = event.start;
191-
const eventEndDate = event.end;
162+
const eventStart = event.start;
163+
const eventEnd = event.end;
192164

193-
if (eventStartDate instanceof Date && eventEndDate instanceof Date && eventEndDate >= startDate && eventStartDate <= endDate) {
165+
if (eventStart instanceof Date && eventEnd instanceof Date && eventEnd >= startDate && eventStart <= endDate) {
194166
events.push({
195-
startDate: wrapDate(eventStartDate),
196-
endDate: wrapDate(eventEndDate),
167+
start: eventStart,
168+
end: eventEnd,
197169
summary: stringValue(event.summary),
198170
location: stringValue(event.location),
199171
description: stringValue(event.description),
200172
uid: stringValue(event.uid),
201-
isRecurring: () => false,
202-
duration: wrapDuration(getDurationSeconds(event)),
203-
component,
204-
attendees
173+
isRecurring: false,
174+
duration: getDurationSeconds(event),
175+
status: eventProp(event, "status"),
176+
ms_busystatus: eventProp(event, "x-microsoft-cdo-busystatus"),
177+
attendees: extractAttendees(event)
205178
});
206179
}
207180
}

node_helper.js

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -129,21 +129,15 @@ module.exports = NodeHelper.create({
129129
let eventPool = [];
130130

131131
wholeEvents.forEach((item) => {
132-
const ri = Object.hasOwn(item, "item") ? item.item : item;
132+
const ri = item;
133133
const ev = {};
134134
ev.calendarId = calendar.uid;
135135
ev.location = ri.location;
136136
ev.description = ri.description;
137137
ev.title = ri.summary;
138-
ev.isRecurring = ri.isRecurring();
138+
ev.isRecurring = ri.isRecurring;
139139
ev.attendees = ri.attendees || [];
140-
ev.isCancelled =
141-
Object.hasOwn(item, "component") &&
142-
item.component &&
143-
typeof item.component.getFirstPropertyValue === "function" &&
144-
// eslint-disable-next-line no-eq-null, eqeqeq
145-
item.component.getFirstPropertyValue("status") != null &&
146-
item.component.getFirstPropertyValue("status").toUpperCase() === "CANCELLED";
140+
ev.isCancelled = ri.status?.toUpperCase() === "CANCELLED";
147141
if (
148142
Array.isArray(calendar.replaceTitle) &&
149143
calendar.replaceTitle.length > 0
@@ -161,17 +155,17 @@ module.exports = NodeHelper.create({
161155
if (calendar.forceLocalTZ) {
162156
// Interpret event times in the local timezone to mitigate bad TZ info in some iCals
163157
const localTZ = dayjs.tz.guess();
164-
startDate = dayjs.tz(item.startDate.toJSDate(), localTZ);
165-
endDate = dayjs.tz(item.endDate.toJSDate(), localTZ);
158+
startDate = dayjs.tz(item.start, localTZ);
159+
endDate = dayjs.tz(item.end, localTZ);
166160
} else {
167-
startDate = dayjs(item.startDate.toJSDate());
168-
endDate = dayjs(item.endDate.toJSDate());
161+
startDate = dayjs(item.start);
162+
endDate = dayjs(item.end);
169163
}
170164
ev.startDate = startDate.unix();
171165
ev.endDate = endDate.unix();
172166
ev.startDateJ = startDate.toJSON();
173167
ev.endDateJ = endDate.toJSON();
174-
ev.duration = ri.duration.toSeconds();
168+
ev.duration = ri.duration;
175169
ev.isMoment = ev.duration === 0;
176170
ev.isPassed = Boolean(endDate.isBefore(dayjs()));
177171
if (ev.duration <= 86400) {
@@ -192,12 +186,7 @@ module.exports = NodeHelper.create({
192186
// import the Microsoft property X-MICROSOFT-CDO-BUSYSTATUS, fall back to "BUSY" in case none was found
193187
// possible values are 'FREE'|'TENTATIVE'|'BUSY'|'OOF' according to
194188
// https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/cd68eae7-ed65-4dd3-8ea7-ad585c76c736
195-
ev.ms_busystatus = "BUSY";
196-
if (ri.component && typeof ri.component.getFirstPropertyValue === "function") {
197-
ev.ms_busystatus =
198-
ri.component.getFirstPropertyValue("x-microsoft-cdo-busystatus") ||
199-
"BUSY";
200-
}
189+
ev.ms_busystatus = ri.ms_busystatus || "BUSY";
201190

202191
ev.uid = ri.uid
203192
? `${calendar.uid}:${ev.startDate}:${ev.endDate}:${ri.uid}`

test/ical-utils.test.js

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ test("parses a non-recurring event with attendee extraction and obfuscation", ()
4444
assert.equal(event.location, "Office");
4545
assert.equal(event.description, "Weekly team sync");
4646
assert.equal(event.uid, "single-1");
47-
assert.equal(event.isRecurring(), false);
47+
assert.equal(event.isRecurring, false);
4848

4949
assert.equal(event.attendees.length, 1);
5050
assert.deepEqual(event.attendees[0], {
@@ -102,7 +102,7 @@ test("expands recurring events and applies EXDATE exclusions", () => {
102102
assert.equal(result.events.length, 0);
103103
assert.equal(result.occurrences.length, 4);
104104

105-
const starts = result.occurrences.map((occurrence) => occurrence.startDate.toJSDate().toISOString());
105+
const starts = result.occurrences.map((occurrence) => occurrence.start.toISOString());
106106
assert.deepEqual(starts, [
107107
"2026-02-01T10:00:00.000Z",
108108
"2026-02-02T10:00:00.000Z",
@@ -111,7 +111,7 @@ test("expands recurring events and applies EXDATE exclusions", () => {
111111
]);
112112

113113
for (const occurrence of result.occurrences) {
114-
assert.equal(occurrence.item.isRecurring(), true);
114+
assert.equal(occurrence.isRecurring, true);
115115
}
116116
});
117117

@@ -191,10 +191,10 @@ test("parses all-day events with correct duration and date span", () => {
191191

192192
const event = result.events[0];
193193
assert.equal(event.summary, "Holiday");
194-
assert.equal(event.duration.toSeconds(), 86400, "all-day event should be exactly 86400s");
194+
assert.equal(event.duration, 86400, "all-day event should be exactly 86400s");
195195

196-
const startMs = event.startDate.toJSDate().getTime();
197-
const endMs = event.endDate.toJSDate().getTime();
196+
const startMs = event.start.getTime();
197+
const endMs = event.end.getTime();
198198
assert.equal(endMs - startMs, 86400000, "end - start should be exactly 1 day in ms");
199199
});
200200

@@ -221,10 +221,10 @@ test("provides correct endDate and duration for timed events", () => {
221221

222222
const event = result.events[0];
223223
assert.equal(
224-
event.endDate.toJSDate().toISOString(),
224+
event.end.toISOString(),
225225
"2026-03-01T11:30:00.000Z"
226226
);
227-
assert.equal(event.duration.toSeconds(), 5400, "1.5h = 5400s");
227+
assert.equal(event.duration, 5400, "1.5h = 5400s");
228228
});
229229

230230
test("expands recurring all-day events with correct duration", () => {
@@ -250,10 +250,10 @@ test("expands recurring all-day events with correct duration", () => {
250250
assert.equal(result.occurrences.length, 3);
251251

252252
for (const occurrence of result.occurrences) {
253-
assert.equal(occurrence.item.duration.toSeconds(), 86400);
253+
assert.equal(occurrence.duration, 86400);
254254

255-
const startMs = occurrence.startDate.toJSDate().getTime();
256-
const endMs = occurrence.endDate.toJSDate().getTime();
255+
const startMs = occurrence.start.getTime();
256+
const endMs = occurrence.end.getTime();
257257
assert.equal(endMs - startMs, 86400000);
258258
}
259259
});
@@ -290,3 +290,79 @@ test("returns plain strings for properties that carry ICAL parameters (e.g. SUMM
290290
assert.equal(ev.location, "Büro");
291291
assert.equal(ev.description, "Wöchentliches Meeting");
292292
});
293+
294+
test("exposes status=CANCELLED on a non-recurring event", () => {
295+
const iCalData = joinIcs(
296+
"BEGIN:VCALENDAR",
297+
"VERSION:2.0",
298+
"BEGIN:VEVENT",
299+
"UID:cancelled-1",
300+
"SUMMARY:Cancelled Meeting",
301+
"STATUS:CANCELLED",
302+
"DTSTART:20260301T100000Z",
303+
"DTEND:20260301T103000Z",
304+
"END:VEVENT",
305+
"END:VCALENDAR"
306+
);
307+
308+
const result = parseAndExpandEvents(
309+
iCalData,
310+
new Date("2026-03-01T00:00:00Z"),
311+
new Date("2026-03-02T00:00:00Z")
312+
);
313+
314+
assert.equal(result.events.length, 1);
315+
const ev = result.events[0];
316+
assert.equal(ev.status, "CANCELLED");
317+
});
318+
319+
test("exposes ms_busystatus for Microsoft X-CDO property", () => {
320+
const iCalData = joinIcs(
321+
"BEGIN:VCALENDAR",
322+
"VERSION:2.0",
323+
"BEGIN:VEVENT",
324+
"UID:oof-1",
325+
"SUMMARY:OOF Event",
326+
"X-MICROSOFT-CDO-BUSYSTATUS:OOF",
327+
"DTSTART:20260301T100000Z",
328+
"DTEND:20260301T103000Z",
329+
"END:VEVENT",
330+
"END:VCALENDAR"
331+
);
332+
333+
const result = parseAndExpandEvents(
334+
iCalData,
335+
new Date("2026-03-01T00:00:00Z"),
336+
new Date("2026-03-02T00:00:00Z")
337+
);
338+
339+
assert.equal(result.events.length, 1);
340+
assert.equal(result.events[0].ms_busystatus, "OOF");
341+
});
342+
343+
test("exposes status on a recurring occurrence (from parent event)", () => {
344+
const iCalData = joinIcs(
345+
"BEGIN:VCALENDAR",
346+
"VERSION:2.0",
347+
"BEGIN:VEVENT",
348+
"UID:cancelled-rec-1",
349+
"SUMMARY:Daily Cancelled",
350+
"STATUS:CANCELLED",
351+
"DTSTART:20260301T100000Z",
352+
"DTEND:20260301T103000Z",
353+
"RRULE:FREQ=DAILY;COUNT=2",
354+
"END:VEVENT",
355+
"END:VCALENDAR"
356+
);
357+
358+
const result = parseAndExpandEvents(
359+
iCalData,
360+
new Date("2026-03-01T00:00:00Z"),
361+
new Date("2026-03-05T00:00:00Z")
362+
);
363+
364+
assert.equal(result.occurrences.length, 2);
365+
for (const occurrence of result.occurrences) {
366+
assert.equal(occurrence.status, "CANCELLED");
367+
}
368+
});

0 commit comments

Comments
 (0)