22 * @external Moment
33 */
44const moment = require ( "moment-timezone" ) ;
5+ const ical = require ( "node-ical" ) ;
56
67const Log = require ( "logger" ) ;
78
@@ -40,58 +41,15 @@ const CalendarFetcherUtils = {
4041 return moment . tz . guess ( ) ;
4142 } ,
4243
43- /**
44- * This function returns a list of moments for a recurring event.
45- * @param {object } event the current event which is a recurring event
46- * @param {moment.Moment } pastLocalMoment The past date to search for recurring events
47- * @param {moment.Moment } futureLocalMoment The future date to search for recurring events
48- * @param {number } durationInMs the duration of the event, this is used to take into account currently running events
49- * @returns {moment.Moment[] } All moments for the recurring event
50- */
51- getMomentsFromRecurringEvent ( event , pastLocalMoment , futureLocalMoment , durationInMs ) {
52- const rule = event . rrule ;
53- const isFullDayEvent = CalendarFetcherUtils . isFullDayEvent ( event ) ;
54- const eventTimezone = event . start . tz || CalendarFetcherUtils . getLocalTimezone ( ) ;
55-
56- // rrule.js interprets years < 1900 as offsets from 1900, causing issues with some birthday calendars
57- if ( rule . origOptions ?. dtstart ?. getFullYear ( ) < 1900 ) {
58- rule . origOptions . dtstart . setFullYear ( 1900 ) ;
59- }
60- if ( rule . options ?. dtstart ?. getFullYear ( ) < 1900 ) {
61- rule . options . dtstart . setFullYear ( 1900 ) ;
62- }
63-
64- // Expand search window to include ongoing events
65- const oneDayInMs = 24 * 60 * 60 * 1000 ;
66- const searchFromDate = pastLocalMoment . clone ( ) . subtract ( Math . max ( durationInMs , oneDayInMs ) , "milliseconds" ) . toDate ( ) ;
67- const searchToDate = futureLocalMoment . clone ( ) . add ( 1 , "days" ) . toDate ( ) ;
68-
69- const dates = rule . between ( searchFromDate , searchToDate , true ) || [ ] ;
70-
71- // Convert dates to moments in the event's timezone.
72- // Full-day events need UTC component extraction to avoid date shifts across timezone boundaries.
73- return dates . map ( ( date ) => {
74- if ( isFullDayEvent ) {
75- return moment . tz ( [ date . getUTCFullYear ( ) , date . getUTCMonth ( ) , date . getUTCDate ( ) ] , eventTimezone ) ;
76- }
77- return moment . tz ( date , eventTimezone ) ;
78- } ) ;
79- } ,
80-
8144 /**
8245 * Filter the events from ical according to the given config
8346 * @param {object } data the calendar data from ical
8447 * @param {object } config The configuration object
85- * @returns {string [] } the filtered events
48+ * @returns {object [] } the filtered events
8649 */
8750 filterEvents ( data , config ) {
8851 const newEvents = [ ] ;
8952
90- const eventDate = function ( event , time ) {
91- const startMoment = event [ time ] . tz ? moment . tz ( event [ time ] , event [ time ] . tz ) : moment . tz ( event [ time ] , CalendarFetcherUtils . getLocalTimezone ( ) ) ;
92- return CalendarFetcherUtils . isFullDayEvent ( event ) ? startMoment . startOf ( "day" ) : startMoment ;
93- } ;
94-
9553 Log . debug ( `There are ${ Object . entries ( data ) . length } calendar entries.` ) ;
9654
9755 const now = moment ( ) ;
@@ -105,102 +63,60 @@ const CalendarFetcherUtils = {
10563 . subtract ( 1 , "seconds" ) ;
10664
10765 Object . entries ( data ) . forEach ( ( [ key , event ] ) => {
108- Log . debug ( "Processing entry..." ) ;
66+ if ( event . type !== "VEVENT" ) {
67+ return ;
68+ }
10969
11070 const title = CalendarFetcherUtils . getTitleFromEvent ( event ) ;
11171 Log . debug ( `title: ${ title } ` ) ;
11272
11373 // Return quickly if event should be excluded.
114- let { excluded, eventFilterUntil } = this . shouldEventBeExcluded ( config , title ) ;
74+ const { excluded, until : eventFilterUntil } = CalendarFetcherUtils . shouldEventBeExcluded ( config , title ) ;
11575 if ( excluded ) {
11676 return ;
11777 }
11878
119- // FIXME: Ugly fix to solve the facebook birthday issue.
120- // Otherwise, the recurring events only show the birthday for next year.
121- let isFacebookBirthday = false ;
122- if ( typeof event . uid !== "undefined" ) {
123- if ( event . uid . indexOf ( "@facebook.com" ) !== - 1 ) {
124- isFacebookBirthday = true ;
125- }
126- }
79+ Log . debug ( `Event: ${ title } | start: ${ event . start } | end: ${ event . end } | recurring: ${ ! ! event . rrule } ` ) ;
12780
128- if ( event . type === "VEVENT" ) {
129- Log . debug ( `Event:\n${ JSON . stringify ( event , null , 2 ) } ` ) ;
130- let eventStartMoment = eventDate ( event , "start" ) ;
131- let eventEndMoment ;
81+ const location = CalendarFetcherUtils . unwrapParameterValue ( event . location ) || false ;
82+ const geo = event . geo || false ;
83+ const description = CalendarFetcherUtils . unwrapParameterValue ( event . description ) || false ;
13284
133- if ( typeof event . end !== "undefined" ) {
134- eventEndMoment = eventDate ( event , "end" ) ;
135- } else if ( typeof event . duration !== "undefined" ) {
136- eventEndMoment = eventStartMoment . clone ( ) . add ( moment . duration ( event . duration ) ) ;
137- } else {
138- if ( ! isFacebookBirthday ) {
139- // make copy of start date, separate storage area
140- eventEndMoment = eventStartMoment . clone ( ) ;
141- } else {
142- eventEndMoment = eventStartMoment . clone ( ) . add ( 1 , "days" ) ;
143- }
144- }
145-
146- Log . debug ( `start: ${ eventStartMoment . toDate ( ) } ` ) ;
147- Log . debug ( `end: ${ eventEndMoment . toDate ( ) } ` ) ;
148-
149- // Calculate the duration of the event for use with recurring events.
150- const durationMs = eventEndMoment . valueOf ( ) - eventStartMoment . valueOf ( ) ;
151- Log . debug ( `duration: ${ durationMs } ` ) ;
85+ let instances ;
86+ try {
87+ instances = CalendarFetcherUtils . expandRecurringEvent ( event , pastLocalMoment , futureLocalMoment ) ;
88+ } catch ( error ) {
89+ Log . error ( `Could not expand event "${ title } ": ${ error . message } ` ) ;
90+ return ;
91+ }
15292
153- const location = event . location || false ;
154- const geo = event . geo || false ;
155- const description = event . description || false ;
93+ for ( const instance of instances ) {
94+ const { event : instanceEvent , startMoment, endMoment, isRecurring, isFullDay } = instance ;
15695
157- let instances = [ ] ;
158- if ( event . rrule && typeof event . rrule !== "undefined" && ! isFacebookBirthday ) {
159- instances = CalendarFetcherUtils . expandRecurringEvent ( event , pastLocalMoment , futureLocalMoment , durationMs ) ;
160- } else {
161- const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils . isFullDayEvent ( event ) ;
162- let end = eventEndMoment ;
163- if ( fullDayEvent && eventStartMoment . valueOf ( ) === end . valueOf ( ) ) {
164- end = end . endOf ( "day" ) ;
165- }
166-
167- instances . push ( {
168- event : event ,
169- startMoment : eventStartMoment ,
170- endMoment : end ,
171- isRecurring : false
172- } ) ;
96+ // Filter logic
97+ if ( endMoment . isBefore ( pastLocalMoment ) || startMoment . isAfter ( futureLocalMoment ) ) {
98+ continue ;
17399 }
174100
175- for ( const instance of instances ) {
176- const { event : instanceEvent , startMoment, endMoment, isRecurring } = instance ;
177-
178- // Filter logic
179- if ( endMoment . isBefore ( pastLocalMoment ) || startMoment . isAfter ( futureLocalMoment ) ) {
180- continue ;
181- }
182-
183- if ( CalendarFetcherUtils . timeFilterApplies ( now , endMoment , eventFilterUntil ) ) {
184- continue ;
185- }
186-
187- const title = CalendarFetcherUtils . getTitleFromEvent ( instanceEvent ) ;
188- const fullDay = isFacebookBirthday ? true : CalendarFetcherUtils . isFullDayEvent ( event ) ;
189-
190- Log . debug ( `saving event: ${ title } ` ) ;
191- newEvents . push ( {
192- title : title ,
193- startDate : startMoment . format ( "x" ) ,
194- endDate : endMoment . format ( "x" ) ,
195- fullDayEvent : fullDay ,
196- recurringEvent : isRecurring ,
197- class : event . class ,
198- firstYear : event . start . getFullYear ( ) ,
199- location : instanceEvent . location || location ,
200- geo : instanceEvent . geo || geo ,
201- description : instanceEvent . description || description
202- } ) ;
101+ if ( CalendarFetcherUtils . timeFilterApplies ( now , endMoment , eventFilterUntil ) ) {
102+ continue ;
203103 }
104+
105+ const instanceTitle = CalendarFetcherUtils . getTitleFromEvent ( instanceEvent ) ;
106+
107+ Log . debug ( `saving event: ${ instanceTitle } , start: ${ startMoment . toDate ( ) } , end: ${ endMoment . toDate ( ) } ` ) ;
108+ newEvents . push ( {
109+ title : instanceTitle ,
110+ startDate : startMoment . format ( "x" ) ,
111+ endDate : endMoment . format ( "x" ) ,
112+ fullDayEvent : isFullDay ,
113+ recurringEvent : isRecurring ,
114+ class : event . class ,
115+ firstYear : event . start . getFullYear ( ) ,
116+ location : CalendarFetcherUtils . unwrapParameterValue ( instanceEvent . location ) || location ,
117+ geo : instanceEvent . geo || geo ,
118+ description : CalendarFetcherUtils . unwrapParameterValue ( instanceEvent . description ) || description
119+ } ) ;
204120 }
205121 } ) ;
206122
@@ -217,35 +133,21 @@ const CalendarFetcherUtils = {
217133 * @returns {string } The title of the event, or "Event" if no title is found.
218134 */
219135 getTitleFromEvent ( event ) {
220- let title = "Event" ;
221- if ( event . summary ) {
222- title = typeof event . summary . val !== "undefined" ? event . summary . val : event . summary ;
223- } else if ( event . description ) {
224- title = event . description ;
225- }
226-
227- return title ;
136+ return CalendarFetcherUtils . unwrapParameterValue ( event . summary || event . description ) || "Event" ;
228137 } ,
229138
230139 /**
231- * Checks if an event is a fullday event.
232- * @param {object } event The event object to check.
233- * @returns {boolean } True if the event is a fullday event, false otherwise
140+ * Extracts the string value from a node-ical ParameterValue object ({val, params})
141+ * or returns the value as-is if it is already a plain string.
142+ * This handles ICS properties with parameters, e.g. DESCRIPTION;LANGUAGE=de:Text.
143+ * @param {string|object } value The raw value from node-ical
144+ * @returns {string|object } The unwrapped string value, or the original value if not a ParameterValue
234145 */
235- isFullDayEvent ( event ) {
236- if ( event . start . length === 8 || event . start . dateOnly || event . datetype === "date" ) {
237- return true ;
238- }
239-
240- const start = event . start || 0 ;
241- const startDate = new Date ( start ) ;
242- const end = event . end || 0 ;
243- if ( ( end - start ) % ( 24 * 60 * 60 * 1000 ) === 0 && startDate . getHours ( ) === 0 && startDate . getMinutes ( ) === 0 ) {
244- // Is 24 hours, and starts on the middle of the night.
245- return true ;
146+ unwrapParameterValue ( value ) {
147+ if ( value && typeof value === "object" && typeof value . val !== "undefined" ) {
148+ return value . val ;
246149 }
247-
248- return false ;
150+ return value ;
249151 } ,
250152
251153 /**
@@ -262,7 +164,7 @@ const CalendarFetcherUtils = {
262164 increment = until [ 1 ] . slice ( - 1 ) === "s" ? until [ 1 ] : `${ until [ 1 ] } s` , // Massage the data for moment js
263165 filterUntil = moment ( endDate . format ( ) ) . subtract ( value , increment ) ;
264166
265- return now < filterUntil ;
167+ return now . isBefore ( filterUntil ) ;
266168 }
267169
268170 return false ;
@@ -282,7 +184,7 @@ const CalendarFetcherUtils = {
282184 // Assume if leading slash, there is also trailing slash
283185 if ( filter [ 0 ] === "/" ) {
284186 // Strip leading and trailing slashes
285- regexFilter = filter . substr ( 1 ) . slice ( 0 , - 1 ) ;
187+ regexFilter = filter . slice ( 1 , - 1 ) ;
286188 }
287189 return new RegExp ( regexFilter , regexFlags ) . test ( title ) ;
288190 } else {
@@ -291,65 +193,38 @@ const CalendarFetcherUtils = {
291193 } ,
292194
293195 /**
294- * Expands a recurring event into individual event instances.
196+ * Expands a recurring event into individual event instances using node-ical.
197+ * Handles RRULE expansion, EXDATE filtering, RECURRENCE-ID overrides, and ongoing events.
295198 * @param {object } event The recurring event object
296199 * @param {moment.Moment } pastLocalMoment The past date limit
297200 * @param {moment.Moment } futureLocalMoment The future date limit
298- * @param {number } durationMs The duration of the event in milliseconds
299- * @returns {object[] } Array of event instances
201+ * @returns {object[] } Array of event instances with startMoment/endMoment in the local timezone
300202 */
301- expandRecurringEvent ( event , pastLocalMoment , futureLocalMoment , durationMs ) {
302- const moments = CalendarFetcherUtils . getMomentsFromRecurringEvent ( event , pastLocalMoment , futureLocalMoment , durationMs ) ;
303- const instances = [ ] ;
304-
305- for ( const startMoment of moments ) {
306- let curEvent = event ;
307- let showRecurrence = true ;
308- let recurringEventStartMoment = startMoment . clone ( ) . tz ( CalendarFetcherUtils . getLocalTimezone ( ) ) ;
309- let recurringEventEndMoment = recurringEventStartMoment . clone ( ) . add ( durationMs , "ms" ) ;
310-
311- // For full-day events, use local date components to match node-ical's getDateKey behavior
312- // For timed events, use UTC to match ISO string slice
313- const isFullDay = CalendarFetcherUtils . isFullDayEvent ( event ) ;
314- const dateKey = isFullDay
315- ? recurringEventStartMoment . format ( "YYYY-MM-DD" )
316- : recurringEventStartMoment . tz ( "UTC" ) . format ( "YYYY-MM-DD" ) ;
317-
318- // Check for overrides
319- if ( curEvent . recurrences !== undefined ) {
320- if ( curEvent . recurrences [ dateKey ] !== undefined ) {
321- curEvent = curEvent . recurrences [ dateKey ] ;
322- // Re-calculate start/end based on override
323- const start = curEvent . start ;
324- const end = curEvent . end ;
325- const localTimezone = CalendarFetcherUtils . getLocalTimezone ( ) ;
326-
327- recurringEventStartMoment = ( start . tz ? moment ( start ) . tz ( start . tz ) : moment ( start ) ) . tz ( localTimezone ) ;
328- recurringEventEndMoment = ( end . tz ? moment ( end ) . tz ( end . tz ) : moment ( end ) ) . tz ( localTimezone ) ;
329- }
330- }
331-
332- // Check for exceptions
333- if ( curEvent . exdate !== undefined ) {
334- if ( curEvent . exdate [ dateKey ] !== undefined ) {
335- showRecurrence = false ;
203+ expandRecurringEvent ( event , pastLocalMoment , futureLocalMoment ) {
204+ const localTimezone = CalendarFetcherUtils . getLocalTimezone ( ) ;
205+
206+ return ical
207+ . expandRecurringEvent ( event , {
208+ from : pastLocalMoment . toDate ( ) ,
209+ to : futureLocalMoment . toDate ( ) ,
210+ includeOverrides : true ,
211+ excludeExdates : true ,
212+ expandOngoing : true
213+ } )
214+ . map ( ( inst ) => {
215+ let startMoment , endMoment ;
216+ if ( inst . isFullDay ) {
217+ startMoment = moment . tz ( [ inst . start . getFullYear ( ) , inst . start . getMonth ( ) , inst . start . getDate ( ) ] , localTimezone ) ;
218+ endMoment = moment . tz ( [ inst . end . getFullYear ( ) , inst . end . getMonth ( ) , inst . end . getDate ( ) ] , localTimezone ) ;
219+ } else {
220+ startMoment = moment ( inst . start ) . tz ( localTimezone ) ;
221+ endMoment = moment ( inst . end ) . tz ( localTimezone ) ;
336222 }
337- }
338-
339- if ( recurringEventStartMoment . valueOf ( ) === recurringEventEndMoment . valueOf ( ) ) {
340- recurringEventEndMoment = recurringEventEndMoment . endOf ( "day" ) ;
341- }
342-
343- if ( showRecurrence ) {
344- instances . push ( {
345- event : curEvent ,
346- startMoment : recurringEventStartMoment ,
347- endMoment : recurringEventEndMoment ,
348- isRecurring : true
349- } ) ;
350- }
351- }
352- return instances ;
223+ // Events without DTEND (e.g. reminders) get start === end from node-ical;
224+ // extend to end-of-day so they remain visible on the calendar.
225+ if ( startMoment . valueOf ( ) === endMoment . valueOf ( ) ) endMoment = endMoment . endOf ( "day" ) ;
226+ return { event : inst . event , startMoment, endMoment, isRecurring : inst . isRecurring , isFullDay : inst . isFullDay } ;
227+ } ) ;
353228 } ,
354229
355230 /**
0 commit comments