Export/Import Json pour les agenda aussi #2

Open
opened 2026-08-28 20:05:07 +01:00 by maxime · 4 comments
Owner

Description

L'export JSON (D36) emporte les listes, les recettes et le planning des repas. L'agenda familial en est absent : une famille qui déménage son espace perd ses événements.

Ce qui existe
services/transfer-export.ts et services/transfer-import.ts, un fichier JSON téléchargé depuis les paramètres de la famille, et un import qui ajoute sans jamais remplacer — chaque ligne reçoit un nouvel identifiant, importer deux fois donne deux copies.

Pourquoi l'agenda avait été écarté
D36 s'appuyait sur le fait que Nextcloud Calendar sait déjà exporter un .ics. C'est vrai, mais ça oblige l'utilisateur à faire deux exports dans deux applications et à savoir lesquels vont ensemble.

Ce qu'il faut trancher

  • Quels agendas. Seul l'agenda familial a un contenu qui lui appartient. Les échéances de tâches et les repas sont des projections (D31) : les exporter dupliquerait ce que les listes et le planning portent déjà, et les réimporter écrirait dans deux agendas en lecture seule. Un abonnement externe n'a aucun contenu propre — son URL source, en revanche, se transporte.
  • Le format. Du VEVENT iCalendar brut dans le JSON plutôt qu'un modèle intermédiaire : les récurrences, EXDATE et RECURRENCE-ID sont déjà conservés tels quels quand ils ne sont pas modélisables (D15), et les remodéliser pour l'export perdrait exactement ça.
  • Le volume. Le fichier doit rester quelque chose qu'on peut s'envoyer par courriel — c'est la raison pour laquelle les photos de recettes sont hors périmètre. Un agenda de plusieurs années est gros : borner la fenêtre exportée, ou l'assumer.
  • L'import. Écrire N événements en CalDAV, un par un, depuis une route synchrone. L'import actuel évite précisément ça pour les projections en appelant reconcileFamily une seule fois (D36) ; ici il n'y a pas de projection à réconcilier, donc rien qui rattrape ce qui échoue en route.
  • Les identités. CATEGORIES porte les membres attribués (D14). Comme pour assignee, un membre absent de la famille cible doit disparaître de l'événement plutôt que faire échouer l'import.

Plan d'action

La conception est dans ce commentaire ; les retours de revue dans celui-ci et le bilan du développement dans celui-là.

  • dav/client.ts : fetchCalendarObjects — la collection entière, non dépliée. Décidé en route : aucun filtre time-range plutôt qu'une borne large, puisqu'un filtre ne retient un objet que si une occurrence tombe dedans.
  • contracts/transfer.ts : section calendar (objects, subscriptions, members, unreadable, omitted), EXPORT_VERSION à 2, ImportReport élargi.
  • services/ical/rewrite.ts : réécriture ligne à ligne — toutes les lignes UID:, CATEGORIES filtré en gardant ses paramètres. fold, unfold et l'échappement ont été sortis dans services/ical/text.ts plutôt qu'exportés depuis build.ts : ils étaient déjà en deux moitiés.
  • services/transfer-export.ts : l'agenda lu en entier, findSubscriptions, membersOf ; unreadable porté et non avalé.
  • services/transfer-export.ts : le plafond appliqué et annoncé — jamais une RRULE, puis les ponctuels les plus récents, le reste dans omitted.
  • services/transfer-import.ts : UID dérivé ; écritures CalDAV après la transaction, concurrence bornée à 4, échecs comptés ; abonnements via provisionSubscription, source déjà présente sautée.
  • routes/transfer.ts : schéma Zod de la section calendar, version en z.literal(2) qui refuse les v1 sans code de compatibilité, MAX_IMPORT_BYTES de 5 à 25 Mio, bornes du nombre et de la taille des objets.
  • DataSettings.vue : textes d'export (agenda entier, URL des abonnements, agenda illisible ou tronqué) et d'import (les événements sont rejoués) ; nouvelles phrases de rapport, passées en table — onze if dépassaient la règle de complexité.
  • stores/transfer.ts : l'emploi du temps et les agendas rechargés après un import ; lastExport pour que l'écran puisse dire ce que l'export a laissé.
  • Tests backend : export (objet non aplati, abonnements, unreadable, plafond), import (UID stable, rejeu sans doublon, catégories, échec partiel), ical/rewrite.test.ts, routes/calendar-schema.test.ts.
  • Tests frontend : stores/transfer.test.ts — nouvelles phrases, lastExport.
  • e2e : une série avec sa règle retrouvée après l'aller-retour ; un second import qui ne double pas.
  • Non prévu, découvert en revue : routes/calendar-schema.ts, parce que le schéma d'import acceptait javascript: et file: là où la route dédiée les refuse.

Recette

  • make lint typecheck test — 0 erreur, 426 tests backend, 344 frontend
  • make build-frontend, puis vérifier que le bundle est bien une IIFE
  • cd e2e && npm test — 96 tests verts
  • /code-review, retours déposés en commentaire du ticket
  • Corriger les retours de revue — trois corrigés, un vérifié et laissé tel quel
  • Mesurer un import de plusieurs centaines d'événements à travers le proxy AppAPI — 500 en 8,8 s, 3 000 en 48 s, aucun refusé
  • Établir le vrai plafond de corps de requête du proxy AppAPI — au moins 24 Mio, le 413 à 30 Mio vient de notre bodyLimit. Le transport n'est pas la contrainte ; la durée d'écriture l'est
  • Vérifier qu'un fichier v1 est refusé avec « version de fichier non reconnue »
  • Vérifier qu'un import n'écrit rien dans les agendas projetés, et que la passe horaire ne prend pas les événements importés pour des intrus
  • Vérifier qu'un import ne remplace pas un événement de la famille cible
  • Mise à jour du wiki — Architecture : D37, et D36 marqué comme dépassé sur ce point ; UX : les avertissements de l'écran « Données »
  • Trancher le plafond contre la durée d'import — descendu à 3 000 objets, réglé sur les 48 s mesurées (commentaire)
  • Revue technique personnelle
  • Revue fonctionnelle

