Skip to content

Commit 1901a85

Browse files
authored
Merge pull request #281 from MarcelRobitaille/support_recurrence
Feat: Support recurrence exceptions
2 parents ce6d707 + 468de44 commit 1901a85

1 file changed

Lines changed: 163 additions & 102 deletions

File tree

lib/Service/GoogleCalendarAPIService.php

Lines changed: 163 additions & 102 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
/**
@@ -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

Comments
 (0)