diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 2ca5cf66f901f..4dce5560286fb 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -61,6 +61,7 @@ 'OCA\\DAV\\CalDAV\\CalendarObject' => $baseDir . '/../lib/CalDAV/CalendarObject.php', 'OCA\\DAV\\CalDAV\\CalendarProvider' => $baseDir . '/../lib/CalDAV/CalendarProvider.php', 'OCA\\DAV\\CalDAV\\CalendarRoot' => $baseDir . '/../lib/CalDAV/CalendarRoot.php', + 'OCA\\DAV\\CalDAV\\DefaultCalendarAlarms' => $baseDir . '/../lib/CalDAV/DefaultCalendarAlarms.php', 'OCA\\DAV\\CalDAV\\DefaultCalendarValidator' => $baseDir . '/../lib/CalDAV/DefaultCalendarValidator.php', 'OCA\\DAV\\CalDAV\\EmbeddedCalDavServer' => $baseDir . '/../lib/CalDAV/EmbeddedCalDavServer.php', 'OCA\\DAV\\CalDAV\\EventComparisonService' => $baseDir . '/../lib/CalDAV/EventComparisonService.php', @@ -396,6 +397,7 @@ 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => $baseDir . '/../lib/Migration/Version1036Date20251202000000.php', 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => $baseDir . '/../lib/Migration/Version1038Date20260302000000.php', 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php', + 'OCA\\DAV\\Migration\\Version1040Date20260703120000' => $baseDir . '/../lib/Migration/Version1040Date20260703120000.php', 'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index c35dd97c02c0e..275066f0e67c4 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -76,6 +76,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\CalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarObject.php', 'OCA\\DAV\\CalDAV\\CalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarProvider.php', 'OCA\\DAV\\CalDAV\\CalendarRoot' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarRoot.php', + 'OCA\\DAV\\CalDAV\\DefaultCalendarAlarms' => __DIR__ . '/..' . '/../lib/CalDAV/DefaultCalendarAlarms.php', 'OCA\\DAV\\CalDAV\\DefaultCalendarValidator' => __DIR__ . '/..' . '/../lib/CalDAV/DefaultCalendarValidator.php', 'OCA\\DAV\\CalDAV\\EmbeddedCalDavServer' => __DIR__ . '/..' . '/../lib/CalDAV/EmbeddedCalDavServer.php', 'OCA\\DAV\\CalDAV\\EventComparisonService' => __DIR__ . '/..' . '/../lib/CalDAV/EventComparisonService.php', @@ -411,6 +412,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => __DIR__ . '/..' . '/../lib/Migration/Version1036Date20251202000000.php', 'OCA\\DAV\\Migration\\Version1038Date20260302000000' => __DIR__ . '/..' . '/../lib/Migration/Version1038Date20260302000000.php', 'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php', + 'OCA\\DAV\\Migration\\Version1040Date20260703120000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260703120000.php', 'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 9254ba2ad102a..e25189442c71a 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -138,6 +138,15 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription public const CLASSIFICATION_PRIVATE = 1; public const CLASSIFICATION_CONFIDENTIAL = 2; + public const DEFAULT_ALARMS_PART_DAY_PROPERTY = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}default-alarms-part-day'; + public const DEFAULT_ALARMS_FULL_DAY_PROPERTY = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}default-alarms-full-day'; + + /** @var string[] DB columns for calendar properties outside propertyMap (custom serialization). */ + private const SERIALIZED_CALENDAR_DB_COLUMNS = [ + 'default_alarms_pday', + 'default_alarms_fday', + ]; + /** * List of CalDAV properties, and how they map to database field names and their type * Add your own properties by simply adding on to this array. @@ -326,7 +335,7 @@ public function getCalendarsForUser($principalUri) { return $this->atomic(function () use ($principalUri) { $principalUriOriginal = $principalUri; $principalUri = $this->convertPrincipal($principalUri, true); - $fields = array_column($this->propertyMap, 0); + $fields = $this->getCalendarMergeSerializedProperties(); $fields[] = 'id'; $fields[] = 'uri'; $fields[] = 'synctoken'; @@ -382,10 +391,9 @@ public function getCalendarsForUser($principalUri) { $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal)); $principals[] = $principalUri; - $fields = array_column($this->propertyMap, 0); $fields = array_map(function (string $field) { return 'a.' . $field; - }, $fields); + }, $this->getCalendarMergeSerializedProperties()); $fields[] = 'a.id'; $fields[] = 'a.uri'; $fields[] = 'a.synctoken'; @@ -468,7 +476,7 @@ public function getCalendarsForUser($principalUri) { */ public function getUsersOwnCalendars($principalUri) { $principalUri = $this->convertPrincipal($principalUri, true); - $fields = array_column($this->propertyMap, 0); + $fields = $this->getCalendarMergeSerializedProperties(); $fields[] = 'id'; $fields[] = 'uri'; $fields[] = 'synctoken'; @@ -514,7 +522,7 @@ public function getUsersOwnCalendars($principalUri) { * @return array */ public function getPublicCalendars() { - $fields = array_column($this->propertyMap, 0); + $fields = array_map(static fn (string $field): string => 'a.' . $field, $this->getCalendarMergeSerializedProperties()); $fields[] = 'a.id'; $fields[] = 'a.uri'; $fields[] = 'a.synctoken'; @@ -572,7 +580,7 @@ public function getPublicCalendars() { * @throws NotFound */ public function getPublicCalendar($uri) { - $fields = array_column($this->propertyMap, 0); + $fields = array_map(static fn (string $field): string => 'a.' . $field, $this->getCalendarMergeSerializedProperties()); $fields[] = 'a.id'; $fields[] = 'a.uri'; $fields[] = 'a.synctoken'; @@ -631,7 +639,7 @@ public function getPublicCalendar($uri) { * @return array|null */ public function getCalendarByUri($principal, $uri) { - $fields = array_column($this->propertyMap, 0); + $fields = $this->getCalendarMergeSerializedProperties(); $fields[] = 'id'; $fields[] = 'uri'; $fields[] = 'synctoken'; @@ -681,7 +689,7 @@ public function getCalendarByUri($principal, $uri) { * @return array|null */ public function getCalendarById(int $calendarId): ?array { - $fields = array_column($this->propertyMap, 0); + $fields = $this->getCalendarMergeSerializedProperties(); $fields[] = 'id'; $fields[] = 'uri'; $fields[] = 'synctoken'; @@ -850,9 +858,29 @@ public function createCalendar($principalUri, $calendarUri, array $properties) { foreach ($this->propertyMap as $xmlName => [$dbName, $type]) { if (isset($properties[$xmlName])) { $values[$dbName] = $properties[$xmlName]; + if ($dbName === 'default_alarm_pday') { + $values['default_alarms_pday'] = DefaultCalendarAlarms::encodeFromLegacyInt( + $properties[$xmlName] !== null ? (int)$properties[$xmlName] : null, + ); + } elseif ($dbName === 'default_alarm_fday') { + $values['default_alarms_fday'] = DefaultCalendarAlarms::encodeFromLegacyInt( + $properties[$xmlName] !== null ? (int)$properties[$xmlName] : null, + ); + } } } + if (isset($properties[self::DEFAULT_ALARMS_PART_DAY_PROPERTY])) { + $encoded = DefaultCalendarAlarms::validateAndEncode($properties[self::DEFAULT_ALARMS_PART_DAY_PROPERTY]); + $values['default_alarms_pday'] = $encoded; + $values['default_alarm_pday'] = DefaultCalendarAlarms::legacyIntFromJson($encoded); + } + if (isset($properties[self::DEFAULT_ALARMS_FULL_DAY_PROPERTY])) { + $encoded = DefaultCalendarAlarms::validateAndEncode($properties[self::DEFAULT_ALARMS_FULL_DAY_PROPERTY]); + $values['default_alarms_fday'] = $encoded; + $values['default_alarm_fday'] = DefaultCalendarAlarms::legacyIntFromJson($encoded); + } + [$calendarId, $calendarData] = $this->atomic(function () use ($values) { $query = $this->db->getQueryBuilder(); $query->insert('calendars'); @@ -891,8 +919,11 @@ public function createCalendar($principalUri, $calendarUri, array $properties) { public function updateCalendar($calendarId, PropPatch $propPatch) { $supportedProperties = array_keys($this->propertyMap); $supportedProperties[] = '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp'; + $supportedProperties[] = self::DEFAULT_ALARMS_PART_DAY_PROPERTY; + $supportedProperties[] = self::DEFAULT_ALARMS_FULL_DAY_PROPERTY; $propPatch->handle($supportedProperties, function ($mutations) use ($calendarId) { + $storedDefaultAlarmsJson = $this->getCalendarDefaultAlarmsJson($calendarId); $newValues = []; foreach ($mutations as $propertyName => $propertyValue) { switch ($propertyName) { @@ -900,9 +931,30 @@ public function updateCalendar($calendarId, PropPatch $propPatch) { $fieldName = 'transparent'; $newValues[$fieldName] = (int)($propertyValue->getValue() === 'transparent'); break; + case self::DEFAULT_ALARMS_PART_DAY_PROPERTY: + $encoded = DefaultCalendarAlarms::validateAndEncode($propertyValue); + $newValues['default_alarms_pday'] = $encoded; + $newValues['default_alarm_pday'] = DefaultCalendarAlarms::legacyIntFromJson($encoded); + break; + case self::DEFAULT_ALARMS_FULL_DAY_PROPERTY: + $encoded = DefaultCalendarAlarms::validateAndEncode($propertyValue); + $newValues['default_alarms_fday'] = $encoded; + $newValues['default_alarm_fday'] = DefaultCalendarAlarms::legacyIntFromJson($encoded); + break; default: $fieldName = $this->propertyMap[$propertyName][0]; $newValues[$fieldName] = $propertyValue; + if ($fieldName === 'default_alarm_pday') { + $newValues['default_alarms_pday'] = DefaultCalendarAlarms::mergeLegacyIntIntoJson( + $storedDefaultAlarmsJson['default_alarms_pday'], + $propertyValue !== null ? (int)$propertyValue : null, + ); + } elseif ($fieldName === 'default_alarm_fday') { + $newValues['default_alarms_fday'] = DefaultCalendarAlarms::mergeLegacyIntIntoJson( + $storedDefaultAlarmsJson['default_alarms_fday'], + $propertyValue !== null ? (int)$propertyValue : null, + ); + } break; } } @@ -3968,9 +4020,56 @@ private function rowToCalendar($row, array $calendar): array { } $calendar[$xmlName] = $value; } + + $calendar[self::DEFAULT_ALARMS_PART_DAY_PROPERTY] = DefaultCalendarAlarms::formatForCalDav( + isset($row['default_alarms_pday']) ? (string)$row['default_alarms_pday'] : null, + isset($row['default_alarm_pday']) && $row['default_alarm_pday'] !== null ? (int)$row['default_alarm_pday'] : null, + ); + $calendar[self::DEFAULT_ALARMS_FULL_DAY_PROPERTY] = DefaultCalendarAlarms::formatForCalDav( + isset($row['default_alarms_fday']) ? (string)$row['default_alarms_fday'] : null, + isset($row['default_alarm_fday']) && $row['default_alarm_fday'] !== null ? (int)$row['default_alarm_fday'] : null, + ); + return $calendar; } + /** + * calendars-table SELECT columns: propertyMap DB fields plus columns for + * CalDAV properties stored outside propertyMap (custom serialization). + * + * @return string[] + */ + private function getCalendarMergeSerializedProperties(): array { + return array_merge( + array_column($this->propertyMap, 0), + self::SERIALIZED_CALENDAR_DB_COLUMNS, + ); + } + + /** + * @return array{default_alarms_pday: ?string, default_alarms_fday: ?string} + */ + private function getCalendarDefaultAlarmsJson(int $calendarId): array { + $query = $this->db->getQueryBuilder(); + $query->select('default_alarms_pday', 'default_alarms_fday') + ->from('calendars') + ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT))) + ->setMaxResults(1); + $row = $query->executeQuery()->fetchAssociative(); + + if ($row === false) { + return [ + 'default_alarms_pday' => null, + 'default_alarms_fday' => null, + ]; + } + + return [ + 'default_alarms_pday' => $row['default_alarms_pday'] !== null ? (string)$row['default_alarms_pday'] : null, + 'default_alarms_fday' => $row['default_alarms_fday'] !== null ? (string)$row['default_alarms_fday'] : null, + ]; + } + /** * Amend the subscription info with database row data * diff --git a/apps/dav/lib/CalDAV/DefaultCalendarAlarms.php b/apps/dav/lib/CalDAV/DefaultCalendarAlarms.php new file mode 100644 index 0000000000000..d9f3bc3437a84 --- /dev/null +++ b/apps/dav/lib/CalDAV/DefaultCalendarAlarms.php @@ -0,0 +1,195 @@ +, "action": "DISPLAY"|"EMAIL"} + */ +class DefaultCalendarAlarms { + private const ALLOWED_ACTIONS = ['DISPLAY', 'EMAIL']; + + /** + * @throws BadRequest + * @throws \RuntimeException + */ + public static function validateAndEncode(mixed $value): ?string { + if ($value === null || $value === '') { + return null; + } + + try { + if (is_string($value)) { + /** @var mixed $decoded */ + $decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } elseif (is_array($value)) { + $decoded = $value; + } else { + throw new BadRequest('Default alarms must be a JSON array'); + } + } catch (JsonException) { + throw new BadRequest('Default alarms must be valid JSON'); + } + + if (!is_array($decoded)) { + throw new BadRequest('Default alarms must be a JSON array'); + } + + if ($decoded === []) { + return null; + } + + $normalized = self::normalizeDecodedArray($decoded); + + try { + return json_encode($normalized, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new \RuntimeException('Internal error encoding default alarms', 0, $e); + } + } + + /** + * Returns the JSON string exposed on CalDAV, synthesizing from legacy int when needed. + */ + public static function formatForCalDav(?string $json, ?int $legacyInt): ?string { + if ($json !== null && $json !== '') { + try { + /** @var mixed $decoded */ + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded) || $decoded === []) { + return null; + } + + return $json; + } catch (JsonException) { + return null; + } + } + + return self::encodeFromLegacyInt($legacyInt); + } + + public static function legacyIntFromJson(?string $json): ?int { + if ($json === null || $json === '') { + return null; + } + + try { + /** @var mixed $decoded */ + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded) || $decoded === []) { + return null; + } + + $first = $decoded[0]; + if (!is_array($first) || !array_key_exists('trigger', $first)) { + return null; + } + + return (int)$first['trigger']; + } catch (JsonException) { + return null; + } + } + + public static function encodeFromLegacyInt(?int $legacyInt): ?string { + if ($legacyInt === null) { + return null; + } + + try { + return json_encode([ + ['trigger' => $legacyInt, 'action' => 'DISPLAY'], + ], JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + } + + /** + * Applies a legacy single-int update without discarding additional stored alarms. + * + * Updates the first alarm's trigger (preserving its action when present). + * Falls back to a single DISPLAY alarm when no JSON list exists yet. + */ + public static function mergeLegacyIntIntoJson(?string $existingJson, ?int $legacyInt): ?string { + if ($legacyInt === null) { + return null; + } + + if ($existingJson === null || $existingJson === '') { + return self::encodeFromLegacyInt($legacyInt); + } + + try { + /** @var mixed $decoded */ + $decoded = json_decode($existingJson, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($decoded) || $decoded === []) { + return self::encodeFromLegacyInt($legacyInt); + } + + $first = $decoded[0]; + if (!is_array($first)) { + return self::encodeFromLegacyInt($legacyInt); + } + + $action = 'DISPLAY'; + if (isset($first['action']) && is_string($first['action']) && in_array($first['action'], self::ALLOWED_ACTIONS, true)) { + $action = $first['action']; + } + + $decoded[0] = [ + 'trigger' => $legacyInt, + 'action' => $action, + ]; + + return json_encode($decoded, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return self::encodeFromLegacyInt($legacyInt); + } + } + + /** + * @param array $alarms + * @return list + * @throws BadRequest + */ + private static function normalizeDecodedArray(array $alarms): array { + $normalized = []; + foreach ($alarms as $alarm) { + if (!is_array($alarm)) { + throw new BadRequest('Each default alarm must be an object'); + } + + if (!array_key_exists('trigger', $alarm) || !array_key_exists('action', $alarm)) { + throw new BadRequest('Each default alarm requires trigger and action'); + } + + if (!is_int($alarm['trigger']) && !is_float($alarm['trigger']) && !(is_string($alarm['trigger']) && is_numeric($alarm['trigger']))) { + throw new BadRequest('Default alarm trigger must be an integer'); + } + + if (!is_string($alarm['action']) || !in_array($alarm['action'], self::ALLOWED_ACTIONS, true)) { + throw new BadRequest('Default alarm action must be DISPLAY or EMAIL'); + } + + $normalized[] = [ + 'trigger' => (int)$alarm['trigger'], + 'action' => $alarm['action'], + ]; + } + + return $normalized; + } +} diff --git a/apps/dav/lib/Migration/Version1040Date20260703120000.php b/apps/dav/lib/Migration/Version1040Date20260703120000.php new file mode 100644 index 0000000000000..00d34d7b85968 --- /dev/null +++ b/apps/dav/lib/Migration/Version1040Date20260703120000.php @@ -0,0 +1,96 @@ +getTable('calendars'); + + if (!$calendarsTable->hasColumn('default_alarms_pday')) { + $calendarsTable->addColumn('default_alarms_pday', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + if (!$calendarsTable->hasColumn('default_alarms_fday')) { + $calendarsTable->addColumn('default_alarms_fday', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } + + #[Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $select = $this->db->getQueryBuilder(); + $select->select('id', 'default_alarm_pday', 'default_alarm_fday', 'default_alarms_pday', 'default_alarms_fday') + ->from('calendars') + ->where($select->expr()->orX( + $select->expr()->andX( + $select->expr()->isNotNull('default_alarm_pday'), + $select->expr()->isNull('default_alarms_pday'), + ), + $select->expr()->andX( + $select->expr()->isNotNull('default_alarm_fday'), + $select->expr()->isNull('default_alarms_fday'), + ), + )); + + $result = $select->executeQuery(); + while (($row = $result->fetchAssociative()) !== false) { + $id = (int)$row['id']; + + if ($row['default_alarms_pday'] === null && $row['default_alarm_pday'] !== null) { + $this->updateAlarmsColumn($id, 'default_alarms_pday', DefaultCalendarAlarms::encodeFromLegacyInt((int)$row['default_alarm_pday'])); + } + + if ($row['default_alarms_fday'] === null && $row['default_alarm_fday'] !== null) { + $this->updateAlarmsColumn($id, 'default_alarms_fday', DefaultCalendarAlarms::encodeFromLegacyInt((int)$row['default_alarm_fday'])); + } + } + $result->closeCursor(); + } + + private function updateAlarmsColumn(int $calendarId, string $column, ?string $value): void { + $update = $this->db->getQueryBuilder(); + $update->update('calendars') + ->set($column, $update->createNamedParameter($value, IQueryBuilder::PARAM_STR)) + ->where($update->expr()->eq('id', $update->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT))); + $update->executeStatement(); + } +} diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index e0f507fbe358d..cd2b29c3694a2 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -1960,6 +1960,12 @@ public function testDefaultAlarmProperties(): void { $this->assertCount(1, $calendars); $this->assertEquals(-900, $calendars[0]['{http://nextcloud.com/ns}default-alarm-part-day']); $this->assertEquals(-3600, $calendars[0]['{http://nextcloud.com/ns}default-alarm-full-day']); + $this->assertEquals(json_encode([ + ['trigger' => -900, 'action' => 'DISPLAY'], + ], JSON_THROW_ON_ERROR), $calendars[0][CalDavBackend::DEFAULT_ALARMS_PART_DAY_PROPERTY]); + $this->assertEquals(json_encode([ + ['trigger' => -3600, 'action' => 'DISPLAY'], + ], JSON_THROW_ON_ERROR), $calendars[0][CalDavBackend::DEFAULT_ALARMS_FULL_DAY_PROPERTY]); // Test updating to different values $patch = new PropPatch([ @@ -1972,6 +1978,12 @@ public function testDefaultAlarmProperties(): void { $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER); $this->assertEquals(-86400, $calendars[0]['{http://nextcloud.com/ns}default-alarm-part-day']); $this->assertEquals(-43200, $calendars[0]['{http://nextcloud.com/ns}default-alarm-full-day']); + $this->assertEquals(json_encode([ + ['trigger' => -86400, 'action' => 'DISPLAY'], + ], JSON_THROW_ON_ERROR), $calendars[0][CalDavBackend::DEFAULT_ALARMS_PART_DAY_PROPERTY]); + $this->assertEquals(json_encode([ + ['trigger' => -43200, 'action' => 'DISPLAY'], + ], JSON_THROW_ON_ERROR), $calendars[0][CalDavBackend::DEFAULT_ALARMS_FULL_DAY_PROPERTY]); // Test setting to null $patch = new PropPatch([ @@ -1984,8 +1996,64 @@ public function testDefaultAlarmProperties(): void { $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER); $this->assertNull($calendars[0]['{http://nextcloud.com/ns}default-alarm-part-day']); $this->assertNull($calendars[0]['{http://nextcloud.com/ns}default-alarm-full-day']); + $this->assertNull($calendars[0][CalDavBackend::DEFAULT_ALARMS_PART_DAY_PROPERTY]); + $this->assertNull($calendars[0][CalDavBackend::DEFAULT_ALARMS_FULL_DAY_PROPERTY]); // Clean up $this->backend->deleteCalendar($calendars[0]['id'], true); } + + public function testDefaultAlarmsProperties(): void { + $calendarId = $this->createTestCalendar(); + + $alarmsJson = json_encode([ + ['trigger' => -86400, 'action' => 'EMAIL'], + ['trigger' => -900, 'action' => 'DISPLAY'], + ], JSON_THROW_ON_ERROR); + + $patch = new PropPatch([ + CalDavBackend::DEFAULT_ALARMS_PART_DAY_PROPERTY => $alarmsJson, + CalDavBackend::DEFAULT_ALARMS_FULL_DAY_PROPERTY => json_encode([ + ['trigger' => -3600, 'action' => 'EMAIL'], + ], JSON_THROW_ON_ERROR), + ]); + $this->backend->updateCalendar($calendarId, $patch); + $patch->commit(); + + $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER); + $this->assertCount(1, $calendars); + $this->assertEquals($alarmsJson, $calendars[0][CalDavBackend::DEFAULT_ALARMS_PART_DAY_PROPERTY]); + $this->assertEquals(-86400, $calendars[0]['{http://nextcloud.com/ns}default-alarm-part-day']); + $this->assertEquals(-3600, $calendars[0]['{http://nextcloud.com/ns}default-alarm-full-day']); + + // Legacy client update should adjust only the first alarm, not wipe the list + $patch = new PropPatch([ + '{http://nextcloud.com/ns}default-alarm-part-day' => -1800, + ]); + $this->backend->updateCalendar($calendarId, $patch); + $patch->commit(); + + $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER); + $this->assertEquals(json_encode([ + ['trigger' => -1800, 'action' => 'EMAIL'], + ['trigger' => -900, 'action' => 'DISPLAY'], + ], JSON_THROW_ON_ERROR), $calendars[0][CalDavBackend::DEFAULT_ALARMS_PART_DAY_PROPERTY]); + $this->assertEquals(-1800, $calendars[0]['{http://nextcloud.com/ns}default-alarm-part-day']); + $this->assertEquals(json_encode([ + ['trigger' => -3600, 'action' => 'EMAIL'], + ], JSON_THROW_ON_ERROR), $calendars[0][CalDavBackend::DEFAULT_ALARMS_FULL_DAY_PROPERTY]); + + $patch = new PropPatch([ + CalDavBackend::DEFAULT_ALARMS_PART_DAY_PROPERTY => null, + CalDavBackend::DEFAULT_ALARMS_FULL_DAY_PROPERTY => null, + ]); + $this->backend->updateCalendar($calendarId, $patch); + $patch->commit(); + + $calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER); + $this->assertNull($calendars[0][CalDavBackend::DEFAULT_ALARMS_PART_DAY_PROPERTY]); + $this->assertNull($calendars[0][CalDavBackend::DEFAULT_ALARMS_FULL_DAY_PROPERTY]); + + $this->backend->deleteCalendar($calendars[0]['id'], true); + } }