Second Brain documentation

ADR 0011 – `#tags` du corps : grammaire commune avec les liens wiki, `node_tag.origin`, le corps fait foi

Accepté (2026-10-15, mode autonome)

← Toutes les décisions

ADR 0011 – #tags du corps : grammaire commune avec les liens wiki, node_tag.origin, le corps fait foi

Statut : Accepté (2026-10-15, mode autonome)

Décisions prises en mode autonome (Aurélien, 2026-10-15 : options les plus simples, sûres et réversibles) : un # ouvre un tag s’il n’est pas collé à un caractère de mot ; corps du tag = forme canonique de l’ADR 0006 (tout-chiffres et plus de 100 caractères refusés, donc laissés en texte) ; zones exclues = celles de WikiLinks, plus les liens et images Markdown et les occurrences [[…]] / ![[…]] ; table de cas JSON dédiée, lue par Java et Vitest ; colonne node_tag.origin (manual | body) dans la clé primaire, deux ensembles indépendants ; PUT /tags ne touche que manual ; tags.updated avec origin ; NodeView.tags garde son sens (tags saisis), bodyTags s’ajoute ; filtre et /api/tags comptent les deux origines ; rendu en lien vers /nodes?tag=… ; décoration (pas de nœud) dans l’éditeur visuel ; \# préservé ; plafond de 200 tags distincts par corps ; pas de réindexation des corps existants.

Contexte

Le cahier (jalon 1, « Tags ») cite #perso/maison comme syntaxe. L’ADR 0006 a fixé la forme canonique des tags saisis en chips et renvoyé les #tags du corps à plus tard, avec une contrainte : un remplacement des tags (PUT /api/nodes/{id}/tags) ne doit pas les effacer. L’ADR 0008 conserve le corps tel que saisi (Tiptap ne réécrit qu’après une édition réelle). L’ADR 0009 a posé le principe « le corps fait foi, le serveur indexe dans la transaction d’écriture » pour [[…]], avec WikiLinks (commonmark-java) qui calcule les zones exclues, et annoncé pour les tags du corps une colonne node_tag.origin.

Choix difficiles à défaire une fois des notes écrites : la grammaire (ce qui est ou non un tag dans un texte existant, export et import Obsidian en V2), la représentation en base des deux origines, la forme des événements lus par les règles du jalon 3, et le sens de NodeView.tags dans le contrat du client généré.

