Repository navigation
Conversation
- The migration is dated after the ones main has run since, under a name no open branch uses (#109 and #112 hold the earlier slots today). - The inbox's query ceiling is one above the shared one again: main raised that to 28 for the sidebar's change counter, and the own-tabs read is still the one statement the inbox adds on top. - French for the new strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for CardDAV, @jo2904. Contacts are wanted, and building it on the CalDAV layer rather than a second one is the right call. It's accepted for v0.4.0 (#157). One thing has to change before review: how it connects. We decided in #158 that a Nextcloud connection is made once and gives files, calendars and contacts, with the one app password the existing Nextcloud integration (Settings → Integrations) already holds. So for Nextcloud, address books should come from that connection, not from a separate CardDAV connection with its own address and password. A standalone CardDAV connection is still right for servers that aren't Nextcloud: Radicale, Baïkal, Fastmail, iCloud. The sync engine, It also needs the French catalogue ( |
6333baf to
d04cb5e
Compare
…u carnet d'adresses) Préparation de CardDAV. RFC 6764 décrit le même amorçage pour les deux protocoles : .well-known, principal, propriété *-home-set, listing du home. Seuls les noms changent (/.well-known/carddav, addressbook-home-set, resourcetype addressbook). Plutôt que de copier CalDavDiscovery, les noms sortent dans un enum DavService et l'échelle reste écrite une fois, avec ses garde-fous SSRF : chaque redirection est revalidée, et le nombre de sauts est borné. Calendar est la valeur par défaut d'endpointFor() : les appelants existants ne changent pas, et les 72 tests CalDAV passent sans modification hormis le renommage ci-dessous. - CalDavEndpoint::$calendarHome devient $home, sinon le code carnet d'adresses lirait « calendarHome ». - CalDavClient::baseUrl() cherche l'adresse épinglée par l'admin pour le fournisseur de la connexion et non plus pour CalDav en dur. Sans ça, un épinglage CalDAV s'appliquerait aussi aux contacts. Aucun effet sur une connexion CalDAV. - Le message « aucun service à cette adresse » vient du service : on ne dit pas « CalDAV » à quelqu'un qui configure ses contacts. Un piège testé : une URL de calendrier collée dans un formulaire CardDAV n'est pas acceptée comme carnet d'adresses. Sans le test isCollection() par service, la première collection rencontrée gagnerait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
…directionnelle Les contacts d'un serveur CardDAV (Nextcloud, Radicale, Baïkal, Fastmail…) sont maintenant stockés dans plMail, et une modification faite ici repart vers le serveur. Ce commit couvre le modèle et la synchro ; l'interface vient au commit suivant. Modèle : Provider::CardDav est une connexion Integration comme CalDAV, avec un nouveau ServiceKind::Contacts pour qu'elle n'apparaisse ni dans le sélecteur de fichiers ni sur les écrans calendrier. C'est une connexion distincte de CalDAV : certains serveurs ne font que l'un des deux, et un même utilisateur peut avoir ses contacts et ses calendriers à deux endroits différents. AddressBook est l'équivalent de Calendar (une collection suivie, son jeton de synchro, sa dernière erreur) et ContactCard est une vCard. ContactCard garde la vCard brute, pas seulement les champs affichés. Une modification faite ici est appliquée au texte stocké par VCardConverter : seuls FN/N/EMAIL/TEL/ORG/NOTE changent, et une adresse conservée garde son TYPE et son groupe Apple (item1.EMAIL). Reconstruire la carte à partir des colonnes aurait effacé la photo, l'anniversaire, l'adresse postale et les X- propriétés du téléphone dès la première correction d'une faute de frappe. Le test testAnEditKeepsEverythingPlMailDoesNotOwn est là pour ça. N n'est réécrit que si le nom a changé : un N structuré (préfixe, deuxième prénom) ne se reconstruit pas à partir de FN. Transport : CardDavDriver réutilise CalDavClient, CalDavDiscovery (via DavService, commit précédent) et MultiStatusParser. Il hérite donc tel quel des contrôles SSRF, des redirections suivies à la main et de la classification des erreurs. Les exceptions restent CalendarSync* parce que leur classe décide des relances Messenger, et cette décision est la même pour un serveur de contacts. Seule la formulation change : CalDavClient::say() choisit « contacts server » pour une connexion CardDAV, sinon l'utilisateur lirait « the calendar server rejected the app password » sous un carnet d'adresses. Synchro (AddressBookSync), quatre règles et leurs pièges : - On pousse avant de tirer. Dans l'autre ordre, la copie plus ancienne du serveur écraserait une modification locale avant qu'elle parte. - En conflit (412 sur un If-Match), le serveur gagne et le carnet est relu en entier. Fusionner deux vCards champ par champ est une fonctionnalité en soi ; une erreur là-dedans corrompt un carnet. Perdre une modification locale est journalisé avec le nom du contact. - Seule une liste complète (jeton nul) supprime ce qu'elle ne mentionne pas. Une liste delta ne dit rien du reste, et une liste sync-collection tronquée au plafond de pages n'est jamais considérée complète. Comme pour CalDAV, un ctag qui a bougé abandonne le jeton plutôt que de faire passer un listing pour un delta. - Les suppressions sont appliquées en dernier. Doctrine insère avant de supprimer : un serveur qui « déplace » une carte (même UID, nouvel href) ferait sauter la contrainte unique (carnet, uid) et échouer toute la synchro. Planification : app:contacts:sync --stale tourne toutes les 15 min, décalé à :11 pour ne pas s'empiler sur les synchros mail (:00) et calendrier (:07), qui partagent le même worker. SyncAddressBookMessage va sur ingest, comme la synchro calendrier. Une erreur permanente est enregistrée sur le carnet puis absorbée, pour ne pas classer une enveloppe morte toutes les 15 minutes. Le journal n'écrit l'erreur que quand elle change. Tests (sans base) : convertisseur vCard, driver contre un serveur scripté (découverte, sync-collection, repli ctag, écritures conditionnelles), et service de synchro avec un dépôt en mémoire. La migration et le mapping sont vérifiés avec doctrine:schema:validate --skip-sync ; les noms d'index et de clés suivent l'algorithme de DBAL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
L'interface du commit précédent. Deux écrans, sur le modèle des calendriers pour qu'on n'ait rien de nouveau à apprendre. Réglages → Contacts : connecter un serveur CardDAV, puis cocher les carnets à refléter. C'est le même parcours que pour CalDAV : adresse et identifiants, puis la liste de ce que le serveur propose, dans la même modale. Le formulaire est CalDavConnectType avec une option protocol : mêmes questions, même ordre, souvent les mêmes réponses sur le même serveur. Seules les clés de traduction changent. Le passage par CalDavConnector reprend tel quel la règle « réutiliser le mot de passe mail seulement si la case est cochée » et le test de connexion à l'enregistrement, sans en écrire une deuxième copie. Seuls les ids cochés sont lus dans le POST : le nom et le statut lecture seule d'un carnet viennent d'une nouvelle découverte, comme pour les calendriers. /contacts : liste, recherche, création, modification, suppression. Une modification est enregistrée localement, marquée en attente, puis mise en file pour le worker (ContactCardEditor). Personne n'attend le serveur CardDAV, et un serveur en panne retarde l'envoi sans perdre la modification. L'horloge sur une ligne indique qu'elle n'est pas encore partie. Le formulaire n'a pas de champ pour la photo, l'anniversaire ou l'adresse postale. Une note sous les champs précise que ces données restent sur le serveur, pour qu'on ne croie pas les avoir perdues. Pièges couverts : - Un contact créé ici puis modifié avant le passage du worker reste un PendingCreate. Le passer en PendingUpdate enverrait un PUT avec If-Match sans etag vers un href qui n'existe pas encore. - Un carnet que le serveur a rendu lecture seule refuse toute écriture (403), et le formulaire ne le propose pas à la création. L'éditeur refuse aussi, pour un POST forgé. - Chaque route qui prend un id vérifie le propriétaire via le carnet : un id de carte est un numéro de séquence, il se devine. Navigation : une entrée « Contacts » dans le menu utilisateur, pas dans la barre latérale dont les lignes sont des vues de mail. La section est ajoutée dans le groupe Mail des réglages et dans la liste du test e2e des marges de cartes. Tests : ContactCardEditorTest (sans base) ; ContactsFlowTest de bout en bout avec un Nextcloud scripté sur un hôte .test. Il couvre la découverte qui ne propose que les carnets, l'abonnement qui met la première synchro en file, la recherche, l'édition qui garde le BDAY, les droits lecture seule et l'isolation entre utilisateurs. Ce dernier test demande Postgres et n'a pas tourné ici ; les gabarits ont été rendus à blanc avec des entités en mémoire, et chaque clé de traduction utilisée a été vérifiée dans le catalogue anglais. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
Les carnets synchronisés servent d'abord à ça : proposer « Ada » dans le champ À sans qu'on lui ait jamais écrit depuis plMail. Le composer web et JMAP Contact/autocomplete lisent maintenant la même liste (ContactSuggester). C'était déjà le principe quand ils partageaient ContactRepository::findForAutocomplete : deux classements feraient dépendre l'ordre des suggestions de l'appareil. Classement : fréquence dans le courrier, puis présence dans un carnet, puis récence, puis nom. Conséquences voulues : - Sans carnet, l'ordre est exactement celui d'avant. Le test testWithNoAddressBookTheHarvestOrderStands l'épingle. - Être dans un carnet ne fait que départager des égalités. Ça ne fait jamais passer quelqu'un devant une adresse à qui on écrit plus souvent. - Un contact de carnet jamais écrit (fréquence 0) passe devant une adresse tapée une fois dans un brouillon, elle aussi à fréquence 0 (createUnsent). Une adresse présente des deux côtés donne une seule suggestion, avec le nom du carnet : celui que la personne a écrit elle-même, plutôt que ce que le client mail de l'expéditeur a mis dans l'en-tête. Une correspondance sur le nom propose toutes les adresses de la carte (taper « ada » propose le perso et le pro). Une correspondance sur une adresse ne propose que celle-là. Prendre les $limit premiers de chaque côté puis fusionner donne le bon résultat, pas une approximation : chaque côté est déjà trié dans l'ordre final, donc rien au-delà de sa limite ne pouvait entrer dans le top fusionné. JMAP : un champ inAddressBook s'ajoute à chaque suggestion. Il explique pourquoi une suggestion a fréquence 0 et lastSeenAt nul. CLIENT_DEVELOPMENT.md (en et de) ne dit plus que le carnet est uniquement récolté depuis les en-têtes. La route web garde ses clés JSON et ajoute aussi inAddressBook, que le JS ignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
docs/providers/carddav.md, traduit en allemand comme toutes les autres pages fournisseurs. Le guide renvoie à la page CalDAV pour ce qui est identique (les quatre champs du formulaire, la découverte RFC 6764, les adresses refusées par le garde SSRF) plutôt que de le recopier : deux copies divergeraient à la première correction. Ce qui est propre aux contacts y est écrit : - la connexion est distincte de CalDAV, donc l'app password se saisit deux fois pour une même Nextcloud ; - en conflit, le serveur gagne ; - les champs sans équivalent dans plMail (photo, anniversaire, adresse postale) sont conservés ; - la page liste 300 contacts au maximum ; - décocher un carnet perd les modifications pas encore envoyées. Le CHANGELOG signale la migration (deux tables nouvelles, rien d'existant modifié) et le champ inAddressBook ajouté à JMAP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
… de bloquer le carnet CardDavDriver::pull() signale un jeton refusé de deux façons : le résultat resyncRequired (ctag qui a bougé) et une CalendarResyncRequiredException, levée par CalDavClient::classify() pour la précondition DAV `valid-sync-token` (403, 409 ou 400 selon le serveur) et pour un 507 sur le REPORT. AddressBookSync::pull() ne traitait que le premier cas. L'exception remontait donc jusqu'à sync(), qui enregistrait l'échec sur le carnet et la relançait — avec le jeton mort toujours stocké. Comme la classe n'est pas permanente, Messenger réessayait, renvoyait le même jeton, recevait le même refus, et ainsi de suite : un carnet dont le serveur a expiré le jeton (Nextcloud le fait après un certain temps) ne se synchronisait plus jamais, avec « The contacts server no longer recognises our sync position… » affiché en permanence dans les réglages. Le correctif reprend ce que fait CalendarSyncService::pullOnce() : la pull avec jeton est enveloppée, l'exception devient AddressBookChangeSet::resyncRequired(), et le chemin existant efface le jeton puis relit tout. Seulement quand un jeton a été envoyé : sans jeton, le serveur n'a aucune position à refuser, et l'échec est rapporté tel quel plutôt que de boucler. Le test rejoue exactement ce scénario (403 avec `valid-sync-token`, puis listing complet) et échoue sans le correctif. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
… d'une pull apply() construit une fois l'index des cartes locales par href et par UID, puis le consulte pour chaque carte distante. Or une carte adoptée par UID change de href en cours de route, et l'index ne le savait pas. Le cas qui le montre est le « déplacement » CardDAV : le serveur supprime l'ancien href et recrée la carte sous un nouveau, avec le même UID. Si le delta liste la recréation d'abord, la ligne locale est adoptée par UID et prend le nouveau href ; puis la suppression de l'ancien href est lue, byHref[ancien] pointe toujours vers cette même ligne, et elle est condamnée. Résultat : le contact disparaît localement alors qu'il existe sur le serveur, et ne revient qu'au prochain listing complet. Dans l'ordre inverse (suppression puis recréation) tout allait bien, ce qui explique que la logique de suppression différée semblait couvrir ce cas. Les deux tables sont maintenant mises à jour à chaque adoption ou création : l'ancien href est retiré de byHref, le nouveau y est inscrit, et byUid est indexé par $local->uid (l'UID tel que stocké, tronqué à 255 caractères comme dans la base) plutôt que par la valeur lue dans la vCard. Inscrire le nouveau href règle aussi le cas d'un href créé puis supprimé dans la même fenêtre de sync-collection paginée, qui restait sinon localement jusqu'au listing complet suivant. Le test joue le déplacement dans les deux ordres ; l'ordre « suppression en second » échoue sans le correctif. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
Trois défauts dans push(), tous du côté « qu'est-ce qui arrive à l'édition locale quand le serveur dit non ». 1. Toute CalendarResyncRequiredException était traitée comme un conflit 412, donc l'édition locale était jetée. Mais classify() range aussi le 507 dans cette classe — sur un PUT, c'est le quota du serveur plein, pas une modification concurrente. Un utilisateur dont le Nextcloud est plein perdait son édition en silence. Seul le 412 laisse maintenant gagner le serveur ; le reste remonte, la carte reste en attente et le retry de Messenger la reproposera, comme CalendarPusher le fait pour les événements. 2. Un 403 sur le PUT ou le DELETE d'une carte faisait échouer toute la synchronisation en permanent, et la carte restait en attente : le run suivant renvoyait le même PUT, recevait le même 403, et la pull qui suit ne s'exécutait plus jamais. Le cas typique est un carnet dont le serveur ne publie pas current-user-privilege-set : CardDavDriver le suppose alors modifiable (le commentaire de isReadOnly() dit bien que cela coûte « au pire un 403 au premier push »), et une seule édition suffisait à geler tout le carnet. Le 403 est désormais traité par carte, comme CalendarPusher::abandon() : journal en error avec la vCard abandonnée, carte remise à Clean, relecture complète. Les autres échecs permanents (401, adresse refusée par la garde SSRF, identifiants manquants) concernent la connexion et non la carte : ils remontent et l'édition est gardée pour quand ce sera réparé. 3. Le test « carnet devenu lecture seule » ne venait qu'après la branche PendingDelete, donc une suppression dans un tel carnet partait quand même en DELETE, recevait un 403 et retombait dans le point 2. Le test est remonté en tête de boucle et couvre les deux opérations. Chaque point a son test, qui échoue sans le correctif ; un quatrième vérifie qu'un 401 garde bien l'édition en attente. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
L'autocomplétion demandait au dépôt les $limit premières cartes correspondant à la saisie, triées par nom, puis n'en gardait que les adresses e-mail. Une carte sans adresse consommait donc une place du LIMIT sans rien proposer. Or un carnet synchronisé depuis un téléphone est rempli de cartes qui n'ont qu'un numéro : taper « ma » dans un carnet où les huit premières « Ma… » par ordre alphabétique n'ont pas d'e-mail ne proposait aucun contact du carnet, même si « Martin » avec une adresse venait juste après. Le filtre est fait en SQL, avant le LIMIT, par une nouvelle méthode ContactCardRepository::findForAutocomplete(). Il teste searchText LIKE '%@%' : searchText contient toutes les adresses de la carte (voir ContactCard::fill()), donc une carte avec une adresse contient un @. C'est le seul test possible sur une colonne existante sans opérateur jsonb (absent du DQL) ni nouvelle colonne et migration. Une carte sans adresse dont le nom contiendrait un @ passerait le filtre et ne proposerait rien, comme avant : pas de régression possible. Le SQL généré a été vérifié (getSQL() sur la QueryBuilder), pas exécuté — pas de Postgres ici. La requête commune est extraite dans visibleTo(), et le paramètre $book de search(), que rien n'appelait, disparaît au passage. Le docblock affirmait aussi que fusionner le top $limit de chaque côté était « exact ». C'est vrai pour le côté récolté, pas pour les cartes, qui reviennent par ordre de nom : une carte au-delà des $limit premières peut être manquée même si la récolte connaît son adresse, quand la recherche de la récolte ne l'a pas trouvée sur ce texte. Le docblock dit maintenant ce qui est vrai. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
…g avec le pilote CalDAV CardDavDriver recopiait mot pour mot deux morceaux de CalDavCalendarDriver, avec un « See CalDavCalendarDriver::… » en guise de docblock : la liste WRITE_PRIVILEGES et isReadOnly(), et etagAfterWrite() (le GET de rattrapage quand un PUT ne renvoie pas d'ETag). Les deux ne sont pas des détails de pilote : - isReadOnly() est une politique (« un privilege-set absent vaut modifiable ») que le commentaire de AddressBookSync invoque désormais pour justifier l'abandon d'une carte sur 403. Si les deux copies divergeaient, un calendrier et un carnet du même serveur se contrediraient sur la même question. Elle devient DavResource::isReadOnly(), puisqu'elle ne lit que la ressource. - etagAfterWrite() est la même requête avec la même tolérance aux 404/410 et le même rattrapage d'erreur. Elle devient CalDavClient::etagAfterWrite(), à côté de request(). Le client reçoit pour cela un LoggerInterface, optionnel (NullLogger par défaut) pour que les deux fixtures de test qui le construisent à la main n'aient pas à changer ; l'autowiring fournit le vrai. La ligne de log porte le provider, puisqu'elle ne dit plus « CardDav: » ou « CalDav: » d'elle-même. Aucun changement de comportement : les suites CalDAV et AddressBook (129 tests) passent sans modification, lint:container et PHPStan aussi. isTruncated() (huit lignes) et probeCollection() (qui diffère par le corps PROPFIND et le message d'erreur) restent dupliqués : les factoriser coûterait plus de paramètres que de lignes économisées. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
Le guide ne décrivait qu'un seul refus, le conflit 412 (« la version du serveur gagne »). Depuis 97848c5, AddressBookSync distingue trois cas et l'utilisateur doit pouvoir savoir lequel s'applique quand une édition ne passe pas : - 403 sur une carte : abandonnée comme un conflit, journalisée, et le reste du carnet continue de se synchroniser ; - 507 (serveur plein), 401, serveur injoignable : l'édition reste en attente et repart à une synchronisation suivante. Paragraphe ajouté dans la section Sync, traduit dans docs/de (tutoiement inutile ici, la phrase ne s'adresse à personne) ; le marqueur sha1 de la traduction est mis à jour. tests/Documentation et bin/mirror-wiki.php --check passent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
…neur setUp() récupérait UserRepository puis seulement ensuite remplaçait app.http_client.integration par le MockHttpClient. Le conteneur de test refuse de remplacer un service déjà instancié (« already initialized, you cannot replace it »), et récupérer le dépôt construit l'entity manager et ce qu'il tire avec lui. Rien aujourd'hui ne dépend du client HTTP sur ce chemin, donc le test passe probablement tel quel — mais c'est un ordre qui casse le jour où un listener Doctrine ou un service eager en dépendra, et alors avec une erreur sans rapport avec les contacts. IcsControllerTest::signIn() documente déjà la règle (« First, before the container has had a reason to build the real one ») ; ce test la suit maintenant aussi. Non exécuté ici (pas de Postgres). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
…sting complet pullBySyncCollection() sortait de la boucle par la même branche dans trois cas : réponse non tronquée, réponse tronquée sans nouveau jeton, réponse tronquée avec le même jeton. Et cette branche marquait le résultat « listing complet » dès que la pull était partie d'un jeton nul. Dans les deux derniers cas, ce qui est arrivé n'est qu'une page. Les conséquences : AddressBookSync supprime localement toute carte qu'un listing complet ne mentionne pas. Après une resynchronisation (jeton mort, ctag qui a bougé, conflit 412 — tous repartent d'un jeton nul), un serveur qui tronque sans donner de quoi continuer faisait disparaître du miroir toutes les cartes au-delà de la première page. Et comme le jeton stocké restait nul, la pull suivante redemandait tout, recevait la même première page, et supprimait de nouveau : les cartes ne revenaient jamais. La sortie par plafond de pages (MAX_SYNC_PAGES) gérait déjà ce cas correctement (isFullListing à false, avec un commentaire qui le dit) ; seule cette sortie-là l'oubliait. La sortie « tronqué sans suite » est maintenant séparée, journalisée en warning, et renvoie isFullListing à false. Aucune suppression n'a lieu sur une réponse partielle ; les cartes présentes dans la page sont appliquées normalement. Deux tests : la pagination suivie jusqu'au bout reste un listing complet (cas nominal, qui n'était pas couvert), et une page tronquée sans jeton n'en est pas un — ce second test échoue sans le correctif. CalDavCalendarDriver a la même forme de sortie, mais CalendarChangeSet n'a pas de drapeau isFullListing (le moteur calendrier déduit le caractère complet du jeton envoyé) : le corriger touche le moteur calendrier et sort du périmètre de cette branche. Signalé dans la description de la PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
$addressBook était la seule relation d'identité des deux nouvelles entités à rester `public` en écriture : AddressBook::$usr et ::$integration sont `public private(set)`, comme le reste du modèle. Rien ne l'assigne hors du constructeur, et rien ne doit le faire : changer le carnet d'une carte ne la déplacerait pas sur le serveur (son href pointe toujours dans l'ancienne collection), casserait la clé unique (address_book_id, uid) sans que rien ne la vérifie avant le flush, et le formulaire dit explicitement que le déplacement n'est pas proposé. private(set), avec un docblock qui renvoie à cette raison. PHPStan, doctrine:schema:validate et la suite AddressBook passent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
Le catalogue français est arrivé sur main après le départ de cette branche ; rebasée, elle ajoutait 69 clés à messages.en.yaml et 4 à validators.en.yaml sans équivalent français, et CatalogueParityTest (qui vérifie de, fr et en_PI) échouait sur fr. Vocabulaire repris des clés CalDAV déjà traduites pour que les deux écrans se lisent pareil : « reflété » pour mirrored, « mot de passe d'application », guillemets « » et apostrophe typographique. Les textes du formulaire CardDAV (réutilisation d'un mot de passe de compte mail, aide sur l'adresse) suivent mot pour mot leurs jumeaux CalDAV, à « carnets d'adresses » près. Vérifié : plus aucune clé de en absente de fr dans messages, validators et security ; les deux fichiers se chargent en YAML. PHPUnit n'a pas tourné ici (pas de Docker sur cette machine). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP7Zev5qvDRzKbcmecvRGW
d04cb5e to
7d3ec7e
Compare
Closes #157
Adds CardDAV: mirror address books from Nextcloud (or Radicale, Baïkal, Fastmail, iCloud…), suggest their contacts while composing, and edit them from a new Contacts page with two-way sync.
It is built the way CalDAV already is, and reuses its WebDAV layer rather than adding a second one.
What a user gets
Settings → Contacts: connect a CardDAV server (same form and flow as CalDAV: address, credential, then tick the address books to mirror). Each book shows its last sync or its last error, and has a sync-now button.
Contacts page (user menu → Contacts): list, search, create, edit, delete. Read-only books (shared or global address lists) are shown but not editable.
Composer autocomplete and JMAP
Contact/autocompletenow suggest address-book contacts as well as harvested addresses, in one ranking shared by both:With no address book the order is exactly as before. JMAP suggestions gain an
inAddressBookflag.How it is built (14 commits, one idea each)
The first five build the feature; the nine after them are fixes and one refactor from a review pass, each with its own test.
CalDavDiscoveryrefactor. The RFC 6764 bootstrap now takes aDavService(Calendar or AddressBook), so.well-known/carddavandaddressbook-home-setuse the same discovery code. This includes its SSRF re-validation on every redirect. Calendars are the default; no behaviour change for CalDAV.Provider::CardDavis a connection under a newServiceKind::Contacts. It is kept out of the file picker and the calendar screens.AddressBook(the counterpart ofCalendar) andContactCard.CardDavDriverreusesCalDavClientandMultiStatusParser. Error wording is provider-aware, so a contacts failure never says "calendar server". The read-only rule (DavResource::isReadOnly()) and the etag read-back after a PUT (CalDavClient::etagAfterWrite()) are shared with the CalDAV driver rather than copied; no CalDAV behaviour changes.If-None-Match: *andIf-Match). On a 412 the server's version wins and the loss is logged.valid-sync-tokenprecondition, is dropped and the book is read from scratch.app:contacts:sync --staleruns every 15 minutes, offset to :11 so it does not stack on the mail and calendar sweeps.CalDavConnectTypewith aprotocoloption, so the reuse-a-mail-password rule and the probe-on-save rule are not duplicated.ContactSuggester), used by both the web route and JMAP. Only cards that carry an address are asked for, in SQL before the limit, so a book full of number-only phone contacts does not crowd out the ones you can write to.docs/providers/carddav.md(English and German), the index, and CHANGELOG.vCards are edited, not rebuilt.
ContactCardkeeps the raw vCard, and an edit only touches FN, N, EMAIL, TEL, ORG and NOTE. Photos, birthdays, postal addresses and phone-specific X- properties survive a round trip. A kept address also keeps its TYPE and its Apple grouping.Migration
Adds the
address_bookandcontact_cardtables. Nothing existing changes.doctrine:schema:validate --skip-syncpasses; index and FK names follow DBAL's generator.Testing
Run here without Postgres:
lint:container,lint:twigand targeted PHPStan (src and tests) are clean.main, the full suite shows no newly failing test. The remaining failures are the DB-dependent ones, plus Graph time-zone tests that need an ICU this host lacks; they fail the same way onmain.Not run here (needs the stack):
tests/Controller/AddressBook/ContactsFlowTest.php, end to end with a scripted Nextcloud on a.testhost: connect, discover, subscribe, list, search, edit, create, delete, read-only, cross-user isolation.inAddressBookkey.Please run them with
npm run test:unit:docker, or let CI run them.Not in this PR
Inherited from the CalDAV code this mirrors, and left alone here because fixing them means changing the calendar side too:
CalDavCalendarDriverhas the same "truncated sync report with no token" exit that is fixed here for CardDAV.CalendarChangeSethas no full-listing flag, so the fix there touches the calendar engine.(usr, provider, name)unique key.Out of scope:
NEXTCLOUD_URLin Connect my Nextcloud: one-click CalDAV connection when NEXTCLOUD_URL is set #108 and can follow once that is merged.🤖 Generated with Claude Code
https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni