Export/Import Json pour les agenda aussi #2
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
priority
high
priority
low
priority
medium
question
step
backlog
step
delivered
step
done
step
in-progress
step
todo
wontfix
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
maxime/OrganisateurFamilial#2
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.tsetservices/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
VEVENTiCalendar brut dans le JSON plutôt qu'un modèle intermédiaire : les récurrences,EXDATEetRECURRENCE-IDsont déjà conservés tels quels quand ils ne sont pas modélisables (D15), et les remodéliser pour l'export perdrait exactement ça.reconcileFamilyune seule fois (D36) ; ici il n'y a pas de projection à réconcilier, donc rien qui rattrape ce qui échoue en route.CATEGORIESporte les membres attribués (D14). Comme pourassignee, 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 filtretime-rangeplutôt qu'une borne large, puisqu'un filtre ne retient un objet que si une occurrence tombe dedans.contracts/transfer.ts: sectioncalendar(objects,subscriptions,members,unreadable,omitted),EXPORT_VERSIONà2,ImportReportélargi.services/ical/rewrite.ts: réécriture ligne à ligne — toutes les lignesUID:,CATEGORIESfiltré en gardant ses paramètres.fold,unfoldet l'échappement ont été sortis dansservices/ical/text.tsplutôt qu'exportés depuisbuild.ts: ils étaient déjà en deux moitiés.services/transfer-export.ts: l'agenda lu en entier,findSubscriptions,membersOf;unreadableporté et non avalé.services/transfer-export.ts: le plafond appliqué et annoncé — jamais uneRRULE, puis les ponctuels les plus récents, le reste dansomitted.services/transfer-import.ts: UID dérivé ; écritures CalDAV après la transaction, concurrence bornée à 4, échecs comptés ; abonnements viaprovisionSubscription, source déjà présente sautée.routes/transfer.ts: schéma Zod de la sectioncalendar,versionenz.literal(2)qui refuse les v1 sans code de compatibilité,MAX_IMPORT_BYTESde 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 — onzeifdépassaient la règle de complexité.stores/transfer.ts: l'emploi du temps et les agendas rechargés après un import ;lastExportpour que l'écran puisse dire ce que l'export a laissé.unreadable, plafond), import (UID stable, rejeu sans doublon, catégories, échec partiel),ical/rewrite.test.ts,routes/calendar-schema.test.ts.stores/transfer.test.ts— nouvelles phrases,lastExport.routes/calendar-schema.ts, parce que le schéma d'import acceptaitjavascript:etfile:là où la route dédiée les refuse.Recette
make lint typecheck test— 0 erreur, 426 tests backend, 344 frontendmake build-frontend, puis vérifier que le bundle est bien une IIFEcd e2e && npm test— 96 tests verts/code-review, retours déposés en commentaire du ticketbodyLimit. Le transport n'est pas la contrainte ; la durée d'écriture l'estArchitecture: D37, et D36 marqué comme dépassé sur ce point ;UX: les avertissements de l'écran « Données »Plan de MEP
provisionSubscription.^/api/.*déclare déjàGETetPOST—appinfo/info.xmlest inchangé,routes.test.tsle confirme.mainen avance rapide, historique linéaire, branche supprimée. Rien poussé surorigin.make build-frontendavant la construction de l'image.Conception — l'agenda dans l'export/import
1. Ce qui voyage, et ce qui n'en est pas
CalendarKind.EVENTS) : ses objets, tels qu'ils sont stockés.leur contenu. Rien ici ne l'a écrit, et un flux se réabonne.
tasksetmealsse reconstruisent à partir des lignesdé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.objectsest un tableau de corpsVCALENDARcomplets, un par objet de lacollection, tels que le serveur les rend.
Le modèle de D15 ne porte qu'un sous-ensemble d'iCalendar.
parseObjectgarde déjà lesexceptions 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
CalendarEventreferait cette perte sur tout, la série comprise —
EXDATE,RECURRENCE-ID,VALARM,ATTENDEE,X-*, le fuseau nommé.L'objet entier, et non un
VEVENTpar entrée : une série et ses exceptions partagent un UIDet n'ont de sens qu'ensemble.
3. La lecture : un
REPORTsans<c:expand>et sans filtre de datereportCalendarObjectsne convient pas — il demande<c:expand>, qui aplatit une règle enune 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: uncalendar-queryréduit au filtre de composantVEVENT, 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,readerIdnonposé). 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
buildIcsd'un côté, les 1 236 objets de fluxexternes en cache sur l'instance de développement de l'autre :
RRULEEXDATEVTIMEZONECe 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— cesont 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
PUTCalDAV, hors transaction, sans réconciliation derrière pourrattraper ce qui échoue en route.
L'UID importé est dérivé de (famille cible, UID source) — un
sha256tronqué remis enforme d'UUID — au lieu d'un
randomUUID().Ce que ça achète :
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.
randomUUID(); il ne peut pas valoirH(familleCible, uidSource).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 sesexceptions, 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 étiquetteCATEGORIESporte les membres (D14) — maisparseIcslit 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), etl'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 sourcedé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 portecalendar.unreadable: trueet zéro objet, et l'exportn'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_VERSIONpasse à2, et la route refuse un fichier v1. Pas de rétrocompatibilité :l'application n'est pas en production, et le champ
versionexiste précisément pour refuserune forme que ce code ne sait pas lire. Le
z.literal(EXPORT_VERSION)déjà en place le faitsans une ligne de plus, avec le message qu'il porte déjà.
MAX_IMPORT_BYTES: 5 → 25 Mio, sur la mesure ci-dessus.MAX_IMPORT_LISTS— empêcher qu'un fichier fabriqué rendele travail non borné : le plafond d'objets, une longueur maximale par objet, et
MAX_IMPORT_SUBSCRIPTIONS = 5.ç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.
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 :routes/calendars.tsrefuse 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ù laroute impose
HEX_COLOR, et la valeur finit dans une propriété de style.Corrigé en extrayant
routes/calendar-schema.ts— le motif quelist-schema.tsetrecipe-schema.tssuivent déjà. Les deux routes valident contre la même déclaration, etroutes/calendar-schema.test.tscouvre les schémas acceptés et refusés.C'est exactement ce que la note de
list-schema.tsannonçait : « une borne déclarée deuxfois 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é parz.string().max(), qui compte des unités UTF-16. Dutexte 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
Vérifié :
routes/events.tsvalideuidenz.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.droppedMemberscompte par objet, donc un membre retiré de cent événements comptecent. C'est ce qu'il faut : la phrase parle de participations retirées, pas de personnes.
withinCaplit le premierVEVENTcomme la série. Si celui-ci n'a pas deDTSTART,parseIcsle laisse tomber et c'est une exception qui est lue à sa place, donc l'objet estclassé « ponctuel ». N'affecte que l'ordre de coupe d'un objet malformé, au-delà de 20 000.
Bilan du développement
Sept commits sur
feat/export-agenda, dans l'ordre où ils tiennent debout seuls :refactor(ical)services/ical/text.ts—build.tsetparse.tsen avaient chacun leur moitiéfeat(ical)rewrite.ts: réécrire un objet ligne à ligne, sans jamais passer parCalendarEventfeat(dav)fetchCalendarObjects: la collection entière, non dépliée, sans filtre de daterefactor(routes)calendar-schema.ts: un seul schéma d'abonnement pour ses deux écrivainsfeat(transfer)feat(transfer)test(e2e)Ce qui a été mesuré plutôt que supposé
Sur l'instance de développement, à travers le proxy AppAPI :
bodyLimit, pas du proxyLe transport n'est donc pas la contrainte — l'inconnue que la conception avait mise en
Recetteest levée, etMAX_IMPORT_BYTESest 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 doncenviron cinq minutes d'import, sur une requête HTTP synchrone. Voir le point ouvert
ci-dessous.
Autres vérifications de recette
famille-…= 3 500,famille-…-taches= 0,famille-…-repas= 0. Rien n'a été écrit dans les projections.{"ran":true,"repaired":0,"applied":0}etlaisse les 3 500 objets en place —
SOURCESne couvre quetasksetmeals(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).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 :
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.
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.
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
calendar.membersbornait le tableau, pas ses entrées, à deux champs d'unassigneequi a
MAX_ASSIGNEEdepuis toujours. Même valeur, une seule réponse :list-schema.tsexpose désormais
memberKey, etassigneevautmemberKey.nullable().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 frontendcd e2e && npm test: 96 tests vertsIntégration
Les 9 commits sont rebasés sur
main, en avance rapide —mainn'avait pas bougé depuis lacréation de la branche, l'historique reste linéaire.
feat/export-agendaest supprimée ;elle n'avait jamais été poussée. Rien n'est poussé sur
origin, tu ne l'as pas demandé —mainest 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.