Skip to content

Commit cb5f267

Browse files
Support recurring events
Signed-off-by: Marcel Robitaille <mail@marcelrobitaille.me>
1 parent 0083ddc commit cb5f267

1 file changed

Lines changed: 156 additions & 100 deletions

File tree

lib/Service/GoogleCalendarAPIService.php

Lines changed: 156 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@
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
*/
3739
class 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
/**
@@ -61,15 +65,152 @@ public function getCalendarList(string $userId): array {
6165
/**
6266
* @param string $userId
6367
* @param string $uri
64-
* @return ?int the calendar ID
68+
* @return ?string the calendar ID
6569
*/
66-
private function calendarExists(string $userId, string $uri): ?int {
70+
private function calendarExists(string $userId, string $uri): ?string {
6771
$res = $this->caldavBackend->getCalendarByUri('principals/users/' . $userId, $uri);
6872
return is_null($res)
6973
? null
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> $events The collection of all events.
106+
* @param string $ncCalId The id of the event's calendar.
107+
* @param array $eventColors The event colors mapping.
108+
*/
109+
private function generateEventData(array $e, array $events, string $ncCalId, array $eventColors): string {
110+
$objectUri = $e['id'];
111+
112+
$eventData = 'BEGIN:VEVENT' . "\n";
113+
114+
$eventData .= 'UID:' . $ncCalId . '-' . $e['iCalUID'] . "\n";
115+
/* $eventData .= 'UID:' . $ncCalId . '-' . $objectUri . "\n"; */
116+
if (isset($e['colorId'], $eventColors[$e['colorId']], $eventColors[$e['colorId']]['background'])) {
117+
$closestCssColor = $this->getClosestCssColor($eventColors[$e['colorId']]['background']);
118+
$eventData .= 'COLOR:' . $closestCssColor . "\n";
119+
}
120+
$eventData .= isset($e['summary'])
121+
? ('SUMMARY:' . substr(str_replace("\n", '\n', $e['summary']), 0, 250) . "\n")
122+
: (($e['visibility'] ?? '') === 'private'
123+
? ('SUMMARY:' . $this->l10n->t('Private event') . "\n")
124+
: '');
125+
$eventData .= isset($e['sequence']) ? ('SEQUENCE:' . $e['sequence'] . "\n") : '';
126+
$eventData .= isset($e['location'])
127+
? ('LOCATION:' . substr(str_replace("\n", '\n', $e['location']), 0, 250) . "\n")
128+
: '';
129+
$eventData .= isset($e['description'])
130+
? ('DESCRIPTION:' . substr(str_replace("\n", '\n', $e['description']), 0, 250) . "\n")
131+
: '';
132+
$eventData .= isset($e['status']) ? ('STATUS:' . strtoupper(str_replace("\n", '\n', $e['status'])) . "\n") : '';
133+
134+
if (isset($e['created'])) {
135+
$created = new DateTime($e['created']);
136+
$created->setTimezone($this->utcTimezone);
137+
$eventData .= 'CREATED:' . $created->format('Ymd\THis\Z') . "\n";
138+
}
139+
140+
if (isset($e['updated'])) {
141+
$updated = new DateTime($e['updated']);
142+
$updated->setTimezone($this->utcTimezone);
143+
$eventData .= 'LAST-MODIFIED:' . $updated->format('Ymd\THis\Z') . "\n";
144+
}
145+
146+
if (isset($e['reminders'], $e['reminders']['useDefault']) && $e['reminders']['useDefault']) {
147+
// 15 min before, default alarm
148+
$eventData .= 'BEGIN:VALARM' . "\n"
149+
. 'ACTION:DISPLAY' . "\n"
150+
. 'TRIGGER;RELATED=START:-PT15M' . "\n"
151+
. 'END:VALARM' . "\n";
152+
}
153+
if (isset($e['reminders'], $e['reminders']['overrides'])) {
154+
foreach ($e['reminders']['overrides'] as $o) {
155+
$nbMin = 0;
156+
if (isset($o['minutes'])) {
157+
$nbMin += (int)$o['minutes'];
158+
}
159+
if (isset($o['hours'])) {
160+
$nbMin += ((int)$o['hours']) * 60;
161+
}
162+
if (isset($o['days'])) {
163+
$nbMin += ((int)$o['days']) * 60 * 24;
164+
}
165+
if (isset($o['weeks'])) {
166+
$nbMin += ((int)$o['weeks']) * 60 * 24 * 7;
167+
}
168+
$eventData .= 'BEGIN:VALARM' . "\n"
169+
. 'ACTION:DISPLAY' . "\n"
170+
. 'TRIGGER;RELATED=START:-PT' . $nbMin . 'M' . "\n"
171+
. 'END:VALARM' . "\n";
172+
}
173+
}
174+
175+
if (isset($e['recurrence']) && is_array($e['recurrence'])) {
176+
foreach ($e['recurrence'] as $r) {
177+
$eventData .= $r . "\n";
178+
}
179+
}
180+
181+
// skip entries without any date
182+
if (!isset($e['start']) || !isset($e['end'])) {
183+
return '';
184+
}
185+
186+
$start = $this->mapTime($e['start']);
187+
$end = $this->mapTime($e['end']);
188+
189+
// skip entries without any date
190+
if ($start == '' || $end == '') {
191+
return '';
192+
}
193+
194+
$eventData .= "DTSTART;$start\n";
195+
$eventData .= "DTEND;$end\n";
196+
197+
if (isset($e['recurringEventId'], $e['originalStartTime'])) {
198+
$recurrenceId = $this->mapTime($e['originalStartTime']);
199+
$eventData .= "RECURRENCE-ID;$recurrenceId\n";
200+
}
201+
202+
$eventData .= 'CLASS:PUBLIC' . "\n"
203+
. 'END:VEVENT' . "\n";
204+
205+
foreach ($events as $candidateEvent) {
206+
if (($candidateEvent['recurringEventId'] == $e['id']) && ($candidateEvent['id'] != $e['id'])) {
207+
$eventData .= $this->generateEventData($candidateEvent, $events, $ncCalId, $eventColors);
208+
}
209+
}
210+
211+
return $eventData;
212+
}
213+
73214
/**
74215
* @param string $hexColor
75216
* @return string closest CSS color name
@@ -194,10 +335,11 @@ public function importCalendar(string $userId, string $calId, string $calName, ?
194335
date_default_timezone_set('UTC');
195336
$utcTimezone = new DateTimeZone('-0000');
196337
$allEvents = $this->config->getUserValue($userId, Application::APP_ID, 'consider_all_events', '1') === '1';
197-
$events = $this->getCalendarEvents($userId, $calId, $allEvents);
338+
$eventsGenerator = $this->getCalendarEvents($userId, $calId, $allEvents);
339+
$events = iterator_to_array($eventsGenerator);
198340
$nbAdded = 0;
199341
$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 */
342+
/** @var Event $e */
201343
foreach ($events as $e) {
202344
$objectUri = $e['id'];
203345

@@ -226,107 +368,21 @@ public function importCalendar(string $userId, string $calId, string $calName, ?
226368
}
227369
}
228370

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-
}
371+
// For recurring events, the parent event recursively calls generateEventData
372+
if (isset($e['recurringEventId'])) {
373+
continue;
292374
}
293375

294-
if (isset($e['recurrence']) && is_array($e['recurrence'])) {
295-
foreach ($e['recurrence'] as $r) {
296-
$calData .= $r . "\n";
297-
}
298-
}
376+
$eventData = $this->generateEventData($e, $events, $ncCalId, $eventColors);
299377

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
378+
if ($eventData == '') {
325379
continue;
326380
}
327381

328-
$calData .= 'CLASS:PUBLIC' . "\n"
329-
. 'END:VEVENT' . "\n"
382+
$calData = 'BEGIN:VCALENDAR' . "\n"
383+
. 'VERSION:2.0' . "\n"
384+
. 'PRODID:NextCloud Calendar' . "\n"
385+
. $eventData
330386
. 'END:VCALENDAR';
331387

332388
if ($existingEvent !== null) {
@@ -352,7 +408,7 @@ public function importCalendar(string $userId, string $calId, string $calName, ?
352408
}
353409
}
354410

355-
$eventGeneratorReturn = $events->getReturn();
411+
$eventGeneratorReturn = $eventsGenerator->getReturn();
356412
if (isset($eventGeneratorReturn['error'])) {
357413
return $eventGeneratorReturn;
358414
}

0 commit comments

Comments
 (0)