3333
3434/**
3535 * Service to make requests to Google v3 (JSON) API
36+ *
37+ * @phpstan-type Event array{id: string, iCalUID: string, start?: array{date?: string, dateTime?: string, timeZone?: string}, end?: array{date?: string, dateTime?: string, timeZone?: string}, originalStartTime?: array{date?: string, dateTime?: string, timeZone?: string}, recurringEventId?: string, colorId?: string, summary?: string, visibility?: string, sequence?: string, location?: string, description?: string, status?: string, created?: string, updated?: string, reminders?: array{useDefault?: bool, overrides?: list{array{minutes?: string, hours?: string, days?: string, weeks?: string}}}, recurrence?: list<string>}
3638 */
3739class GoogleCalendarAPIService {
40+ private DateTimeZone $ utcTimezone ;
3841
3942 public function __construct (
4043 string $ appName ,
@@ -44,6 +47,7 @@ public function __construct(
4447 private GoogleAPIService $ googleApiService ,
4548 private IConfig $ config ,
4649 ) {
50+ $ this ->utcTimezone = new DateTimeZone ('-0000 ' );
4751 }
4852
4953 /**
@@ -70,6 +74,140 @@ private function calendarExists(string $userId, string $uri): ?int {
7074 : $ res ['id ' ];
7175 }
7276
77+ /**
78+ * @param array{date?: string, dateTime?: string, timeZone?: string} $obj The datetime object to map.
79+ * @return string The date time mapped to the best representation from the available data.
80+ */
81+ private function mapTime (array $ obj ): string {
82+ if (isset ($ obj ['dateTime ' ])) {
83+ $ dateTime = new DateTime ($ obj ['dateTime ' ]);
84+
85+ if (isset ($ obj ['timeZone ' ])) {
86+ $ timezone = $ obj ['timeZone ' ];
87+ $ dateTime ->setTimezone (new DateTimeZone ($ timezone ));
88+ return "TZID= $ timezone: " . $ dateTime ->format ('Ymd\THis ' );
89+ } else {
90+ $ dateTime ->setTimezone ($ this ->utcTimezone );
91+ return 'VALUE=DATE-TIME: ' . $ dateTime ->format ('Ymd\THis\Z ' );
92+ }
93+ } elseif (isset ($ obj ['date ' ])) {
94+ // whole days
95+ $ date = new DateTime ($ obj ['date ' ]);
96+ return 'VALUE=DATE: ' . $ date ->format ('Ymd ' );
97+ } else {
98+ // skip entries without any date
99+ return '' ;
100+ }
101+ }
102+
103+ /**
104+ * @param Event $e The event from which to generate the data.
105+ * @param array<Event> $exceptions The events that represent recurring exceptions.
106+ * @param int $ncCalId The id of the event's calendar.
107+ * @param array $eventColors The event colors mapping.
108+ */
109+ private function generateEventData (array $ e , array $ exceptions , int $ ncCalId , array $ eventColors ): string {
110+ $ eventData = 'BEGIN:VEVENT ' . "\n" ;
111+
112+ $ eventData .= 'UID: ' . strval ($ ncCalId ) . '- ' . $ e ['iCalUID ' ] . "\n" ;
113+ if (isset ($ e ['colorId ' ], $ eventColors [$ e ['colorId ' ]], $ eventColors [$ e ['colorId ' ]]['background ' ])) {
114+ $ closestCssColor = $ this ->getClosestCssColor ($ eventColors [$ e ['colorId ' ]]['background ' ]);
115+ $ eventData .= 'COLOR: ' . $ closestCssColor . "\n" ;
116+ }
117+ $ eventData .= isset ($ e ['summary ' ])
118+ ? ('SUMMARY: ' . substr (str_replace ("\n" , '\n ' , $ e ['summary ' ]), 0 , 250 ) . "\n" )
119+ : (($ e ['visibility ' ] ?? '' ) === 'private '
120+ ? ('SUMMARY: ' . $ this ->l10n ->t ('Private event ' ) . "\n" )
121+ : '' );
122+ $ eventData .= isset ($ e ['sequence ' ]) ? ('SEQUENCE: ' . $ e ['sequence ' ] . "\n" ) : '' ;
123+ $ eventData .= isset ($ e ['location ' ])
124+ ? ('LOCATION: ' . substr (str_replace ("\n" , '\n ' , $ e ['location ' ]), 0 , 250 ) . "\n" )
125+ : '' ;
126+ $ eventData .= isset ($ e ['description ' ])
127+ ? ('DESCRIPTION: ' . substr (str_replace ("\n" , '\n ' , $ e ['description ' ]), 0 , 250 ) . "\n" )
128+ : '' ;
129+ $ eventData .= isset ($ e ['status ' ]) ? ('STATUS: ' . strtoupper (str_replace ("\n" , '\n ' , $ e ['status ' ])) . "\n" ) : '' ;
130+
131+ if (isset ($ e ['created ' ])) {
132+ $ created = new DateTime ($ e ['created ' ]);
133+ $ created ->setTimezone ($ this ->utcTimezone );
134+ $ eventData .= 'CREATED: ' . $ created ->format ('Ymd\THis\Z ' ) . "\n" ;
135+ }
136+
137+ if (isset ($ e ['updated ' ])) {
138+ $ updated = new DateTime ($ e ['updated ' ]);
139+ $ updated ->setTimezone ($ this ->utcTimezone );
140+ $ eventData .= 'LAST-MODIFIED: ' . $ updated ->format ('Ymd\THis\Z ' ) . "\n" ;
141+ }
142+
143+ if (isset ($ e ['reminders ' ], $ e ['reminders ' ]['useDefault ' ]) && $ e ['reminders ' ]['useDefault ' ]) {
144+ // 15 min before, default alarm
145+ $ eventData .= 'BEGIN:VALARM ' . "\n"
146+ . 'ACTION:DISPLAY ' . "\n"
147+ . 'TRIGGER;RELATED=START:-PT15M ' . "\n"
148+ . 'END:VALARM ' . "\n" ;
149+ }
150+ if (isset ($ e ['reminders ' ], $ e ['reminders ' ]['overrides ' ])) {
151+ foreach ($ e ['reminders ' ]['overrides ' ] as $ o ) {
152+ $ nbMin = 0 ;
153+ if (isset ($ o ['minutes ' ])) {
154+ $ nbMin += (int )$ o ['minutes ' ];
155+ }
156+ if (isset ($ o ['hours ' ])) {
157+ $ nbMin += ((int )$ o ['hours ' ]) * 60 ;
158+ }
159+ if (isset ($ o ['days ' ])) {
160+ $ nbMin += ((int )$ o ['days ' ]) * 60 * 24 ;
161+ }
162+ if (isset ($ o ['weeks ' ])) {
163+ $ nbMin += ((int )$ o ['weeks ' ]) * 60 * 24 * 7 ;
164+ }
165+ $ eventData .= 'BEGIN:VALARM ' . "\n"
166+ . 'ACTION:DISPLAY ' . "\n"
167+ . 'TRIGGER;RELATED=START:-PT ' . $ nbMin . 'M ' . "\n"
168+ . 'END:VALARM ' . "\n" ;
169+ }
170+ }
171+
172+ if (isset ($ e ['recurrence ' ]) && is_array ($ e ['recurrence ' ])) {
173+ foreach ($ e ['recurrence ' ] as $ r ) {
174+ $ eventData .= $ r . "\n" ;
175+ }
176+ }
177+
178+ // skip entries without any date
179+ if (!isset ($ e ['start ' ]) || !isset ($ e ['end ' ])) {
180+ return '' ;
181+ }
182+
183+ $ start = $ this ->mapTime ($ e ['start ' ]);
184+ $ end = $ this ->mapTime ($ e ['end ' ]);
185+
186+ // skip entries without any date
187+ if ($ start == '' || $ end == '' ) {
188+ return '' ;
189+ }
190+
191+ $ eventData .= "DTSTART; $ start \n" ;
192+ $ eventData .= "DTEND; $ end \n" ;
193+
194+ if (isset ($ e ['recurringEventId ' ], $ e ['originalStartTime ' ])) {
195+ $ recurrenceId = $ this ->mapTime ($ e ['originalStartTime ' ]);
196+ $ eventData .= "RECURRENCE-ID; $ recurrenceId \n" ;
197+ }
198+
199+ $ eventData .= 'CLASS:PUBLIC ' . "\n"
200+ . 'END:VEVENT ' . "\n" ;
201+
202+ foreach ($ exceptions as $ candidateException ) {
203+ if (($ candidateException ['recurringEventId ' ] == $ e ['id ' ]) && ($ candidateException ['id ' ] != $ e ['id ' ])) {
204+ $ eventData .= $ this ->generateEventData ($ candidateException , $ exceptions , $ ncCalId , $ eventColors );
205+ }
206+ }
207+
208+ return $ eventData ;
209+ }
210+
73211 /**
74212 * @param string $hexColor
75213 * @return string closest CSS color name
@@ -192,12 +330,26 @@ public function importCalendar(string $userId, string $calId, string $calName, ?
192330 }
193331
194332 date_default_timezone_set ('UTC ' );
195- $ utcTimezone = new DateTimeZone ('-0000 ' );
196333 $ allEvents = $ this ->config ->getUserValue ($ userId , Application::APP_ID , 'consider_all_events ' , '1 ' ) === '1 ' ;
197- $ events = $ this ->getCalendarEvents ($ userId , $ calId , $ allEvents );
334+ $ eventsGenerator = $ this ->getCalendarEvents ($ userId , $ calId , $ allEvents );
335+
336+ // Normal events
337+ $ events = [];
338+ // Exceptions to recurring events (recurringEventId set).
339+ $ exceptions = [];
340+
341+ foreach ($ eventsGenerator as $ e ) {
342+ if (isset ($ e ['recurringEventId ' ])) {
343+ array_push ($ exceptions , $ e );
344+ } else {
345+ array_push ($ events , $ e );
346+ }
347+ }
348+
198349 $ nbAdded = 0 ;
199350 $ nbUpdated = 0 ;
200- /** @var array{id: string, start?: array{date?: string, dateTime?: string, timeZone?: string}, end?: array{date?: string, dateTime?: string, timeZone?: string}, colorId?: string, summary?: string, visibility?: string, sequence?: string, location?: string, description?: string, status?: string, created?: string, updated?: string, reminders?: array{useDefault?: bool, overrides?: list{array{minutes?: string, hours?: string, days?: string, weeks?: string}}}, recurrence?: list<string>} $e */
351+
352+ /** @var Event $e */
201353 foreach ($ events as $ e ) {
202354 $ objectUri = $ e ['id ' ];
203355
@@ -226,107 +378,16 @@ public function importCalendar(string $userId, string $calId, string $calName, ?
226378 }
227379 }
228380
229- $ calData = 'BEGIN:VCALENDAR ' . "\n"
230- . 'VERSION:2.0 ' . "\n"
231- . 'PRODID:NextCloud Calendar ' . "\n"
232- . 'BEGIN:VEVENT ' . "\n" ;
233-
234- $ calData .= 'UID: ' . $ ncCalId . '- ' . $ objectUri . "\n" ;
235- if (isset ($ e ['colorId ' ], $ eventColors [$ e ['colorId ' ]], $ eventColors [$ e ['colorId ' ]]['background ' ])) {
236- $ closestCssColor = $ this ->getClosestCssColor ($ eventColors [$ e ['colorId ' ]]['background ' ]);
237- $ calData .= 'COLOR: ' . $ closestCssColor . "\n" ;
238- }
239- $ calData .= isset ($ e ['summary ' ])
240- ? ('SUMMARY: ' . substr (str_replace ("\n" , '\n ' , $ e ['summary ' ]), 0 , 250 ) . "\n" )
241- : (($ e ['visibility ' ] ?? '' ) === 'private '
242- ? ('SUMMARY: ' . $ this ->l10n ->t ('Private event ' ) . "\n" )
243- : '' );
244- $ calData .= isset ($ e ['sequence ' ]) ? ('SEQUENCE: ' . $ e ['sequence ' ] . "\n" ) : '' ;
245- $ calData .= isset ($ e ['location ' ])
246- ? ('LOCATION: ' . substr (str_replace ("\n" , '\n ' , $ e ['location ' ]), 0 , 250 ) . "\n" )
247- : '' ;
248- $ calData .= isset ($ e ['description ' ])
249- ? ('DESCRIPTION: ' . substr (str_replace ("\n" , '\n ' , $ e ['description ' ]), 0 , 250 ) . "\n" )
250- : '' ;
251- $ calData .= isset ($ e ['status ' ]) ? ('STATUS: ' . strtoupper (str_replace ("\n" , '\n ' , $ e ['status ' ])) . "\n" ) : '' ;
252-
253- if (isset ($ e ['created ' ])) {
254- $ created = new DateTime ($ e ['created ' ]);
255- $ created ->setTimezone ($ utcTimezone );
256- $ calData .= 'CREATED: ' . $ created ->format ('Ymd\THis\Z ' ) . "\n" ;
257- }
258-
259- if (isset ($ e ['updated ' ])) {
260- $ updated = new DateTime ($ e ['updated ' ]);
261- $ updated ->setTimezone ($ utcTimezone );
262- $ calData .= 'LAST-MODIFIED: ' . $ updated ->format ('Ymd\THis\Z ' ) . "\n" ;
263- }
264-
265- if (isset ($ e ['reminders ' ], $ e ['reminders ' ]['useDefault ' ]) && $ e ['reminders ' ]['useDefault ' ]) {
266- // 15 min before, default alarm
267- $ calData .= 'BEGIN:VALARM ' . "\n"
268- . 'ACTION:DISPLAY ' . "\n"
269- . 'TRIGGER;RELATED=START:-PT15M ' . "\n"
270- . 'END:VALARM ' . "\n" ;
271- }
272- if (isset ($ e ['reminders ' ], $ e ['reminders ' ]['overrides ' ])) {
273- foreach ($ e ['reminders ' ]['overrides ' ] as $ o ) {
274- $ nbMin = 0 ;
275- if (isset ($ o ['minutes ' ])) {
276- $ nbMin += (int )$ o ['minutes ' ];
277- }
278- if (isset ($ o ['hours ' ])) {
279- $ nbMin += ((int )$ o ['hours ' ]) * 60 ;
280- }
281- if (isset ($ o ['days ' ])) {
282- $ nbMin += ((int )$ o ['days ' ]) * 60 * 24 ;
283- }
284- if (isset ($ o ['weeks ' ])) {
285- $ nbMin += ((int )$ o ['weeks ' ]) * 60 * 24 * 7 ;
286- }
287- $ calData .= 'BEGIN:VALARM ' . "\n"
288- . 'ACTION:DISPLAY ' . "\n"
289- . 'TRIGGER;RELATED=START:-PT ' . $ nbMin . 'M ' . "\n"
290- . 'END:VALARM ' . "\n" ;
291- }
292- }
293-
294- if (isset ($ e ['recurrence ' ]) && is_array ($ e ['recurrence ' ])) {
295- foreach ($ e ['recurrence ' ] as $ r ) {
296- $ calData .= $ r . "\n" ;
297- }
298- }
381+ $ eventData = $ this ->generateEventData ($ e , $ exceptions , $ ncCalId , $ eventColors );
299382
300- if (isset ($ e ['start ' ], $ e ['start ' ]['dateTime ' ], $ e ['end ' ], $ e ['end ' ]['dateTime ' ])) {
301- $ start = new DateTime ($ e ['start ' ]['dateTime ' ]);
302- $ end = new DateTime ($ e ['end ' ]['dateTime ' ]);
303-
304- if (isset ($ e ['start ' ]['timeZone ' ], $ e ['end ' ]['timeZone ' ])) {
305- $ timezoneStart = $ e ['start ' ]['timeZone ' ];
306- $ start ->setTimezone (new DateTimeZone ($ timezoneStart ));
307- $ calData .= "DTSTART;TZID= $ timezoneStart: " . $ start ->format ('Ymd\THis ' ) . "\n" ;
308- $ timezoneEnd = $ e ['end ' ]['timeZone ' ];
309- $ end ->setTimezone (new DateTimeZone ($ timezoneEnd ));
310- $ calData .= "DTEND;TZID= $ timezoneEnd: " . $ end ->format ('Ymd\THis ' ) . "\n" ;
311- } else {
312- $ start ->setTimezone ($ utcTimezone );
313- $ calData .= 'DTSTART;VALUE=DATE-TIME: ' . $ start ->format ('Ymd\THis\Z ' ) . "\n" ;
314- $ end ->setTimezone ($ utcTimezone );
315- $ calData .= 'DTEND;VALUE=DATE-TIME: ' . $ end ->format ('Ymd\THis\Z ' ) . "\n" ;
316- }
317- } elseif (isset ($ e ['start ' ], $ e ['start ' ]['date ' ], $ e ['end ' ], $ e ['end ' ]['date ' ])) {
318- // whole days
319- $ start = new DateTime ($ e ['start ' ]['date ' ]);
320- $ calData .= 'DTSTART;VALUE=DATE: ' . $ start ->format ('Ymd ' ) . "\n" ;
321- $ end = new DateTime ($ e ['end ' ]['date ' ]);
322- $ calData .= 'DTEND;VALUE=DATE: ' . $ end ->format ('Ymd ' ) . "\n" ;
323- } else {
324- // skip entries without any date
383+ if ($ eventData == '' ) {
325384 continue ;
326385 }
327386
328- $ calData .= 'CLASS:PUBLIC ' . "\n"
329- . 'END:VEVENT ' . "\n"
387+ $ calData = 'BEGIN:VCALENDAR ' . "\n"
388+ . 'VERSION:2.0 ' . "\n"
389+ . 'PRODID:NextCloud Calendar ' . "\n"
390+ . $ eventData
330391 . 'END:VCALENDAR ' ;
331392
332393 if ($ existingEvent !== null ) {
@@ -352,7 +413,7 @@ public function importCalendar(string $userId, string $calId, string $calName, ?
352413 }
353414 }
354415
355- $ eventGeneratorReturn = $ events ->getReturn ();
416+ $ eventGeneratorReturn = $ eventsGenerator ->getReturn ();
356417 if (isset ($ eventGeneratorReturn ['error ' ])) {
357418 return $ eventGeneratorReturn ;
358419 }
@@ -367,7 +428,7 @@ public function importCalendar(string $userId, string $calId, string $calName, ?
367428 * @param string $userId
368429 * @param string $calId
369430 * @param bool $allEvents
370- * @return Generator
431+ * @return Generator<Event>
371432 */
372433 private function getCalendarEvents (string $ userId , string $ calId , bool $ allEvents ): Generator {
373434 $ params = [
0 commit comments