Completed
Push — master ( b1f009...daa985 )
by Daniel
34:13 queued 15s
created
apps/dav/lib/CardDAV/CardDavBackend.php 1 patch
Indentation   +1425 added lines, -1425 removed lines patch added patch discarded remove patch
@@ -34,1429 +34,1429 @@
 block discarded – undo
34 34
 use Sabre\VObject\Reader;
35 35
 
36 36
 class CardDavBackend implements BackendInterface, SyncSupport {
37
-	use TTransactional;
38
-	public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
39
-	public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
40
-
41
-	private string $dbCardsTable = 'cards';
42
-	private string $dbCardsPropertiesTable = 'cards_properties';
43
-
44
-	/** @var array properties to index */
45
-	public static array $indexProperties = [
46
-		'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
47
-		'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
48
-		'CLOUD', 'X-SOCIALPROFILE'];
49
-
50
-	/**
51
-	 * @var string[] Map of uid => display name
52
-	 */
53
-	protected array $userDisplayNames;
54
-	private array $etagCache = [];
55
-
56
-	public function __construct(
57
-		private IDBConnection $db,
58
-		private Principal $principalBackend,
59
-		private IUserManager $userManager,
60
-		private IEventDispatcher $dispatcher,
61
-		private Sharing\Backend $sharingBackend,
62
-	) {
63
-	}
64
-
65
-	/**
66
-	 * Return the number of address books for a principal
67
-	 *
68
-	 * @param $principalUri
69
-	 * @return int
70
-	 */
71
-	public function getAddressBooksForUserCount($principalUri) {
72
-		$principalUri = $this->convertPrincipal($principalUri, true);
73
-		$query = $this->db->getQueryBuilder();
74
-		$query->select($query->func()->count('*'))
75
-			->from('addressbooks')
76
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
77
-
78
-		$result = $query->executeQuery();
79
-		$column = (int)$result->fetchOne();
80
-		$result->closeCursor();
81
-		return $column;
82
-	}
83
-
84
-	/**
85
-	 * Returns the list of address books for a specific user.
86
-	 *
87
-	 * Every addressbook should have the following properties:
88
-	 *   id - an arbitrary unique id
89
-	 *   uri - the 'basename' part of the url
90
-	 *   principaluri - Same as the passed parameter
91
-	 *
92
-	 * Any additional clark-notation property may be passed besides this. Some
93
-	 * common ones are :
94
-	 *   {DAV:}displayname
95
-	 *   {urn:ietf:params:xml:ns:carddav}addressbook-description
96
-	 *   {http://calendarserver.org/ns/}getctag
97
-	 *
98
-	 * @param string $principalUri
99
-	 * @return array
100
-	 */
101
-	public function getAddressBooksForUser($principalUri) {
102
-		return $this->atomic(function () use ($principalUri) {
103
-			$principalUriOriginal = $principalUri;
104
-			$principalUri = $this->convertPrincipal($principalUri, true);
105
-			$select = $this->db->getQueryBuilder();
106
-			$select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
107
-				->from('addressbooks')
108
-				->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
109
-
110
-			$addressBooks = [];
111
-
112
-			$result = $select->executeQuery();
113
-			while ($row = $result->fetch()) {
114
-				$addressBooks[$row['id']] = [
115
-					'id' => $row['id'],
116
-					'uri' => $row['uri'],
117
-					'principaluri' => $this->convertPrincipal($row['principaluri'], false),
118
-					'{DAV:}displayname' => $row['displayname'],
119
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
120
-					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
121
-					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
122
-				];
123
-
124
-				$this->addOwnerPrincipal($addressBooks[$row['id']]);
125
-			}
126
-			$result->closeCursor();
127
-
128
-			// query for shared addressbooks
129
-			$principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
130
-
131
-			$principals[] = $principalUri;
132
-
133
-			$select = $this->db->getQueryBuilder();
134
-			$subSelect = $this->db->getQueryBuilder();
135
-
136
-			$subSelect->select('id')
137
-				->from('dav_shares', 'd')
138
-				->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
139
-				->andWhere($subSelect->expr()->eq('d.principaluri', $select->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));
140
-
141
-
142
-			$select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
143
-				->from('dav_shares', 's')
144
-				->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
145
-				->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
146
-				->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
147
-				->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
148
-			$result = $select->executeQuery();
149
-
150
-			$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
151
-			while ($row = $result->fetch()) {
152
-				if ($row['principaluri'] === $principalUri) {
153
-					continue;
154
-				}
155
-
156
-				$readOnly = (int)$row['access'] === Backend::ACCESS_READ;
157
-				if (isset($addressBooks[$row['id']])) {
158
-					if ($readOnly) {
159
-						// New share can not have more permissions then the old one.
160
-						continue;
161
-					}
162
-					if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) &&
163
-						$addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
164
-						// Old share is already read-write, no more permissions can be gained
165
-						continue;
166
-					}
167
-				}
168
-
169
-				[, $name] = \Sabre\Uri\split($row['principaluri']);
170
-				$uri = $row['uri'] . '_shared_by_' . $name;
171
-				$displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
172
-
173
-				$addressBooks[$row['id']] = [
174
-					'id' => $row['id'],
175
-					'uri' => $uri,
176
-					'principaluri' => $principalUriOriginal,
177
-					'{DAV:}displayname' => $displayName,
178
-					'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
179
-					'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
180
-					'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
181
-					'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
182
-					$readOnlyPropertyName => $readOnly,
183
-				];
184
-
185
-				$this->addOwnerPrincipal($addressBooks[$row['id']]);
186
-			}
187
-			$result->closeCursor();
188
-
189
-			return array_values($addressBooks);
190
-		}, $this->db);
191
-	}
192
-
193
-	public function getUsersOwnAddressBooks($principalUri) {
194
-		$principalUri = $this->convertPrincipal($principalUri, true);
195
-		$query = $this->db->getQueryBuilder();
196
-		$query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
197
-			->from('addressbooks')
198
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
199
-
200
-		$addressBooks = [];
201
-
202
-		$result = $query->executeQuery();
203
-		while ($row = $result->fetch()) {
204
-			$addressBooks[$row['id']] = [
205
-				'id' => $row['id'],
206
-				'uri' => $row['uri'],
207
-				'principaluri' => $this->convertPrincipal($row['principaluri'], false),
208
-				'{DAV:}displayname' => $row['displayname'],
209
-				'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
210
-				'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
211
-				'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
212
-			];
213
-
214
-			$this->addOwnerPrincipal($addressBooks[$row['id']]);
215
-		}
216
-		$result->closeCursor();
217
-
218
-		return array_values($addressBooks);
219
-	}
220
-
221
-	/**
222
-	 * @param int $addressBookId
223
-	 */
224
-	public function getAddressBookById(int $addressBookId): ?array {
225
-		$query = $this->db->getQueryBuilder();
226
-		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
227
-			->from('addressbooks')
228
-			->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
229
-			->executeQuery();
230
-		$row = $result->fetch();
231
-		$result->closeCursor();
232
-		if (!$row) {
233
-			return null;
234
-		}
235
-
236
-		$addressBook = [
237
-			'id' => $row['id'],
238
-			'uri' => $row['uri'],
239
-			'principaluri' => $row['principaluri'],
240
-			'{DAV:}displayname' => $row['displayname'],
241
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
242
-			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
243
-			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
244
-		];
245
-
246
-		$this->addOwnerPrincipal($addressBook);
247
-
248
-		return $addressBook;
249
-	}
250
-
251
-	public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
252
-		$query = $this->db->getQueryBuilder();
253
-		$result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
254
-			->from('addressbooks')
255
-			->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
256
-			->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
257
-			->setMaxResults(1)
258
-			->executeQuery();
259
-
260
-		$row = $result->fetch();
261
-		$result->closeCursor();
262
-		if ($row === false) {
263
-			return null;
264
-		}
265
-
266
-		$addressBook = [
267
-			'id' => $row['id'],
268
-			'uri' => $row['uri'],
269
-			'principaluri' => $row['principaluri'],
270
-			'{DAV:}displayname' => $row['displayname'],
271
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
272
-			'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
273
-			'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
274
-
275
-		];
276
-
277
-		// system address books are always read only
278
-		if ($principal === 'principals/system/system') {
279
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
280
-			$addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
281
-		}
282
-
283
-		$this->addOwnerPrincipal($addressBook);
284
-
285
-		return $addressBook;
286
-	}
287
-
288
-	/**
289
-	 * Updates properties for an address book.
290
-	 *
291
-	 * The list of mutations is stored in a Sabre\DAV\PropPatch object.
292
-	 * To do the actual updates, you must tell this object which properties
293
-	 * you're going to process with the handle() method.
294
-	 *
295
-	 * Calling the handle method is like telling the PropPatch object "I
296
-	 * promise I can handle updating this property".
297
-	 *
298
-	 * Read the PropPatch documentation for more info and examples.
299
-	 *
300
-	 * @param string $addressBookId
301
-	 * @param \Sabre\DAV\PropPatch $propPatch
302
-	 * @return void
303
-	 */
304
-	public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
305
-		$supportedProperties = [
306
-			'{DAV:}displayname',
307
-			'{' . Plugin::NS_CARDDAV . '}addressbook-description',
308
-		];
309
-
310
-		$propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
311
-			$updates = [];
312
-			foreach ($mutations as $property => $newValue) {
313
-				switch ($property) {
314
-					case '{DAV:}displayname':
315
-						$updates['displayname'] = $newValue;
316
-						break;
317
-					case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
318
-						$updates['description'] = $newValue;
319
-						break;
320
-				}
321
-			}
322
-			[$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
323
-				$query = $this->db->getQueryBuilder();
324
-				$query->update('addressbooks');
325
-
326
-				foreach ($updates as $key => $value) {
327
-					$query->set($key, $query->createNamedParameter($value));
328
-				}
329
-				$query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
330
-					->executeStatement();
331
-
332
-				$this->addChange($addressBookId, '', 2);
333
-
334
-				$addressBookRow = $this->getAddressBookById((int)$addressBookId);
335
-				$shares = $this->getShares((int)$addressBookId);
336
-				return [$addressBookRow, $shares];
337
-			}, $this->db);
338
-
339
-			$this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
340
-
341
-			return true;
342
-		});
343
-	}
344
-
345
-	/**
346
-	 * Creates a new address book
347
-	 *
348
-	 * @param string $principalUri
349
-	 * @param string $url Just the 'basename' of the url.
350
-	 * @param array $properties
351
-	 * @return int
352
-	 * @throws BadRequest
353
-	 * @throws Exception
354
-	 */
355
-	public function createAddressBook($principalUri, $url, array $properties) {
356
-		if (strlen($url) > 255) {
357
-			throw new BadRequest('URI too long. Address book not created');
358
-		}
359
-
360
-		$values = [
361
-			'displayname' => null,
362
-			'description' => null,
363
-			'principaluri' => $principalUri,
364
-			'uri' => $url,
365
-			'synctoken' => 1
366
-		];
367
-
368
-		foreach ($properties as $property => $newValue) {
369
-			switch ($property) {
370
-				case '{DAV:}displayname':
371
-					$values['displayname'] = $newValue;
372
-					break;
373
-				case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
374
-					$values['description'] = $newValue;
375
-					break;
376
-				default:
377
-					throw new BadRequest('Unknown property: ' . $property);
378
-			}
379
-		}
380
-
381
-		// Fallback to make sure the displayname is set. Some clients may refuse
382
-		// to work with addressbooks not having a displayname.
383
-		if (is_null($values['displayname'])) {
384
-			$values['displayname'] = $url;
385
-		}
386
-
387
-		[$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
388
-			$query = $this->db->getQueryBuilder();
389
-			$query->insert('addressbooks')
390
-				->values([
391
-					'uri' => $query->createParameter('uri'),
392
-					'displayname' => $query->createParameter('displayname'),
393
-					'description' => $query->createParameter('description'),
394
-					'principaluri' => $query->createParameter('principaluri'),
395
-					'synctoken' => $query->createParameter('synctoken'),
396
-				])
397
-				->setParameters($values)
398
-				->executeStatement();
399
-
400
-			$addressBookId = $query->getLastInsertId();
401
-			return [
402
-				$addressBookId,
403
-				$this->getAddressBookById($addressBookId),
404
-			];
405
-		}, $this->db);
406
-
407
-		$this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
408
-
409
-		return $addressBookId;
410
-	}
411
-
412
-	/**
413
-	 * Deletes an entire addressbook and all its contents
414
-	 *
415
-	 * @param mixed $addressBookId
416
-	 * @return void
417
-	 */
418
-	public function deleteAddressBook($addressBookId) {
419
-		$this->atomic(function () use ($addressBookId): void {
420
-			$addressBookId = (int)$addressBookId;
421
-			$addressBookData = $this->getAddressBookById($addressBookId);
422
-			$shares = $this->getShares($addressBookId);
423
-
424
-			$query = $this->db->getQueryBuilder();
425
-			$query->delete($this->dbCardsTable)
426
-				->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
427
-				->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
428
-				->executeStatement();
429
-
430
-			$query = $this->db->getQueryBuilder();
431
-			$query->delete('addressbookchanges')
432
-				->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
433
-				->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
434
-				->executeStatement();
435
-
436
-			$query = $this->db->getQueryBuilder();
437
-			$query->delete('addressbooks')
438
-				->where($query->expr()->eq('id', $query->createParameter('id')))
439
-				->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
440
-				->executeStatement();
441
-
442
-			$this->sharingBackend->deleteAllShares($addressBookId);
443
-
444
-			$query = $this->db->getQueryBuilder();
445
-			$query->delete($this->dbCardsPropertiesTable)
446
-				->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
447
-				->executeStatement();
448
-
449
-			if ($addressBookData) {
450
-				$this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
451
-			}
452
-		}, $this->db);
453
-	}
454
-
455
-	/**
456
-	 * Returns all cards for a specific addressbook id.
457
-	 *
458
-	 * This method should return the following properties for each card:
459
-	 *   * carddata - raw vcard data
460
-	 *   * uri - Some unique url
461
-	 *   * lastmodified - A unix timestamp
462
-	 *
463
-	 * It's recommended to also return the following properties:
464
-	 *   * etag - A unique etag. This must change every time the card changes.
465
-	 *   * size - The size of the card in bytes.
466
-	 *
467
-	 * If these last two properties are provided, less time will be spent
468
-	 * calculating them. If they are specified, you can also omit carddata.
469
-	 * This may speed up certain requests, especially with large cards.
470
-	 *
471
-	 * @param mixed $addressbookId
472
-	 * @return array
473
-	 */
474
-	public function getCards($addressbookId) {
475
-		$query = $this->db->getQueryBuilder();
476
-		$query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
477
-			->from($this->dbCardsTable)
478
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
479
-
480
-		$cards = [];
481
-
482
-		$result = $query->executeQuery();
483
-		while ($row = $result->fetch()) {
484
-			$row['etag'] = '"' . $row['etag'] . '"';
485
-
486
-			$modified = false;
487
-			$row['carddata'] = $this->readBlob($row['carddata'], $modified);
488
-			if ($modified) {
489
-				$row['size'] = strlen($row['carddata']);
490
-			}
491
-
492
-			$cards[] = $row;
493
-		}
494
-		$result->closeCursor();
495
-
496
-		return $cards;
497
-	}
498
-
499
-	/**
500
-	 * Returns a specific card.
501
-	 *
502
-	 * The same set of properties must be returned as with getCards. The only
503
-	 * exception is that 'carddata' is absolutely required.
504
-	 *
505
-	 * If the card does not exist, you must return false.
506
-	 *
507
-	 * @param mixed $addressBookId
508
-	 * @param string $cardUri
509
-	 * @return array
510
-	 */
511
-	public function getCard($addressBookId, $cardUri) {
512
-		$query = $this->db->getQueryBuilder();
513
-		$query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
514
-			->from($this->dbCardsTable)
515
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
516
-			->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
517
-			->setMaxResults(1);
518
-
519
-		$result = $query->executeQuery();
520
-		$row = $result->fetch();
521
-		if (!$row) {
522
-			return false;
523
-		}
524
-		$row['etag'] = '"' . $row['etag'] . '"';
525
-
526
-		$modified = false;
527
-		$row['carddata'] = $this->readBlob($row['carddata'], $modified);
528
-		if ($modified) {
529
-			$row['size'] = strlen($row['carddata']);
530
-		}
531
-
532
-		return $row;
533
-	}
534
-
535
-	/**
536
-	 * Returns a list of cards.
537
-	 *
538
-	 * This method should work identical to getCard, but instead return all the
539
-	 * cards in the list as an array.
540
-	 *
541
-	 * If the backend supports this, it may allow for some speed-ups.
542
-	 *
543
-	 * @param mixed $addressBookId
544
-	 * @param array $uris
545
-	 * @return array
546
-	 */
547
-	public function getMultipleCards($addressBookId, array $uris) {
548
-		if (empty($uris)) {
549
-			return [];
550
-		}
551
-
552
-		$chunks = array_chunk($uris, 100);
553
-		$cards = [];
554
-
555
-		$query = $this->db->getQueryBuilder();
556
-		$query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
557
-			->from($this->dbCardsTable)
558
-			->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
559
-			->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
560
-
561
-		foreach ($chunks as $uris) {
562
-			$query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
563
-			$result = $query->executeQuery();
564
-
565
-			while ($row = $result->fetch()) {
566
-				$row['etag'] = '"' . $row['etag'] . '"';
567
-
568
-				$modified = false;
569
-				$row['carddata'] = $this->readBlob($row['carddata'], $modified);
570
-				if ($modified) {
571
-					$row['size'] = strlen($row['carddata']);
572
-				}
573
-
574
-				$cards[] = $row;
575
-			}
576
-			$result->closeCursor();
577
-		}
578
-		return $cards;
579
-	}
580
-
581
-	/**
582
-	 * Creates a new card.
583
-	 *
584
-	 * The addressbook id will be passed as the first argument. This is the
585
-	 * same id as it is returned from the getAddressBooksForUser method.
586
-	 *
587
-	 * The cardUri is a base uri, and doesn't include the full path. The
588
-	 * cardData argument is the vcard body, and is passed as a string.
589
-	 *
590
-	 * It is possible to return an ETag from this method. This ETag is for the
591
-	 * newly created resource, and must be enclosed with double quotes (that
592
-	 * is, the string itself must contain the double quotes).
593
-	 *
594
-	 * You should only return the ETag if you store the carddata as-is. If a
595
-	 * subsequent GET request on the same card does not have the same body,
596
-	 * byte-by-byte and you did return an ETag here, clients tend to get
597
-	 * confused.
598
-	 *
599
-	 * If you don't return an ETag, you can just return null.
600
-	 *
601
-	 * @param mixed $addressBookId
602
-	 * @param string $cardUri
603
-	 * @param string $cardData
604
-	 * @param bool $checkAlreadyExists
605
-	 * @return string
606
-	 */
607
-	public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
608
-		$etag = md5($cardData);
609
-		$uid = $this->getUID($cardData);
610
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
611
-			if ($checkAlreadyExists) {
612
-				$q = $this->db->getQueryBuilder();
613
-				$q->select('uid')
614
-					->from($this->dbCardsTable)
615
-					->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
616
-					->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
617
-					->setMaxResults(1);
618
-				$result = $q->executeQuery();
619
-				$count = (bool)$result->fetchOne();
620
-				$result->closeCursor();
621
-				if ($count) {
622
-					throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
623
-				}
624
-			}
625
-
626
-			$query = $this->db->getQueryBuilder();
627
-			$query->insert('cards')
628
-				->values([
629
-					'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
630
-					'uri' => $query->createNamedParameter($cardUri),
631
-					'lastmodified' => $query->createNamedParameter(time()),
632
-					'addressbookid' => $query->createNamedParameter($addressBookId),
633
-					'size' => $query->createNamedParameter(strlen($cardData)),
634
-					'etag' => $query->createNamedParameter($etag),
635
-					'uid' => $query->createNamedParameter($uid),
636
-				])
637
-				->executeStatement();
638
-
639
-			$etagCacheKey = "$addressBookId#$cardUri";
640
-			$this->etagCache[$etagCacheKey] = $etag;
641
-
642
-			$this->addChange($addressBookId, $cardUri, 1);
643
-			$this->updateProperties($addressBookId, $cardUri, $cardData);
644
-
645
-			$addressBookData = $this->getAddressBookById($addressBookId);
646
-			$shares = $this->getShares($addressBookId);
647
-			$objectRow = $this->getCard($addressBookId, $cardUri);
648
-			$this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
649
-
650
-			return '"' . $etag . '"';
651
-		}, $this->db);
652
-	}
653
-
654
-	/**
655
-	 * Updates a card.
656
-	 *
657
-	 * The addressbook id will be passed as the first argument. This is the
658
-	 * same id as it is returned from the getAddressBooksForUser method.
659
-	 *
660
-	 * The cardUri is a base uri, and doesn't include the full path. The
661
-	 * cardData argument is the vcard body, and is passed as a string.
662
-	 *
663
-	 * It is possible to return an ETag from this method. This ETag should
664
-	 * match that of the updated resource, and must be enclosed with double
665
-	 * quotes (that is: the string itself must contain the actual quotes).
666
-	 *
667
-	 * You should only return the ETag if you store the carddata as-is. If a
668
-	 * subsequent GET request on the same card does not have the same body,
669
-	 * byte-by-byte and you did return an ETag here, clients tend to get
670
-	 * confused.
671
-	 *
672
-	 * If you don't return an ETag, you can just return null.
673
-	 *
674
-	 * @param mixed $addressBookId
675
-	 * @param string $cardUri
676
-	 * @param string $cardData
677
-	 * @return string
678
-	 */
679
-	public function updateCard($addressBookId, $cardUri, $cardData) {
680
-		$uid = $this->getUID($cardData);
681
-		$etag = md5($cardData);
682
-
683
-		return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
684
-			$query = $this->db->getQueryBuilder();
685
-
686
-			// check for recently stored etag and stop if it is the same
687
-			$etagCacheKey = "$addressBookId#$cardUri";
688
-			if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
689
-				return '"' . $etag . '"';
690
-			}
691
-
692
-			$query->update($this->dbCardsTable)
693
-				->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
694
-				->set('lastmodified', $query->createNamedParameter(time()))
695
-				->set('size', $query->createNamedParameter(strlen($cardData)))
696
-				->set('etag', $query->createNamedParameter($etag))
697
-				->set('uid', $query->createNamedParameter($uid))
698
-				->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
699
-				->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
700
-				->executeStatement();
701
-
702
-			$this->etagCache[$etagCacheKey] = $etag;
703
-
704
-			$this->addChange($addressBookId, $cardUri, 2);
705
-			$this->updateProperties($addressBookId, $cardUri, $cardData);
706
-
707
-			$addressBookData = $this->getAddressBookById($addressBookId);
708
-			$shares = $this->getShares($addressBookId);
709
-			$objectRow = $this->getCard($addressBookId, $cardUri);
710
-			$this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
711
-			return '"' . $etag . '"';
712
-		}, $this->db);
713
-	}
714
-
715
-	/**
716
-	 * @throws Exception
717
-	 */
718
-	public function moveCard(int $sourceAddressBookId, int $targetAddressBookId, string $cardUri, string $oldPrincipalUri): bool {
719
-		return $this->atomic(function () use ($sourceAddressBookId, $targetAddressBookId, $cardUri, $oldPrincipalUri) {
720
-			$card = $this->getCard($sourceAddressBookId, $cardUri);
721
-			if (empty($card)) {
722
-				return false;
723
-			}
724
-
725
-			$query = $this->db->getQueryBuilder();
726
-			$query->update('cards')
727
-				->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
728
-				->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
729
-				->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
730
-				->executeStatement();
731
-
732
-			$this->purgeProperties($sourceAddressBookId, (int)$card['id']);
733
-			$this->updateProperties($sourceAddressBookId, $card['uri'], $card['carddata']);
734
-
735
-			$this->addChange($sourceAddressBookId, $card['uri'], 3);
736
-			$this->addChange($targetAddressBookId, $card['uri'], 1);
737
-
738
-			$card = $this->getCard($targetAddressBookId, $cardUri);
739
-			// Card wasn't found - possibly because it was deleted in the meantime by a different client
740
-			if (empty($card)) {
741
-				return false;
742
-			}
743
-
744
-			$targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
745
-			// the address book this card is being moved to does not exist any longer
746
-			if (empty($targetAddressBookRow)) {
747
-				return false;
748
-			}
749
-
750
-			$sourceShares = $this->getShares($sourceAddressBookId);
751
-			$targetShares = $this->getShares($targetAddressBookId);
752
-			$sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId);
753
-			$this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card));
754
-			return true;
755
-		}, $this->db);
756
-	}
757
-
758
-	/**
759
-	 * Deletes a card
760
-	 *
761
-	 * @param mixed $addressBookId
762
-	 * @param string $cardUri
763
-	 * @return bool
764
-	 */
765
-	public function deleteCard($addressBookId, $cardUri) {
766
-		return $this->atomic(function () use ($addressBookId, $cardUri) {
767
-			$addressBookData = $this->getAddressBookById($addressBookId);
768
-			$shares = $this->getShares($addressBookId);
769
-			$objectRow = $this->getCard($addressBookId, $cardUri);
770
-
771
-			try {
772
-				$cardId = $this->getCardId($addressBookId, $cardUri);
773
-			} catch (\InvalidArgumentException $e) {
774
-				$cardId = null;
775
-			}
776
-			$query = $this->db->getQueryBuilder();
777
-			$ret = $query->delete($this->dbCardsTable)
778
-				->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
779
-				->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
780
-				->executeStatement();
781
-
782
-			$this->addChange($addressBookId, $cardUri, 3);
783
-
784
-			if ($ret === 1) {
785
-				if ($cardId !== null) {
786
-					$this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
787
-					$this->purgeProperties($addressBookId, $cardId);
788
-				}
789
-				return true;
790
-			}
791
-
792
-			return false;
793
-		}, $this->db);
794
-	}
795
-
796
-	/**
797
-	 * The getChanges method returns all the changes that have happened, since
798
-	 * the specified syncToken in the specified address book.
799
-	 *
800
-	 * This function should return an array, such as the following:
801
-	 *
802
-	 * [
803
-	 *   'syncToken' => 'The current synctoken',
804
-	 *   'added'   => [
805
-	 *      'new.txt',
806
-	 *   ],
807
-	 *   'modified'   => [
808
-	 *      'modified.txt',
809
-	 *   ],
810
-	 *   'deleted' => [
811
-	 *      'foo.php.bak',
812
-	 *      'old.txt'
813
-	 *   ]
814
-	 * ];
815
-	 *
816
-	 * The returned syncToken property should reflect the *current* syncToken
817
-	 * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
818
-	 * property. This is needed here too, to ensure the operation is atomic.
819
-	 *
820
-	 * If the $syncToken argument is specified as null, this is an initial
821
-	 * sync, and all members should be reported.
822
-	 *
823
-	 * The modified property is an array of nodenames that have changed since
824
-	 * the last token.
825
-	 *
826
-	 * The deleted property is an array with nodenames, that have been deleted
827
-	 * from collection.
828
-	 *
829
-	 * The $syncLevel argument is basically the 'depth' of the report. If it's
830
-	 * 1, you only have to report changes that happened only directly in
831
-	 * immediate descendants. If it's 2, it should also include changes from
832
-	 * the nodes below the child collections. (grandchildren)
833
-	 *
834
-	 * The $limit argument allows a client to specify how many results should
835
-	 * be returned at most. If the limit is not specified, it should be treated
836
-	 * as infinite.
837
-	 *
838
-	 * If the limit (infinite or not) is higher than you're willing to return,
839
-	 * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
840
-	 *
841
-	 * If the syncToken is expired (due to data cleanup) or unknown, you must
842
-	 * return null.
843
-	 *
844
-	 * The limit is 'suggestive'. You are free to ignore it.
845
-	 *
846
-	 * @param string $addressBookId
847
-	 * @param string $syncToken
848
-	 * @param int $syncLevel
849
-	 * @param int|null $limit
850
-	 * @return array
851
-	 */
852
-	public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
853
-		// Current synctoken
854
-		return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
855
-			$qb = $this->db->getQueryBuilder();
856
-			$qb->select('synctoken')
857
-				->from('addressbooks')
858
-				->where(
859
-					$qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
860
-				);
861
-			$stmt = $qb->executeQuery();
862
-			$currentToken = $stmt->fetchOne();
863
-			$stmt->closeCursor();
864
-
865
-			if (is_null($currentToken)) {
866
-				return [];
867
-			}
868
-
869
-			$result = [
870
-				'syncToken' => $currentToken,
871
-				'added' => [],
872
-				'modified' => [],
873
-				'deleted' => [],
874
-			];
875
-
876
-			if ($syncToken) {
877
-				$qb = $this->db->getQueryBuilder();
878
-				$qb->select('uri', 'operation')
879
-					->from('addressbookchanges')
880
-					->where(
881
-						$qb->expr()->andX(
882
-							$qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
883
-							$qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
884
-							$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
885
-						)
886
-					)->orderBy('synctoken');
887
-
888
-				if (is_int($limit) && $limit > 0) {
889
-					$qb->setMaxResults($limit);
890
-				}
891
-
892
-				// Fetching all changes
893
-				$stmt = $qb->executeQuery();
894
-
895
-				$changes = [];
896
-
897
-				// This loop ensures that any duplicates are overwritten, only the
898
-				// last change on a node is relevant.
899
-				while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
900
-					$changes[$row['uri']] = $row['operation'];
901
-				}
902
-				$stmt->closeCursor();
903
-
904
-				foreach ($changes as $uri => $operation) {
905
-					switch ($operation) {
906
-						case 1:
907
-							$result['added'][] = $uri;
908
-							break;
909
-						case 2:
910
-							$result['modified'][] = $uri;
911
-							break;
912
-						case 3:
913
-							$result['deleted'][] = $uri;
914
-							break;
915
-					}
916
-				}
917
-			} else {
918
-				$qb = $this->db->getQueryBuilder();
919
-				$qb->select('uri')
920
-					->from('cards')
921
-					->where(
922
-						$qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
923
-					);
924
-				// No synctoken supplied, this is the initial sync.
925
-				$stmt = $qb->executeQuery();
926
-				$result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
927
-				$stmt->closeCursor();
928
-			}
929
-			return $result;
930
-		}, $this->db);
931
-	}
932
-
933
-	/**
934
-	 * Adds a change record to the addressbookchanges table.
935
-	 *
936
-	 * @param mixed $addressBookId
937
-	 * @param string $objectUri
938
-	 * @param int $operation 1 = add, 2 = modify, 3 = delete
939
-	 * @return void
940
-	 */
941
-	protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
942
-		$this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
943
-			$query = $this->db->getQueryBuilder();
944
-			$query->select('synctoken')
945
-				->from('addressbooks')
946
-				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
947
-			$result = $query->executeQuery();
948
-			$syncToken = (int)$result->fetchOne();
949
-			$result->closeCursor();
950
-
951
-			$query = $this->db->getQueryBuilder();
952
-			$query->insert('addressbookchanges')
953
-				->values([
954
-					'uri' => $query->createNamedParameter($objectUri),
955
-					'synctoken' => $query->createNamedParameter($syncToken),
956
-					'addressbookid' => $query->createNamedParameter($addressBookId),
957
-					'operation' => $query->createNamedParameter($operation),
958
-					'created_at' => time(),
959
-				])
960
-				->executeStatement();
961
-
962
-			$query = $this->db->getQueryBuilder();
963
-			$query->update('addressbooks')
964
-				->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
965
-				->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
966
-				->executeStatement();
967
-		}, $this->db);
968
-	}
969
-
970
-	/**
971
-	 * @param resource|string $cardData
972
-	 * @param bool $modified
973
-	 * @return string
974
-	 */
975
-	private function readBlob($cardData, &$modified = false) {
976
-		if (is_resource($cardData)) {
977
-			$cardData = stream_get_contents($cardData);
978
-		}
979
-
980
-		// Micro optimisation
981
-		// don't loop through
982
-		if (str_starts_with($cardData, 'PHOTO:data:')) {
983
-			return $cardData;
984
-		}
985
-
986
-		$cardDataArray = explode("\r\n", $cardData);
987
-
988
-		$cardDataFiltered = [];
989
-		$removingPhoto = false;
990
-		foreach ($cardDataArray as $line) {
991
-			if (str_starts_with($line, 'PHOTO:data:')
992
-				&& !str_starts_with($line, 'PHOTO:data:image/')) {
993
-				// Filter out PHOTO data of non-images
994
-				$removingPhoto = true;
995
-				$modified = true;
996
-				continue;
997
-			}
998
-
999
-			if ($removingPhoto) {
1000
-				if (str_starts_with($line, ' ')) {
1001
-					continue;
1002
-				}
1003
-				// No leading space means this is a new property
1004
-				$removingPhoto = false;
1005
-			}
1006
-
1007
-			$cardDataFiltered[] = $line;
1008
-		}
1009
-		return implode("\r\n", $cardDataFiltered);
1010
-	}
1011
-
1012
-	/**
1013
-	 * @param list<array{href: string, commonName: string, readOnly: bool}> $add
1014
-	 * @param list<string> $remove
1015
-	 */
1016
-	public function updateShares(IShareable $shareable, array $add, array $remove): void {
1017
-		$this->atomic(function () use ($shareable, $add, $remove): void {
1018
-			$addressBookId = $shareable->getResourceId();
1019
-			$addressBookData = $this->getAddressBookById($addressBookId);
1020
-			$oldShares = $this->getShares($addressBookId);
1021
-
1022
-			$this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
1023
-
1024
-			$this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
1025
-		}, $this->db);
1026
-	}
1027
-
1028
-	/**
1029
-	 * Search contacts in a specific address-book
1030
-	 *
1031
-	 * @param int $addressBookId
1032
-	 * @param string $pattern which should match within the $searchProperties
1033
-	 * @param array $searchProperties defines the properties within the query pattern should match
1034
-	 * @param array $options = array() to define the search behavior
1035
-	 *                       - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array
1036
-	 *                       - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
1037
-	 *                       - 'limit' - Set a numeric limit for the search results
1038
-	 *                       - 'offset' - Set the offset for the limited search results
1039
-	 *                       - 'wildcard' - Whether the search should use wildcards
1040
-	 * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1041
-	 * @return array an array of contacts which are arrays of key-value-pairs
1042
-	 */
1043
-	public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1044
-		return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1045
-			return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1046
-		}, $this->db);
1047
-	}
1048
-
1049
-	/**
1050
-	 * Search contacts in all address-books accessible by a user
1051
-	 *
1052
-	 * @param string $principalUri
1053
-	 * @param string $pattern
1054
-	 * @param array $searchProperties
1055
-	 * @param array $options
1056
-	 * @return array
1057
-	 */
1058
-	public function searchPrincipalUri(string $principalUri,
1059
-		string $pattern,
1060
-		array $searchProperties,
1061
-		array $options = []): array {
1062
-		return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1063
-			$addressBookIds = array_map(static function ($row):int {
1064
-				return (int)$row['id'];
1065
-			}, $this->getAddressBooksForUser($principalUri));
1066
-
1067
-			return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1068
-		}, $this->db);
1069
-	}
1070
-
1071
-	/**
1072
-	 * @param int[] $addressBookIds
1073
-	 * @param string $pattern
1074
-	 * @param array $searchProperties
1075
-	 * @param array $options
1076
-	 * @psalm-param array{
1077
-	 *   types?: bool,
1078
-	 *   escape_like_param?: bool,
1079
-	 *   limit?: int,
1080
-	 *   offset?: int,
1081
-	 *   wildcard?: bool,
1082
-	 *   since?: DateTimeFilter|null,
1083
-	 *   until?: DateTimeFilter|null,
1084
-	 *   person?: string
1085
-	 * } $options
1086
-	 * @return array
1087
-	 */
1088
-	private function searchByAddressBookIds(array $addressBookIds,
1089
-		string $pattern,
1090
-		array $searchProperties,
1091
-		array $options = []): array {
1092
-		if (empty($addressBookIds)) {
1093
-			return [];
1094
-		}
1095
-		$escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1096
-		$useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1097
-
1098
-		if ($escapePattern) {
1099
-			$searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1100
-				if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1101
-					// There can be no spaces in emails
1102
-					return false;
1103
-				}
1104
-
1105
-				if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1106
-					// There can be no chars in cloud ids which are not valid for user ids plus :/
1107
-					// worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1108
-					return false;
1109
-				}
1110
-
1111
-				return true;
1112
-			});
1113
-		}
1114
-
1115
-		if (empty($searchProperties)) {
1116
-			return [];
1117
-		}
1118
-
1119
-		$query2 = $this->db->getQueryBuilder();
1120
-		$query2->selectDistinct('cp.cardid')
1121
-			->from($this->dbCardsPropertiesTable, 'cp')
1122
-			->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
1123
-			->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY)));
1124
-
1125
-		// No need for like when the pattern is empty
1126
-		if ($pattern !== '') {
1127
-			if (!$useWildcards) {
1128
-				$query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1129
-			} elseif (!$escapePattern) {
1130
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1131
-			} else {
1132
-				$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1133
-			}
1134
-		}
1135
-		if (isset($options['limit'])) {
1136
-			$query2->setMaxResults($options['limit']);
1137
-		}
1138
-		if (isset($options['offset'])) {
1139
-			$query2->setFirstResult($options['offset']);
1140
-		}
1141
-
1142
-		if (isset($options['person'])) {
1143
-			$query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1144
-		}
1145
-		if (isset($options['since']) || isset($options['until'])) {
1146
-			$query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
1147
-			$query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY')));
1148
-			/**
1149
-			 * FIXME Find a way to match only 4 last digits
1150
-			 * BDAY can be --1018 without year or 20001019 with it
1151
-			 * $bDayOr = [];
1152
-			 * if ($options['since'] instanceof DateTimeFilter) {
1153
-			 * $bDayOr[] =
1154
-			 * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)',
1155
-			 * $query2->createNamedParameter($options['since']->get()->format('md'))
1156
-			 * );
1157
-			 * }
1158
-			 * if ($options['until'] instanceof DateTimeFilter) {
1159
-			 * $bDayOr[] =
1160
-			 * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)',
1161
-			 * $query2->createNamedParameter($options['until']->get()->format('md'))
1162
-			 * );
1163
-			 * }
1164
-			 * $query2->andWhere($query2->expr()->orX(...$bDayOr));
1165
-			 */
1166
-		}
1167
-
1168
-		$result = $query2->executeQuery();
1169
-		$matches = $result->fetchAll();
1170
-		$result->closeCursor();
1171
-		$matches = array_map(function ($match) {
1172
-			return (int)$match['cardid'];
1173
-		}, $matches);
1174
-
1175
-		$cards = [];
1176
-		$query = $this->db->getQueryBuilder();
1177
-		$query->select('c.addressbookid', 'c.carddata', 'c.uri')
1178
-			->from($this->dbCardsTable, 'c')
1179
-			->where($query->expr()->in('c.id', $query->createParameter('matches')));
1180
-
1181
-		foreach (array_chunk($matches, 1000) as $matchesChunk) {
1182
-			$query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1183
-			$result = $query->executeQuery();
1184
-			$cards = array_merge($cards, $result->fetchAll());
1185
-			$result->closeCursor();
1186
-		}
1187
-
1188
-		return array_map(function ($array) {
1189
-			$array['addressbookid'] = (int)$array['addressbookid'];
1190
-			$modified = false;
1191
-			$array['carddata'] = $this->readBlob($array['carddata'], $modified);
1192
-			if ($modified) {
1193
-				$array['size'] = strlen($array['carddata']);
1194
-			}
1195
-			return $array;
1196
-		}, $cards);
1197
-	}
1198
-
1199
-	/**
1200
-	 * @param int $bookId
1201
-	 * @param string $name
1202
-	 * @return array
1203
-	 */
1204
-	public function collectCardProperties($bookId, $name) {
1205
-		$query = $this->db->getQueryBuilder();
1206
-		$result = $query->selectDistinct('value')
1207
-			->from($this->dbCardsPropertiesTable)
1208
-			->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1209
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1210
-			->executeQuery();
1211
-
1212
-		$all = $result->fetchAll(PDO::FETCH_COLUMN);
1213
-		$result->closeCursor();
1214
-
1215
-		return $all;
1216
-	}
1217
-
1218
-	/**
1219
-	 * get URI from a given contact
1220
-	 *
1221
-	 * @param int $id
1222
-	 * @return string
1223
-	 */
1224
-	public function getCardUri($id) {
1225
-		$query = $this->db->getQueryBuilder();
1226
-		$query->select('uri')->from($this->dbCardsTable)
1227
-			->where($query->expr()->eq('id', $query->createParameter('id')))
1228
-			->setParameter('id', $id);
1229
-
1230
-		$result = $query->executeQuery();
1231
-		$uri = $result->fetch();
1232
-		$result->closeCursor();
1233
-
1234
-		if (!isset($uri['uri'])) {
1235
-			throw new \InvalidArgumentException('Card does not exists: ' . $id);
1236
-		}
1237
-
1238
-		return $uri['uri'];
1239
-	}
1240
-
1241
-	/**
1242
-	 * return contact with the given URI
1243
-	 *
1244
-	 * @param int $addressBookId
1245
-	 * @param string $uri
1246
-	 * @returns array
1247
-	 */
1248
-	public function getContact($addressBookId, $uri) {
1249
-		$result = [];
1250
-		$query = $this->db->getQueryBuilder();
1251
-		$query->select('*')->from($this->dbCardsTable)
1252
-			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1253
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1254
-		$queryResult = $query->executeQuery();
1255
-		$contact = $queryResult->fetch();
1256
-		$queryResult->closeCursor();
1257
-
1258
-		if (is_array($contact)) {
1259
-			$modified = false;
1260
-			$contact['etag'] = '"' . $contact['etag'] . '"';
1261
-			$contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1262
-			if ($modified) {
1263
-				$contact['size'] = strlen($contact['carddata']);
1264
-			}
1265
-
1266
-			$result = $contact;
1267
-		}
1268
-
1269
-		return $result;
1270
-	}
1271
-
1272
-	/**
1273
-	 * Returns the list of people whom this address book is shared with.
1274
-	 *
1275
-	 * Every element in this array should have the following properties:
1276
-	 *   * href - Often a mailto: address
1277
-	 *   * commonName - Optional, for example a first + last name
1278
-	 *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1279
-	 *   * readOnly - boolean
1280
-	 *
1281
-	 * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1282
-	 */
1283
-	public function getShares(int $addressBookId): array {
1284
-		return $this->sharingBackend->getShares($addressBookId);
1285
-	}
1286
-
1287
-	/**
1288
-	 * update properties table
1289
-	 *
1290
-	 * @param int $addressBookId
1291
-	 * @param string $cardUri
1292
-	 * @param string $vCardSerialized
1293
-	 */
1294
-	protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1295
-		$this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1296
-			$cardId = $this->getCardId($addressBookId, $cardUri);
1297
-			$vCard = $this->readCard($vCardSerialized);
1298
-
1299
-			$this->purgeProperties($addressBookId, $cardId);
1300
-
1301
-			$query = $this->db->getQueryBuilder();
1302
-			$query->insert($this->dbCardsPropertiesTable)
1303
-				->values(
1304
-					[
1305
-						'addressbookid' => $query->createNamedParameter($addressBookId),
1306
-						'cardid' => $query->createNamedParameter($cardId),
1307
-						'name' => $query->createParameter('name'),
1308
-						'value' => $query->createParameter('value'),
1309
-						'preferred' => $query->createParameter('preferred')
1310
-					]
1311
-				);
1312
-
1313
-			foreach ($vCard->children() as $property) {
1314
-				if (!in_array($property->name, self::$indexProperties)) {
1315
-					continue;
1316
-				}
1317
-				$preferred = 0;
1318
-				foreach ($property->parameters as $parameter) {
1319
-					if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1320
-						$preferred = 1;
1321
-						break;
1322
-					}
1323
-				}
1324
-				$query->setParameter('name', $property->name);
1325
-				$query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1326
-				$query->setParameter('preferred', $preferred);
1327
-				$query->executeStatement();
1328
-			}
1329
-		}, $this->db);
1330
-	}
1331
-
1332
-	/**
1333
-	 * read vCard data into a vCard object
1334
-	 *
1335
-	 * @param string $cardData
1336
-	 * @return VCard
1337
-	 */
1338
-	protected function readCard($cardData) {
1339
-		return Reader::read($cardData);
1340
-	}
1341
-
1342
-	/**
1343
-	 * delete all properties from a given card
1344
-	 *
1345
-	 * @param int $addressBookId
1346
-	 * @param int $cardId
1347
-	 */
1348
-	protected function purgeProperties($addressBookId, $cardId) {
1349
-		$query = $this->db->getQueryBuilder();
1350
-		$query->delete($this->dbCardsPropertiesTable)
1351
-			->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1352
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1353
-		$query->executeStatement();
1354
-	}
1355
-
1356
-	/**
1357
-	 * Get ID from a given contact
1358
-	 */
1359
-	protected function getCardId(int $addressBookId, string $uri): int {
1360
-		$query = $this->db->getQueryBuilder();
1361
-		$query->select('id')->from($this->dbCardsTable)
1362
-			->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1363
-			->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1364
-
1365
-		$result = $query->executeQuery();
1366
-		$cardIds = $result->fetch();
1367
-		$result->closeCursor();
1368
-
1369
-		if (!isset($cardIds['id'])) {
1370
-			throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1371
-		}
1372
-
1373
-		return (int)$cardIds['id'];
1374
-	}
1375
-
1376
-	/**
1377
-	 * For shared address books the sharee is set in the ACL of the address book
1378
-	 *
1379
-	 * @param int $addressBookId
1380
-	 * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1381
-	 * @return list<array{privilege: string, principal: string, protected: bool}>
1382
-	 */
1383
-	public function applyShareAcl(int $addressBookId, array $acl): array {
1384
-		$shares = $this->sharingBackend->getShares($addressBookId);
1385
-		return $this->sharingBackend->applyShareAcl($shares, $acl);
1386
-	}
1387
-
1388
-	/**
1389
-	 * @throws \InvalidArgumentException
1390
-	 */
1391
-	public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
1392
-		if ($keep < 0) {
1393
-			throw new \InvalidArgumentException();
1394
-		}
1395
-
1396
-		$query = $this->db->getQueryBuilder();
1397
-		$query->select($query->func()->max('id'))
1398
-			->from('addressbookchanges');
1399
-
1400
-		$result = $query->executeQuery();
1401
-		$maxId = (int)$result->fetchOne();
1402
-		$result->closeCursor();
1403
-		if (!$maxId || $maxId < $keep) {
1404
-			return 0;
1405
-		}
1406
-
1407
-		$query = $this->db->getQueryBuilder();
1408
-		$query->delete('addressbookchanges')
1409
-			->where(
1410
-				$query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
1411
-				$query->expr()->lte('created_at', $query->createNamedParameter($retention)),
1412
-			);
1413
-		return $query->executeStatement();
1414
-	}
1415
-
1416
-	private function convertPrincipal(string $principalUri, bool $toV2): string {
1417
-		if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1418
-			[, $name] = \Sabre\Uri\split($principalUri);
1419
-			if ($toV2 === true) {
1420
-				return "principals/users/$name";
1421
-			}
1422
-			return "principals/$name";
1423
-		}
1424
-		return $principalUri;
1425
-	}
1426
-
1427
-	private function addOwnerPrincipal(array &$addressbookInfo): void {
1428
-		$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1429
-		$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1430
-		if (isset($addressbookInfo[$ownerPrincipalKey])) {
1431
-			$uri = $addressbookInfo[$ownerPrincipalKey];
1432
-		} else {
1433
-			$uri = $addressbookInfo['principaluri'];
1434
-		}
1435
-
1436
-		$principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1437
-		if (isset($principalInformation['{DAV:}displayname'])) {
1438
-			$addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1439
-		}
1440
-	}
1441
-
1442
-	/**
1443
-	 * Extract UID from vcard
1444
-	 *
1445
-	 * @param string $cardData the vcard raw data
1446
-	 * @return string the uid
1447
-	 * @throws BadRequest if no UID is available or vcard is empty
1448
-	 */
1449
-	private function getUID(string $cardData): string {
1450
-		if ($cardData !== '') {
1451
-			$vCard = Reader::read($cardData);
1452
-			if ($vCard->UID) {
1453
-				$uid = $vCard->UID->getValue();
1454
-				return $uid;
1455
-			}
1456
-			// should already be handled, but just in case
1457
-			throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1458
-		}
1459
-		// should already be handled, but just in case
1460
-		throw new BadRequest('vCard can not be empty');
1461
-	}
37
+    use TTransactional;
38
+    public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
39
+    public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
40
+
41
+    private string $dbCardsTable = 'cards';
42
+    private string $dbCardsPropertiesTable = 'cards_properties';
43
+
44
+    /** @var array properties to index */
45
+    public static array $indexProperties = [
46
+        'BDAY', 'UID', 'N', 'FN', 'TITLE', 'ROLE', 'NOTE', 'NICKNAME',
47
+        'ORG', 'CATEGORIES', 'EMAIL', 'TEL', 'IMPP', 'ADR', 'URL', 'GEO',
48
+        'CLOUD', 'X-SOCIALPROFILE'];
49
+
50
+    /**
51
+     * @var string[] Map of uid => display name
52
+     */
53
+    protected array $userDisplayNames;
54
+    private array $etagCache = [];
55
+
56
+    public function __construct(
57
+        private IDBConnection $db,
58
+        private Principal $principalBackend,
59
+        private IUserManager $userManager,
60
+        private IEventDispatcher $dispatcher,
61
+        private Sharing\Backend $sharingBackend,
62
+    ) {
63
+    }
64
+
65
+    /**
66
+     * Return the number of address books for a principal
67
+     *
68
+     * @param $principalUri
69
+     * @return int
70
+     */
71
+    public function getAddressBooksForUserCount($principalUri) {
72
+        $principalUri = $this->convertPrincipal($principalUri, true);
73
+        $query = $this->db->getQueryBuilder();
74
+        $query->select($query->func()->count('*'))
75
+            ->from('addressbooks')
76
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
77
+
78
+        $result = $query->executeQuery();
79
+        $column = (int)$result->fetchOne();
80
+        $result->closeCursor();
81
+        return $column;
82
+    }
83
+
84
+    /**
85
+     * Returns the list of address books for a specific user.
86
+     *
87
+     * Every addressbook should have the following properties:
88
+     *   id - an arbitrary unique id
89
+     *   uri - the 'basename' part of the url
90
+     *   principaluri - Same as the passed parameter
91
+     *
92
+     * Any additional clark-notation property may be passed besides this. Some
93
+     * common ones are :
94
+     *   {DAV:}displayname
95
+     *   {urn:ietf:params:xml:ns:carddav}addressbook-description
96
+     *   {http://calendarserver.org/ns/}getctag
97
+     *
98
+     * @param string $principalUri
99
+     * @return array
100
+     */
101
+    public function getAddressBooksForUser($principalUri) {
102
+        return $this->atomic(function () use ($principalUri) {
103
+            $principalUriOriginal = $principalUri;
104
+            $principalUri = $this->convertPrincipal($principalUri, true);
105
+            $select = $this->db->getQueryBuilder();
106
+            $select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
107
+                ->from('addressbooks')
108
+                ->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
109
+
110
+            $addressBooks = [];
111
+
112
+            $result = $select->executeQuery();
113
+            while ($row = $result->fetch()) {
114
+                $addressBooks[$row['id']] = [
115
+                    'id' => $row['id'],
116
+                    'uri' => $row['uri'],
117
+                    'principaluri' => $this->convertPrincipal($row['principaluri'], false),
118
+                    '{DAV:}displayname' => $row['displayname'],
119
+                    '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
120
+                    '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
121
+                    '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
122
+                ];
123
+
124
+                $this->addOwnerPrincipal($addressBooks[$row['id']]);
125
+            }
126
+            $result->closeCursor();
127
+
128
+            // query for shared addressbooks
129
+            $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
130
+
131
+            $principals[] = $principalUri;
132
+
133
+            $select = $this->db->getQueryBuilder();
134
+            $subSelect = $this->db->getQueryBuilder();
135
+
136
+            $subSelect->select('id')
137
+                ->from('dav_shares', 'd')
138
+                ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
139
+                ->andWhere($subSelect->expr()->eq('d.principaluri', $select->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));
140
+
141
+
142
+            $select->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
143
+                ->from('dav_shares', 's')
144
+                ->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
145
+                ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
146
+                ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
147
+                ->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
148
+            $result = $select->executeQuery();
149
+
150
+            $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
151
+            while ($row = $result->fetch()) {
152
+                if ($row['principaluri'] === $principalUri) {
153
+                    continue;
154
+                }
155
+
156
+                $readOnly = (int)$row['access'] === Backend::ACCESS_READ;
157
+                if (isset($addressBooks[$row['id']])) {
158
+                    if ($readOnly) {
159
+                        // New share can not have more permissions then the old one.
160
+                        continue;
161
+                    }
162
+                    if (isset($addressBooks[$row['id']][$readOnlyPropertyName]) &&
163
+                        $addressBooks[$row['id']][$readOnlyPropertyName] === 0) {
164
+                        // Old share is already read-write, no more permissions can be gained
165
+                        continue;
166
+                    }
167
+                }
168
+
169
+                [, $name] = \Sabre\Uri\split($row['principaluri']);
170
+                $uri = $row['uri'] . '_shared_by_' . $name;
171
+                $displayName = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? $name ?? '') . ')';
172
+
173
+                $addressBooks[$row['id']] = [
174
+                    'id' => $row['id'],
175
+                    'uri' => $uri,
176
+                    'principaluri' => $principalUriOriginal,
177
+                    '{DAV:}displayname' => $displayName,
178
+                    '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
179
+                    '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
180
+                    '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
181
+                    '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $row['principaluri'],
182
+                    $readOnlyPropertyName => $readOnly,
183
+                ];
184
+
185
+                $this->addOwnerPrincipal($addressBooks[$row['id']]);
186
+            }
187
+            $result->closeCursor();
188
+
189
+            return array_values($addressBooks);
190
+        }, $this->db);
191
+    }
192
+
193
+    public function getUsersOwnAddressBooks($principalUri) {
194
+        $principalUri = $this->convertPrincipal($principalUri, true);
195
+        $query = $this->db->getQueryBuilder();
196
+        $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
197
+            ->from('addressbooks')
198
+            ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
199
+
200
+        $addressBooks = [];
201
+
202
+        $result = $query->executeQuery();
203
+        while ($row = $result->fetch()) {
204
+            $addressBooks[$row['id']] = [
205
+                'id' => $row['id'],
206
+                'uri' => $row['uri'],
207
+                'principaluri' => $this->convertPrincipal($row['principaluri'], false),
208
+                '{DAV:}displayname' => $row['displayname'],
209
+                '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
210
+                '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
211
+                '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
212
+            ];
213
+
214
+            $this->addOwnerPrincipal($addressBooks[$row['id']]);
215
+        }
216
+        $result->closeCursor();
217
+
218
+        return array_values($addressBooks);
219
+    }
220
+
221
+    /**
222
+     * @param int $addressBookId
223
+     */
224
+    public function getAddressBookById(int $addressBookId): ?array {
225
+        $query = $this->db->getQueryBuilder();
226
+        $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
227
+            ->from('addressbooks')
228
+            ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
229
+            ->executeQuery();
230
+        $row = $result->fetch();
231
+        $result->closeCursor();
232
+        if (!$row) {
233
+            return null;
234
+        }
235
+
236
+        $addressBook = [
237
+            'id' => $row['id'],
238
+            'uri' => $row['uri'],
239
+            'principaluri' => $row['principaluri'],
240
+            '{DAV:}displayname' => $row['displayname'],
241
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
242
+            '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
243
+            '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
244
+        ];
245
+
246
+        $this->addOwnerPrincipal($addressBook);
247
+
248
+        return $addressBook;
249
+    }
250
+
251
+    public function getAddressBooksByUri(string $principal, string $addressBookUri): ?array {
252
+        $query = $this->db->getQueryBuilder();
253
+        $result = $query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
254
+            ->from('addressbooks')
255
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($addressBookUri)))
256
+            ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
257
+            ->setMaxResults(1)
258
+            ->executeQuery();
259
+
260
+        $row = $result->fetch();
261
+        $result->closeCursor();
262
+        if ($row === false) {
263
+            return null;
264
+        }
265
+
266
+        $addressBook = [
267
+            'id' => $row['id'],
268
+            'uri' => $row['uri'],
269
+            'principaluri' => $row['principaluri'],
270
+            '{DAV:}displayname' => $row['displayname'],
271
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
272
+            '{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
273
+            '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
274
+
275
+        ];
276
+
277
+        // system address books are always read only
278
+        if ($principal === 'principals/system/system') {
279
+            $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal'] = $row['principaluri'];
280
+            $addressBook['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'] = true;
281
+        }
282
+
283
+        $this->addOwnerPrincipal($addressBook);
284
+
285
+        return $addressBook;
286
+    }
287
+
288
+    /**
289
+     * Updates properties for an address book.
290
+     *
291
+     * The list of mutations is stored in a Sabre\DAV\PropPatch object.
292
+     * To do the actual updates, you must tell this object which properties
293
+     * you're going to process with the handle() method.
294
+     *
295
+     * Calling the handle method is like telling the PropPatch object "I
296
+     * promise I can handle updating this property".
297
+     *
298
+     * Read the PropPatch documentation for more info and examples.
299
+     *
300
+     * @param string $addressBookId
301
+     * @param \Sabre\DAV\PropPatch $propPatch
302
+     * @return void
303
+     */
304
+    public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
305
+        $supportedProperties = [
306
+            '{DAV:}displayname',
307
+            '{' . Plugin::NS_CARDDAV . '}addressbook-description',
308
+        ];
309
+
310
+        $propPatch->handle($supportedProperties, function ($mutations) use ($addressBookId) {
311
+            $updates = [];
312
+            foreach ($mutations as $property => $newValue) {
313
+                switch ($property) {
314
+                    case '{DAV:}displayname':
315
+                        $updates['displayname'] = $newValue;
316
+                        break;
317
+                    case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
318
+                        $updates['description'] = $newValue;
319
+                        break;
320
+                }
321
+            }
322
+            [$addressBookRow, $shares] = $this->atomic(function () use ($addressBookId, $updates) {
323
+                $query = $this->db->getQueryBuilder();
324
+                $query->update('addressbooks');
325
+
326
+                foreach ($updates as $key => $value) {
327
+                    $query->set($key, $query->createNamedParameter($value));
328
+                }
329
+                $query->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
330
+                    ->executeStatement();
331
+
332
+                $this->addChange($addressBookId, '', 2);
333
+
334
+                $addressBookRow = $this->getAddressBookById((int)$addressBookId);
335
+                $shares = $this->getShares((int)$addressBookId);
336
+                return [$addressBookRow, $shares];
337
+            }, $this->db);
338
+
339
+            $this->dispatcher->dispatchTyped(new AddressBookUpdatedEvent((int)$addressBookId, $addressBookRow, $shares, $mutations));
340
+
341
+            return true;
342
+        });
343
+    }
344
+
345
+    /**
346
+     * Creates a new address book
347
+     *
348
+     * @param string $principalUri
349
+     * @param string $url Just the 'basename' of the url.
350
+     * @param array $properties
351
+     * @return int
352
+     * @throws BadRequest
353
+     * @throws Exception
354
+     */
355
+    public function createAddressBook($principalUri, $url, array $properties) {
356
+        if (strlen($url) > 255) {
357
+            throw new BadRequest('URI too long. Address book not created');
358
+        }
359
+
360
+        $values = [
361
+            'displayname' => null,
362
+            'description' => null,
363
+            'principaluri' => $principalUri,
364
+            'uri' => $url,
365
+            'synctoken' => 1
366
+        ];
367
+
368
+        foreach ($properties as $property => $newValue) {
369
+            switch ($property) {
370
+                case '{DAV:}displayname':
371
+                    $values['displayname'] = $newValue;
372
+                    break;
373
+                case '{' . Plugin::NS_CARDDAV . '}addressbook-description':
374
+                    $values['description'] = $newValue;
375
+                    break;
376
+                default:
377
+                    throw new BadRequest('Unknown property: ' . $property);
378
+            }
379
+        }
380
+
381
+        // Fallback to make sure the displayname is set. Some clients may refuse
382
+        // to work with addressbooks not having a displayname.
383
+        if (is_null($values['displayname'])) {
384
+            $values['displayname'] = $url;
385
+        }
386
+
387
+        [$addressBookId, $addressBookRow] = $this->atomic(function () use ($values) {
388
+            $query = $this->db->getQueryBuilder();
389
+            $query->insert('addressbooks')
390
+                ->values([
391
+                    'uri' => $query->createParameter('uri'),
392
+                    'displayname' => $query->createParameter('displayname'),
393
+                    'description' => $query->createParameter('description'),
394
+                    'principaluri' => $query->createParameter('principaluri'),
395
+                    'synctoken' => $query->createParameter('synctoken'),
396
+                ])
397
+                ->setParameters($values)
398
+                ->executeStatement();
399
+
400
+            $addressBookId = $query->getLastInsertId();
401
+            return [
402
+                $addressBookId,
403
+                $this->getAddressBookById($addressBookId),
404
+            ];
405
+        }, $this->db);
406
+
407
+        $this->dispatcher->dispatchTyped(new AddressBookCreatedEvent($addressBookId, $addressBookRow));
408
+
409
+        return $addressBookId;
410
+    }
411
+
412
+    /**
413
+     * Deletes an entire addressbook and all its contents
414
+     *
415
+     * @param mixed $addressBookId
416
+     * @return void
417
+     */
418
+    public function deleteAddressBook($addressBookId) {
419
+        $this->atomic(function () use ($addressBookId): void {
420
+            $addressBookId = (int)$addressBookId;
421
+            $addressBookData = $this->getAddressBookById($addressBookId);
422
+            $shares = $this->getShares($addressBookId);
423
+
424
+            $query = $this->db->getQueryBuilder();
425
+            $query->delete($this->dbCardsTable)
426
+                ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
427
+                ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
428
+                ->executeStatement();
429
+
430
+            $query = $this->db->getQueryBuilder();
431
+            $query->delete('addressbookchanges')
432
+                ->where($query->expr()->eq('addressbookid', $query->createParameter('addressbookid')))
433
+                ->setParameter('addressbookid', $addressBookId, IQueryBuilder::PARAM_INT)
434
+                ->executeStatement();
435
+
436
+            $query = $this->db->getQueryBuilder();
437
+            $query->delete('addressbooks')
438
+                ->where($query->expr()->eq('id', $query->createParameter('id')))
439
+                ->setParameter('id', $addressBookId, IQueryBuilder::PARAM_INT)
440
+                ->executeStatement();
441
+
442
+            $this->sharingBackend->deleteAllShares($addressBookId);
443
+
444
+            $query = $this->db->getQueryBuilder();
445
+            $query->delete($this->dbCardsPropertiesTable)
446
+                ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
447
+                ->executeStatement();
448
+
449
+            if ($addressBookData) {
450
+                $this->dispatcher->dispatchTyped(new AddressBookDeletedEvent($addressBookId, $addressBookData, $shares));
451
+            }
452
+        }, $this->db);
453
+    }
454
+
455
+    /**
456
+     * Returns all cards for a specific addressbook id.
457
+     *
458
+     * This method should return the following properties for each card:
459
+     *   * carddata - raw vcard data
460
+     *   * uri - Some unique url
461
+     *   * lastmodified - A unix timestamp
462
+     *
463
+     * It's recommended to also return the following properties:
464
+     *   * etag - A unique etag. This must change every time the card changes.
465
+     *   * size - The size of the card in bytes.
466
+     *
467
+     * If these last two properties are provided, less time will be spent
468
+     * calculating them. If they are specified, you can also omit carddata.
469
+     * This may speed up certain requests, especially with large cards.
470
+     *
471
+     * @param mixed $addressbookId
472
+     * @return array
473
+     */
474
+    public function getCards($addressbookId) {
475
+        $query = $this->db->getQueryBuilder();
476
+        $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
477
+            ->from($this->dbCardsTable)
478
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressbookId)));
479
+
480
+        $cards = [];
481
+
482
+        $result = $query->executeQuery();
483
+        while ($row = $result->fetch()) {
484
+            $row['etag'] = '"' . $row['etag'] . '"';
485
+
486
+            $modified = false;
487
+            $row['carddata'] = $this->readBlob($row['carddata'], $modified);
488
+            if ($modified) {
489
+                $row['size'] = strlen($row['carddata']);
490
+            }
491
+
492
+            $cards[] = $row;
493
+        }
494
+        $result->closeCursor();
495
+
496
+        return $cards;
497
+    }
498
+
499
+    /**
500
+     * Returns a specific card.
501
+     *
502
+     * The same set of properties must be returned as with getCards. The only
503
+     * exception is that 'carddata' is absolutely required.
504
+     *
505
+     * If the card does not exist, you must return false.
506
+     *
507
+     * @param mixed $addressBookId
508
+     * @param string $cardUri
509
+     * @return array
510
+     */
511
+    public function getCard($addressBookId, $cardUri) {
512
+        $query = $this->db->getQueryBuilder();
513
+        $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
514
+            ->from($this->dbCardsTable)
515
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
516
+            ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
517
+            ->setMaxResults(1);
518
+
519
+        $result = $query->executeQuery();
520
+        $row = $result->fetch();
521
+        if (!$row) {
522
+            return false;
523
+        }
524
+        $row['etag'] = '"' . $row['etag'] . '"';
525
+
526
+        $modified = false;
527
+        $row['carddata'] = $this->readBlob($row['carddata'], $modified);
528
+        if ($modified) {
529
+            $row['size'] = strlen($row['carddata']);
530
+        }
531
+
532
+        return $row;
533
+    }
534
+
535
+    /**
536
+     * Returns a list of cards.
537
+     *
538
+     * This method should work identical to getCard, but instead return all the
539
+     * cards in the list as an array.
540
+     *
541
+     * If the backend supports this, it may allow for some speed-ups.
542
+     *
543
+     * @param mixed $addressBookId
544
+     * @param array $uris
545
+     * @return array
546
+     */
547
+    public function getMultipleCards($addressBookId, array $uris) {
548
+        if (empty($uris)) {
549
+            return [];
550
+        }
551
+
552
+        $chunks = array_chunk($uris, 100);
553
+        $cards = [];
554
+
555
+        $query = $this->db->getQueryBuilder();
556
+        $query->select(['id', 'uri', 'lastmodified', 'etag', 'size', 'carddata', 'uid'])
557
+            ->from($this->dbCardsTable)
558
+            ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
559
+            ->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
560
+
561
+        foreach ($chunks as $uris) {
562
+            $query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
563
+            $result = $query->executeQuery();
564
+
565
+            while ($row = $result->fetch()) {
566
+                $row['etag'] = '"' . $row['etag'] . '"';
567
+
568
+                $modified = false;
569
+                $row['carddata'] = $this->readBlob($row['carddata'], $modified);
570
+                if ($modified) {
571
+                    $row['size'] = strlen($row['carddata']);
572
+                }
573
+
574
+                $cards[] = $row;
575
+            }
576
+            $result->closeCursor();
577
+        }
578
+        return $cards;
579
+    }
580
+
581
+    /**
582
+     * Creates a new card.
583
+     *
584
+     * The addressbook id will be passed as the first argument. This is the
585
+     * same id as it is returned from the getAddressBooksForUser method.
586
+     *
587
+     * The cardUri is a base uri, and doesn't include the full path. The
588
+     * cardData argument is the vcard body, and is passed as a string.
589
+     *
590
+     * It is possible to return an ETag from this method. This ETag is for the
591
+     * newly created resource, and must be enclosed with double quotes (that
592
+     * is, the string itself must contain the double quotes).
593
+     *
594
+     * You should only return the ETag if you store the carddata as-is. If a
595
+     * subsequent GET request on the same card does not have the same body,
596
+     * byte-by-byte and you did return an ETag here, clients tend to get
597
+     * confused.
598
+     *
599
+     * If you don't return an ETag, you can just return null.
600
+     *
601
+     * @param mixed $addressBookId
602
+     * @param string $cardUri
603
+     * @param string $cardData
604
+     * @param bool $checkAlreadyExists
605
+     * @return string
606
+     */
607
+    public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
608
+        $etag = md5($cardData);
609
+        $uid = $this->getUID($cardData);
610
+        return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
611
+            if ($checkAlreadyExists) {
612
+                $q = $this->db->getQueryBuilder();
613
+                $q->select('uid')
614
+                    ->from($this->dbCardsTable)
615
+                    ->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
616
+                    ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
617
+                    ->setMaxResults(1);
618
+                $result = $q->executeQuery();
619
+                $count = (bool)$result->fetchOne();
620
+                $result->closeCursor();
621
+                if ($count) {
622
+                    throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
623
+                }
624
+            }
625
+
626
+            $query = $this->db->getQueryBuilder();
627
+            $query->insert('cards')
628
+                ->values([
629
+                    'carddata' => $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB),
630
+                    'uri' => $query->createNamedParameter($cardUri),
631
+                    'lastmodified' => $query->createNamedParameter(time()),
632
+                    'addressbookid' => $query->createNamedParameter($addressBookId),
633
+                    'size' => $query->createNamedParameter(strlen($cardData)),
634
+                    'etag' => $query->createNamedParameter($etag),
635
+                    'uid' => $query->createNamedParameter($uid),
636
+                ])
637
+                ->executeStatement();
638
+
639
+            $etagCacheKey = "$addressBookId#$cardUri";
640
+            $this->etagCache[$etagCacheKey] = $etag;
641
+
642
+            $this->addChange($addressBookId, $cardUri, 1);
643
+            $this->updateProperties($addressBookId, $cardUri, $cardData);
644
+
645
+            $addressBookData = $this->getAddressBookById($addressBookId);
646
+            $shares = $this->getShares($addressBookId);
647
+            $objectRow = $this->getCard($addressBookId, $cardUri);
648
+            $this->dispatcher->dispatchTyped(new CardCreatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
649
+
650
+            return '"' . $etag . '"';
651
+        }, $this->db);
652
+    }
653
+
654
+    /**
655
+     * Updates a card.
656
+     *
657
+     * The addressbook id will be passed as the first argument. This is the
658
+     * same id as it is returned from the getAddressBooksForUser method.
659
+     *
660
+     * The cardUri is a base uri, and doesn't include the full path. The
661
+     * cardData argument is the vcard body, and is passed as a string.
662
+     *
663
+     * It is possible to return an ETag from this method. This ETag should
664
+     * match that of the updated resource, and must be enclosed with double
665
+     * quotes (that is: the string itself must contain the actual quotes).
666
+     *
667
+     * You should only return the ETag if you store the carddata as-is. If a
668
+     * subsequent GET request on the same card does not have the same body,
669
+     * byte-by-byte and you did return an ETag here, clients tend to get
670
+     * confused.
671
+     *
672
+     * If you don't return an ETag, you can just return null.
673
+     *
674
+     * @param mixed $addressBookId
675
+     * @param string $cardUri
676
+     * @param string $cardData
677
+     * @return string
678
+     */
679
+    public function updateCard($addressBookId, $cardUri, $cardData) {
680
+        $uid = $this->getUID($cardData);
681
+        $etag = md5($cardData);
682
+
683
+        return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $uid, $etag) {
684
+            $query = $this->db->getQueryBuilder();
685
+
686
+            // check for recently stored etag and stop if it is the same
687
+            $etagCacheKey = "$addressBookId#$cardUri";
688
+            if (isset($this->etagCache[$etagCacheKey]) && $this->etagCache[$etagCacheKey] === $etag) {
689
+                return '"' . $etag . '"';
690
+            }
691
+
692
+            $query->update($this->dbCardsTable)
693
+                ->set('carddata', $query->createNamedParameter($cardData, IQueryBuilder::PARAM_LOB))
694
+                ->set('lastmodified', $query->createNamedParameter(time()))
695
+                ->set('size', $query->createNamedParameter(strlen($cardData)))
696
+                ->set('etag', $query->createNamedParameter($etag))
697
+                ->set('uid', $query->createNamedParameter($uid))
698
+                ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
699
+                ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
700
+                ->executeStatement();
701
+
702
+            $this->etagCache[$etagCacheKey] = $etag;
703
+
704
+            $this->addChange($addressBookId, $cardUri, 2);
705
+            $this->updateProperties($addressBookId, $cardUri, $cardData);
706
+
707
+            $addressBookData = $this->getAddressBookById($addressBookId);
708
+            $shares = $this->getShares($addressBookId);
709
+            $objectRow = $this->getCard($addressBookId, $cardUri);
710
+            $this->dispatcher->dispatchTyped(new CardUpdatedEvent($addressBookId, $addressBookData, $shares, $objectRow));
711
+            return '"' . $etag . '"';
712
+        }, $this->db);
713
+    }
714
+
715
+    /**
716
+     * @throws Exception
717
+     */
718
+    public function moveCard(int $sourceAddressBookId, int $targetAddressBookId, string $cardUri, string $oldPrincipalUri): bool {
719
+        return $this->atomic(function () use ($sourceAddressBookId, $targetAddressBookId, $cardUri, $oldPrincipalUri) {
720
+            $card = $this->getCard($sourceAddressBookId, $cardUri);
721
+            if (empty($card)) {
722
+                return false;
723
+            }
724
+
725
+            $query = $this->db->getQueryBuilder();
726
+            $query->update('cards')
727
+                ->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
728
+                ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
729
+                ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
730
+                ->executeStatement();
731
+
732
+            $this->purgeProperties($sourceAddressBookId, (int)$card['id']);
733
+            $this->updateProperties($sourceAddressBookId, $card['uri'], $card['carddata']);
734
+
735
+            $this->addChange($sourceAddressBookId, $card['uri'], 3);
736
+            $this->addChange($targetAddressBookId, $card['uri'], 1);
737
+
738
+            $card = $this->getCard($targetAddressBookId, $cardUri);
739
+            // Card wasn't found - possibly because it was deleted in the meantime by a different client
740
+            if (empty($card)) {
741
+                return false;
742
+            }
743
+
744
+            $targetAddressBookRow = $this->getAddressBookById($targetAddressBookId);
745
+            // the address book this card is being moved to does not exist any longer
746
+            if (empty($targetAddressBookRow)) {
747
+                return false;
748
+            }
749
+
750
+            $sourceShares = $this->getShares($sourceAddressBookId);
751
+            $targetShares = $this->getShares($targetAddressBookId);
752
+            $sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId);
753
+            $this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $card));
754
+            return true;
755
+        }, $this->db);
756
+    }
757
+
758
+    /**
759
+     * Deletes a card
760
+     *
761
+     * @param mixed $addressBookId
762
+     * @param string $cardUri
763
+     * @return bool
764
+     */
765
+    public function deleteCard($addressBookId, $cardUri) {
766
+        return $this->atomic(function () use ($addressBookId, $cardUri) {
767
+            $addressBookData = $this->getAddressBookById($addressBookId);
768
+            $shares = $this->getShares($addressBookId);
769
+            $objectRow = $this->getCard($addressBookId, $cardUri);
770
+
771
+            try {
772
+                $cardId = $this->getCardId($addressBookId, $cardUri);
773
+            } catch (\InvalidArgumentException $e) {
774
+                $cardId = null;
775
+            }
776
+            $query = $this->db->getQueryBuilder();
777
+            $ret = $query->delete($this->dbCardsTable)
778
+                ->where($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)))
779
+                ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($cardUri)))
780
+                ->executeStatement();
781
+
782
+            $this->addChange($addressBookId, $cardUri, 3);
783
+
784
+            if ($ret === 1) {
785
+                if ($cardId !== null) {
786
+                    $this->dispatcher->dispatchTyped(new CardDeletedEvent($addressBookId, $addressBookData, $shares, $objectRow));
787
+                    $this->purgeProperties($addressBookId, $cardId);
788
+                }
789
+                return true;
790
+            }
791
+
792
+            return false;
793
+        }, $this->db);
794
+    }
795
+
796
+    /**
797
+     * The getChanges method returns all the changes that have happened, since
798
+     * the specified syncToken in the specified address book.
799
+     *
800
+     * This function should return an array, such as the following:
801
+     *
802
+     * [
803
+     *   'syncToken' => 'The current synctoken',
804
+     *   'added'   => [
805
+     *      'new.txt',
806
+     *   ],
807
+     *   'modified'   => [
808
+     *      'modified.txt',
809
+     *   ],
810
+     *   'deleted' => [
811
+     *      'foo.php.bak',
812
+     *      'old.txt'
813
+     *   ]
814
+     * ];
815
+     *
816
+     * The returned syncToken property should reflect the *current* syncToken
817
+     * of the calendar, as reported in the {http://sabredav.org/ns}sync-token
818
+     * property. This is needed here too, to ensure the operation is atomic.
819
+     *
820
+     * If the $syncToken argument is specified as null, this is an initial
821
+     * sync, and all members should be reported.
822
+     *
823
+     * The modified property is an array of nodenames that have changed since
824
+     * the last token.
825
+     *
826
+     * The deleted property is an array with nodenames, that have been deleted
827
+     * from collection.
828
+     *
829
+     * The $syncLevel argument is basically the 'depth' of the report. If it's
830
+     * 1, you only have to report changes that happened only directly in
831
+     * immediate descendants. If it's 2, it should also include changes from
832
+     * the nodes below the child collections. (grandchildren)
833
+     *
834
+     * The $limit argument allows a client to specify how many results should
835
+     * be returned at most. If the limit is not specified, it should be treated
836
+     * as infinite.
837
+     *
838
+     * If the limit (infinite or not) is higher than you're willing to return,
839
+     * you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
840
+     *
841
+     * If the syncToken is expired (due to data cleanup) or unknown, you must
842
+     * return null.
843
+     *
844
+     * The limit is 'suggestive'. You are free to ignore it.
845
+     *
846
+     * @param string $addressBookId
847
+     * @param string $syncToken
848
+     * @param int $syncLevel
849
+     * @param int|null $limit
850
+     * @return array
851
+     */
852
+    public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
853
+        // Current synctoken
854
+        return $this->atomic(function () use ($addressBookId, $syncToken, $syncLevel, $limit) {
855
+            $qb = $this->db->getQueryBuilder();
856
+            $qb->select('synctoken')
857
+                ->from('addressbooks')
858
+                ->where(
859
+                    $qb->expr()->eq('id', $qb->createNamedParameter($addressBookId))
860
+                );
861
+            $stmt = $qb->executeQuery();
862
+            $currentToken = $stmt->fetchOne();
863
+            $stmt->closeCursor();
864
+
865
+            if (is_null($currentToken)) {
866
+                return [];
867
+            }
868
+
869
+            $result = [
870
+                'syncToken' => $currentToken,
871
+                'added' => [],
872
+                'modified' => [],
873
+                'deleted' => [],
874
+            ];
875
+
876
+            if ($syncToken) {
877
+                $qb = $this->db->getQueryBuilder();
878
+                $qb->select('uri', 'operation')
879
+                    ->from('addressbookchanges')
880
+                    ->where(
881
+                        $qb->expr()->andX(
882
+                            $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)),
883
+                            $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)),
884
+                            $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
885
+                        )
886
+                    )->orderBy('synctoken');
887
+
888
+                if (is_int($limit) && $limit > 0) {
889
+                    $qb->setMaxResults($limit);
890
+                }
891
+
892
+                // Fetching all changes
893
+                $stmt = $qb->executeQuery();
894
+
895
+                $changes = [];
896
+
897
+                // This loop ensures that any duplicates are overwritten, only the
898
+                // last change on a node is relevant.
899
+                while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
900
+                    $changes[$row['uri']] = $row['operation'];
901
+                }
902
+                $stmt->closeCursor();
903
+
904
+                foreach ($changes as $uri => $operation) {
905
+                    switch ($operation) {
906
+                        case 1:
907
+                            $result['added'][] = $uri;
908
+                            break;
909
+                        case 2:
910
+                            $result['modified'][] = $uri;
911
+                            break;
912
+                        case 3:
913
+                            $result['deleted'][] = $uri;
914
+                            break;
915
+                    }
916
+                }
917
+            } else {
918
+                $qb = $this->db->getQueryBuilder();
919
+                $qb->select('uri')
920
+                    ->from('cards')
921
+                    ->where(
922
+                        $qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId))
923
+                    );
924
+                // No synctoken supplied, this is the initial sync.
925
+                $stmt = $qb->executeQuery();
926
+                $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
927
+                $stmt->closeCursor();
928
+            }
929
+            return $result;
930
+        }, $this->db);
931
+    }
932
+
933
+    /**
934
+     * Adds a change record to the addressbookchanges table.
935
+     *
936
+     * @param mixed $addressBookId
937
+     * @param string $objectUri
938
+     * @param int $operation 1 = add, 2 = modify, 3 = delete
939
+     * @return void
940
+     */
941
+    protected function addChange(int $addressBookId, string $objectUri, int $operation): void {
942
+        $this->atomic(function () use ($addressBookId, $objectUri, $operation): void {
943
+            $query = $this->db->getQueryBuilder();
944
+            $query->select('synctoken')
945
+                ->from('addressbooks')
946
+                ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)));
947
+            $result = $query->executeQuery();
948
+            $syncToken = (int)$result->fetchOne();
949
+            $result->closeCursor();
950
+
951
+            $query = $this->db->getQueryBuilder();
952
+            $query->insert('addressbookchanges')
953
+                ->values([
954
+                    'uri' => $query->createNamedParameter($objectUri),
955
+                    'synctoken' => $query->createNamedParameter($syncToken),
956
+                    'addressbookid' => $query->createNamedParameter($addressBookId),
957
+                    'operation' => $query->createNamedParameter($operation),
958
+                    'created_at' => time(),
959
+                ])
960
+                ->executeStatement();
961
+
962
+            $query = $this->db->getQueryBuilder();
963
+            $query->update('addressbooks')
964
+                ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
965
+                ->where($query->expr()->eq('id', $query->createNamedParameter($addressBookId)))
966
+                ->executeStatement();
967
+        }, $this->db);
968
+    }
969
+
970
+    /**
971
+     * @param resource|string $cardData
972
+     * @param bool $modified
973
+     * @return string
974
+     */
975
+    private function readBlob($cardData, &$modified = false) {
976
+        if (is_resource($cardData)) {
977
+            $cardData = stream_get_contents($cardData);
978
+        }
979
+
980
+        // Micro optimisation
981
+        // don't loop through
982
+        if (str_starts_with($cardData, 'PHOTO:data:')) {
983
+            return $cardData;
984
+        }
985
+
986
+        $cardDataArray = explode("\r\n", $cardData);
987
+
988
+        $cardDataFiltered = [];
989
+        $removingPhoto = false;
990
+        foreach ($cardDataArray as $line) {
991
+            if (str_starts_with($line, 'PHOTO:data:')
992
+                && !str_starts_with($line, 'PHOTO:data:image/')) {
993
+                // Filter out PHOTO data of non-images
994
+                $removingPhoto = true;
995
+                $modified = true;
996
+                continue;
997
+            }
998
+
999
+            if ($removingPhoto) {
1000
+                if (str_starts_with($line, ' ')) {
1001
+                    continue;
1002
+                }
1003
+                // No leading space means this is a new property
1004
+                $removingPhoto = false;
1005
+            }
1006
+
1007
+            $cardDataFiltered[] = $line;
1008
+        }
1009
+        return implode("\r\n", $cardDataFiltered);
1010
+    }
1011
+
1012
+    /**
1013
+     * @param list<array{href: string, commonName: string, readOnly: bool}> $add
1014
+     * @param list<string> $remove
1015
+     */
1016
+    public function updateShares(IShareable $shareable, array $add, array $remove): void {
1017
+        $this->atomic(function () use ($shareable, $add, $remove): void {
1018
+            $addressBookId = $shareable->getResourceId();
1019
+            $addressBookData = $this->getAddressBookById($addressBookId);
1020
+            $oldShares = $this->getShares($addressBookId);
1021
+
1022
+            $this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
1023
+
1024
+            $this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
1025
+        }, $this->db);
1026
+    }
1027
+
1028
+    /**
1029
+     * Search contacts in a specific address-book
1030
+     *
1031
+     * @param int $addressBookId
1032
+     * @param string $pattern which should match within the $searchProperties
1033
+     * @param array $searchProperties defines the properties within the query pattern should match
1034
+     * @param array $options = array() to define the search behavior
1035
+     *                       - 'types' boolean (since 15.0.0) If set to true, fields that come with a TYPE property will be an array
1036
+     *                       - 'escape_like_param' - If set to false wildcards _ and % are not escaped, otherwise they are
1037
+     *                       - 'limit' - Set a numeric limit for the search results
1038
+     *                       - 'offset' - Set the offset for the limited search results
1039
+     *                       - 'wildcard' - Whether the search should use wildcards
1040
+     * @psalm-param array{types?: bool, escape_like_param?: bool, limit?: int, offset?: int, wildcard?: bool} $options
1041
+     * @return array an array of contacts which are arrays of key-value-pairs
1042
+     */
1043
+    public function search($addressBookId, $pattern, $searchProperties, $options = []): array {
1044
+        return $this->atomic(function () use ($addressBookId, $pattern, $searchProperties, $options) {
1045
+            return $this->searchByAddressBookIds([$addressBookId], $pattern, $searchProperties, $options);
1046
+        }, $this->db);
1047
+    }
1048
+
1049
+    /**
1050
+     * Search contacts in all address-books accessible by a user
1051
+     *
1052
+     * @param string $principalUri
1053
+     * @param string $pattern
1054
+     * @param array $searchProperties
1055
+     * @param array $options
1056
+     * @return array
1057
+     */
1058
+    public function searchPrincipalUri(string $principalUri,
1059
+        string $pattern,
1060
+        array $searchProperties,
1061
+        array $options = []): array {
1062
+        return $this->atomic(function () use ($principalUri, $pattern, $searchProperties, $options) {
1063
+            $addressBookIds = array_map(static function ($row):int {
1064
+                return (int)$row['id'];
1065
+            }, $this->getAddressBooksForUser($principalUri));
1066
+
1067
+            return $this->searchByAddressBookIds($addressBookIds, $pattern, $searchProperties, $options);
1068
+        }, $this->db);
1069
+    }
1070
+
1071
+    /**
1072
+     * @param int[] $addressBookIds
1073
+     * @param string $pattern
1074
+     * @param array $searchProperties
1075
+     * @param array $options
1076
+     * @psalm-param array{
1077
+     *   types?: bool,
1078
+     *   escape_like_param?: bool,
1079
+     *   limit?: int,
1080
+     *   offset?: int,
1081
+     *   wildcard?: bool,
1082
+     *   since?: DateTimeFilter|null,
1083
+     *   until?: DateTimeFilter|null,
1084
+     *   person?: string
1085
+     * } $options
1086
+     * @return array
1087
+     */
1088
+    private function searchByAddressBookIds(array $addressBookIds,
1089
+        string $pattern,
1090
+        array $searchProperties,
1091
+        array $options = []): array {
1092
+        if (empty($addressBookIds)) {
1093
+            return [];
1094
+        }
1095
+        $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
1096
+        $useWildcards = !\array_key_exists('wildcard', $options) || $options['wildcard'] !== false;
1097
+
1098
+        if ($escapePattern) {
1099
+            $searchProperties = array_filter($searchProperties, function ($property) use ($pattern) {
1100
+                if ($property === 'EMAIL' && str_contains($pattern, ' ')) {
1101
+                    // There can be no spaces in emails
1102
+                    return false;
1103
+                }
1104
+
1105
+                if ($property === 'CLOUD' && preg_match('/[^a-zA-Z0-9 :_.@\/\-\']/', $pattern) === 1) {
1106
+                    // There can be no chars in cloud ids which are not valid for user ids plus :/
1107
+                    // worst case: CA61590A-BBBC-423E-84AF-E6DF01455A53@https://my.nxt/srv/
1108
+                    return false;
1109
+                }
1110
+
1111
+                return true;
1112
+            });
1113
+        }
1114
+
1115
+        if (empty($searchProperties)) {
1116
+            return [];
1117
+        }
1118
+
1119
+        $query2 = $this->db->getQueryBuilder();
1120
+        $query2->selectDistinct('cp.cardid')
1121
+            ->from($this->dbCardsPropertiesTable, 'cp')
1122
+            ->where($query2->expr()->in('cp.addressbookid', $query2->createNamedParameter($addressBookIds, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
1123
+            ->andWhere($query2->expr()->in('cp.name', $query2->createNamedParameter($searchProperties, IQueryBuilder::PARAM_STR_ARRAY)));
1124
+
1125
+        // No need for like when the pattern is empty
1126
+        if ($pattern !== '') {
1127
+            if (!$useWildcards) {
1128
+                $query2->andWhere($query2->expr()->eq('cp.value', $query2->createNamedParameter($pattern)));
1129
+            } elseif (!$escapePattern) {
1130
+                $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter($pattern)));
1131
+            } else {
1132
+                $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
1133
+            }
1134
+        }
1135
+        if (isset($options['limit'])) {
1136
+            $query2->setMaxResults($options['limit']);
1137
+        }
1138
+        if (isset($options['offset'])) {
1139
+            $query2->setFirstResult($options['offset']);
1140
+        }
1141
+
1142
+        if (isset($options['person'])) {
1143
+            $query2->andWhere($query2->expr()->ilike('cp.value', $query2->createNamedParameter('%' . $this->db->escapeLikeParameter($options['person']) . '%')));
1144
+        }
1145
+        if (isset($options['since']) || isset($options['until'])) {
1146
+            $query2->join('cp', $this->dbCardsPropertiesTable, 'cp_bday', 'cp.cardid = cp_bday.cardid');
1147
+            $query2->andWhere($query2->expr()->eq('cp_bday.name', $query2->createNamedParameter('BDAY')));
1148
+            /**
1149
+             * FIXME Find a way to match only 4 last digits
1150
+             * BDAY can be --1018 without year or 20001019 with it
1151
+             * $bDayOr = [];
1152
+             * if ($options['since'] instanceof DateTimeFilter) {
1153
+             * $bDayOr[] =
1154
+             * $query2->expr()->gte('SUBSTR(cp_bday.value, -4)',
1155
+             * $query2->createNamedParameter($options['since']->get()->format('md'))
1156
+             * );
1157
+             * }
1158
+             * if ($options['until'] instanceof DateTimeFilter) {
1159
+             * $bDayOr[] =
1160
+             * $query2->expr()->lte('SUBSTR(cp_bday.value, -4)',
1161
+             * $query2->createNamedParameter($options['until']->get()->format('md'))
1162
+             * );
1163
+             * }
1164
+             * $query2->andWhere($query2->expr()->orX(...$bDayOr));
1165
+             */
1166
+        }
1167
+
1168
+        $result = $query2->executeQuery();
1169
+        $matches = $result->fetchAll();
1170
+        $result->closeCursor();
1171
+        $matches = array_map(function ($match) {
1172
+            return (int)$match['cardid'];
1173
+        }, $matches);
1174
+
1175
+        $cards = [];
1176
+        $query = $this->db->getQueryBuilder();
1177
+        $query->select('c.addressbookid', 'c.carddata', 'c.uri')
1178
+            ->from($this->dbCardsTable, 'c')
1179
+            ->where($query->expr()->in('c.id', $query->createParameter('matches')));
1180
+
1181
+        foreach (array_chunk($matches, 1000) as $matchesChunk) {
1182
+            $query->setParameter('matches', $matchesChunk, IQueryBuilder::PARAM_INT_ARRAY);
1183
+            $result = $query->executeQuery();
1184
+            $cards = array_merge($cards, $result->fetchAll());
1185
+            $result->closeCursor();
1186
+        }
1187
+
1188
+        return array_map(function ($array) {
1189
+            $array['addressbookid'] = (int)$array['addressbookid'];
1190
+            $modified = false;
1191
+            $array['carddata'] = $this->readBlob($array['carddata'], $modified);
1192
+            if ($modified) {
1193
+                $array['size'] = strlen($array['carddata']);
1194
+            }
1195
+            return $array;
1196
+        }, $cards);
1197
+    }
1198
+
1199
+    /**
1200
+     * @param int $bookId
1201
+     * @param string $name
1202
+     * @return array
1203
+     */
1204
+    public function collectCardProperties($bookId, $name) {
1205
+        $query = $this->db->getQueryBuilder();
1206
+        $result = $query->selectDistinct('value')
1207
+            ->from($this->dbCardsPropertiesTable)
1208
+            ->where($query->expr()->eq('name', $query->createNamedParameter($name)))
1209
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($bookId)))
1210
+            ->executeQuery();
1211
+
1212
+        $all = $result->fetchAll(PDO::FETCH_COLUMN);
1213
+        $result->closeCursor();
1214
+
1215
+        return $all;
1216
+    }
1217
+
1218
+    /**
1219
+     * get URI from a given contact
1220
+     *
1221
+     * @param int $id
1222
+     * @return string
1223
+     */
1224
+    public function getCardUri($id) {
1225
+        $query = $this->db->getQueryBuilder();
1226
+        $query->select('uri')->from($this->dbCardsTable)
1227
+            ->where($query->expr()->eq('id', $query->createParameter('id')))
1228
+            ->setParameter('id', $id);
1229
+
1230
+        $result = $query->executeQuery();
1231
+        $uri = $result->fetch();
1232
+        $result->closeCursor();
1233
+
1234
+        if (!isset($uri['uri'])) {
1235
+            throw new \InvalidArgumentException('Card does not exists: ' . $id);
1236
+        }
1237
+
1238
+        return $uri['uri'];
1239
+    }
1240
+
1241
+    /**
1242
+     * return contact with the given URI
1243
+     *
1244
+     * @param int $addressBookId
1245
+     * @param string $uri
1246
+     * @returns array
1247
+     */
1248
+    public function getContact($addressBookId, $uri) {
1249
+        $result = [];
1250
+        $query = $this->db->getQueryBuilder();
1251
+        $query->select('*')->from($this->dbCardsTable)
1252
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1253
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1254
+        $queryResult = $query->executeQuery();
1255
+        $contact = $queryResult->fetch();
1256
+        $queryResult->closeCursor();
1257
+
1258
+        if (is_array($contact)) {
1259
+            $modified = false;
1260
+            $contact['etag'] = '"' . $contact['etag'] . '"';
1261
+            $contact['carddata'] = $this->readBlob($contact['carddata'], $modified);
1262
+            if ($modified) {
1263
+                $contact['size'] = strlen($contact['carddata']);
1264
+            }
1265
+
1266
+            $result = $contact;
1267
+        }
1268
+
1269
+        return $result;
1270
+    }
1271
+
1272
+    /**
1273
+     * Returns the list of people whom this address book is shared with.
1274
+     *
1275
+     * Every element in this array should have the following properties:
1276
+     *   * href - Often a mailto: address
1277
+     *   * commonName - Optional, for example a first + last name
1278
+     *   * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants.
1279
+     *   * readOnly - boolean
1280
+     *
1281
+     * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
1282
+     */
1283
+    public function getShares(int $addressBookId): array {
1284
+        return $this->sharingBackend->getShares($addressBookId);
1285
+    }
1286
+
1287
+    /**
1288
+     * update properties table
1289
+     *
1290
+     * @param int $addressBookId
1291
+     * @param string $cardUri
1292
+     * @param string $vCardSerialized
1293
+     */
1294
+    protected function updateProperties($addressBookId, $cardUri, $vCardSerialized) {
1295
+        $this->atomic(function () use ($addressBookId, $cardUri, $vCardSerialized): void {
1296
+            $cardId = $this->getCardId($addressBookId, $cardUri);
1297
+            $vCard = $this->readCard($vCardSerialized);
1298
+
1299
+            $this->purgeProperties($addressBookId, $cardId);
1300
+
1301
+            $query = $this->db->getQueryBuilder();
1302
+            $query->insert($this->dbCardsPropertiesTable)
1303
+                ->values(
1304
+                    [
1305
+                        'addressbookid' => $query->createNamedParameter($addressBookId),
1306
+                        'cardid' => $query->createNamedParameter($cardId),
1307
+                        'name' => $query->createParameter('name'),
1308
+                        'value' => $query->createParameter('value'),
1309
+                        'preferred' => $query->createParameter('preferred')
1310
+                    ]
1311
+                );
1312
+
1313
+            foreach ($vCard->children() as $property) {
1314
+                if (!in_array($property->name, self::$indexProperties)) {
1315
+                    continue;
1316
+                }
1317
+                $preferred = 0;
1318
+                foreach ($property->parameters as $parameter) {
1319
+                    if ($parameter->name === 'TYPE' && strtoupper($parameter->getValue()) === 'PREF') {
1320
+                        $preferred = 1;
1321
+                        break;
1322
+                    }
1323
+                }
1324
+                $query->setParameter('name', $property->name);
1325
+                $query->setParameter('value', mb_strcut($property->getValue(), 0, 254));
1326
+                $query->setParameter('preferred', $preferred);
1327
+                $query->executeStatement();
1328
+            }
1329
+        }, $this->db);
1330
+    }
1331
+
1332
+    /**
1333
+     * read vCard data into a vCard object
1334
+     *
1335
+     * @param string $cardData
1336
+     * @return VCard
1337
+     */
1338
+    protected function readCard($cardData) {
1339
+        return Reader::read($cardData);
1340
+    }
1341
+
1342
+    /**
1343
+     * delete all properties from a given card
1344
+     *
1345
+     * @param int $addressBookId
1346
+     * @param int $cardId
1347
+     */
1348
+    protected function purgeProperties($addressBookId, $cardId) {
1349
+        $query = $this->db->getQueryBuilder();
1350
+        $query->delete($this->dbCardsPropertiesTable)
1351
+            ->where($query->expr()->eq('cardid', $query->createNamedParameter($cardId)))
1352
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1353
+        $query->executeStatement();
1354
+    }
1355
+
1356
+    /**
1357
+     * Get ID from a given contact
1358
+     */
1359
+    protected function getCardId(int $addressBookId, string $uri): int {
1360
+        $query = $this->db->getQueryBuilder();
1361
+        $query->select('id')->from($this->dbCardsTable)
1362
+            ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
1363
+            ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($addressBookId)));
1364
+
1365
+        $result = $query->executeQuery();
1366
+        $cardIds = $result->fetch();
1367
+        $result->closeCursor();
1368
+
1369
+        if (!isset($cardIds['id'])) {
1370
+            throw new \InvalidArgumentException('Card does not exists: ' . $uri);
1371
+        }
1372
+
1373
+        return (int)$cardIds['id'];
1374
+    }
1375
+
1376
+    /**
1377
+     * For shared address books the sharee is set in the ACL of the address book
1378
+     *
1379
+     * @param int $addressBookId
1380
+     * @param list<array{privilege: string, principal: string, protected: bool}> $acl
1381
+     * @return list<array{privilege: string, principal: string, protected: bool}>
1382
+     */
1383
+    public function applyShareAcl(int $addressBookId, array $acl): array {
1384
+        $shares = $this->sharingBackend->getShares($addressBookId);
1385
+        return $this->sharingBackend->applyShareAcl($shares, $acl);
1386
+    }
1387
+
1388
+    /**
1389
+     * @throws \InvalidArgumentException
1390
+     */
1391
+    public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
1392
+        if ($keep < 0) {
1393
+            throw new \InvalidArgumentException();
1394
+        }
1395
+
1396
+        $query = $this->db->getQueryBuilder();
1397
+        $query->select($query->func()->max('id'))
1398
+            ->from('addressbookchanges');
1399
+
1400
+        $result = $query->executeQuery();
1401
+        $maxId = (int)$result->fetchOne();
1402
+        $result->closeCursor();
1403
+        if (!$maxId || $maxId < $keep) {
1404
+            return 0;
1405
+        }
1406
+
1407
+        $query = $this->db->getQueryBuilder();
1408
+        $query->delete('addressbookchanges')
1409
+            ->where(
1410
+                $query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
1411
+                $query->expr()->lte('created_at', $query->createNamedParameter($retention)),
1412
+            );
1413
+        return $query->executeStatement();
1414
+    }
1415
+
1416
+    private function convertPrincipal(string $principalUri, bool $toV2): string {
1417
+        if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
1418
+            [, $name] = \Sabre\Uri\split($principalUri);
1419
+            if ($toV2 === true) {
1420
+                return "principals/users/$name";
1421
+            }
1422
+            return "principals/$name";
1423
+        }
1424
+        return $principalUri;
1425
+    }
1426
+
1427
+    private function addOwnerPrincipal(array &$addressbookInfo): void {
1428
+        $ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
1429
+        $displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
1430
+        if (isset($addressbookInfo[$ownerPrincipalKey])) {
1431
+            $uri = $addressbookInfo[$ownerPrincipalKey];
1432
+        } else {
1433
+            $uri = $addressbookInfo['principaluri'];
1434
+        }
1435
+
1436
+        $principalInformation = $this->principalBackend->getPrincipalByPath($uri);
1437
+        if (isset($principalInformation['{DAV:}displayname'])) {
1438
+            $addressbookInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
1439
+        }
1440
+    }
1441
+
1442
+    /**
1443
+     * Extract UID from vcard
1444
+     *
1445
+     * @param string $cardData the vcard raw data
1446
+     * @return string the uid
1447
+     * @throws BadRequest if no UID is available or vcard is empty
1448
+     */
1449
+    private function getUID(string $cardData): string {
1450
+        if ($cardData !== '') {
1451
+            $vCard = Reader::read($cardData);
1452
+            if ($vCard->UID) {
1453
+                $uid = $vCard->UID->getValue();
1454
+                return $uid;
1455
+            }
1456
+            // should already be handled, but just in case
1457
+            throw new BadRequest('vCards on CardDAV servers MUST have a UID property');
1458
+        }
1459
+        // should already be handled, but just in case
1460
+        throw new BadRequest('vCard can not be empty');
1461
+    }
1462 1462
 }
Please login to merge, or discard this patch.