ADR 0018 – Export complet : archive ZIP en flux, un Markdown à frontmatter par nœud, fichiers d'origine, `links.json`
Accepté (2026-10-15, mode autonome) ; format 2 (liste `files`, `filePaths`) introduit par l’ADR 0032
ADR 0018 – Export complet : archive ZIP en flux, un Markdown à frontmatter par nœud, fichiers d’origine, links.json
Statut : Accepté (2026-10-15, mode autonome) ; format 2 (liste files, filePaths) introduit par l’ADR 0032
Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles).
- Endpoint :
GET /api/exportrenvoie une archive ZIP écrite en flux dans la réponse, de façon synchrone, dans une seule transaction en lecture seuleREPEATABLE READ. Rien n’est chargé en mémoire en entier, et aucune archive n’est stockée sur le disque du serveur. Un seul export à la fois : un second export pendant le premier reçoit 409. - Contenu de l’archive :
- un fichier
nodes/<Titre>.mdpar nœud hors corbeille. Le frontmatter YAML contient l’identifiant, le type, les aspects avec leur version, les tags,bodyTags,parentId,inboxAt, les dates et le fichier. Le corps suit, octet pour octet tel qu’il est stocké ; - le fichier d’origine du nœud dans
nodes/<Titre>/, sous son nom d’origine rendu sûr, recopié pour chaque nœud qui le porte ; links.json: liens typés, liens wiki et champs référence ;- une copie de
config/; export.json: version du format, compteurs, index des chemins et anomalies.
- un fichier
- Noms de fichiers : on part du titre, on le rend sûr pour Windows, macOS et Linux, puis on le rend unique sans tenir compte de la casse. En cas de doublon, le nœud le plus ancien garde le nom sans suffixe, comme pour la résolution des liens wiki. Aucun chemin ne dépasse 180 caractères.
- Hors de l’archive : la corbeille et l’historique
node_event. - Pas de migration. Pas de changement du format YAML ni des méta-schémas. Pas de nouvel événement : un export n’écrit rien.
- Interface : une page « Données » (
/data) affiche un résumé (GET /api/export/summary) et un lien de téléchargement direct, sans passer par le client généré.
Contexte
Le cahier pose le principe « Pas de verrouillage : export complet en formats ouverts (Markdown, JSON, fichiers d’origine) » (l. 14). Il le précise dans « API et intégrations » (MVP) : « Export complet | Markdown + frontmatter YAML + fichiers d’origine + JSON des liens | O, N ». Le jalon 1 cite l’export avec OIDC, le déploiement et les sauvegardes. La ligne de backlog correspondante est « Export complet (Markdown + frontmatter + fichiers + liens en JSON) ».
Les ADR précédents ont reporté plusieurs obligations sur cette tranche :
| ADR | Obligation |
|---|---|
| 0005 | La valeur d’un champ node-ref est lue dans le JSONB et figure dans l’export, sans migration. |
| 0007 | parentId figure dans le frontmatter. |
| 0008 | Le stockage n’est jamais assaini : l’export restitue le texte saisi. |
| 0009 | La grammaire [[…]] est conservée telle quelle. L’export doit rester lisible par Obsidian, ce qui a justifié la réécriture des liens au renommage. |
| 0011 | NodeView.tags (tags saisis) et bodyTags (tags du corps) forment deux ensembles distincts. Le frontmatter tags sera lu à l’import Obsidian (V2). |
| 0012 | Le fichier d’origine est placé à côté du Markdown, sous son nom d’origine. Le sha256 figure dans le frontmatter. Un contenu partagé est copié une fois par nœud. |
| 0014 | inboxAt figure dans le frontmatter. |
| 0015 | Le type est un attribut du nœud : rien de plus à exporter. |
| 0016 | La provenance template de node.created peut être ignorée. Les modèles vivent dans config/templates/. |
| 0017 | Les x-default sont déjà résolus dans les données : rien de plus à exporter. |
Les données dérivées, recalculables à partir du reste, ne sont pas des données de l’utilisateur. C’est le cas de node.aspect_text (ADR 0013), de title_key et de node_wiki_unresolved (ADR 0009).
État du code :
- Schéma :
node:id,title,type,body,parent_id,file_ref,file_name,file_media_type,created_at,updated_at,inbox_at,deleted_at, ainsi quearchived_at, jamais écrit ;node_aspect:aspect,aspect_version,data;node_link:link_type,wikicompris ;node_tag:originvautmanualoubody;file_blob:sha256,size;node_event.
- Fichiers :
FileStore.path(sha256)donne le chemin d’un contenu. Les contenus sont immuables, et rien n’est supprimé avant la purge du jalon 3. - Champs référence :
DefinitionRegistry.refFields(aspect)liste les champsnode-refdes définitions courantes. Les rétroliens de champ (NodeLinkService.backlinks) s’en servent déjà. - Verrous consultatifs :
file.gc(partagé à l’écriture d’un fichier),node.parent,node.wiki. - YAML :
jackson-dataformat-yaml3.1.x est déjà une dépendance (DefinitionLoader). - Front :
nodeFileUrl(id)construit déjà une URL d’API utilisée hors du client généré (ADR 0012).
Sonde du générateur YAML de Jackson 3.1.4 (2026-10-15) :
YAMLWriteFeature.MINIMIZE_QUOTESest désactivé par défaut. Toutes les chaînes sont alors écrites entre guillemets doubles :"no","2026-10-15","0123","null","~". Une clé ambiguë est elle aussi mise entre guillemets ("no":). Les nombres, booléens etnullrestent nus.- Les caractères non ASCII sont écrits littéralement en UTF-8 (
é,—, emoji). Un saut de ligne est échappé ("ligne 1\nligne 2"). WRITE_DOC_START_MARKERetSPLIT_LINESsont activés par défaut. Ils ajoutent un---initial et coupent les longues chaînes. Il faut les désactiver.- Un aller-retour écriture puis lecture redonne un arbre identique, à condition de lire les décimaux en
BigDecimal(USE_BIG_DECIMAL_FOR_FLOATS). Sinon,0.1revient enDoubleNode.
Plusieurs choix sont difficiles à défaire une fois que des archives existent, puisqu’un import (V2) devra les relire :
- la disposition de l’archive et la règle de nommage ;
- les clés du frontmatter et leur sens ;
- la forme de
links.jsonet deexport.json; - la frontière entre ce qui est exporté et ce qui ne l’est pas.
Décision
Archive
-
Format : ZIP (
java.util.zip.ZipOutputStream). Les noms d’entrées sont en UTF-8 avec le drapeau « language encoding » (bit 11), que Java pose de lui-même. ZIP64 s’active automatiquement au-delà de 4 Go ou de 65 535 entrées. -
Pas de dossier racine dans l’archive. L’extracteur de Windows et 7-Zip créent déjà un dossier au nom de l’archive ; un dossier racine en plus allongerait tous les chemins.
-
Compression :
DEFLATEDpour toutes les entrées. Niveau par défaut pour le texte (.md,.json,.yaml).Deflater.BEST_SPEEDpour les fichiers rattachés, souvent déjà compressés (PDF, JPEG).ZipOutputStream.setLevels’applique aux entrées suivantes. -
Horodatage des entrées :
updated_atdu nœud pour son Markdown et son fichier, date de l’export pour les autres entrées. -
Nom de l’archive :
second-brain-export-<aaaammjj>-<hhmmss>Z.zip, en UTC, sans:. L’heure vient duClockdeTimeConfig. -
Disposition (exemple complet plus bas) :
export.json links.json config/<sous-dossier>/<fichier>.yaml nodes/<Nom>.md nodes/<Nom>/<nom d'origine> seulement si le nœud a un fichierLes nœuds sont à plat dans
nodes/. La hiérarchie passe parparentId, et non par des dossiers imbriqués. Le fichier rattaché est « à côté du Markdown » au sens de l’ADR 0012 : il est rangé dans un dossier qui porte le même nom que le Markdown, selon la convention de l’export Notion. Un même nom d’origine (scan.pdf) peut ainsi exister pour plusieurs nœuds sans collision.
Noms de fichiers sûrs
Une classe pure export/ExportNames, testée unitairement.
segment(String brut, int maxUnités) : assainit un segment de chemin en conservant l’extension s’il y en a une. Les étapes, dans l’ordre :
- Normalisation NFC.
- Suppression des caractères de contrôle C0 et C1, de U+2028/U+2029, des marques bidirectionnelles (U+200E/F, U+202A–U+202E, U+2066–U+2069) et de U+FEFF. C’est le même ensemble que
FileNames.sanitize. - Toute suite d’espaces Unicode (tabulation et saut de ligne compris) devient une espace.
- Les caractères
< > : " / \ | ? *deviennent_. Ce sont les caractères interdits par Windows ; ils couvrent aussi/(Unix) et:(Finder). - Retrait des espaces aux extrémités. Chaque
.initial devient_: pas de fichier caché, et ni.ni..ne sont possibles. - Troncature à
maxUnitésunités UTF-16 et à 200 octets UTF-8, sans couper une paire de substituts. Pour un nom de fichier, l’extension (32 points de code au plus, comme dansFileNames) est conservée. - Retrait des
.et des espaces finaux, que Windows interdit. On répète après la troncature. - Noms réservés de Windows :
CON,PRN,AUX,NUL,COM0–COM9,LPT0–LPT9,COM¹–COM³,LPT¹–LPT³. La comparaison porte sur la partie avant le premier., sans tenir compte de la casse ni des espaces finales. Un nom réservé reçoit un_à la fin de cette partie :CONdonneCON_,con.txtdonnecon_.txt. - Un résultat vide donne
Sans titre.
Base d’un Markdown : segment(titre, 80).
Unicité : la classe ExportNames.Allocator attribue les bases dans l’ordre created_at, id des nœuds exportés.
- La clé d’unicité est la base en minuscules (
Locale.ROOT, sur une chaîne déjà en NFC). Elle protège des systèmes de fichiers insensibles à la casse (NTFS, APFS par défaut). - Une base déjà prise reçoit le suffixe
(2),(3)… Le suffixe est ajouté après la troncature, puis le résultat est recontrôlé. - Chaque nœud réserve deux clés :
<base>(son dossier éventuel) et<base>.md(son Markdown). Cela évite qu’un nœud intitulé « X.md » et un nœud intitulé « X » se disputentnodes/X.md. - Le nœud le plus ancien obtient le nom sans suffixe. C’est la règle de résolution des liens wiki (ADR 0009) : dans Obsidian,
[[Titre]]ouvreTitre.md, c’est-à-dire le même nœud qu’ici, tant que le titre n’a été ni modifié par les étapes 1 à 9 ni tronqué.
Nom du fichier rattaché : segment(nom d'origine, 80). Le nom d’origine exact reste dans le frontmatter (file.name). Le fichier est seul dans son dossier, donc aucune collision n’est possible.
Budget de chemin :
| Élément | Taille maximale |
|---|---|
| Base | 80 unités + suffixe de 8 unités au plus ( (99999)) |
Markdown (nodes/ + base + .md) |
97 unités |
Fichier (nodes/ + base + / + nom) |
175 unités |
Il reste au moins 80 caractères pour le dossier de destination sous la limite MAX_PATH de l’Explorateur Windows (260). Un segment ne dépasse jamais 255 octets UTF-8, la limite d’ext4 et d’APFS.
Markdown et frontmatter
Chaque nœud donne un fichier nodes/<Nom>.md, encodé en UTF-8 sans BOM. Il est composé de :
---\n;- le frontmatter YAML ;
---\n;- le corps (
node.body), octet pour octet. Les fins de ligne sont conservées, et rien n’est ajouté : ni titre#, ni ligne vide, ni saut de ligne final. Un corpsnullne produit rien après le second---.
Un lecteur de frontmatter (Obsidian, un import) s’arrête au premier --- qui ferme le bloc. Un corps qui commence lui-même par --- est donc restitué intact.
Clés du frontmatter, dans cet ordre fixe. Les noms reprennent ceux de NodeView :
| Clé | Présence | Valeur |
|---|---|---|
id |
toujours | UUID |
title |
toujours | titre exact |
type |
si le nœud a un type | nom du type |
parentId |
si le nœud n’est pas à la racine | UUID du parent, même si le parent n’est pas exporté (corbeille) |
createdAt, updatedAt |
toujours | ISO 8601 en UTC, avec microsecondes (2026-10-12T08:14:03.512381Z) |
inboxAt |
si le nœud est dans l’inbox | idem |
tags |
toujours, liste éventuellement vide | tags saisis, triés (ADR 0011). C’est la propriété que lit Obsidian ; les #tags du corps y sont déjà visibles |
bodyTags |
toujours, liste éventuellement vide | tags du corps, triés. Donnée dérivée, fournie pour les scripts ; un import la recalculera à partir du corps |
aspects |
toujours, objet éventuellement vide | <aspect>: { version, data }, aspects triés par nom ; data reprend le JSONB exact, clés triées à tous les niveaux |
file |
si le nœud a un fichier | name (nom d’origine exact), path (chemin relatif au Markdown, avec /), mediaType, size, sha256 |
Précisions :
- Clé absente =
null. Les clés absentes évitent du bruit (type: null) dans les propriétés Obsidian de chaque note. - Champs référence (ADR 0005) : la valeur reste l’UUID stocké dans
data. La relation se retrouve aussi, résolue, danslinks.json. file.pathest omis si le contenu manque sur le disque. Voir « Anomalies ».- Hors du frontmatter :
archived_at: jamais écrit, il sera ajouté sous le nomarchivedAtavec la fonctionnalité d’archivage ;aspect_textettitle_key: dérivés ;- la provenance
template: elle appartient à l’historique.
Écriture : la classe pure export/Frontmatter utilise un YAMLMapper dédié :
WRITE_DOC_START_MARKERetSPLIT_LINESdésactivés ;MINIMIZE_QUOTESlaissé désactivé : toutes les chaînes sont entre guillemets doubles, si bien qu’aucune valeur n’est réinterprétée par un lecteur YAML 1.1 (js-yaml dans Obsidian, PyYAML) comme une date, un booléen ou un nombre ;- tri des clés de
data(JsonNodeFeature.WRITE_PROPERTIES_SORTED, ou tri explicite de l’arbre).
Le JSONB est lu en arbre avec USE_BIG_DECIMAL_FOR_FLOATS : 49.99 reste 49.99.
Fichiers d’origine
- Un nœud qui a un fichier produit l’entrée
nodes/<Nom>/<nom sûr>, juste après son Markdown. Un contenu partagé par plusieurs nœuds est donc copié pour chacun (ADR 0012). Le ZIP ne connaît pas de lien portable entre entrées. - La copie lit
FileStore.path(sha256)par blocs de 64 Kio à travers unDigestInputStream. Le SHA-256 recalculé est comparé àfile_ref: l’export sert aussi de contrôle d’intégrité. - Avant d’écrire le Markdown, on vérifie que le contenu existe et a la taille de
file_blob.size. Sinon, l’entrée du fichier n’est pas écrite,file.pathest omis du frontmatter et une anomalie est consignée. L’export continue.
links.json
Un objet JSON écrit en flux (JsonGenerator) après tous les nœuds :
{
"formatVersion": 1,
"links": [
{ "kind": "link", "id": "…", "type": "concerne-projet", "sourceId": "…", "sourcePath": "nodes/Payer facture Proximus.md",
"targetId": "…", "targetPath": "nodes/Projet maison.md", "createdAt": "2026-10-12T08:20:11.004512Z" },
{ "kind": "wiki", "id": "…", "type": "wiki", "sourceId": "…", "sourcePath": "nodes/Note.md",
"targetId": "…", "targetPath": null, "createdAt": "…" },
{ "kind": "field", "type": "tache.projet", "aspect": "tache", "field": "projet", "sourceId": "…",
"sourcePath": "nodes/Payer facture Proximus.md", "targetId": "…", "targetPath": "nodes/Projet maison.md" }
]
}
link: lignesnode_linkdont le type n’est paswiki.wiki: lignesnode_linkde typewiki. Elles sont dérivées du corps, mais elles enregistrent la résolution, c’est-à-dire quel nœud désigne un[[Titre]]quand plusieurs nœuds portent ce titre (ADR 0009).field: un champnode-refnon vide d’un aspect porté. La liste des champs vient deDefinitionRegistry.refFields, comme pour les rétroliens. Il y a une requête par champ (select node_id, data->>'<champ>' from node_aspect where aspect = ? and data ? '<champ>'). Une valeur qui n’est pas un UUID est ignorée : elle reste visible dansdata. Un champ retiré deconfig/ne produit plus de ligne, mais sa valeur reste dansdata.- Périmètre : toute relation dont la source est exportée. Si la cible n’est pas dans l’archive (corbeille, nœud purgé ou inexistant),
targetPathvautnullettargetIdest conservé. - Ordre :
kind(link,wiki,field), puissourceId, puiscreatedAtoutype, puisidoutargetId. Deux exports d’un même état sont identiques. - Les liens non résolus (
node_wiki_unresolved) ne sont pas exportés : ils se déduisent du corps.
export.json
Dernière entrée de l’archive :
{
"format": "second-brain-export",
"formatVersion": 1,
"exportedAt": "2026-10-15T14:30:00.123456Z",
"includes": { "trash": false, "history": false, "config": true },
"counts": { "nodes": 1234, "files": 56, "links": 789, "configFiles": 17 },
"nodes": [ { "id": "…", "path": "nodes/Projet maison.md" },
{ "id": "…", "path": "nodes/Facture Proximus octobre.md", "filePath": "nodes/Facture Proximus octobre/proximus-2026-10.pdf" } ],
"problems": [ { "kind": "file.missing", "nodeId": "…", "sha256": "…" } ]
}
nodes: index identifiant → chemin, dans l’ordre d’attribution des noms. Il permet de résoudreparentIdsans lire tous les frontmatters.problems: une ligne par anomalie. Les types sontfile.missing(contenu absent ou de mauvaise taille),file.corrupted(SHA-256 recalculé différent, l’entrée est tout de même écrite) etconfig.skipped(fichier deconfig/non copié, avecpathetreason). Le tableau est vide si tout va bien.formatVersionvaut1. Toute modification incompatible (clé renommée, disposition changée) l’incrémente. L’ajout d’une clé ne l’incrémente pas.
config/
- On copie tout fichier régulier
*.yamlou*.ymlsitué directement dans un sous-dossier direct deapp.definitions.path(aspects/,types/,links/,templates/,views/,dashboards/…). Les noms de dossiers ne sont pas codés en dur : ce sont ceux qui existent sur le disque. - Le fichier est copié tel quel, valide ou non : c’est le fichier de l’utilisateur.
- Ne sont pas copiés, et chacun produit une anomalie
config.skipped:- un dossier ou un fichier caché (
.initial) ; - un lien symbolique (
NOFOLLOW_LINKS) ; - un fichier de plus de 1 Mo (limite du chargeur) ;
- un nom hors de
[A-Za-z0-9._-]+; - un doublon insensible à la casse.
- un dossier ou un fichier caché (
config/ne fait pas partie de l’instantané de la base. Un fichier modifié pendant l’export est copié dans l’état où il est lu. C’est sans conséquence : les définitions ne s’appliquent qu’à l’écriture.
Ce qui n’est pas exporté
- La corbeille : les nœuds où
deleted_atn’est pas nul ne sont pas exportés, ni leur fichier. L’utilisateur les a supprimés, et aucun écran ne les montre avant le jalon 3. Un enfant vivant d’un parent en corbeille gardeparentId(ADR 0007), qui désigne alors un nœud absent de l’archive. Une relation vers un nœud en corbeille garde sontargetId, avectargetPath: null. On pourra ajouter?trash=trueplus tard (dossiertrash/) sans rien casser. - L’historique
node_event: le cahier ne le demande pas, et son volume peut dépasser celui des contenus (chaquenode.updatedporte le corps avant et après). La sauvegardepg_dump(tranche suivante) le couvre. On pourra ajouter?history=trueplus tard (events.jsonl). - Les données dérivées :
aspect_text,title_key,node_wiki_unresolved, le tsvector.
Ces trois points figurent dans export.json.includes et sur la page « Données ».
Endpoint
Le contrôleur ExportController (paquet node) porte le tag OpenAPI export.
| Méthode | Chemin | Réponse |
|---|---|---|
GET |
/api/export |
200 application/zip en flux, Content-Disposition: attachment; filename="second-brain-export-….zip", Cache-Control: no-store, X-Content-Type-Options: nosniff, sans Content-Length (encodage par blocs) ; 409 si un export est en cours |
GET |
/api/export/summary |
200 ExportSummary { long nodes, long files, long fileBytes } : nœuds hors corbeille, nœuds avec fichier, somme des tailles par nœud (un contenu partagé est compté autant de fois qu’il est copié) |
Réponse synchrone, écrite dans HttpServletResponse.getOutputStream(). La méthode du contrôleur renvoie void. On n’utilise ni StreamingResponseBody ni une tâche asynchrone, pour deux raisons :
StreamingResponseBodys’exécute sur un autre thread, soumis au délai asynchrone du conteneur (30 s par défaut chez Tomcat sispring.mvc.async.request-timeoutn’est pas fixé). Un gros export serait coupé ;- la transaction de lecture devrait alors être ouverte à la main dans ce thread.
Écrire sur le thread de la requête, dans une méthode de service @Transactional, est plus simple. Le thread occupé ne coûte rien pour un seul utilisateur.
Le mapping ne déclare pas produces = application/zip. Sinon, la négociation de contenu empêcherait d’écrire le ProblemDetail du 409. Le type de réponse est documenté par @ApiResponse(content = @Content(mediaType = "application/zip", schema = @Schema(type = "string", format = "binary"))).
Cohérence, concurrence, échecs
-
Instantané :
ExportService.exportest annotée@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ). PostgreSQL fixe l’instantané à la première requête de la transaction. Noms, nœuds, aspects, tags, liens et index sont tous lus dans ce même état, même si des écritures ont lieu pendant le téléchargement. Une transaction en lecture seule ne subit pas d’échec de sérialisation. Les contenus de fichiers sont immuables et ne sont jamais supprimés avant le jalon 3 : ils sont cohérents avec l’instantané. -
Un export à la fois : la première requête est
select pg_try_advisory_xact_lock(hashtext('export')). Si elle renvoiefalse, on lèveConflictException(409, « Un export est déjà en cours »). Le verrou est libéré à la fin de la transaction. -
Obligation pour la purge (jalon 3) : elle prendra
pg_advisory_xact_lock(hashtext('export'))pour attendre la fin d’un export en cours. Indépendamment de ce verrou, l’export tolère un contenu disparu (file.missing). -
Ordre des opérations :
- prise du verrou ;
- lecture de la table des noms ;
- ensuite seulement, ouverture du flux de réponse (en-têtes posés).
Une erreur aux étapes 1 et 2 donne donc un vrai 409 ou 500 en ProblemDetail.
-
Lecture par lots : les nœuds sont lus par pagination sur la clé (
where id > :dernier order by id limit 100). Viennent ensuite, pour le lot, les aspects, les tags (deux origines) et les fichiers (jointurefile_blob), en trois requêtes. La mémoire est bornée par un lot : 100 corps de 200 000 caractères au plus, plus la table des noms (identifiant et base, environ 30 Mo pour 100 000 nœuds). Les liens sont lus par pagination surnode_link.id. -
Échec en cours d’écriture (base, disque, client parti) : l’exception remonte sans appeler
ZipOutputStream.finish()niclose(), qui écriraient le répertoire central. L’archive reçue est tronquée et sans répertoire central :ZipFileet la plupart des outils la refusent, mais un lecteur en flux (ZipInputStream, 7-Zip) peut en extraire le début. La présence deexport.json, dernière entrée, est le marqueur de complétude (amendé après revue). Une archive incomplète ne passe jamais pour une archive complète. On n’utilise donc pastry-with-resourcessur leZipOutputStream. Spring journalise que la réponse était déjà engagée. -
Journal : une ligne INFO au début et une à la fin (compteurs, durée, nombre d’anomalies), sans titre ni contenu.
Sécurité
/api/exportet/api/export/summaryrelèvent de/api/**: session OIDC obligatoire en modeoidc, 401 sans session, ouverts en profildev. Les jetons d’API (jalon 3) permettront un export par script (curl -H "Authorization: Bearer …") sans changement.- CSRF : sans objet. C’est un
GETsans effet de bord. Une page tierce peut déclencher le téléchargement dans le navigateur de l’utilisateur, mais ne peut pas lire la réponse (aucun en-tête CORS). Le verrouexportlimite l’effet d’une répétition. - Taille : aucune limite. L’archive contient les données de l’utilisateur, et sa taille est annoncée par le résumé.
- Noms : aucune entrée ne contient
..,\,:, un chemin absolu ni un caractère de contrôle. L’archive ne peut donc pas provoquer de traversée de répertoires (« zip slip ») chez celui qui l’extrait. L’import V2 devra tout de même revérifier chaque nom, puisqu’une archive peut avoir été modifiée. - Les fichiers ne sont jamais servis
inlinepar cet endpoint : seule l’archive est téléchargée. La politique de l’ADR 0012 ne s’applique pas ici. - L’archive est en clair. Le chiffrement relève de l’utilisateur (voir Reporté).
Moteur (signatures)
Paquet com.anorys.secondbrain.export (classes pures, publiques, sur le modèle de file/FileNames) :
public final class ExportNames {
public static final int MAX_BASE_UNITS = 80;
public static final int MAX_FILE_UNITS = 80;
public static final String UNTITLED = "Sans titre";
public static String segment(String raw, int maxUnits); // étapes 1 à 9, extension conservée
public static String key(String segment); // minuscules Locale.ROOT
public static final class Allocator {
public String allocate(String title); // base unique, réserve <base> et <base>.md
}
}
public final class Frontmatter {
/** "---\n" + YAML des propriétés (ordre conservé, chaînes entre guillemets) + "---\n". */
public static String render(ObjectNode properties);
}
/** Écrit les entrées ; finish() seulement en cas de succès. */
public final class ExportZip {
public ExportZip(OutputStream out);
public void text(String path, String content, Instant modified) throws IOException;
public OutputStream entry(String path, Instant modified, int level) throws IOException; // flux non fermant
public void finish() throws IOException;
}
Paquet com.anorys.secondbrain.node (accès à Db, ADR 0003) :
final class ExportRepository {
boolean tryLock(); // pg_try_advisory_xact_lock
List<NameRow> namesInCreationOrder(); // (id, title), hors corbeille, created_at, id
List<ExportNodeRow> nodesAfter(UUID lastId, int limit); // + file_blob.size
Map<UUID, Map<String, AspectRow>> aspects(Collection<UUID> ids); // (aspect, version, data JSON brut)
Map<UUID, NodeTags> tags(Collection<UUID> ids);
List<LinkRow> linksAfter(boolean wiki, LinkCursor after, int limit); // sources hors corbeille ; curseur (source_id, created_at, id), amendé après revue
List<FieldRefRow> fieldRefs(String aspect, String field);
ExportSummary summary();
}
@Service
public class ExportService {
public interface ExportTarget { OutputStream open(String fileName) throws IOException; }
@Transactional(readOnly = true)
public ExportSummary summary();
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public void export(ExportTarget target);
}
@RestController @RequestMapping("/api") @Tag(name = "export")
public class ExportController {
@GetMapping("/export") public void exportAll(HttpServletResponse response) throws IOException;
@GetMapping("/export/summary") public ExportSummary getExportSummary();
}
ExportSummary rejoint NodeDtos. ExportService reçoit ExportRepository, FileStore, DefinitionRegistry, DefinitionsProperties, Clock et le JsonMapper de Spring Boot.
Front
-
Page
/data, « Données » (features/data/data-page.ts, composant autonome,OnPush) :- une carte « Exporter toutes les données » ;
- le résumé : « 1 234 nœuds, 56 fichiers (2,3 Go) », lu par le client généré (
ExportService.getExportSummary, taille formatée parformatSize) ; - le contenu de l’archive en une phrase : Markdown avec frontmatter, fichiers d’origine,
links.json,config/; - la mention « La corbeille et l’historique des modifications ne sont pas inclus » ;
- le bouton
<a mat-flat-button [href]="exportUrl" download>Télécharger l'archive</a>.
Route
data(titre « Données · Second Brain »). Entrée deNAV_ITEMS:{ link: '/data', label: 'Données', icon: 'download', exact: true }. La palette la reprend d’elle-même (« Aller à : Données »). -
Le téléchargement ne passe pas par le client généré. Celui-ci lirait l’archive entière dans un
Bloben mémoire. Le lien laisse le navigateur écrire le flux sur le disque.core/export.tsexporteEXPORT_URL = '/api/export'. C’est une exception de même nature quenodeFileUrl(ADR 0012) : une URL, pas un DTO. Le proxy de développement relaie/apiaussi pour une navigation. -
Le résumé sert aussi de contrôle de session. Il est chargé à l’ouverture de la page, et l’intercepteur redirige vers la connexion sur un 401. Si la session expire entre l’affichage et le clic, le navigateur affiche un téléchargement en échec, ce qui est acceptable.
-
Un échec du résumé affiche « Résumé indisponible ». Le bouton reste actif.
-
Pourquoi une page plutôt qu’un bouton dans « Définitions » : l’export concerne les données, pas les définitions. La page accueillera aussi l’import (V2) et pourra afficher l’état des sauvegardes.
Documentation
- Backlog : ligne cochée avec un résumé et les éléments reportés.
- Cet ADR décrit le format.
export.json.formatVersiony renvoie. - Skill
backend-conventions: ajouter la règle « réponse en flux : ne jamais terminer un ZIP en cas d’erreur ; ouvrir le flux après les contrôles ». - Pas de changement du skill
definitions-formatni deCLAUDE.md.
Tests
Backend, unitaires :
-
ExportNamesTest:- caractères interdits (
a/b,a\b,a:b,a*b?,"q",<x>,a|b) ; - noms réservés (
CON,con.txt,Lpt1,nul,COM¹,AUX.) ; .,..et.cache;Fin.,Fin, tabulation, saut de ligne, caractères de contrôle, U+202E ;éen NFC et en NFD, qui donnent le même nom ;- troncature à 80 unités sans substitut isolé (emoji à la frontière) et à 200 octets UTF-8 (texte en idéogrammes) ;
- extension conservée ; titre vidé par l’assainissement, qui donne
Sans titre.
Allocator:Note,noteetNOTEdonnentNote,note (2)etNOTE (3);X.mdpuisXdonnentX.mdetX (2);- trois titres longs de même début donnent des suffixes, sans dépasser le budget ;
- le résultat est le même à chaque exécution.
- caractères interdits (
-
FrontmatterTest:- ordre des clés, et clés facultatives absentes ;
- valeurs ambiguës (
no,2026-10-15,0123,true,~,null,#tag,a: b) entre guillemets ; - aller-retour avec
YAMLMapper(USE_BIG_DECIMAL_FOR_FLOATS) égal à l’arbre d’origine,0.1et un entier long compris ; - non ASCII écrit littéralement ;
- saut de ligne dans une valeur ; clés de
datatriées.
-
ExportZipTest(sur unByteArrayOutputStream) :- après
finish(),ZipFilerelit les entrées et leurs noms UTF-8 ; - sans
finish()(simulation d’une erreur),ZipFilerefuse l’archive ; - le flux de la réponse n’est jamais fermé par
ExportZip.
- après
Backend, intégration (ExportIT, Testcontainers, @SpringBootTest(webEnvironment = RANDOM_PORT), app.files.path sur un @TempDir, Clock fixée). Le jeu de données est créé par les services :
- une facture fournisseur (trois aspects, tags saisis,
#tagdans le corps,[[Projet maison]], fichier PDF, dans l’inbox) ; - une tâche avec
tache.projetvers le projet, un lienconcerne-projetet un enfant du projet ; Notepuisnote;- les titres piégés
CON,a/b,.., un titre de 300 caractères,X.mdetX; - un même contenu rattaché à deux nœuds ;
- un nœud en corbeille, parent d’un enfant vivant et cible d’un lien ;
- un contenu supprimé du disque ;
- un corps avec
\r\net un corps qui commence par---.
L’archive est lue en flux par HTTP (ZipInputStream sur le corps de la réponse). Les assertions :
- En-têtes :
Content-Type: application/zip, nom de fichier attendu, absence deContent-Length. - Noms : l’ensemble exact des noms d’entrées ; aucun nom avec
..,\ou:; tous les noms font au plus 180 unités et sont uniques sans tenir compte de la casse. - Markdown : le frontmatter relu est égal à l’attendu (
parentId,inboxAt,tags,bodyTags,aspects.*.version,file.sha256) ; le corps est identique octet pour octet. - Fichiers : le contenu partagé apparaît deux fois, avec le bon SHA-256 ; le contenu manquant donne
file.missing, sansfile.path, et l’archive reste valide. - Corbeille : le nœud en corbeille est absent ; l’enfant garde
parentId; le lien vers ce nœud atargetPath: null. links.json: trois sortes de relations dans l’ordre attendu.export.json: compteurs et index.config/: les fichiers deconfig/de test sont présents.- Instantané : le
ExportTargetde test crée un nœud dans une transactionREQUIRES_NEWau moment deopen. Ce nœud est absent de l’archive. - 409 : un export lancé pendant qu’une autre connexion tient
pg_advisory_lock(hashtext('export'))reçoit 409 en ProblemDetail. - Résumé :
GET /api/export/summarydonne les compteurs attendus (contenu partagé compté deux fois, corbeille exclue).
Autres tests d’intégration :
ExportSecurityIT(app.security.mode=oidc) :GET /api/exportsans session donne 401.OpenApiContractIT:/api/export(application/zip, binaire) et/api/export/summarysont présents.
Front (Vitest) :
data-page.spec.ts:- le résumé s’affiche (nombres, taille formatée) ;
- le lien vise
/api/exportet portedownload; - l’échec du résumé affiche « Résumé indisponible » et laisse le bouton actif ;
- la mention de la corbeille et de l’historique est présente.
navigation.spec.ts(ou celui de la palette) : l’entrée « Données » est présente.
Découpage
-
Noms et frontmatter (
feat(export): noms de fichiers sûrs et frontmatter YAML) :ExportNames,FrontmatteretExportZip;- les tests unitaires.
Testable seule par les tests. Rien n’est exposé.
-
Archive en flux (
feat(export): archive ZIP complète en flux, GET /api/export) :ExportRepository,ExportServiceetExportController;- Markdown, fichiers,
config/etexport.json, avec unlinks.jsonqui contient seulementformatVersionet une liste vide ; ExportIT(sauf les liens),ExportSecurityIT,OpenApiContractIT;- la règle du skill
backend-conventions.
Testable seule :
curl -OJ http://localhost:8080/api/exporten profildev, puis ouverture de l’archive dans 7-Zip et dans Obsidian (nodes/comme coffre). -
Relations (
feat(export): liens typés, wiki et champs référence dans links.json) :linksAfter,fieldRefset l’écriture delinks.json;- les assertions correspondantes d’
ExportIT.
-
Page « Données » (
feat(export): page Données avec résumé et téléchargement de l'export) :GET /api/export/summaryet son test ;- client régénéré (skill
api-contract) ; core/export.ts,features/data/data-page.ts, route et entrée de navigation ;- Vitest ;
- la case du backlog.
Alternatives écartées
- Un dossier par nœud pour tous les nœuds (
nodes/<Nom>/<Nom>.md) : le nom figure deux fois dans le chemin. Pour tenir sousMAX_PATH, il faudrait tronquer les titres vers 60 caractères, ce qui casse la résolution[[Titre]]dans Obsidian pour beaucoup de titres capturés à l’inbox. Le dossier n’est utile qu’aux nœuds qui ont un fichier. - Arborescence calquée sur la hiérarchie (enfants dans le dossier du parent) : la profondeur fait exploser la longueur des chemins sous Windows. Un déplacement change le chemin de tout un sous-arbre d’un export à l’autre.
parentIdet l’index d’export.jsonsuffisent à reconstruire l’arbre. - Noms par identifiant (
nodes/<uuid>.md) : sûrs et sans collision, mais illisibles dans un explorateur de fichiers et sans aucun lien wiki résolu dans Obsidian. Contraire à « formats ouverts » au sens d’utilisable. - Titre suivi d’un identifiant court (
Note 3c0f6f0e.md, comme Notion) : toujours aucun lien wiki résolu. Huit caractères hexadécimaux ne garantissent pas l’unicité (paradoxe des anniversaires), il faudrait donc gérer les collisions de toute façon. - Fichiers rattachés tous dans un dossier commun (
files/<sha256>/<nom>, dédupliqués) : l’archive serait plus petite, mais contraire à l’ADR 0012 (« copié une fois par nœud », « à côté du Markdown »), et le lien entre une note et son fichier deviendrait moins évident. - Aspects aplatis en propriétés (
facture.montant: 49.99), plus agréables dans le panneau des propriétés d’Obsidian : ambigus pour les objets et tableaux imbriqués, la version de l’aspect se perd, et ils pourraient entrer en collision avec des propriétés de l’utilisateur à l’import. La fidélité prime :aspects.<nom>.dataest le JSONB exact. - Guillemets minimaux (
MINIMIZE_QUOTES) : plus joli, mais"2026-10-15"deviendrait une date pour js-yaml,noun booléen YAML 1.1, et un nombre écrit en chaîne changerait de type sansALWAYS_QUOTE_NUMBERS_AS_STRINGS. Les guillemets doubles partout sont sans ambiguïté et vérifiés par la sonde. - Titre ajouté en
# Titreau début du corps, ou ligne vide après le frontmatter : le corps exporté ne serait plus le texte saisi (ADR 0008), et un import ne saurait pas quoi retirer. - Liens réécrits dans le corps (
[[Titre]]remplacé par[[Nom de fichier]]quand l’assainissement a changé le nom) : contraire à l’ADR 0008. Le corps fait foi, etlinks.jsondonne la résolution exacte. StreamingResponseBody: délai asynchrone du conteneur (30 s par défaut) et transaction à gérer à la main sur un autre thread.- Tâche asynchrone (archive préparée sur le disque, puis lien de téléchargement, avec progression) : un état à stocker, de l’espace disque à nettoyer et deux endpoints de plus. Elle ne sera utile que pour un export planifié ou très volumineux (voir Reporté).
- Export par lots en plusieurs transactions : sans instantané commun, un nœud renommé pendant l’export pourrait figurer sous deux noms ou pas du tout, et
links.jsonpourrait viser des chemins absents. - Isolation
SERIALIZABLE: rien de plus pour une transaction en lecture seule, qui ne peut de toute façon pas provoquer d’échec de sérialisation enREPEATABLE READ. - TAR ou TAR.GZ : flux plus simple à produire, mais sans extracteur intégré à Windows avant les versions récentes et sans accès direct à une entrée. ZIP est le format qu’attendent Obsidian, Notion et l’utilisateur.
- Entrées
STOREDpour les fichiers déjà compressés : il faut connaître le CRC-32 avant d’écrire l’entrée, donc lire chaque fichier deux fois.BEST_SPEEDest presque aussi rapide. - Bouton dans la page « Définitions » : cette page décrit
config/en lecture seule, alors que l’export concerne les données. - Téléchargement par le client généré (
Blob) : l’archive entière serait en mémoire dans le navigateur, ce qui est rédhibitoire sur mobile et pour quelques gigaoctets. - Inclure la corbeille et l’historique dès maintenant : ce sont deux options de plus à tester et un volume imprévisible (corps avant et après à chaque enregistrement), pour des données que la sauvegarde couvre déjà. Les ajouter plus tard ne casse pas le format.
Conséquences
- L’utilisateur peut quitter l’outil à tout moment avec ses notes lisibles. Le dossier
nodes/s’ouvre comme un coffre Obsidian : propriétés, tags et la plupart des liens wiki fonctionnent. Les aspects, versions, fichiers et relations restent exploitables par script. - Les noms de fichiers ne sont pas stables d’un export à l’autre : renommer, créer ou mettre à la corbeille un nœud plus ancien du même titre change les suffixes. Les scripts et l’import V2 doivent s’appuyer sur
id, jamais sur le chemin. - Interopérabilité Obsidian partielle :
- un titre modifié par l’assainissement (
Réunion 12/10donneRéunion 12_10.md) ou tronqué au-delà de 80 caractères n’est pas résolu par[[Réunion 12/10]]; - les fichiers rattachés n’apparaissent pas dans les notes (aucun
![[…]]ajouté) ; - Obsidian affiche
aspectscomme un objet brut.
- un titre modifié par l’assainissement (
- L’archive peut être plus grosse que le stockage : un contenu partagé est copié pour chaque nœud. Le résumé annonce cette taille.
- Transaction longue : un téléchargement lent garde une connexion du pool et un instantané ouvert. VACUUM ne peut alors pas nettoyer les lignes mortes postérieures. C’est sans effet notable pour un usage personnel. Si
idle_in_transaction_session_timeoutest un jour fixé sur la base, il devra rester supérieur à la durée d’un export. - Proxy inverse : Caddy (tranche déploiement) ne doit imposer ni délai de réponse ni mise en mémoire tampon sur
/api/export. À vérifier avec un export de plusieurs gigaoctets. - L’Explorateur Windows ancien ignore parfois le drapeau UTF-8 des noms (accents mal affichés) et refuse les chemins de plus de 260 caractères. Le budget de 180 couvre ce second point, et 7-Zip couvre les deux.
- Un fichier abîmé sur le disque est signalé dans
export.json. L’export devient un contrôle d’intégrité gratuit, qui reste toutefois à lire. - Obligations reportées :
- la purge du jalon 3 prend le verrou
export; - l’archivage ajoute
archivedAtau frontmatter ; - un futur fichier par version (V2) devra choisir entre exporter la seule version courante et un dossier de versions ;
- le multi-emplacement (V2) ajoutera ses emplacements secondaires à
export.jsonou au frontmatter (ajout de clé, sans changement deformatVersion) ; - l’import V2 relira ce format sans autre engagement de cet ADR. Il devra revérifier les noms, refuser les entrées hors de la racine, ignorer
bodyTagset recalculer les index.
- la purge du jalon 3 prend le verrou
Reporté
- Corbeille dans l’export (
?trash=true, dossiertrash/, clédeletedAt). - Historique (
?history=true,events.jsonl). - Export partiel : un sous-arbre, un résultat de recherche, une vue (jalon 2).
- Export planifié vers un dossier du serveur, ou tâche asynchrone avec progression. À reconsidérer avec la tranche sauvegardes si le besoin apparaît.
- Archive chiffrée (ZIP AES ou
age) : l’utilisateur chiffre lui-même au besoin. aliasesObsidian quand le nom diffère du titre, et lien![[fichier]]dans une note dédiée : à évaluer avec l’import Obsidian (V2).- Déduplication des contenus dans l’archive.
- Export par jeton d’API (jalon 3, sans changement de l’endpoint).
- Import de ce format (V2).
Questions ouvertes tranchées (mode autonome)
- Archive : ZIP en flux, sans dossier racine,
DEFLATED(BEST_SPEEDpour les fichiers rattachés), ZIP64 automatique. - Arborescence : Markdown à plat dans
nodes/, fichier rattaché dansnodes/<Nom>/. La hiérarchie est portée parparentId. - Noms : titre assaini (interdits de Windows remplacés par
_, noms réservés suffixés de_, points initiaux remplacés, points et espaces finaux retirés, 80 unités et 200 octets) et rendu unique sans tenir compte de la casse dans l’ordrecreated_at, id. Le plus ancien n’a pas de suffixe. Chemins de 180 unités au plus. - Nom du fichier rattaché : nom d’origine assaini par la même règle. Le nom exact reste dans le frontmatter.
- Frontmatter :
id,title,type,parentId,createdAt,updatedAt,inboxAt,tags(saisis),bodyTags,aspects.<nom>.{version, data},file.{name, path, mediaType, size, sha256}. Clé absente =null. Chaînes toujours entre guillemets doubles. Dates en UTC. - Corps : octet pour octet, rien d’ajouté.
links.json: liens typés, wiki et champs référence, dont la source est exportée. Cible absente :targetPath: null.- Corbeille : exclue. Historique
node_event: exclu.config/: incluse, copie brute des YAML de premier niveau des sous-dossiers. - Endpoint :
GET /api/exportsynchrone, écrit dans la réponse, sansStreamingResponseBodyni tâche asynchrone.GET /api/export/summarypour le résumé. - Cohérence : une transaction
REPEATABLE READen lecture seule, lecture par lots de 100 nœuds, un export à la fois (409). - Échec : archive laissée sans répertoire central, donc invalide, jamais présentée comme complète.
- Sécurité : session obligatoire (401), CSRF sans objet, pas de limite de taille.
- Interface : page « Données » (
/data), lien de téléchargement direct, hors client généré. - Migration : aucune. Événement : aucun. Format YAML et méta-schémas : inchangés.
Exemple d’archive
second-brain-export-20261015-143000Z.zip :
export.json
links.json
config/aspects/courrier.yaml
config/aspects/document.yaml
config/aspects/facture.yaml
config/aspects/projet.yaml
config/aspects/tache.yaml
config/links/concerne-projet.yaml
config/templates/facture-papier.yaml
config/types/facture-fournisseur.yaml
config/views/factures-a-payer.yaml
config/dashboards/accueil.yaml
nodes/Projet maison.md
nodes/Facture Proximus octobre.md
nodes/Facture Proximus octobre/proximus-2026-10.pdf
nodes/Copie scan Proximus.md
nodes/Copie scan Proximus/proximus-2026-10.pdf même contenu, copié une seconde fois
nodes/Payer facture Proximus.md
nodes/Réunion 12_10.md titre « Réunion 12/10 »
nodes/Note.md
nodes/note (2).md titre « note », créé après « Note »
nodes/CON_.md titre « CON »
nodes/Facture Proximus octobre.md :
---
id: "3c0f6f0e-2b8d-4a57-9a51-6f3b1d0c7e21"
title: "Facture Proximus octobre"
type: "facture-fournisseur"
parentId: "0b7e5a10-8d1e-4c4f-b0a2-5b1f2d7c9e33"
createdAt: "2026-10-12T08:14:03.512381Z"
updatedAt: "2026-10-14T19:02:44.031007Z"
inboxAt: "2026-10-12T08:14:03.512381Z"
tags:
- "perso/maison"
bodyTags:
- "a-payer"
aspects:
courrier:
version: 1
data:
canal: "poste"
dateReception: "2026-10-12"
expediteur: "Proximus"
document:
version: 1
data: {}
facture:
version: 1
data:
communication: "+++090/9337/55493+++"
echeance: "2026-10-30"
montant: 49.99
payee: false
file:
name: "proximus-2026-10.pdf"
path: "Facture Proximus octobre/proximus-2026-10.pdf"
mediaType: "application/pdf"
size: 183422
sha256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
---
Reçue par courrier, à payer avant la fin du mois. #a-payer
Concerne [[Projet maison]].
nodes/Payer facture Proximus.md porte l’aspect tache, dont le champ projet vaut l’UUID du projet. On retrouve cette relation dans links.json sous la forme {"kind": "field", "type": "tache.projet", …, "targetPath": "nodes/Projet maison.md"}, à côté de son lien concerne-projet et du lien wiki produit par [[Projet maison]].
Amendement (ADR 0033) : une tâche ne porte plus de lien
concerne-projetà côté de son champprojet; le lien est dérivé du champ. L’export garde la relation de champ (kind: field) ; les exemples de tâche « à côté de son lienconcerne-projet» décrivent les données antérieures.
Amendement (ADR 0057)
L’en-tête de chaque nœud gagne la clé journal (notes de suivi : createdAt, updatedAt facultatif, text), absente pour un nœud sans note. Clé ajoutée : la version du format reste 2.