Skip to content

Commit d1a8338

Browse files
SoleroTGSebastianKrupinski
authored andcommitted
feat(dav): allow muting reminders per calendar
Signed-off-by: SoleroTG <github-29h@solero.quietmail.eu> Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
1 parent 61bb327 commit d1a8338

11 files changed

Lines changed: 246 additions & 8 deletions

File tree

apps/dav/appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<name>WebDAV</name>
1111
<summary>WebDAV endpoint</summary>
1212
<description>WebDAV endpoint</description>
13-
<version>3.0.0-dev.0</version>
13+
<version>3.0.0-dev.1</version>
1414
<licence>agpl</licence>
1515
<author>owncloud.org</author>
1616
<namespace>DAV</namespace>

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@
415415
'OCA\\DAV\\Migration\\Version1038Date20260302000000' => $baseDir . '/../lib/Migration/Version1038Date20260302000000.php',
416416
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php',
417417
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => $baseDir . '/../lib/Migration/Version1040Date20260805000000.php',
418+
'OCA\\DAV\\Migration\\Version3000Date20260828000000' => $baseDir . '/../lib/Migration/Version3000Date20260828000000.php',
418419
'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php',
419420
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php',
420421
'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ class ComposerStaticInitDAV
430430
'OCA\\DAV\\Migration\\Version1038Date20260302000000' => __DIR__ . '/..' . '/../lib/Migration/Version1038Date20260302000000.php',
431431
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php',
432432
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260805000000.php',
433+
'OCA\\DAV\\Migration\\Version3000Date20260828000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20260828000000.php',
433434
'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php',
434435
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php',
435436
'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php',

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
153153
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => ['deleted_at', 'int'],
154154
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}default-alarm-part-day' => ['default_alarm_pday', 'int'],
155155
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}default-alarm-full-day' => ['default_alarm_fday', 'int'],
156+
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}disable-alarm-notifications' => ['disable_alarm_notifications', 'bool'],
156157
];
157158