Ce qu’on a vérifié (2026-10-15) :

  • @tiptap/markdown 3.32.0 : escapeMarkdownSyntax n’échappe que \ ` * _ [ ] ~ ; # n’est jamais échappé, en début de ligne non plus. Un jeton escape de marked devient du texte sans antislash : \#projet saisi en source devient #projet après une édition visuelle. Le garde-fou d’aller-retour (markdown-roundtrip.ts, instance marked sans extension) ne le voit pas, car les deux rendent <p>#projet</p>.
  • marked 17.0.6 : une extension en ligne est appelée avant les tokenizers natifs avec (src, tokens) (tokens = jetons déjà produits dans le contexte en ligne courant) et this.lexer.state.inLink vaut vrai pendant l’analyse du texte d’un lien (Markdown, mais aussi après une balise HTML brute <a …>). Le texte d’une emphase, d’un barré ou d’une cellule de tableau est analysé dans un nouveau contexte (tokens vide).
  • Les délimiteurs qui ouvrent un tel contexte (*, _, ~, |, >, marqueurs de liste et de titre suivis d’une espace) ne sont jamais un caractère de mot : la règle « précédé d’un caractère qui n’est pas un caractère de mot » donne donc le même résultat sur le texte brut (serveur) et au début d’un contexte en ligne (marked). La règle « précédé d’une espace » divergerait (**#a** : * côté serveur, début de contexte côté marked).

Décision

Grammaire (identique côté Java et TypeScript)

  • Une occurrence est # suivi du plus long corps de la forme S(/S)*, où S = ([\p{L}\p{M}\p{N}-]|\\?_)+. \_ compte pour un soulignement : @tiptap/markdown échappe toujours _, donc #snake_case saisi en visuel est enregistré #snake\_case ; l’antislash reste dans le texte (raw) mais est retiré de la valeur (snake_case) et du texte affiché au rendu. Un / final, ou un //, arrête le corps : #a/b/ donne a/b, #a//b donne a.
  • Frontière avant : le # est en début de corps, ou le caractère qui le précède (point de code) n’est ni une lettre, ni une marque, ni un chiffre (\p{L}\p{M}\p{N}), ni #, /, \ ou &. Conséquences :
    • pas de tag dans C#, foo#bar, https://ex.com/page#ancre, https://ex.com/#/route, &#35; ;
    • \#projet n’est pas un tag (c’est l’échappement), \\#projet non plus (même règle, sans compter les antislashs) ;
    • ##projet n’est pas un tag ;
    • (#a), "#a", l'#a, **#a**, _#a_, ~~#a~~, |#a|, >#a sont des tags.
  • # suivi d’une espace ou d’un caractère hors de S n’est pas une occurrence : # Titre reste un titre Markdown, la séquence de fermeture ## Titre ## n’est rien. #Titre en début de ligne (sans espace) n’est pas un titre CommonMark : c’est un tag, comme dans Obsidian.
  • Valeur : le corps passe par la forme canonique de l’ADR 0006 (NFC, minuscules Locale.ROOT). Une occurrence dont la forme canonique serait refusée (#2024, #123 : uniquement des chiffres ; plus de 100 caractères) n’est pas un tag et reste du texte, sans erreur. Le texte affiché garde la casse saisie (#Projet), la valeur indexée est projet. #fff est un tag (comme dans Obsidian).
  • Zones exclues : celles de WikiLinks (spans et blocs de code, blocs et balises HTML, liens automatiques <…>, définitions de liens par référence), plus :
    • les liens et images Markdown en entier (texte, destination, titre) : [voir #a](url), [x](https://ex.com/#a), ![#a](img.png). Un tag dans le texte d’un lien produirait un lien imbriqué au rendu ;
    • les occurrences de liens wiki et de transclusions au sens de l’ADR 0009 : [[Note #a]], [[Note#a]], ![[f #a]]. Ce qui n’est pas une occurrence reste analysé : \[[x #a]] et [[ #a]] (titre vide) contiennent le tag a.
  • Les tags sont reconnus partout ailleurs : titres, listes, tâches, citations, titre et corps des callouts, cellules de tableau, texte entre balises HTML en ligne (<span>#a</span>).
  • Une table de cas commune, frontend/src/app/core/markdown/body-tag-cases.json (sections occurrences : source → liste ordonnée de {raw, tag}, et nonGuaranteed), lue par Vitest (import JSON) et par BodyTagsTest (Path.of("..", "frontend", …), comme WikiLinksTest). Non ASCII écrit en \uXXXX.
  • Non garanti (listé dans la table, jamais produit par l’application) : texte entre <a …> et </a> HTML bruts (indexé par le serveur, non rendu comme tag car marked est alors inLink) ; URL nue GFM où # suit une ponctuation (https://ex.com/a(#b) : marked en fait un lien, le serveur sans extension autolink y voit un tag) ; | dans un span de code d’un tableau ; écarts de version Unicode entre Java 25 et V8 sur \p{L} ; emphase par soulignements (_#a_, __#a__ : le serveur lit a_ / a__, marked rend a) ; URL nue GFM où # suit un point (https://ex.com/a.#b).

Analyse côté serveur

  • La méthode de masquage de WikiLinks est extraite dans une classe package-private MarkdownZones (comportement inchangé pour WikiLinks, garanti par WikiLinksTest). Elle prend en option le masquage des nœuds Link et Image de commonmark-java.
  • WikiLinks expose en package-private les plages de ses occurrences et transclusions, calculées sur le texte déjà masqué, avec ses règles d’échappement et de titre vide. Pas de seconde implémentation de la grammaire wiki.
  • Classe pure node/BodyTags : masque (zones communes + liens/images + occurrences wiki), balaie avec l’expression régulière, applique la frontière (un caractère masqué compte comme frontière, comme le jeton précédent côté marked), puis la forme canonique sans exception (Tags.canonical).

Stockage : node_tag.origin

  • Migration V5 : colonne origin text not null ('manual' pour les lignes existantes, puis sans valeur par défaut, pour qu’une insertion qui oublie l’origine échoue), contrainte check (origin in ('manual', 'body')), clé primaire (node_id, origin, tag) à la place de (node_id, tag). Index text_pattern_ops et contrainte de forme de l’ADR 0006 inchangés.
  • Un même tag peut avoir deux lignes (manual et body). Les deux ensembles sont indépendants : PUT /tags ne lit et n’écrit que origin = 'manual', la réindexation du corps que origin = 'body'. Aucune règle de priorité, aucun recalcul croisé.
  • Plafonds : 50 tags saisis (ADR 0006, inchangé) ; 200 tags distincts du corps (après forme canonique). Au-delà, 400 avec la clé body (« Au plus 200 #tags distincts par contenu »), contrôlé avant les verrous comme le plafond des liens wiki.

Indexation et événements

  • Le corps fait foi. Les lignes body sont recalculées dans la transaction de toute écriture du corps, par différence avec l’existant : NodeService.create, NodeService.update (après la réécriture éventuelle du renommage) et WikiLinkService.propagateRename pour chaque source réécrite (le renommage ne touche que l’intérieur de [[…]], zone exclue : en pratique aucun changement, mais la règle « toute écriture du corps réindexe » ne souffre pas d’exception).
  • Création : pas d’événement dédié, node.created porte bodyTags à côté de tags (même choix que l’ADR 0006 pour les tags saisis).
  • Modification : si la liste des tags du corps change, un tags.updated avec {"origin": "body", "before": […], "after": […]} (listes triées des tags d’origine body), après le node.updated qui porte le corps. Pas de touch supplémentaire (l’écriture du corps l’a fait). Cet événement ne porte que l’ensemble d’origine body : il ne reflète pas l’ensemble effectif des tags du nœud (un tag à la fois saisi et écrit reste porté quand il quitte le corps). Une règle du jalon 3 qui veut « le tag X est apparu / disparu » doit combiner les deux origines.
  • PUT /tags : le tags.updated porte désormais "origin": "manual". Un événement antérieur sans origin se lit comme manual.
  • Verrous : la réindexation s’exécute sous le verrou de la ligne du nœud déjà pris par update (et par propagateRename pour les sources) ; replaceTags prend le même verrou. Les deux origines n’écrivent de toute façon jamais les mêmes lignes.

Lecture et API

  • NodeView.tags et NodeSummary.tags gardent leur sens : les tags saisis, ceux que PUT /tags remplace. Le chemin d’enregistrement du front reste inchangé : impossible de « figer » par erreur un tag du corps en tag saisi.
  • Nouveau bodyTags (liste triée, jamais nulle) dans NodeView et NodeSummary, et dans la réponse de PUT /tags.
  • Filtre ?tag= : un nœud correspond s’il porte le tag ou un descendant, quelle que soit l’origine (condition EXISTS de l’ADR 0006 inchangée).
  • GET /api/tags : les deux origines ; count = nombre de nœuds distincts hors corbeille (count(distinct node_id)).

Front

  • Ctrl/Cmd+clic dans l’éditeur : ouvre /nodes?tag=… dans un nouvel onglet (window.open(…, '_blank', 'noopener')), comme les liens ; l’éditeur et ses modifications en cours ne sont pas quittés.
  • Coût : l’extension marked ne coupe le texte qu’aux # qui peuvent ouvrir un tag (frontière remplie), pour rester linéaire sur un texte plein de x#a.
  • Rendu : extension marked en ligne bodyTag dans l’instance de rendu (même mécanisme que wikiLink). Frontière lue sur le dernier caractère du raw du dernier jeton du contexte (début de contexte = frontière) ; refus si this.lexer.state.inLink. Sortie : <a href="/nodes?tag=<valeur encodée>" data-body-tag="<valeur>">#Texte saisi</a>, texte échappé, style par [data-body-tag], navigation par le routage existant de MarkdownView.
  • Garde-fou d’aller-retour : l’instance de markdown-roundtrip.ts reçoit l’extension bodyTag. Toute réécriture de Tiptap qui ferait apparaître ou disparaître un tag change le rendu comparé et ouvre la note en mode source.
  • \# préservé : PreservedInline reconnaît \# (motif ajouté à PRESERVED_PATTERNS et à start). Un tag échappé reste échappé après une édition visuelle, et \# texte ne devient plus un titre.
  • Éditeur visuel : les tags restent du texte (aucun nœud Tiptap, aucun effet sur la sérialisation). Un plugin ProseMirror pose une décoration sur les occurrences de chaque bloc de texte, hors bloc de code, marques code et link, et nœuds atomiques (wikiLink, preservedInline, image), avec la même fonction de balayage que le rendu. Ctrl/Cmd+clic ouvre /nodes?tag=…. La décoration est un affichage : de légers écarts avec le serveur sont possibles et sans conséquence.
  • Chips : app-node-tags-field garde l’édition des tags saisis. Sous le champ, un mat-chip-set en lecture seule « Dans le contenu » affiche bodyTags moins les tags saisis, chaque puce menant à /nodes?tag=…, avec l’info-bulle « Tag écrit dans le contenu : retirez #… du texte pour l’enlever ». La liste reflète le corps enregistré et se met à jour à l’enregistrement.
  • Liste des nœuds : la colonne « Tags » affiche l’union triée de tags et bodyTags.

Alternatives écartées

  • Frontière « précédé d’une espace ou en début de ligne » (lecture la plus stricte d’Obsidian) : marked ne connaît pas le caractère brut qui précède un contexte d’emphase ou de cellule. **#a** ou |#a| seraient des tags au rendu et pas au serveur.
  • Tags dans le texte des liens Markdown : lien imbriqué au rendu, et cas rare.
  • Colonnes booléennes manual / body sur une ligne unique : clé primaire inchangée et comptage sans distinct, mais chaque écriture devient un upsert avec suppression quand les deux drapeaux tombent, et les deux origines partagent la même ligne (verrous, journal). Deux ensembles indépendants sont plus simples à raisonner.
  • Origine unique, manual prioritaire : retirer un tag saisi obligerait replaceTags à réanalyser le corps pour savoir s’il faut convertir la ligne en body. État dérivé, source de bogues.
  • Table séparée node_body_tag : filtre, autocomplétion et comptage en union de deux tables, pour rien.
  • NodeView.tags = union, manualTags à part : plus « naturel », mais tout endroit du front qui continuerait à envoyer tags dans PUT /tags transformerait en silence les tags du corps en tags saisis.
  • Événement dédié bodyTags.updated : une règle « tag X ajouté » du jalon 3 devrait écouter deux types. origin dans le payload suffit à les distinguer.
  • Pas d’événement pour les tags du corps (le node.updated contient déjà le corps) : contraire à « chaque modification de données produit un node_event », et une règle sur un tag devrait réanalyser des corps.
  • Un link.added-like par tag : même raison que l’ADR 0006, une ligne d’historique par tag.
  • Nœud Tiptap atomique bodyTag : l’édition d’un tag (retour arrière dans le mot) devient pénible, et un tag tapé sans règle d’entrée resterait du texte. La décoration ne change ni le schéma ni la sérialisation.
  • Échapper # dans le texte sérialisé par Tiptap : le sérialiseur n’a pas de point d’extension (ADR 0008), et un #projet tapé dans l’éditeur visuel doit rester un tag.
  • Compter les antislashs comme pour [[ : la frontière exclut déjà \, une seule règle suffit.
  • 400 sur un #tag invalide du corps (#2024, trop long) : le corps est du texte libre ; ce n’est simplement pas un tag.
  • Extension autolink de commonmark-java pour aligner les URL nues : une dépendance de plus pour un cas marginal, listé comme non garanti.

Conséquences

  • Migration V5 (clé primaire de node_tag modifiée). Les corps déjà enregistrés ne sont indexés qu’à leur prochain enregistrement (données de développement, comme l’ADR 0009) ; l’outil de réindexation complète reste reporté. Soldé par l’ADR 0020 (2026-10-15).
  • Le contrat change de façon additive (bodyTags, origin dans tags.updated). Client à régénérer.
  • Retirer un tag du corps se fait dans le texte, pas par la puce. Un tag à la fois saisi et écrit reste porté tant que l’une des deux origines l’a.
  • GET /api/tags propose aussi les tags du corps à l’autocomplétion des chips : ajouter à la main un tag du corps le « fige » (il survit à sa suppression du texte). C’est voulu.
  • Le corps est analysé deux fois par écriture (liens wiki, tags), ce qui est acceptable avec la borne de 200 000 caractères. Une seule analyse partagée reste possible sans changer de modèle.
  • Le jalon 2 (langage de requête des vues) reprend la même condition : tags: [perso] couvre les deux origines.
  • Renommer ou fusionner un tag (V2) réécrira aussi les corps (#ancien vers #nouveau), avec node.updated et reason, comme le renommage des liens wiki.

Reporté

  • Autocomplétion après # dans l’éditeur visuel (@tiptap/suggestion est déjà là) et dans le mode source.
  • Mise à jour en direct des puces « Dans le contenu » pendant la frappe.
  • Réindexation des corps existants (outil commun avec les liens wiki). Soldé par l’ADR 0020 (2026-10-15).
  • Renommage et fusion de tags (V2).
  • Tags dans un futur frontmatter YAML (export / import Obsidian, V2).

Questions ouvertes tranchées (mode autonome)

  • #fff, #2024b : acceptés comme tags (choix Obsidian ; seuls les tout-chiffres sont refusés).
  • Plafond de 200 tags distincts du corps : 400 avec la clé body (pas d’ignorance silencieuse).
  • Autocomplétion après # : reportée hors jalon 1.
  • Réindexation des corps existants (liens wiki et tags) : ligne ajoutée au backlog, à faire avant le déploiement. Soldé par l’ADR 0020 (2026-10-15).