Plan de MEP

  • Rien à migrer : aucune table, aucune colonne. Les événements voyagent en CalDAV, les abonnements par provisionSubscription.
  • Aucune route nouvelle : ^/api/.* déclare déjà GET et POST — appinfo/info.xml est inchangé, routes.test.ts le confirme.
  • Rebasé sur main en avance rapide, historique linéaire, branche supprimée. Rien poussé sur origin.
  • Livraison applicative simple : image reconstruite, make build-frontend avant la construction de l'image.
  • Rupture assumée : les fichiers déjà téléchargés (v1) ne seront plus importables. L'application n'est pas en production.
  • Rien à purger côté client : l'export et l'import ne sont pas mis en cache par le service worker.
  • Le compte de service doit être en place au moment de la livraison : il possède les collections (D24). S'il manque, l'import des événements échoue — le reste passe et l'écran le dit.
  • Effet immédiat, sans geste des familles : les nouveaux exports emportent l'agenda dès le premier passage sur l'écran « Données ».
## Description L'export JSON (D36) emporte les listes, les recettes et le planning des repas. L'agenda familial en est absent : une famille qui déménage son espace perd ses événements. **Ce qui existe** `services/transfer-export.ts` et `services/transfer-import.ts`, un fichier JSON téléchargé depuis les paramètres de la famille, et un import qui **ajoute** sans jamais remplacer — chaque ligne reçoit un nouvel identifiant, importer deux fois donne deux copies. **Pourquoi l'agenda avait été écarté** D36 s'appuyait sur le fait que Nextcloud Calendar sait déjà exporter un `.ics`. C'est vrai, mais ça oblige l'utilisateur à faire deux exports dans deux applications et à savoir lesquels vont ensemble. **Ce qu'il faut trancher** - **Quels agendas.** Seul l'agenda familial a un contenu qui lui appartient. Les échéances de tâches et les repas sont des projections (D31) : les exporter dupliquerait ce que les listes et le planning portent déjà, et les réimporter écrirait dans deux agendas en lecture seule. Un abonnement externe n'a aucun contenu propre — son URL source, en revanche, se transporte. - **Le format.** Du `VEVENT` iCalendar brut dans le JSON plutôt qu'un modèle intermédiaire : les récurrences, `EXDATE` et `RECURRENCE-ID` sont déjà conservés tels quels quand ils ne sont pas modélisables (D15), et les remodéliser pour l'export perdrait exactement ça. - **Le volume.** Le fichier doit rester quelque chose qu'on peut s'envoyer par courriel — c'est la raison pour laquelle les photos de recettes sont hors périmètre. Un agenda de plusieurs années est gros : borner la fenêtre exportée, ou l'assumer. - **L'import.** Écrire N événements en CalDAV, un par un, depuis une route synchrone. L'import actuel évite précisément ça pour les projections en appelant `reconcileFamily` une seule fois (D36) ; ici il n'y a pas de projection à réconcilier, donc rien qui rattrape ce qui échoue en route. - **Les identités.** `CATEGORIES` porte les membres attribués (D14). Comme pour `assignee`, un membre absent de la famille cible doit disparaître de l'événement plutôt que faire échouer l'import. ## Plan d'action La conception est [dans ce commentaire](https://git.lozach.eu/maxime/OrganisateurFamilial/issues/2#issuecomment-94) ; les retours de revue [dans celui-ci](https://git.lozach.eu/maxime/OrganisateurFamilial/issues/2#issuecomment-98) et le bilan du développement [dans celui-là](https://git.lozach.eu/maxime/OrganisateurFamilial/issues/2#issuecomment-99). - [x] `dav/client.ts` : `fetchCalendarObjects` — la collection entière, non dépliée. Décidé en route : **aucun filtre `time-range`** plutôt qu'une borne large, puisqu'un filtre ne retient un objet que si une occurrence tombe dedans. - [x] `contracts/transfer.ts` : section `calendar` (`objects`, `subscriptions`, `members`, `unreadable`, `omitted`), `EXPORT_VERSION` à `2`, `ImportReport` élargi. - [x] `services/ical/rewrite.ts` : réécriture ligne à ligne — toutes les lignes `UID:`, `CATEGORIES` filtré en gardant ses paramètres. `fold`, `unfold` et l'échappement ont été sortis dans `services/ical/text.ts` plutôt qu'exportés depuis `build.ts` : ils étaient déjà en deux moitiés. - [x] `services/transfer-export.ts` : l'agenda lu en entier, `findSubscriptions`, `membersOf` ; `unreadable` porté et non avalé. - [x] `services/transfer-export.ts` : le plafond appliqué **et annoncé** — jamais une `RRULE`, puis les ponctuels les plus récents, le reste dans `omitted`. - [x] `services/transfer-import.ts` : UID dérivé ; écritures CalDAV après la transaction, concurrence bornée à 4, échecs comptés ; abonnements via `provisionSubscription`, source déjà présente sautée. - [x] `routes/transfer.ts` : schéma Zod de la section `calendar`, `version` en `z.literal(2)` qui refuse les v1 sans code de compatibilité, `MAX_IMPORT_BYTES` de 5 à 25 Mio, bornes du nombre et de la taille des objets. - [x] `DataSettings.vue` : textes d'export (agenda entier, URL des abonnements, agenda illisible ou tronqué) et d'import (les événements sont rejoués) ; nouvelles phrases de rapport, passées en table — onze `if` dépassaient la règle de complexité. - [x] `stores/transfer.ts` : l'emploi du temps et les agendas rechargés après un import ; `lastExport` pour que l'écran puisse dire ce que l'export a laissé. - [x] Tests backend : export (objet non aplati, abonnements, `unreadable`, plafond), import (UID stable, rejeu sans doublon, catégories, échec partiel), `ical/rewrite.test.ts`, `routes/calendar-schema.test.ts`. - [x] Tests frontend : `stores/transfer.test.ts` — nouvelles phrases, `lastExport`. - [x] e2e : une série avec sa règle retrouvée après l'aller-retour ; un second import qui ne double pas. - [x] Non prévu, découvert en revue : `routes/calendar-schema.ts`, parce que le schéma d'import acceptait `javascript:` et `file:` là où la route dédiée les refuse. ## Recette - [x] `make lint typecheck test` — 0 erreur, 426 tests backend, 344 frontend - [x] `make build-frontend`, puis vérifier que le bundle est bien une IIFE - [x] `cd e2e && npm test` — 96 tests verts - [x] `/code-review`, retours déposés en commentaire du ticket - [x] Corriger les retours de revue — trois corrigés, un vérifié et laissé tel quel - [x] Mesurer un import de plusieurs centaines d'événements à travers le proxy AppAPI — 500 en 8,8 s, 3 000 en 48 s, aucun refusé - [x] Établir le vrai plafond de corps de requête du proxy AppAPI — au moins 24 Mio, le 413 à 30 Mio vient de notre `bodyLimit`. Le transport n'est pas la contrainte ; la durée d'écriture l'est - [x] Vérifier qu'un fichier v1 est refusé avec « version de fichier non reconnue » - [x] Vérifier qu'un import n'écrit rien dans les agendas projetés, et que la passe horaire ne prend pas les événements importés pour des intrus - [x] Vérifier qu'un import ne remplace pas un événement de la famille cible - [x] Mise à jour du wiki — `Architecture` : D37, et D36 marqué comme dépassé sur ce point ; `UX` : les avertissements de l'écran « Données » - [x] Trancher le plafond contre la durée d'import — descendu à 3 000 objets, réglé sur les 48 s mesurées ([commentaire](https://git.lozach.eu/maxime/OrganisateurFamilial/issues/2#issuecomment-100)) - [x] Revue technique personnelle - [x] Revue fonctionnelle ## Plan de MEP - Rien à migrer : aucune table, aucune colonne. Les événements voyagent en CalDAV, les abonnements par `provisionSubscription`. - Aucune route nouvelle : `^/api/.*` déclare déjà `GET` et `POST` — `appinfo/info.xml` est inchangé, `routes.test.ts` le confirme. - [x] Rebasé sur `main` en avance rapide, historique linéaire, branche supprimée. Rien poussé sur `origin`. - Livraison applicative simple : image reconstruite, `make build-frontend` avant la construction de l'image. - **Rupture assumée : les fichiers déjà téléchargés (v1) ne seront plus importables.** L'application n'est pas en production. - Rien à purger côté client : l'export et l'import ne sont pas mis en cache par le service worker. - [ ] Le compte de service doit être en place au moment de la livraison : il possède les collections (D24). S'il manque, l'import des événements échoue — le reste passe et l'écran le dit. - Effet immédiat, sans geste des familles : les nouveaux exports emportent l'agenda dès le premier passage sur l'écran « Données ».
maxime added this to the 1.0.x milestone 2026-08-28 20:05:07 +01:00
Collaborator