158159
/**

apps/dav/lib/CalDAV/Reminder/Backend.php

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99

1010
namespace OCA\DAV\CalDAV\Reminder;
1111

12+
use OCA\DAV\DAV\Sharing\Plugin;
13+
use OCA\DAV\Db\PropertyMapper;
1214
use OCP\AppFramework\Utility\ITimeFactory;
1315
use OCP\IDBConnection;
16+
use Sabre\Uri;
1417

1518
/**
1619
* Class Backend
@@ -19,15 +22,23 @@
1922
*/
2023
class Backend {
2124

25+
/**
26+
* WebDAV property a sharee sets on their own view of a shared calendar
27+
* to mute reminders, stored via the generic custom-properties backend.
28+
*/
29+
private const string PROPERTY_DISABLE_ALARM_NOTIFICATIONS = '{' . Plugin::NS_NEXTCLOUD . '}disable-alarm-notifications';
30+
2231
/**
2332
* Backend constructor.
2433
*
2534
* @param IDBConnection $db
2635
* @param ITimeFactory $timeFactory
36+
* @param PropertyMapper $propertyMapper
2737
*/
2838
public function __construct(
2939
protected IDBConnection $db,
3040
protected ITimeFactory $timeFactory,
41+
private PropertyMapper $propertyMapper,
3142
) {
3243
}
3344

@@ -39,12 +50,12 @@ public function __construct(
3950
*/
4051
public function getRemindersToProcess():array {
4152
$query = $this->db->getQueryBuilder();
42-
$query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri'])
53+
$query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri', 'c.uri', 'c.disable_alarm_notifications'])
4354
->from('calendar_reminders', 'cr')
4455
->where($query->expr()->lte('cr.notification_date', $query->createNamedParameter($this->timeFactory->getTime())))
4556
->join('cr', 'calendarobjects', 'co', $query->expr()->eq('cr.object_id', 'co.id'))
4657
->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id'))
47-
->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri');
58+
->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri', 'c.uri', 'c.disable_alarm_notifications');
4859
$stmt = $query->executeQuery();
4960

5061
return array_map(
@@ -53,6 +64,35 @@ public function getRemindersToProcess():array {
5364
);
5465
}
5566

67+
/**
68+
* Whether the given principal has muted reminders for this reminder's calendar.
69+
*
70+
* The calendar owner's setting is stored directly on the `calendars` row (already
71+
* part of $reminder). A sharee's setting is their own WebDAV property on their view
72+
* of the shared calendar, stored via the generic custom-properties backend.
73+
*
74+
* @param array $reminder A row as returned by getRemindersToProcess()
75+
*/
76+
public function isReminderMutedForPrincipal(array $reminder, string $principalUri): bool {
77+
if ($principalUri === $reminder['principaluri']) {
78+
return $reminder['disable_alarm_notifications'];
79+
}
80+
81+
[, $shareeUid] = Uri\split($principalUri);
82+
[, $ownerUid] = Uri\split($reminder['principaluri']);
83+
if ($shareeUid === null || $ownerUid === null) {
84+
return false;
85+
}
86+
87+
$path = 'calendars/' . $shareeUid . '/' . $reminder['uri'] . '_shared_by_' . $ownerUid;
88+
$properties = $this->propertyMapper->findPropertyByPathAndName($shareeUid, $path, self::PROPERTY_DISABLE_ALARM_NOTIFICATIONS);
89+
if (empty($properties)) {
90+
return false;
91+
}
92+
93+
return $properties[0]->getPropertyvalue() === '1';
94+
}
95+
5696
/**
5797
* Get all scheduled reminders for an event
5898
*
@@ -192,6 +232,9 @@ private function fixRowTyping(array $row): array {
192232
$row['is_relative'] = (bool)$row['is_relative'];
193233
$row['notification_date'] = (int)$row['notification_date'];
194234
$row['is_repeat_based'] = (bool)$row['is_repeat_based'];
235+
if (array_key_exists('disable_alarm_notifications', $row)) {
236+
$row['disable_alarm_notifications'] = (bool)$row['disable_alarm_notifications'];
237+
}
195238

196239
return $row;
197240
}

apps/dav/lib/CalDAV/Reminder/ReminderService.php

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,30 @@ public function processReminders() :void {
123123
continue;
124124
}
125125

126-
if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'no') {
126+
if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'yes') {
127127
$users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']);
128128
} else {
129129
$users = [];
130130
}
131131

132-
$user = $this->getUserFromPrincipalURI($reminder['principaluri']);
133-
if ($user) {
134-
$users[] = $user;
132+
$ownerUser = $this->getUserFromPrincipalURI($reminder['principaluri']);
133+
if ($ownerUser !== null) {
134+
$users[] = $ownerUser;
135+
}
136+
137+
// Filter out any users who have muted reminders for this calendar
138+
$users = array_values(array_filter($users, function (IUser $u) use ($reminder): bool {
139+
$principalUri = 'principals/users/' . $u->getUID();
140+
return !$this->backend->isReminderMutedForPrincipal($reminder, $principalUri);
141+
}));
142+
143+
if (count($users) === 0) {
144+
$this->logger->debug('Reminder {id} is ignored by all recipient users for calendar {calendarId}', [
145+
'id' => $reminder['id'],
146+
'calendarId' => $reminder['calendar_id'],
147+
]);
148+
$this->deleteOrProcessNext($reminder, $vevent);
149+
continue;
135150
}
136151

137152
$userPrincipalEmailAddresses = [];

apps/dav/lib/DAV/CustomPropertiesBackend.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ public function propFind($path, PropFind $propFind): void {
195195
'{http://apple.com/ns/ical/}calendar-order',
196196
'{http://apple.com/ns/ical/}calendar-color',
197197
'{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
198+
'{http://nextcloud.com/ns}disable-alarm-notifications',
198199
];
199200

200201
foreach ($customPropertiesForShares as $customPropertyForShares) {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\DAV\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\Attributes\AddColumn;
16+
use OCP\Migration\Attributes\ColumnType;
17+
use OCP\Migration\IOutput;
18+
use OCP\Migration\SimpleMigrationStep;
19+
use Override;
20+
21+
#[AddColumn(table: 'calendars', name: 'disable_alarm_notifications', type: ColumnType::BOOLEAN)]
22+
class Version3000Date20260828000000 extends SimpleMigrationStep {
23+
#[Override]
24+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
25+
/** @var ISchemaWrapper $schema */
26+
$schema = $schemaClosure();
27+
$modified = false;
28+
29+
$calendarsTable = $schema->getTable('calendars');
30+
if (!$calendarsTable->hasColumn('disable_alarm_notifications')) {
31+
$calendarsTable->addColumn('disable_alarm_notifications', Types::BOOLEAN, [
32+
'notnull' => false,
33+
'default' => false,
34+
]);
35+
$modified = true;
36+
}
37+
38+
return $modified ? $schema : null;
39+
}
40+
}

apps/dav/tests/unit/CalDAV/CalDavBackendTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2478,4 +2478,35 @@ public function testGetFederatedCalendarByUriHidesPendingCalendar(): void {
24782478

24792479
$this->assertNull($this->backend->getFederatedCalendarByUri(self::UNIT_TEST_USER, 'federated-cal'));
24802480
}
2481+
2482+
public function testDisableAlarmNotificationsProperty(): void {
2483+
$calendarId = $this->backend->createCalendar(self::UNIT_TEST_USER, 'DisableAlarmNotificationsTest', []);
2484+
2485+
// Default should be false
2486+
$calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER);
2487+
$this->assertFalse((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false));
2488+
2489+
// Update to true ('1')
2490+
$patch = new PropPatch([
2491+
'{http://nextcloud.com/ns}disable-alarm-notifications' => '1',
2492+
]);
2493+
$this->backend->updateCalendar($calendarId, $patch);
2494+
$patch->commit();
2495+
2496+
$calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER);
2497+
$this->assertTrue((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false));
2498+
2499+
// Update to false ('0')
2500+
$patch = new PropPatch([
2501+
'{http://nextcloud.com/ns}disable-alarm-notifications' => '0',
2502+
]);
2503+
$this->backend->updateCalendar($calendarId, $patch);
2504+
$patch->commit();
2505+
2506+
$calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER);
2507+
$this->assertFalse((bool)($calendars[0]['{http://nextcloud.com/ns}disable-alarm-notifications'] ?? false));
2508+
2509+
// Clean up
2510+
$this->backend->deleteCalendar($calendars[0]['id'], true);
2511+
}
24812512
}

