Second Brain documentation

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

← Toutes les décisions

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/export renvoie une archive ZIP écrite en flux dans la réponse, de façon synchrone, dans une seule transaction en lecture seule REPEATABLE 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>.md par 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.
  • 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 que archived_at, jamais écrit ;
    • node_aspect : aspect, aspect_version, data ;
    • node_link : link_type, wiki compris ;
    • node_tag : origin vaut manual ou body ;
    • 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 champs node-ref des 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-yaml 3.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_QUOTES est 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 et null restent 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_MARKER et SPLIT_LINES sont 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.1 revient en DoubleNode.

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.json et de export.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 : DEFLATED pour toutes les entrées. Niveau par défaut pour le texte (.md, .json, .yaml). Deflater.BEST_SPEED pour les fichiers rattachés, souvent déjà compressés (PDF, JPEG). ZipOutputStream.setLevel s’applique aux entrées suivantes.

  • Horodatage des entrées : updated_at du 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 du Clock de TimeConfig.

  • 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 fichier

    Les nœuds sont à plat dans nodes/. La hiérarchie passe par parentId, 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 :

  1. Normalisation NFC.
  2. 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.
  3. Toute suite d’espaces Unicode (tabulation et saut de ligne compris) devient une espace.
  4. Les caractères < > : " / \ | ? * deviennent _. Ce sont les caractères interdits par Windows ; ils couvrent aussi / (Unix) et : (Finder).
  5. Retrait des espaces aux extrémités. Chaque . initial devient _ : pas de fichier caché, et ni . ni .. ne sont possibles.
  6. Troncature à maxUnités unité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 dans FileNames) est conservée.
  7. Retrait des . et des espaces finaux, que Windows interdit. On répète après la troncature.
  8. 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 : CON donne CON_, con.txt donne con_.txt.
  9. 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 disputent nodes/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]] ouvre Titre.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 :

  1. ---\n ;
  2. le frontmatter YAML ;
  3. ---\n ;
  4. 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 corps null ne 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, dans links.json.
  • file.path est omis si le contenu manque sur le disque. Voir « Anomalies ».
  • Hors du frontmatter :
    • archived_at : jamais écrit, il sera ajouté sous le nom archivedAt avec la fonctionnalité d’archivage ;
    • aspect_text et title_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_MARKER et SPLIT_LINES désactivés ;
  • MINIMIZE_QUOTES laissé 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 un DigestInputStream. 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.path est 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 : lignes node_link dont le type n’est pas wiki.
  • wiki : lignes node_link de type wiki. 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 champ node-ref non vide d’un aspect porté. La liste des champs vient de DefinitionRegistry.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 dans data. Un champ retiré de config/ ne produit plus de ligne, mais sa valeur reste dans data.
  • 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), targetPath vaut null et targetId est conservé.
  • Ordre : kind (link, wiki, field), puis sourceId, puis createdAt ou type, puis id ou targetId. 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ésoudre parentId sans lire tous les frontmatters.
  • problems : une ligne par anomalie. Les types sont file.missing (contenu absent ou de mauvaise taille), file.corrupted (SHA-256 recalculé différent, l’entrée est tout de même écrite) et config.skipped (fichier de config/ non copié, avec path et reason). Le tableau est vide si tout va bien.
  • formatVersion vaut 1. 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 *.yaml ou *.yml situé directement dans un sous-dossier direct de app.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.
  • 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_at n’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 garde parentId (ADR 0007), qui désigne alors un nœud absent de l’archive. Une relation vers un nœud en corbeille garde son targetId, avec targetPath: null. On pourra ajouter ?trash=true plus tard (dossier trash/) sans rien casser.
  • L’historique node_event : le cahier ne le demande pas, et son volume peut dépasser celui des contenus (chaque node.updated porte le corps avant et après). La sauvegarde pg_dump (tranche suivante) le couvre. On pourra ajouter ?history=true plus 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 :

  • StreamingResponseBody s’exécute sur un autre thread, soumis au délai asynchrone du conteneur (30 s par défaut chez Tomcat si spring.mvc.async.request-timeout n’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.export est 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 renvoie false, on lève ConflictException (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 :

    1. prise du verrou ;
    2. lecture de la table des noms ;
    3. 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 (jointure file_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 sur node_link.id.

  • Échec en cours d’écriture (base, disque, client parti) : l’exception remonte sans appeler ZipOutputStream.finish() ni close(), qui écriraient le répertoire central. L’archive reçue est tronquée et sans répertoire central : ZipFile et la plupart des outils la refusent, mais un lecteur en flux (ZipInputStream, 7-Zip) peut en extraire le début. La présence de export.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 pas try-with-resources sur le ZipOutputStream. 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/export et /api/export/summary relèvent de /api/** : session OIDC obligatoire en mode oidc, 401 sans session, ouverts en profil dev. Les jetons d’API (jalon 3) permettront un export par script (curl -H "Authorization: Bearer …") sans changement.
  • CSRF : sans objet. C’est un GET sans 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 verrou export limite 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 inline par 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 par formatSize) ;
    • 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 de NAV_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 Blob en mémoire. Le lien laisse le navigateur écrire le flux sur le disque. core/export.ts exporte EXPORT_URL = '/api/export'. C’est une exception de même nature que nodeFileUrl (ADR 0012) : une URL, pas un DTO. Le proxy de développement relaie /api aussi 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.formatVersion y 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-format ni de CLAUDE.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, note et NOTE donnent Note, note (2) et NOTE (3) ;
    • X.md puis X donnent X.md et X (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.
  • 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.1 et un entier long compris ;
    • non ASCII écrit littéralement ;
    • saut de ligne dans une valeur ; clés de data triées.
  • ExportZipTest (sur un ByteArrayOutputStream) :

    • après finish(), ZipFile relit les entrées et leurs noms UTF-8 ;
    • sans finish() (simulation d’une erreur), ZipFile refuse l’archive ;
    • le flux de la réponse n’est jamais fermé par ExportZip.

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, #tag dans le corps, [[Projet maison]], fichier PDF, dans l’inbox) ;
  • une tâche avec tache.projet vers le projet, un lien concerne-projet et un enfant du projet ;
  • Note puis note ;
  • les titres piégés CON, a/b, .., un titre de 300 caractères, X.md et X ;
  • 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\n et 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 de Content-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, sans file.path, et l’archive reste valide.
  • Corbeille : le nœud en corbeille est absent ; l’enfant garde parentId ; le lien vers ce nœud a targetPath: null.
  • links.json : trois sortes de relations dans l’ordre attendu.
  • export.json : compteurs et index.
  • config/ : les fichiers de config/ de test sont présents.
  • Instantané : le ExportTarget de test crée un nœud dans une transaction REQUIRES_NEW au moment de open. 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/summary donne les compteurs attendus (contenu partagé compté deux fois, corbeille exclue).

Autres tests d’intégration :

  • ExportSecurityIT (app.security.mode=oidc) : GET /api/export sans session donne 401.
  • OpenApiContractIT : /api/export (application/zip, binaire) et /api/export/summary sont présents.

Front (Vitest) :

  • data-page.spec.ts :
    • le résumé s’affiche (nombres, taille formatée) ;
    • le lien vise /api/export et porte download ;
    • 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

  1. Noms et frontmatter (feat(export): noms de fichiers sûrs et frontmatter YAML) :

    • ExportNames, Frontmatter et ExportZip ;
    • les tests unitaires.

    Testable seule par les tests. Rien n’est exposé.

  2. Archive en flux (feat(export): archive ZIP complète en flux, GET /api/export) :

    • ExportRepository, ExportService et ExportController ;
    • Markdown, fichiers, config/ et export.json, avec un links.json qui contient seulement formatVersion et une liste vide ;
    • ExportIT (sauf les liens), ExportSecurityIT, OpenApiContractIT ;
    • la règle du skill backend-conventions.

    Testable seule : curl -OJ http://localhost:8080/api/export en profil dev, puis ouverture de l’archive dans 7-Zip et dans Obsidian (nodes/ comme coffre).

  3. Relations (feat(export): liens typés, wiki et champs référence dans links.json) :

    • linksAfter, fieldRefs et l’écriture de links.json ;
    • les assertions correspondantes d’ExportIT.
  4. Page « Données » (feat(export): page Données avec résumé et téléchargement de l'export) :

    • GET /api/export/summary et 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 sous MAX_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. parentId et l’index d’export.json suffisent à 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>.data est le JSONB exact.
  • Guillemets minimaux (MINIMIZE_QUOTES) : plus joli, mais "2026-10-15" deviendrait une date pour js-yaml, no un booléen YAML 1.1, et un nombre écrit en chaîne changerait de type sans ALWAYS_QUOTE_NUMBERS_AS_STRINGS. Les guillemets doubles partout sont sans ambiguïté et vérifiés par la sonde.
  • Titre ajouté en # Titre au 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, et links.json donne 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.json pourrait 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 en REPEATABLE 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 STORED pour 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_SPEED est 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/10 donne Ré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 aspects comme un objet brut.
  • 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_timeout est 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 archivedAt au 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.json ou au frontmatter (ajout de clé, sans changement de formatVersion) ;
    • 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 bodyTags et recalculer les index.

Reporté

  • Corbeille dans l’export (?trash=true, dossier trash/, 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.
  • aliases Obsidian 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_SPEED pour les fichiers rattachés), ZIP64 automatique.
  • Arborescence : Markdown à plat dans nodes/, fichier rattaché dans nodes/<Nom>/. La hiérarchie est portée par parentId.
  • 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’ordre created_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/export synchrone, écrit dans la réponse, sans StreamingResponseBody ni tâche asynchrone. GET /api/export/summary pour le résumé.
  • Cohérence : une transaction REPEATABLE READ en 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 champ projet ; 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 lien concerne-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.