Conception — l'agenda dans l'export/import

1. Ce qui voyage, et ce qui n'en est pas

  • L'agenda familial (CalendarKind.EVENTS) : ses objets, tels qu'ils sont stockés.
  • Les abonnements externes (D33) : leur définition — nom, couleur, URL source — jamais
    leur contenu. Rien ici ne l'a écrit, et un flux se réabonne.
  • Pas les projections (D31) : tasks et meals se reconstruisent à partir des lignes
    déjà exportées, exactement comme la passe de remise en état les reconstruit (D32). Les
    exporter dupliquerait ce que les listes et le planning portent, et les réimporter écrirait
    dans deux agendas en lecture seule.

2. Le format : l'objet CalDAV brut, pas un modèle intermédiaire

calendar.objects est un tableau de corps VCALENDAR complets, un par objet de la
collection, tels que le serveur les rend.

Le modèle de D15 ne porte qu'un sous-ensemble d'iCalendar. parseObject garde déjà les
exceptions en texte brut pour cette raison précise : les reconstruire perdrait l'alarme,
la couleur, le participant qu'elles portent. Un export qui passerait par CalendarEvent
referait cette perte sur tout, la série comprise — EXDATE, RECURRENCE-ID, VALARM,
ATTENDEE, X-*, le fuseau nommé.

L'objet entier, et non un VEVENT par entrée : une série et ses exceptions partagent un UID
et n'ont de sens qu'ensemble.

3. La lecture : un REPORT sans <c:expand> et sans filtre de date

reportCalendarObjects ne convient pas — il demande <c:expand>, qui aplatit une règle en
une occurrence par semaine. C'est ce qu'il faut pour une grille et exactement ce qu'il ne
faut pas pour un export : mesuré ailleurs dans ce dépôt, sept occurrences entrées, une
sortie.

Nouvelle fonction dans dav/client.ts : un calendar-query réduit au filtre de composant
VEVENT, sans <c:expand>, sans <c:limit-recurrence-set> et sans
<c:time-range>. La collection entière, chaque objet rendu tel qu'il est stocké.

Lecture comme le membre qui demande, à travers le partage d'équipe (refOf, readerId non
posé). Un abonnement, lui, n'est pas lu du tout.

4. Le volume : tout, avec un plafond annoncé

Pas de fenêtre temporelle : un export emporte l'agenda entier, historique compris.

Ce que ça pèse, mesuré et non estimé — avec buildIcs d'un côté, les 1 236 objets de flux
externes en cache sur l'instance de développement de l'autre :

objet ICS échappé en JSON
minimal, écrit par l'app 261 o 287 o
riche : lieu, description, 3 membres, RRULE 494 o 529 o
série + 3 exceptions + 2 EXDATE 1 069 o 1 153 o
écrit depuis Nextcloud Calendar, avec VTIMEZONE ~2 400 o ~2 500 o

Ce qui rend le volume tenable : un événement récurrent est un seul objet, pas une
occurrence par semaine.
La piscine du mercredi pendant dix ans pèse 1 Ko, pas 520 Ko. Le
volume d'un agenda familial est porté par ses événements ponctuels, pas par ses habitudes.

Contre une limite d'import de 5 Mio : ~17 000 objets au format de l'app, ~5 200 en mélange
réaliste, ~2 100 si tout vient de Nextcloud Calendar. Une famille de cinq personnes avec dix
ans d'historique est vers 5 000 objets — c'est-à-dire sur la limite dans le pire mélange. La
limite monte donc à 25 Mio.

Un plafond dur en nombre d'objets reste, et il s'annonce. L'export s'y tient, le fichier
porte le compte de ce qui est resté (calendar.omitted) et l'écran le dit. L'invariant :
un fichier produit ici doit toujours pouvoir être relu ici — un export qui dépasse ce que
son propre import accepte est un fichier mort, et un agenda tronqué qui a l'air complet est
exactement l'erreur que D26 nomme.