apps/dav/tests/unit/CalDAV/Reminder/BackendTest.php

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
namespace OCA\DAV\Tests\unit\CalDAV\Reminder;
1111

1212
use OCA\DAV\CalDAV\Reminder\Backend as ReminderBackend;
13+
use OCA\DAV\Db\PropertyMapper;
1314
use OCP\AppFramework\Utility\ITimeFactory;
15+
use OCP\DB\QueryBuilder\IQueryBuilder;
1416
use PHPUnit\Framework\MockObject\MockObject;
1517
use Test\TestCase;
1618

@@ -25,9 +27,10 @@ protected function setUp(): void {
2527
$query->delete('calendar_reminders')->executeStatement();
2628
$query->delete('calendarobjects')->executeStatement();
2729
$query->delete('calendars')->executeStatement();
30+
$query->delete('properties')->executeStatement();
2831

2932
$this->timeFactory = $this->createMock(ITimeFactory::class);
30-
$this->reminderBackend = new ReminderBackend(self::$realDatabase, $this->timeFactory);
33+
$this->reminderBackend = new ReminderBackend(self::$realDatabase, $this->timeFactory, new PropertyMapper(self::$realDatabase));
3134

3235
$this->createRemindersTestSet();
3336
}
@@ -37,6 +40,7 @@ protected function tearDown(): void {
3740
$query->delete('calendar_reminders')->executeStatement();
3841
$query->delete('calendarobjects')->executeStatement();
3942
$query->delete('calendars')->executeStatement();
43+
$query->delete('properties')->executeStatement();
4044

4145
parent::tearDown();
4246
}
@@ -129,6 +133,8 @@ public function testGetRemindersToProcess(): void {
129133
'calendardata' => 'Calendar data 123',
130134
'displayname' => 'Displayname 123',
131135
'principaluri' => 'principals/users/user001',
136+
'uri' => 'personal',
137+
'disable_alarm_notifications' => false,
132138
];
133139
$expected2 = [
134140
'calendar_id' => 1,
@@ -146,6 +152,8 @@ public function testGetRemindersToProcess(): void {
146152
'calendardata' => 'Calendar data 123',
147153
'displayname' => 'Displayname 123',
148154
'principaluri' => 'principals/users/user001',
155+
'uri' => 'personal',
156+
'disable_alarm_notifications' => false,
149157
];
150158

151159
$this->assertEqualsCanonicalizing([$rows[0],$rows[1]], [$expected1,$expected2]);
@@ -252,13 +260,58 @@ public function testUpdateReminder(): void {
252260
$this->assertEquals((int)$row['notification_date'], 123700);
253261
}
254262

263+
public function testIsReminderMutedForPrincipalUsesCalendarColumnForOwner(): void {
264+
$reminder = [
265+
'principaluri' => 'principals/users/user001',
266+
'uri' => 'personal',
267+
'disable_alarm_notifications' => true,
268+
];
269+
270+
$this->assertTrue($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user001'));
271+
}
272+
273+
public function testIsReminderMutedForPrincipalIsFalseForShareeWithoutProperty(): void {
274+
$reminder = [
275+
'principaluri' => 'principals/users/user001',
276+
'uri' => 'personal',
277+
'disable_alarm_notifications' => false,
278+
];
279+
280+
$this->assertFalse($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user002'));
281+
}
282+
283+
public function testIsReminderMutedForPrincipalReadsShareePropertyOverride(): void {
284+
$reminder = [
285+
'principaluri' => 'principals/users/user001',
286+
'uri' => 'personal',
287+
// Owner has not muted their own calendar ...
288+
'disable_alarm_notifications' => false,
289+
];
290+
// ... but the sharee muted their own view of it.
291+
$path = 'calendars/user002/personal_shared_by_user001';
292+
293+
$query = self::$realDatabase->getQueryBuilder();
294+
$query->insert('properties')
295+
->values([
296+
'userid' => $query->createNamedParameter('user002'),
297+
'propertypath' => $query->createNamedParameter($path),
298+
'propertyname' => $query->createNamedParameter('{http://nextcloud.com/ns}disable-alarm-notifications'),
299+
'propertyvalue' => $query->createNamedParameter('1'),
300+
'valuetype' => $query->createNamedParameter(1, IQueryBuilder::PARAM_INT),
301+
])
302+
->executeStatement();
303+
304+
$this->assertTrue($this->reminderBackend->isReminderMutedForPrincipal($reminder, 'principals/users/user002'));
305+
}
306+
255307
private function createRemindersTestSet(): void {
256308
$query = self::$realDatabase->getQueryBuilder();
257309
$query->insert('calendars')
258310
->values([
259311
'id' => $query->createNamedParameter(1),
260312
'principaluri' => $query->createNamedParameter('principals/users/user001'),
261313
'displayname' => $query->createNamedParameter('Displayname 123'),
314+
'uri' => $query->createNamedParameter('personal'),
262315
])
263316
->executeStatement();
264317

@@ -268,6 +321,7 @@ private function createRemindersTestSet(): void {
268321
'id' => $query->createNamedParameter(99),
269322
'principaluri' => $query->createNamedParameter('principals/users/user002'),
270323
'displayname' => $query->createNamedParameter('Displayname 99'),
324+
'uri' => $query->createNamedParameter('personal'),
271325
])
272326
->executeStatement();
273327

0 commit comments

Comments
 (0)