ADR 0009 – Liens wiki : le corps fait foi, index `node_link` de type `wiki`, résolution par clé de titre
Accepté (2026-10-15)
ADR 0009 – Liens wiki : le corps fait foi, index node_link de type wiki, résolution par clé de titre
Statut : Accepté (2026-10-15)
Décisions retenues (Aurélien, mode autonome du 2026-10-15 : options recommandées) : grammaire [[T]], |alias, #section (section sans effet de navigation), ![[…]] non lien ; clé insensible à la casse, accents conservés ; doublons : le plus ancien gagne, avertissement dans l’autocomplétion ; non résolus dans node_wiki_unresolved, rattrapés à la création ; renommage : réécriture des corps des sources ; un link.added/link.removed par lien ; wiki mécanisme du moteur (nom réservé, libellés en Java) ; retrait manuel refusé (409) ; création immédiate depuis l’autocomplétion, sans type, à la racine ; pas d’autocomplétion en mode source ; dépendances commonmark-java 0.30.1, @tiptap/suggestion 3.32.0 (exacte) et @floating-ui/dom ~1.8.0 (MIT, pair requis de la suggestion) ; plafond de 500 cibles ; #tags du corps reportés à une tranche dédiée ; pas de réindexation des corps existants (données de dev).
Contexte
Le cahier (jalon 1, « Liens wiki » et « Liens libres ») demande que les [[Titre]] écrits dans le corps soient proposés à la frappe, créent le nœud absent, et soient indexés comme liens non typés pour alimenter les backlinks. L’ADR 0001 réserve déjà le type wiki dans node_link. L’ADR 0008 conserve [[…]] et ![[…]] tels quels grâce à l’extension PreservedInline, et prévoit que l’indexation côté serveur reconnaisse [[…]] exactement comme le tokenizer du front, avec une table de cas commune. L’ADR 0006 renvoie à cette tranche la question des #tags du corps.
Plusieurs choix sont difficiles à défaire une fois des notes écrites et des événements journalisés :
- la grammaire reconnue, puisque le texte stocké, l’export et l’import Obsidian (V2) en dépendent ;
- la règle de résolution d’un titre vers un nœud (casse, doublons, corbeille), qui détermine les liens présents dans l’historique ;
- le devenir des liens quand la cible est renommée, créée après coup ou mise à la corbeille ;
- la forme des événements
node_event, lus par l’écran d’historique et les règles du jalon 3 ; - le statut du type
wikiface à la règle « le domaine vit dansconfig/».
Ce qu’on a vérifié (2026-10-15) :
marked17.0.6 avec une extension inline[[…]]: un\[[x]]n’est pas un lien, alors que\\[[x]]en est un (nombre impair d’antislashs = échappement). Le contenu des blocs et spans de code, des blocs HTML, des balises HTML en ligne et des liens automatiques<…>n’est pas analysé.[[x]]est reconnu dans les listes, les citations, les titres, l’emphase et le texte d’un lien Markdown. Dans un tableau GFM, marked découpe d’abord les cellules et transforme\|en|, si bien que[[A\|alias]]arrive sous la forme[[A|alias]].[[a [[b]] c]]donne[[b]],[[[x]]]donne[[x]], et[[a⏎b]]n’est pas un lien.- Le sérialiseur de tableaux de Tiptap échappe les
|du texte des cellules (escapePipesInCellText) : un[[A|alias]]placé dans une cellule est réécrit[[A\|alias]], ce qui reste conforme à la convention Obsidian. org.commonmark:commonmark0.30.1 (BSD-2-Clause, 220 Ko, aucune dépendance d’exécution) fournit des positions source par nœud, y compris en ligne (IncludeSourceSpans.BLOCKS_AND_INLINES,SourceSpan.getInputIndex()). Le prototype « masquerCode,FencedCodeBlock,IndentedCodeBlock,HtmlBlock,HtmlInline,LinkReferenceDefinitionet les liens automatiques, puis balayer le texte brut » rend le même résultat que marked sur les 21 cas sondés (listes, citations avec code clôturé, tableaux, HTML, échappements, imbrications).@tiptap/suggestion3.32.0 (MIT, publié le 2026-10-13) déclare en pair@tiptap/coreet@tiptap/pmen3.32.0exacte, ainsi que@floating-ui/dom ^1.0.0(dernière version : 1.8.0), importé de façon statique. Il fournititems({ query, signal })asynchrone avecdebounceet annulation,findSuggestionMatchsurchargeable et un montage géréprops.mount(element).
Décision
Grammaire (identique côté Java et TypeScript)
- Une occurrence est
[[+ intérieur +]]. L’intérieur fait au moins un caractère et ne contient ni[, ni], ni saut de ligne (\n,\r). Le balayage prend l’occurrence la plus à gauche, puis reprend après]]. - Échappement : un
[[précédé d’un nombre impair de\n’est pas une occurrence. - Exclusions : rien n’est reconnu dans les spans de code, les blocs de code (clôturés ou indentés), les blocs HTML, les balises HTML en ligne, les liens automatiques
<…>ni les définitions de liens par référence. - Découpage de l’intérieur :
\|est d’abord lu comme|. Le premier|sépare la cible de l’alias (espaces retirés ; un alias vide équivaut à aucun). Dans la cible, le premier#sépare le titre de la section (espaces retirés). [[Titre]],[[Titre|alias]]et[[Titre#section]]sont des liens. La section est conservée dans le texte et affichée (Titre > section), mais la navigation ouvre le nœud sans défiler jusqu’au titre (jalon 1).- Un
[[précédé d’un!non échappé est une transclusion (![[…]]). Ce n’est pas un lien au jalon 1 : elle n’est ni indexée ni cliquable, et reste du texte préservé (V2).\![[x]]est un lien ordinaire. - Un titre vide après découpage (
[[ ]],[[|x]],[[#section]]) n’est pas un lien et reste du texte. - Clé d’un titre (fonction
WikiLinks.key/wikiKey) : normalisation NFC, retrait aux extrémités des espaces du jeu\sde JavaScript (U+00A0, U+202F et U+FEFF compris), compactage des suites d’espaces internes en un seul espace, puis passage en minuscules (Locale.ROOT, sigma final compris). Les accents sont conservés, comme pour les tags (ADR 0006). - Titre utilisable comme cible : un titre dont la clé n’est pas vide et qui ne contient ni
[, ni], ni|, ni#, ni accent grave (`: réécrit dans un[[…]], il ouvrirait un span de code qui masquerait les liens suivants), ni saut de ligne. Les autres titres restent valides, mais l’autocomplétion ne les propose pas (option désactivée, avec une explication). - Une seule table de cas,
frontend/src/app/core/markdown/wiki-link-cases.json, couvre les occurrences, les clés et les titres utilisables. Elle est lue par Vitest (import JSON) et par le test Java (Path.of("..", "frontend", …), même procédé queExampleDefinitionsTestpour../config). Ce n’est pas une table dupliquée.
Analyse côté serveur
- Classe pure
node/WikiLinks:org.commonmark:commonmark0.30.1 calcule les zones exclues à partir des positions source, puis une expression régulière balaie le texte brut hors de ces zones, en appliquant la règle d’échappement. Pas d’extension GFM : le découpage des cellules ne change pas les occurrences, puisque\|est traité dans l’intérieur. - Le front ne calcule rien pour l’index : il utilise la même grammaire pour le rendu (extension
marked) et pour l’éditeur (nœud TiptapwikiLink).
Résolution
- Une clé désigne le nœud hors corbeille dont la clé de titre est égale. S’il y en a plusieurs, le plus ancien l’emporte (
created_at, puisid) : créer un doublon ne détourne jamais un lien existant. - Stabilité : à la réindexation d’une source, un lien wiki existant est conservé tant que la clé de titre de sa cible figure encore dans le corps. Une cible passée à la corbeille ne reste liée que si aucun nœud vivant n’a cette clé : sinon le lien bascule vers le nœud vivant le plus ancien (
link.removedpuislink.added), à la prochaine réindexation de la source. Seules les clés qui ne sont pas encore couvertes sont résolues. Au plus un lien par clé et par source ; en cas de collision (renommage vers un titre déjà lié), le nœud le plus ancien l’emporte. - Lien vers soi-même : il n’est pas indexé (pas de rétrolien vers soi). Le front le résout localement.
- La clé de titre est stockée dans la colonne
node.title_key, calculée en Java dansNodeRepositoryà chaque écriture du titre. On n’utilise paslower()de PostgreSQL, qui dépend de la collation (même raison que dans l’ADR 0006).
Index et événements
- Le corps fait foi. Les lignes
node_linkde typewikien sont l’index : une ligne par cible résolue distincte, recalculée dans la transaction de toute écriture du corps ou du titre (NodeService.create,NodeService.update) par différence avec l’existant. - Chaque ligne ajoutée ou retirée produit
link.added/link.removedsur la source, avec la même forme de payload que les liens typés (linkId,type: "wiki",targetId,targetTitle), en plus dunode.updatedqui porte le corps avant/après. Un enregistrement sans changement du corps ni du titre ne produit rien. - Les clés non résolues sont stockées dans
node_wiki_unresolved(source_id, target_key, target_text)(target_text, le titre tel qu’écrit, n’est pas lu au jalon 1 : il est réservé à un affichage futur des liens non résolus, par exemple « Créer X » depuis un écran de liens), sans événement (ce sont du texte du corps, déjà journalisé). À la création d’un nœud, les lignes non résolues de sa clé sont résolues vers lui (rattrapage) :link.addedsur chaque source, sans changer leurupdated_at, puisque leur contenu ne change pas. - Toutes les écritures de l’index wiki prennent d’abord un verrou consultatif de transaction (
pg_advisory_xact_lock(hashtext('node.wiki'))), puis le verrou de la ligne source. Une création et un enregistrement concurrents ne peuvent donc pas laisser une ligne non résolue alors que la cible existe. - Au plus 500 cibles distinctes par corps. Au-delà : 400 avec la clé
body. - Contraintes des liens typés : elles ne s’appliquent pas (
source,target,cardinality). Le typewikicoexiste avec un lien typé entre les mêmes nœuds (l’unicité porte sursource, cible, type).
wiki est un mécanisme du moteur, pas une définition
wikin’a pas de fichier dansconfig/links/. C’est le lien produit par la syntaxe du corps, au même titre queparent_idpour la hiérarchie : aucun nom du domaine n’est codé. Le chargeur refuse un fichierconfig/links/wiki.yaml(« nom réservé au moteur »).- Constantes Java
WikiLinks.TYPE = "wiki". Les libellés sont fixés par le moteur : « Mentionne » pour un lien sortant, « Mentionné dans » pour un rétrolien. - API :
BacklinkGroup.kindprend la valeurwiki(en plus delinketfield) etLinkViewgagnekind(link|wiki).POST /api/nodes/{id}/linksavectype: wikirépond 400.DELETEd’un lien wiki répond 409 (« ce lien vient du contenu : retirez [[…]] du texte »), car il reviendrait au prochain enregistrement. Le panneau « Liens » affiche les liens wiki en lecture seule.
Renommage
- Quand la clé du titre d’un nœud X change, chaque source liée à X par un lien wiki (corbeille comprise) voit ses occurrences de l’ancienne clé réécrites avec le nouveau titre :
[[Ancien|alias]]devient[[Nouveau|alias]]et[[ancien#s]]devient[[Nouveau#s]]. Les transclusions et le code ne sont pas modifiés. Chaque source reçoit unnode.updated(corps avant/après) avec"reason": "wiki.rename"et"renamedNodeId". Les lignesnode_linkrestent inchangées. Les lignes non résolues de la nouvelle clé sont rattrapées. - Le corps soumis avec le renommage est traité comme celui des autres sources : si le nœud était le destinataire de ses propres
[[Ancien]](aucun lien existant vers un autre nœud de cette clé, et le nœud est le plus ancien vivant de la clé), ces occurrences sont réécrites avant l’enregistrement et lenode.updateddu nœud porte aussi"reason": "wiki.rename". - Garde défensive : après réécriture, l’ensemble des clés du corps doit être celui d’avant, l’ancienne clé étant remplacée par celle du nouveau titre ; sinon (titre qui masquerait d’autres liens, par exemple une balise HTML ouverte) rien n’est réécrit, comme pour un titre inutilisable.
- Verrous de lignes :
FOR NO KEY UPDATE(et nonFOR UPDATE), pour ne pas entrer en conflit avec leFOR KEY SHAREque prennent les clés étrangères d’un lien ajouté vers la cible (interblocage renommage / ajout de lien typé). - Si le nouveau titre n’est pas utilisable comme cible, rien n’est réécrit et les sources sont réindexées : les liens vers X disparaissent (
link.removed) et la clé passe en non résolue. - Un changement de casse seul ne change pas la clé : rien n’est réécrit.
Création du nœud absent
- Depuis l’autocomplétion, l’entrée « Créer « X » » crée le nœud aussitôt (
POST /api/nodes {title}, sans type ni parent,node.createdhabituel), puis insère[[X]]. Le nœud existe donc avant l’enregistrement de la source. - Un clic (Ctrl/Cmd+clic dans l’éditeur visuel) sur un lien non résolu ouvre
/nodes/new?title=X, le formulaire de création prérempli. Le rattrapage relie les sources quand ce nœud est enregistré. - Ni type par défaut ni dossier de destination au jalon 1 : choisir
notereviendrait à coder le domaine. Un futur modèle de nœud (tranche « Templates de nœud en YAML ») pourra le déclarer.
Lecture et rendu
-
NodeView.wikiLinks:Map<clé, NodeReference>des cibles des liens wiki indexés de la source (indicateurdeletedcompris), construite à partir denode_linksur le modèle dereferences. Une clé absente de la table signifie « non résolu », ou « pas encore enregistré ». Il n’y a pas d’endpoint de résolution : pour un lien saisi mais pas encore enregistré, le front cherche par titre (GET /api/nodes?q=) au moment du clic. -
GET /api/nodes?q=place en tête les titres de clé égale à la saisie (du plus ancien au plus récent, comme la résolution), puis les titres qui commencent par la saisie, puis les autres. -
Rendu (
renderMarkdown(source, wikiLinks), extension de l’instancemarkeddédiée, puis DOMPurify sans changement de politique) :- lien résolu :
<a href="/nodes/{id}" data-wiki-link="resolved">, routé parMarkdownView; - cible en corbeille :
<span data-wiki-link="deleted">, non navigable (l’éditeur répondrait 404) ; - lien non résolu :
<a href="/nodes/new?title=…" data-wiki-link="unresolved">.
Le texte affiché est l’alias, à défaut la cible écrite (suivie de
> sections’il y en a une), échappé. Les styles passent par les sélecteurs d’attribut[data-wiki-link], car le hook retire les classes. - lien résolu :
-
Éditeur visuel : la partie
[[…]]dePreservedInlineest remplacée par un nœud atomiquewikiLink(attributrawrestitué tel quel). Il a le même aspect que le rendu, et Ctrl/Cmd+clic navigue.![[…]],[!type]et[^n]restent dansPreservedInline. -
Autocomplétion :
@tiptap/suggestion3.32.0 dans l’éditeur visuel uniquement. Le mode source (textarea) n’en a pas au jalon 1.
#tags du corps
- Reportés à une tranche dédiée. Ils réutiliseront
WikiLinks(mêmes zones exclues) et la forme canonique de l’ADR 0006, avec une colonnenode_tag.origin(manual|body), pour quePUT /tagsne retire que les tags saisis à la main.
Alternatives écartées
- Scanner Markdown écrit à la main en Java (blocs clôturés, code indenté, HTML) : il faudrait réimplémenter les conteneurs de CommonMark (listes, citations, indentation), et c’est là que le front et le serveur divergeraient. commonmark-java suit la même spécification que marked.
- Extraction des liens par le front, envoyée avec le corps : un client de l’API (script, import, règle) contournerait l’index. Le serveur doit rester l’autorité.
- Résolution dynamique à la lecture, sans index : les backlinks exigeraient d’analyser tous les corps à chaque consultation.
- Liens « collants » (la clé écrite est mémorisée sur la ligne, la cible est suivie malgré le renommage) : le texte et la cible divergent (
[[Ancien]]mène à « Nouveau »), le même texte n’a pas le même sens d’une note à l’autre, et l’export n’est plus lisible par Obsidian. - Forme par identifiant
[[<uuid>|Titre]]: elle lèverait les ambiguïtés et résisterait au renommage, mais rend la source illisible. Elle reste possible plus tard, de façon additive. - Titres uniques (index unique sur la clé) : des titres en double sont légitimes (factures mensuelles, comptes rendus).
- Doublon ambigu laissé non résolu : créer un second « Réunion » casserait tous les liens existants.
target_idnullable dansnode_linkpour les liens non résolus : change la sémantique d’une table partagée avec les liens typés (jointures, unicité, contraintes).- Rattrapage par balayage des corps (
body like '%[[%') à chaque création : son coût grandit avec la base, à chaque capture rapide. - Un seul événement
wiki.updated {before, after}(commetags.updated) : chaque lignenode_linka son identité et son couple ajout/retrait, comme les liens typés. Les règles du jalon 3 sur « lien créé » n’écoutent qu’un type d’événement. - Fichier
config/links/wiki.yaml: il laisserait croire quesource,targetoucardinalitys’appliquent, et un fichier invalide ou absent changerait le comportement d’un mécanisme du moteur. - Création du nœud à l’enregistrement de la source, pour toutes les clés non résolues : les liens laissés volontairement non résolus (pratique courante dans Obsidian) deviendraient des nœuds vides.
- Type
notepar défaut codé : c’est un nom du domaine. - Endpoint
POST /api/nodes/resolve: il n’est pas nécessaire, puisqueNodeView.wikiLinkscouvre l’état enregistré et la recherche avec correspondance exacte en tête couvre le reste. - Indexer les transclusions
![[…]]dès maintenant : leur cible (nœud ou fichier) et leur type de lien relèvent de la tranche transclusion (V2). Réindexer plus tard ne coûte qu’un traitement par lot.
Conséquences
- Une nouvelle dépendance backend :
org.commonmark:commonmark0.30.1, sans dépendance transitive. Deux nouvelles dépendances front dans le chunk différé de l’éditeur :@tiptap/suggestion3.32.0 (épinglé exactement comme les autres@tiptap/*) et@floating-ui/dom~1.8.0. - Migration
V4: colonnenode.title_key(remplie en SQL pour les données existantes, puis maintenue par Java), index partiel(title_key, created_at, id) where deleted_at is null, tablenode_wiki_unresolved. L’application n’est pas encore déployée : les corps écrits avant cette tranche ne sont indexés qu’au prochain enregistrement. - Le renommage modifie le corps d’autres nœuds. Une source ouverte dans un autre onglet puis enregistrée réécrit l’ancien texte (le dernier enregistrement gagne, ADR 0008), et le lien redevient non résolu.
- Avec des titres en double, un
[[Titre]]mène au plus ancien. L’autocomplétion le signale, mais ne permet pas de viser un autre nœud tant que la forme par identifiant n’existe pas. - La corbeille ne coupe pas les liens wiki existants (même tolérance que les liens typés et les références, ADR 0005). Les nouveaux liens vers ce titre restent non résolus.
- Jalon 3 : la restauration depuis la corbeille devra rattraper les lignes non résolues de la clé restaurée, et la purge devra repasser les clés de ses sources en non résolues (la cascade supprime les lignes
node_linksans événement). - Les divergences possibles entre marked et commonmark-java (destination d’un lien Markdown contenant
[[, URL nue GFM,|dans un span de code d’un tableau) sont listées dans la table de cas comme non garanties. node.updatedpeut désormais porterreason: les consommateurs (historique, règles) distinguent une modification saisie d’une réécriture par le moteur.
Reporté
- Défilement vers la section (
#section) et vers un bloc (#^id). - Transclusion
![[…]](rendu, indexation, réécriture au renommage). - Forme par identifiant
[[<uuid>|Titre]]. - Autocomplétion dans le mode source.
#tagsdu corps (tranche dédiée, voir ci-dessus).- Mentions non liées (V2).
- Type et emplacement du nœud créé depuis un lien, à déclarer par un modèle de nœud.
- Outil de réindexation complète (utile à l’import Obsidian, V2). Soldé par l’ADR 0020 (2026-10-15).