Ce qui est coupé quand le plafond est atteint : jamais un objet portant une RRULE — ce
sont les habitudes de la famille, peu nombreuses et les plus utiles — puis les ponctuels les
plus récents d'abord. Ce qui saute est donc le vieil historique ponctuel, et rien de vivant.

5. L'écriture : un UID dérivé, donc un import rejouable

Le point dur du ticket : N PUT CalDAV, hors transaction, sans réconciliation derrière pour
rattraper ce qui échoue en route.

L'UID importé est dérivé de (famille cible, UID source) — un sha256 tronqué remis en
forme d'UUID — au lieu d'un randomUUID().

Ce que ça achète :

  • Rejouer le même fichier répare. Un import interrompu à 300 événements sur 500 se
    termine en relançant le même fichier : les 300 déjà écrits sont réécrits à l'identique, les
    200 manquants arrivent. C'est la seule réponse possible à « rien ne rattrape ce qui échoue
    en route », puisqu'il n'y a ici aucune projection à réconcilier.
  • Un import n'écrase jamais un événement de la famille cible. Un UID écrit ici est un
    randomUUID() ; il ne peut pas valoir H(familleCible, uidSource).
  • Deux familles cibles ne se marchent pas dessus à partir du même fichier.

Ce que ça coûte : les événements ne suivent pas la règle « importer deux fois donne deux
copies » que D36 pose pour les listes et les recettes. C'est le seul endroit du fichier où
les deux règles diffèrent, et l'écran d'import doit le dire.

La réécriture porte sur toutes les lignes UID: de l'objet — la série et chacune de ses
exceptions, qui la référencent par là.

Écritures après la transaction SQLite, concurrence bornée (4 en vol), échecs comptés et non
levés : un objet refusé ne doit pas emporter les suivants.

6. Les identités : CATEGORIES, et comment distinguer un membre d'une étiquette

CATEGORIES porte les membres (D14) — mais parseIcs lit toute catégorie comme un membre,
et un événement écrit depuis Nextcloud Calendar peut porter « Vacances ».

Le fichier emporte donc la liste des membres de la famille source (calendar.members), et
l'import retire d'un objet une catégorie qui était un membre de la source et n'est pas un
membre de la cible
. Tout le reste passe, paramètres de la propriété compris.

La règle naïve — ne garder une catégorie que si elle nomme un membre de la cible —
supprimerait « Vacances » sans rien dire.

C'est une identité de plus qui traverse le fichier, ce que D36 restreignait à assignee :
elle ne voyage que pour permettre cet arbitrage, n'est jamais écrite comme auteur, et ce
qu'elle fait retirer est compté dans le rapport.

7. Les abonnements

Export : { name, color, source }. Import : provisionSubscription, en sautant une source
déjà présente dans la famille cible et en s'arrêtant au plafond de 5
(MAX_SUBSCRIPTIONS_PER_FAMILY), le reste compté comme ignoré. Naturellement idempotent,
sans dérivation d'identifiant.

Une URL de flux peut porter des identifiants — c'est la raison pour laquelle le panneau
« Agendas » n'affiche que l'hôte (D33). Les mettre dans un fichier qu'on s'envoie par courriel
est un vrai risque : il est assumé et écrit, l'écran d'export le dit avant le clic.
L'alternative — exporter le nom sans l'URL — donne un abonnement mort à recréer à la main,
c'est-à-dire rien.

8. Un agenda illisible se dit, il ne se tait pas

Si le REPORT échoue, le fichier porte calendar.unreadable: true et zéro objet, et l'export
n'est pas refusé pour autant : une famille dont le compte de service a lâché doit pouvoir
emporter ses listes. L'écran d'export l'annonce en avertissement. Un fichier qui montrerait un
agenda vide sans le dire est exactement l'erreur que D26 nomme — vide veut dire inconnu, pas
libre.

9. Les bornes et la version

  • EXPORT_VERSION passe à 2, et la route refuse un fichier v1. Pas de rétrocompatibilité :
    l'application n'est pas en production, et le champ version existe précisément pour refuser
    une forme que ce code ne sait pas lire. Le z.literal(EXPORT_VERSION) déjà en place le fait
    sans une ligne de plus, avec le message qu'il porte déjà.
  • MAX_IMPORT_BYTES : 5 → 25 Mio, sur la mesure ci-dessus.
  • Nouvelles bornes Zod, même rôle que MAX_IMPORT_LISTS — empêcher qu'un fichier fabriqué rende
    le travail non borné : le plafond d'objets, une longueur maximale par objet, et
    MAX_IMPORT_SUBSCRIPTIONS = 5.
  • L'export applique les bornes de l'import. Ce n'est pas vrai aujourd'hui pour les listes ;
    ça doit l'être pour les événements dès le départ.

10. Ce que ça ne fait toujours pas

Pas de détection de conflit (#5), pas de fusion, pas d'export des agendas projetés, pas de
participants ni d'invitations. Un import reste un ajout.

## Conception — l'agenda dans l'export/import ### 1. Ce qui voyage, et ce qui n'en est pas - **L'agenda familial** (`CalendarKind.EVENTS`) : ses objets, tels qu'ils sont stockés. - **Les abonnements externes** (D33) : leur *définition* — nom, couleur, URL source — jamais leur contenu. Rien ici ne l'a écrit, et un flux se réabonne. - **Pas les projections** (D31) : `tasks` et `meals` se reconstruisent à partir des lignes déjà exportées, exactement comme la passe de remise en état les reconstruit (D32). Les exporter dupliquerait ce que les listes et le planning portent, et les réimporter écrirait dans deux agendas en lecture seule. ### 2. Le format : l'objet CalDAV brut, pas un modèle intermédiaire `calendar.objects` est un tableau de corps `VCALENDAR` complets, un par objet de la collection, tels que le serveur les rend. Le modèle de D15 ne porte qu'un sous-ensemble d'iCalendar. `parseObject` garde déjà les exceptions **en texte brut** pour cette raison précise : les reconstruire perdrait l'alarme, la couleur, le participant qu'elles portent. Un export qui passerait par `CalendarEvent` referait cette perte sur *tout*, la série comprise — `EXDATE`, `RECURRENCE-ID`, `VALARM`, `ATTENDEE`, `X-*`, le fuseau nommé. L'objet entier, et non un `VEVENT` par entrée : une série et ses exceptions partagent un UID et n'ont de sens qu'ensemble. ### 3. La lecture : un `REPORT` sans `<c:expand>` et sans filtre de date `reportCalendarObjects` ne convient pas — il demande `<c:expand>`, qui aplatit une règle en une occurrence par semaine. C'est ce qu'il faut pour une grille et exactement ce qu'il ne faut pas pour un export : mesuré ailleurs dans ce dépôt, sept occurrences entrées, une sortie. Nouvelle fonction dans `dav/client.ts` : un `calendar-query` réduit au filtre de composant `VEVENT`, **sans** `<c:expand>`, **sans** `<c:limit-recurrence-set>` et **sans** `<c:time-range>`. La collection entière, chaque objet rendu tel qu'il est stocké. Lecture comme le membre qui demande, à travers le partage d'équipe (`refOf`, `readerId` non posé). Un abonnement, lui, n'est pas lu du tout. ### 4. Le volume : tout, avec un plafond annoncé Pas de fenêtre temporelle : un export emporte l'agenda entier, historique compris. **Ce que ça pèse, mesuré et non estimé** — avec `buildIcs` d'un côté, les 1 236 objets de flux externes en cache sur l'instance de développement de l'autre : | objet | ICS | échappé en JSON | |---|---|---| | minimal, écrit par l'app | 261 o | 287 o | | riche : lieu, description, 3 membres, `RRULE` | 494 o | 529 o | | série + 3 exceptions + 2 `EXDATE` | 1 069 o | 1 153 o | | écrit depuis Nextcloud Calendar, avec `VTIMEZONE` | ~2 400 o | ~2 500 o | Ce qui rend le volume tenable : **un événement récurrent est un seul objet, pas une occurrence par semaine.** La piscine du mercredi pendant dix ans pèse 1 Ko, pas 520 Ko. Le volume d'un agenda familial est porté par ses événements ponctuels, pas par ses habitudes. Contre une limite d'import de 5 Mio : ~17 000 objets au format de l'app, ~5 200 en mélange réaliste, ~2 100 si tout vient de Nextcloud Calendar. Une famille de cinq personnes avec dix ans d'historique est vers 5 000 objets — c'est-à-dire sur la limite dans le pire mélange. La limite monte donc à 25 Mio. **Un plafond dur en nombre d'objets reste, et il s'annonce.** L'export s'y tient, le fichier porte le compte de ce qui est resté (`calendar.omitted`) et l'écran le dit. L'invariant : *un fichier produit ici doit toujours pouvoir être relu ici* — un export qui dépasse ce que son propre import accepte est un fichier mort, et un agenda tronqué qui a l'air complet est exactement l'erreur que D26 nomme. Ce qui est coupé quand le plafond est atteint : **jamais un objet portant une `RRULE`** — ce sont les habitudes de la famille, peu nombreuses et les plus utiles — puis les ponctuels les plus récents d'abord. Ce qui saute est donc le vieil historique ponctuel, et rien de vivant. ### 5. L'écriture : un UID dérivé, donc un import rejouable Le point dur du ticket : N `PUT` CalDAV, hors transaction, sans réconciliation derrière pour rattraper ce qui échoue en route. **L'UID importé est dérivé de (famille cible, UID source)** — un `sha256` tronqué remis en forme d'UUID — au lieu d'un `randomUUID()`. Ce que ça achète : - **Rejouer le même fichier répare.** Un import interrompu à 300 événements sur 500 se termine en relançant le même fichier : les 300 déjà écrits sont réécrits à l'identique, les 200 manquants arrivent. C'est la seule réponse possible à « rien ne rattrape ce qui échoue en route », puisqu'il n'y a ici aucune projection à réconcilier. - **Un import n'écrase jamais un événement de la famille cible.** Un UID écrit ici est un `randomUUID()` ; il ne peut pas valoir `H(familleCible, uidSource)`. - **Deux familles cibles ne se marchent pas dessus** à partir du même fichier. Ce que ça coûte : les événements ne suivent pas la règle « importer deux fois donne deux copies » que D36 pose pour les listes et les recettes. C'est le seul endroit du fichier où les deux règles diffèrent, et l'écran d'import doit le dire. La réécriture porte sur **toutes** les lignes `UID:` de l'objet — la série et chacune de ses exceptions, qui la référencent par là. Écritures après la transaction SQLite, concurrence bornée (4 en vol), échecs comptés et non levés : un objet refusé ne doit pas emporter les suivants. ### 6. Les identités : `CATEGORIES`, et comment distinguer un membre d'une étiquette `CATEGORIES` porte les membres (D14) — mais `parseIcs` lit *toute* catégorie comme un membre, et un événement écrit depuis Nextcloud Calendar peut porter « Vacances ». Le fichier emporte donc la liste des membres de la famille **source** (`calendar.members`), et l'import retire d'un objet une catégorie **qui était un membre de la source et n'est pas un membre de la cible**. Tout le reste passe, paramètres de la propriété compris. La règle naïve — ne garder une catégorie que si elle nomme un membre de la cible — supprimerait « Vacances » sans rien dire. C'est une identité de plus qui traverse le fichier, ce que D36 restreignait à `assignee` : elle ne voyage que pour permettre cet arbitrage, n'est jamais écrite comme auteur, et ce qu'elle fait retirer est compté dans le rapport. ### 7. Les abonnements Export : `{ name, color, source }`. Import : `provisionSubscription`, en sautant une source déjà présente dans la famille cible et en s'arrêtant au plafond de 5 (`MAX_SUBSCRIPTIONS_PER_FAMILY`), le reste compté comme ignoré. Naturellement idempotent, sans dérivation d'identifiant. **Une URL de flux peut porter des identifiants** — c'est la raison pour laquelle le panneau « Agendas » n'affiche que l'hôte (D33). Les mettre dans un fichier qu'on s'envoie par courriel est un vrai risque : il est assumé et écrit, l'écran d'export le dit avant le clic. L'alternative — exporter le nom sans l'URL — donne un abonnement mort à recréer à la main, c'est-à-dire rien. ### 8. Un agenda illisible se dit, il ne se tait pas Si le `REPORT` échoue, le fichier porte `calendar.unreadable: true` et zéro objet, et l'export n'est pas refusé pour autant : une famille dont le compte de service a lâché doit pouvoir emporter ses listes. L'écran d'export l'annonce en avertissement. Un fichier qui montrerait un agenda vide sans le dire est exactement l'erreur que D26 nomme — vide veut dire inconnu, pas libre. ### 9. Les bornes et la version - `EXPORT_VERSION` passe à `2`, et la route **refuse** un fichier v1. Pas de rétrocompatibilité : l'application n'est pas en production, et le champ `version` existe précisément pour refuser une forme que ce code ne sait pas lire. Le `z.literal(EXPORT_VERSION)` déjà en place le fait sans une ligne de plus, avec le message qu'il porte déjà. - `MAX_IMPORT_BYTES` : 5 → 25 Mio, sur la mesure ci-dessus. - Nouvelles bornes Zod, même rôle que `MAX_IMPORT_LISTS` — empêcher qu'un fichier fabriqué rende le travail non borné : le plafond d'objets, une longueur maximale par objet, et `MAX_IMPORT_SUBSCRIPTIONS = 5`. - **L'export applique les bornes de l'import.** Ce n'est pas vrai aujourd'hui pour les listes ; ça doit l'être pour les événements dès le départ. ### 10. Ce que ça ne fait toujours pas Pas de détection de conflit (#5), pas de fusion, pas d'export des agendas projetés, pas de participants ni d'invitations. Un import reste un ajout.
Collaborator

Retours de revue (/code-review, niveau high)

Quatre constats, trois corrigés, un laissé tel quel.

1. L'import contournait la liste blanche de schémas des abonnements — corrigé

Le plus sérieux, et il ne venait pas de l'agenda : le schéma d'import validait l'URL d'un
abonnement avec z.string().url(). Vérifié contre le zod du projet, ça accepte :

true   javascript:alert(1)
true   file:///etc/passwd
true   data:text/calendar,BEGIN:VCALENDAR

routes/calendars.ts refuse précisément ces adresses depuis D33 (SUBSCRIPTION_SCHEMES,
http/https/webcal/webcals), et pour une bonne raison : c'est Nextcloud qui va chercher le
flux
, côté serveur. Un fichier d'import bricolé à la main aurait donc écrit une source
qu'aucune route dédiée n'aurait laissé passer.

Même classe de problème sur la couleur : le schéma d'import acceptait z.string() là où la
route impose HEX_COLOR, et la valeur finit dans une propriété de style.

Corrigé en extrayant routes/calendar-schema.ts — le motif que list-schema.ts et
recipe-schema.ts suivent déjà. Les deux routes valident contre la même déclaration, et
routes/calendar-schema.test.ts couvre les schémas acceptés et refusés.

C'est exactement ce que la note de list-schema.ts annonçait : « une borne déclarée deux
fois est une borne qui dérive ». Elle a dérivé.

2. L'export ne respectait pas la borne de taille par objet de l'import — corrigé

L'export appliquait le plafond en nombre d'objets mais pas celui en taille d'un
objet. Une famille avec une longue série accumulant des exceptions jusqu'à dépasser la borne
aurait produit un fichier refusé en entier — listes et recettes comprises, puisqu'un
échec de schéma Zod rejette le document.

C'est l'invariant de la conception qui tombait : un fichier produit ici doit toujours
pouvoir être relu ici
. Corrigé — l'objet trop gros est laissé et compté dans omitted,
comme n'importe quel autre.

3. Une borne nommée en octets comptait des caractères — corrigé

MAX_OBJECT_BYTES était vérifié par z.string().max(), qui compte des unités UTF-16. Du
texte accentué passait donc à près du double de sa taille réelle. Renommé
MAX_EXPORT_OBJECT_CHARS, déclaré une fois, appliqué des deux côtés.

4. Ce qui a été vérifié et laissé tel quel

  • L'UID dérivé n'est pas un UUID v4 valide (le quartet de version n'est pas posé).
    Vérifié : routes/events.ts valide uid en z.string().min(1).max(MAX_UID), pas en
    .uuid(). Un événement importé s'ouvre et s'édite donc normalement — le test e2e le fait.
  • droppedMembers compte par objet, donc un membre retiré de cent événements compte
    cent. C'est ce qu'il faut : la phrase parle de participations retirées, pas de personnes.
  • withinCap lit le premier VEVENT comme la série. Si celui-ci n'a pas de DTSTART,
    parseIcs le laisse tomber et c'est une exception qui est lue à sa place, donc l'objet est
    classé « ponctuel ». N'affecte que l'ordre de coupe d'un objet malformé, au-delà de 20 000.
## Retours de revue (`/code-review`, niveau high) Quatre constats, trois corrigés, un laissé tel quel. ### 1. L'import contournait la liste blanche de schémas des abonnements — corrigé Le plus sérieux, et il ne venait pas de l'agenda : le schéma d'import validait l'URL d'un abonnement avec `z.string().url()`. Vérifié contre le zod du projet, ça accepte : ``` true javascript:alert(1) true file:///etc/passwd true data:text/calendar,BEGIN:VCALENDAR ``` `routes/calendars.ts` refuse précisément ces adresses depuis D33 (`SUBSCRIPTION_SCHEMES`, http/https/webcal/webcals), et pour une bonne raison : **c'est Nextcloud qui va chercher le flux**, côté serveur. Un fichier d'import bricolé à la main aurait donc écrit une source qu'aucune route dédiée n'aurait laissé passer. Même classe de problème sur la couleur : le schéma d'import acceptait `z.string()` là où la route impose `HEX_COLOR`, et la valeur finit dans une propriété de style. **Corrigé** en extrayant `routes/calendar-schema.ts` — le motif que `list-schema.ts` et `recipe-schema.ts` suivent déjà. Les deux routes valident contre la même déclaration, et `routes/calendar-schema.test.ts` couvre les schémas acceptés et refusés. C'est exactement ce que la note de `list-schema.ts` annonçait : « une borne déclarée deux fois est une borne qui dérive ». Elle a dérivé. ### 2. L'export ne respectait pas la borne de taille par objet de l'import — corrigé L'export appliquait le plafond en **nombre** d'objets mais pas celui en **taille** d'un objet. Une famille avec une longue série accumulant des exceptions jusqu'à dépasser la borne aurait produit un fichier refusé **en entier** — listes et recettes comprises, puisqu'un échec de schéma Zod rejette le document. C'est l'invariant de la conception qui tombait : *un fichier produit ici doit toujours pouvoir être relu ici*. **Corrigé** — l'objet trop gros est laissé et compté dans `omitted`, comme n'importe quel autre. ### 3. Une borne nommée en octets comptait des caractères — corrigé `MAX_OBJECT_BYTES` était vérifié par `z.string().max()`, qui compte des unités UTF-16. Du texte accentué passait donc à près du double de sa taille réelle. Renommé `MAX_EXPORT_OBJECT_CHARS`, déclaré une fois, appliqué des deux côtés. ### 4. Ce qui a été vérifié et laissé tel quel - **L'UID dérivé n'est pas un UUID v4 valide** (le quartet de version n'est pas posé). Vérifié : `routes/events.ts` valide `uid` en `z.string().min(1).max(MAX_UID)`, pas en `.uuid()`. Un événement importé s'ouvre et s'édite donc normalement — le test e2e le fait. - **`droppedMembers` compte par objet**, donc un membre retiré de cent événements compte cent. C'est ce qu'il faut : la phrase parle de participations retirées, pas de personnes. - **`withinCap` lit le premier `VEVENT` comme la série.** Si celui-ci n'a pas de `DTSTART`, `parseIcs` le laisse tomber et c'est une exception qui est lue à sa place, donc l'objet est classé « ponctuel ». N'affecte que l'ordre de coupe d'un objet malformé, au-delà de 20 000.
Collaborator

Bilan du développement

Sept commits sur feat/export-agenda, dans l'ordre où ils tiennent debout seuls :

refactor(ical) pliage, dépliage et échappement dans services/ical/text.ts — build.ts et parse.ts en avaient chacun leur moitié
feat(ical) rewrite.ts : réécrire un objet ligne à ligne, sans jamais passer par CalendarEvent
feat(dav) fetchCalendarObjects : la collection entière, non dépliée, sans filtre de date
refactor(routes) calendar-schema.ts : un seul schéma d'abonnement pour ses deux écrivains
feat(transfer) l'agenda voyage — contrat, export, import, route, tests
feat(transfer) l'écran « Données » dit ce qui part et ce qui n'a pas pu partir
test(e2e) une série survit à l'aller-retour, un rejeu ne double pas

Ce qui a été mesuré plutôt que supposé

Sur l'instance de développement, à travers le proxy AppAPI :

Import de 500 objets 8,8 s, aucun refusé
Import de 3 000 objets 48 s, aucun refusé
Export de 3 500 objets ~1 s, 839 Ko, 240 o par objet une fois échappé
Corps relayé sans dommage par le proxy au moins 24 Mio
D'où vient le 413 à 30 Mio de notre propre bodyLimit, pas du proxy

Le transport n'est donc pas la contrainte — l'inconnue que la conception avait mise en
Recette est levée, et MAX_IMPORT_BYTES est passé de 5 à 25 Mio.

La contrainte est la durée. L'écriture tourne à ~60 objets/seconde là où la lecture
d'une collection entière est un seul REPORT. Le plafond de 20 000 objets représente donc
environ cinq minutes d'import, sur une requête HTTP synchrone. Voir le point ouvert
ci-dessous.

Autres vérifications de recette

  • Un fichier v1 est refusé avec « version de fichier non reconnue ».
  • Après un import de 3 500 événements : famille-… = 3 500, famille-…-taches = 0,
    famille-…-repas = 0. Rien n'a été écrit dans les projections.
  • La passe de remise en état lancée ensuite répond {"ran":true,"repaired":0,"applied":0} et
    laisse les 3 500 objets en place — SOURCES ne couvre que tasks et meals (D32).
  • make lint typecheck test : 0 erreur, 426 tests backend, 344 frontend.
  • cd e2e && npm test : 96 tests verts (94 avant, + les 2 ajoutés).
  • Wiki à jour : Architecture (D37, et D36 marqué comme dépassé sur ce point), UX
    (les trois avertissements de l'écran « Données »).

Le point resté ouvert : le plafond contre la durée

Le plafond de 20 000 objets a été choisi sur la taille du fichier, avant que la durée
d'écriture soit mesurée. À ~60 objets/seconde, un import au plafond dure environ cinq
minutes et risque de se faire couper par une passerelle bien avant d'avoir fini.

Deux réponses possibles, et c'est un arbitrage à trancher :

  1. Laisser 20 000 et s'appuyer sur le rejeu. C'est précisément ce que l'UID dérivé a
    acheté : un import coupé se termine en relançant le même fichier. Mais l'utilisateur voit
    d'abord une erreur, sans compte-rendu, et doit comprendre qu'il faut recommencer.
  2. Descendre le plafond à ce qui tient dans une requête — de l'ordre de 3 000, mesuré à
    48 s. Un export au plafond annonce alors ce qu'il a laissé, ce qu'il fait déjà. Aucune
    famille réelle n'est concernée dans un cas comme dans l'autre.
## Bilan du développement Sept commits sur `feat/export-agenda`, dans l'ordre où ils tiennent debout seuls : | | | |---|---| | `refactor(ical)` | pliage, dépliage et échappement dans `services/ical/text.ts` — `build.ts` et `parse.ts` en avaient chacun leur moitié | | `feat(ical)` | `rewrite.ts` : réécrire un objet ligne à ligne, sans jamais passer par `CalendarEvent` | | `feat(dav)` | `fetchCalendarObjects` : la collection entière, non dépliée, sans filtre de date | | `refactor(routes)` | `calendar-schema.ts` : un seul schéma d'abonnement pour ses deux écrivains | | `feat(transfer)` | l'agenda voyage — contrat, export, import, route, tests | | `feat(transfer)` | l'écran « Données » dit ce qui part et ce qui n'a pas pu partir | | `test(e2e)` | une série survit à l'aller-retour, un rejeu ne double pas | ### Ce qui a été mesuré plutôt que supposé Sur l'instance de développement, **à travers le proxy AppAPI** : | | | |---|---| | Import de 500 objets | 8,8 s, aucun refusé | | Import de 3 000 objets | 48 s, aucun refusé | | Export de 3 500 objets | ~1 s, 839 Ko, 240 o par objet une fois échappé | | Corps relayé sans dommage par le proxy | au moins 24 Mio | | D'où vient le 413 à 30 Mio | de notre propre `bodyLimit`, pas du proxy | **Le transport n'est donc pas la contrainte** — l'inconnue que la conception avait mise en `Recette` est levée, et `MAX_IMPORT_BYTES` est passé de 5 à 25 Mio. **La contrainte est la durée.** L'écriture tourne à ~60 objets/seconde là où la lecture d'une collection entière est un seul `REPORT`. Le plafond de 20 000 objets représente donc environ **cinq minutes d'import**, sur une requête HTTP synchrone. Voir le point ouvert ci-dessous. ### Autres vérifications de recette - Un fichier v1 est refusé avec « version de fichier non reconnue ». - Après un import de 3 500 événements : `famille-…` = 3 500, `famille-…-taches` = 0, `famille-…-repas` = 0. Rien n'a été écrit dans les projections. - La passe de remise en état lancée ensuite répond `{"ran":true,"repaired":0,"applied":0}` et laisse les 3 500 objets en place — `SOURCES` ne couvre que `tasks` et `meals` (D32). - `make lint typecheck test` : 0 erreur, 426 tests backend, 344 frontend. - `cd e2e && npm test` : 96 tests verts (94 avant, + les 2 ajoutés). - Wiki à jour : `Architecture` (D37, et D36 marqué comme dépassé sur ce point), `UX` (les trois avertissements de l'écran « Données »). ### Le point resté ouvert : le plafond contre la durée Le plafond de 20 000 objets a été choisi sur la **taille** du fichier, avant que la durée d'écriture soit mesurée. À ~60 objets/seconde, un import au plafond dure environ cinq minutes et risque de se faire couper par une passerelle bien avant d'avoir fini. Deux réponses possibles, et c'est un arbitrage à trancher : 1. **Laisser 20 000 et s'appuyer sur le rejeu.** C'est précisément ce que l'UID dérivé a acheté : un import coupé se termine en relançant le même fichier. Mais l'utilisateur voit d'abord une erreur, sans compte-rendu, et doit comprendre qu'il faut recommencer. 2. **Descendre le plafond à ce qui tient dans une requête** — de l'ordre de 3 000, mesuré à 48 s. Un export au plafond annonce alors ce qu'il a laissé, ce qu'il fait déjà. Aucune famille réelle n'est concernée dans un cas comme dans l'autre.
Collaborator

Plafond descendu à 3 000, et seconde revue

Le plafond passe de 20 000 à 3 000 objets, réglé sur la durée d'un import et non sur la
taille du fichier — 48 s mesurées à travers le proxy, contre les cinq minutes qu'auraient
demandées 20 000. Le texte d'avertissement de l'écran suivait l'ancienne raison (« l'agenda
dépasse la taille d'un fichier transportable ») et dit maintenant la vraie : ce qu'un import
peut réécrire en une fois.

Seconde revue — deux constats, corrigés

  1. calendar.members bornait le tableau, pas ses entrées, à deux champs d'un assignee
    qui a MAX_ASSIGNEE depuis toujours. Même valeur, une seule réponse : list-schema.ts
    expose désormais memberKey, et assignee vaut memberKey.nullable().
  2. Le commentaire justifiant les 25 Mio raisonnait sur un agenda de 5 000 objets, que le
    plafond ne produit plus. Réécrit sur les chiffres qui s'appliquent.

Portes mécaniques, après les deux corrections

  • make lint typecheck test : 0 erreur, 426 tests backend, 344 frontend
  • bundle IIFE vérifié
  • cd e2e && npm test : 96 tests verts

Intégration

Les 9 commits sont rebasés sur main, en avance rapide — main n'avait pas bougé depuis la
création de la branche, l'historique reste linéaire. feat/export-agenda est supprimée ;
elle n'avait jamais été poussée. Rien n'est poussé sur origin, tu ne l'as pas demandé —
main est en avance de 9 commits en local.

Wiki à jour : D37 porte le plafond de 3 000 et la raison qui le fixe.

Restent deux lignes de recette qui ne sont pas les miennes : la revue technique personnelle
et la revue fonctionnelle.

## Plafond descendu à 3 000, et seconde revue **Le plafond passe de 20 000 à 3 000 objets**, réglé sur la durée d'un import et non sur la taille du fichier — 48 s mesurées à travers le proxy, contre les cinq minutes qu'auraient demandées 20 000. Le texte d'avertissement de l'écran suivait l'ancienne raison (« l'agenda dépasse la taille d'un fichier transportable ») et dit maintenant la vraie : ce qu'un import peut réécrire en une fois. ### Seconde revue — deux constats, corrigés 1. **`calendar.members` bornait le tableau, pas ses entrées**, à deux champs d'un `assignee` qui a `MAX_ASSIGNEE` depuis toujours. Même valeur, une seule réponse : `list-schema.ts` expose désormais `memberKey`, et `assignee` vaut `memberKey.nullable()`. 2. **Le commentaire justifiant les 25 Mio raisonnait sur un agenda de 5 000 objets**, que le plafond ne produit plus. Réécrit sur les chiffres qui s'appliquent. ### Portes mécaniques, après les deux corrections - `make lint typecheck test` : 0 erreur, 426 tests backend, 344 frontend - bundle IIFE vérifié - `cd e2e && npm test` : 96 tests verts ### Intégration Les 9 commits sont rebasés sur `main`, en avance rapide — `main` n'avait pas bougé depuis la création de la branche, l'historique reste linéaire. `feat/export-agenda` est supprimée ; elle n'avait jamais été poussée. **Rien n'est poussé sur `origin`**, tu ne l'as pas demandé — `main` est en avance de 9 commits en local. Wiki à jour : D37 porte le plafond de 3 000 et la raison qui le fixe. Restent deux lignes de recette qui ne sont pas les miennes : la revue technique personnelle et la revue fonctionnelle.
Sign in to join this conversation.
No description provided.