Package Néovim pour la prise de note
  • Lua 98.7%
  • Makefile 0.8%
  • Shell 0.5%
Find a file
Bertrand Benjamin dd9d573749 feat(création): une sélection devient le lien et la note qu'il cite
En visuel, <leader>tn crée l'entité depuis les mots sélectionnés : titre
proposé depuis la sélection, slug depuis le titre, puis la sélection est
remplacée par [libellé](slug.md) dans le buffer. La note est créée avant
que le lien soit posé : renoncer laisse la phrase intacte. On propose
ensuite d'ouvrir la note à côté, « non » par défaut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:12:49 +02:00
demo feat(pieces): une pièce s'insère au curseur, en markdown qui s'exporte 2026-09-23 08:23:28 +02:00
docs feat(création): une sélection devient le lien et la note qu'il cite 2026-09-25 06:12:49 +02:00
lua/trace feat(création): une sélection devient le lien et la note qu'il cite 2026-09-25 06:12:49 +02:00
plugin feat(pieces): une pièce s'insère au curseur, en markdown qui s'exporte 2026-09-23 08:23:28 +02:00
scripts feat(démo): un bac à sable jetable, monté sur un nvim vide 2026-09-18 22:31:06 +02:00
tests feat(création): une sélection devient le lien et la note qu'il cite 2026-09-25 06:12:49 +02:00
.gitignore docs: rassembler la conception et le corpus d'exemple 2026-09-18 22:27:34 +02:00
Makefile feat(démo): un bac à sable jetable, monté sur un nvim vide 2026-09-18 22:31:06 +02:00
README.md feat(création): une sélection devient le lien et la note qu'il cite 2026-09-25 06:12:49 +02:00

trace.nvim

Tracer : poser une ligne datée qui dit ce qui s'est passé.

Paquet Neovim pour le système de notes spécifié dans docs/cahier-des-charges.md. Le système est un dossier de fichiers markdown ; le paquet n'est qu'un éditeur et quelques commandes. Aucune dépendance ne possède les données (N1) : si tout est désinstallé demain, les notes restent lisibles avec rg et n'importe quel éditeur.

Installation

Le paquet est hébergé sur https://forge.opytex.org/benjamin/trace.nvim, pas sur GitHub : on donne donc l'URL complète plutôt qu'un user/repo.

-- lazy.nvim
{
  url = "https://forge.opytex.org/benjamin/trace.nvim",
  opts = {
    root = "~/notes",        -- obligatoire : la racine du corpus, jamais devinée
    -- keymaps = false,      -- pour ne pas poser les raccourcis <leader>t*
    -- editor_opts = false,  -- pour ne pas toucher suffixesadd/path/wrap
  },
}
-- packer.nvim
use({
  "https://forge.opytex.org/benjamin/trace.nvim",
  config = function()
    require("trace").setup({ root = "~/notes" })
  end,
})

Pour travailler sur une copie locale du dépôt, remplacer url = par dir = "/chemin/vers/trace.nvim".

La racine est explicite. Rien n'est cherché, rien n'est deviné : le paquet prévient au démarrage si elle manque ou n'existe pas, et refuse d'écrire.

Les opérations

# Commande Raccourci Ce qu'elle fait
O1 :TraceEntry [texte] <leader>te Pose l'amorce datée dans le journal et vous y met en insertion, dans le buffer. Avec un texte en argument, écrit sans rien demander.
O2 — @ en insertion Propose les entités par titre, slug et alias, et insère [Libellé](slug.md).
— :TraceInbox [texte] <leader>ti Jette une ligne datée dans inbox.md, depuis n'importe où, dans une fenêtre verticale à droite.
O3 :TraceNew [kind] <leader>tn Crée une entité : trois questions — type, titre, slug proposé. En visuel, les mots sélectionnés deviennent le lien et la note qu'il cite.
O4 :TraceOpen [slug] <leader>to Ouvre une entité par nom, titre ou alias.
O5 :TraceGrep [motif] <leader>tg Recherche plein texte, à la frappe avec snacks ou telescope, sinon rg + quickfix.
O6 :TraceBacklinks [slug] <leader>tb Tout ce qui cite une entité, agrégé et groupé par dossier, antéchronologique à l'intérieur de chacun, en quickfix. Suit le lien sous le curseur.
O7 :TraceAlive <leader>ta Projets vivants, du plus figé au plus récent, en quickfix.
O8 :TraceSince {AAAA[-MM]} <leader>ts Les entrées d'une période, le mois courant proposé, groupées par dossier, en quickfix.
O9 :TraceClose [slug] <leader>tC Clôt un projet à la date du jour, après confirmation. Refuse de clore deux fois.
O10 :TraceAsset [chemin] <leader>tA Copie une pièce dans le dossier de l'entité du buffer, la date, et insère son lien à la position du curseur — ![](…) pour une image, [](…) sinon — crochets vides, en insertion, pour dire ce qu'elle montre. Sans argument, propose les pièces récemment téléchargées ou capturées.
O11 :TraceReveal <leader>tr Ouvre en externe la pièce sous le curseur, le dossier de l'entité citée par le lien sous le curseur, sinon celui de l'entité courante.
— :TraceFollow gf, gx, <leader>tf Suit ce qui est sous le curseur : note dans nvim, pièce et URL dehors, note manquante en création.
O12 :TraceIndex — Reconstruit l'index en mémoire.
O13 :TraceCheck <leader>tc Vérifie les invariants : diagnostics dans les buffers ouverts, liste complète en quickfix.

Hors du corpus, chaque commande demande l'entité ; à l'intérieur d'une note, elle travaille sur celle-ci sans rien demander — sauf si l'opération ne sait pas la traiter (clore une personne), auquel cas elle demande aussi.

Deux règles suffisent à retrouver une touche : la lettre est l'initiale du nom anglais de la commande, et la majuscule signale l'irréversible — tC scelle un projet. tA la porte parce que ta est déjà pris par les projets vivants : depuis qu'elle copie au lieu de déplacer, O10 ne détruit plus rien.

On écrit toujours dans le buffer, jamais dans une invite. Une invite de saisie n'a ni complétion, ni abréviations, ni annulation : O1 et :TraceInbox posent donc la ligne datée et vous y déposent en insertion, et O10 son lien à l'endroit du curseur. Rien de tout cela n'est écrit sur le disque : c'est votre :w qui décide.

Créer depuis la phrase

On écrit « vu avec Michel Bernard rue des Écoles », on sélectionne Michel Bernard avec v, puis <leader>tn :

  1. le type, puis le titre — proposé depuis la sélection, et modifiable : « Michel » dans la phrase peut devenir « Bernard Michel » sur la fiche ;
  2. le slug, proposé depuis le titre corrigé, modifiable comme toujours ;
  3. la note est créée, et la sélection devient [Michel Bernard](bernard-michel.md) — le libellé reste celui de la phrase ;
  4. « Ouvrir la note à côté ? » : oui l'ouvre dans une fenêtre verticale, sur la synthèse ; non — la réponse par défaut — vous laisse dans la phrase.

La note d'abord, le lien ensuite : si on renonce en route, la phrase reste intacte. Le lien n'est posé que dans le buffer, c'est :w qui décide. Seule une sélection au caractère près, sur une ligne, est acceptée : un lien ne se coupe pas (I4), et une sélection qui touche un lien existant est refusée.

Suivre un lien, une pièce, une URL

Dans le corpus — et nulle part ailleurs — gf et gx passent par :TraceFollow, qui regarde ce qu'il a sous le curseur :

  • une note ([Michel](bernard-michel.md)) s'ouvre dans nvim ;
  • une pièce s'ouvre avec l'application du système, via vim.ui.open — un plan ou un PDF se lisent en grand, hors du terminal (§8). Qu'elle soit citée par un lien (![](20270612_chaussee-avant.jpg), [](20270115_devis-colas.pdf)) ou en texte nu au fil de la phrase, les deux formes se suivent, y compris le curseur posé sur le ! de l'image ;
  • une URL ouvre le navigateur ;
  • un lien vers une note qui n'existe pas encore propose de la créer, et ne demande que le type : le slug vient du lien, le titre de son libellé.

C'est ce dernier point qui rend tenable « on lie librement, on crée plus tard » : un lien peut précéder sa fiche sans jamais produire de fiche vide.

Un mot ordinaire de la phrase, lui, ne nomme rien : gf dessus ne propose pas de créer une entité « signalée ». Seuls un lien, un slug.md écrit en toutes lettres, ou un mot qui porte déjà le nom d'une entité sont des cibles. follow = false garde gf strictement natif.

Options

opts = {
  root = "~/notes",        -- obligatoire
  picker = "auto",         -- "snacks" | "telescope" | "native"
  follow = true,           -- gf / gx suivent notes, pièces et URLs dans le corpus
  first_entry = "Ouverture du dossier.",  -- false pour ne pas dater les notes neuves
  inbox = "inbox.md",
  asset_source = nil,      -- dossiers d'où arrivent les pièces (O10)
  keymaps = true,
  completion = true,
  editor_opts = true,
}

picker = "auto" prend snacks.picker s'il est là, telescope sinon, et retombe sur vim.ui.select — qui ne demande rien d'installé.

Une règle départage les deux interfaces : on choisit → picker ; on parcourt → quickfix. Choisir une entité (O4), une pièce (O10) ou chercher à la frappe (O5) passe par le picker. Les listes de lignes — rétroliens (O6), projets vivants (O7), période (O8) — vont toujours en quickfix, même avec snacks installé : une entrée tient sur une seule ligne physique (I4), donc la ligne porte déjà le fait entier et un aperçu n'ajoute rien ; et la quickfix persiste, se reparcourt avec ]q / [q et se traite en masse avec :cdo. C'est une liste de travail, pas une sélection. Elle affiche slug │ texte, sans répéter le chemin qu'elle montre déjà.

O6 et O8 groupent ces lignes par entité porteuse : les dossiers sont rangés du plus récemment actif au plus ancien, et à l'intérieur de chacun l'ordre reste antéchronologique, mentions sans date en queue de groupe. Trié par date seule, un dossier se retrouve éparpillé dans toute la liste et on lit une chronologie de la commune ; groupé, on lit l'histoire de chaque dossier. Le format ne change pas : c'est le tri qui groupe, pas un en-tête.

:TraceAsset accepte un chemin qui contient des espaces : tout l'argument est le chemin, il n'y a rien d'autre à y lire. L'entité est celle du buffer, et seulement elle — un slug donné à part écrirait le lien dans une note et le fichier dans un autre dossier. Hors de la note d'une entité, la commande refuse et le dit : le curseur doit être là où la pièce doit apparaître.

asset_source est la liste des dossiers où <leader>tA va chercher les pièces à attacher : leurs fichiers non-markdown de premier niveau, du plus récent au plus ancien — une pièce vient presque toujours d'être téléchargée ou capturée. Par défaut, les dossiers de l'utilisateur donnés par xdg-user-dir (DOWNLOAD, PICTURES, DESKTOP — soit ~/Téléchargements, ~/Images, ~/Bureau sur une machine française), puis $HOME. La question est posée une seule fois, au setup(). La dernière entrée de la liste reste « saisir un chemin… », et :TraceAsset <chemin> ne demande rien.

Les invariants

:TraceCheck est le contrat du système — et celui de tout agent qui écrirait dans le corpus (N9). Les dix invariants du cahier des charges §3.5 sont vérifiés, I10 excepté : un renommage passé ne se voit que sous la forme d'un lien mort (I7).

Erreurs : I1 (une note par dossier, homonyme), I2 (unicité des slugs), I3 (date sur les projets seulement), I4 (une entrée = une ligne datée), I5 (journal antéchronologique), I6 (closed:), I9 (ni tag ni sous-dossier). Avertissements : I7 (lien vers une entité à créer), I8 (pièce que sa note ne cite nulle part).

Complétion des liens (O2)

@ en insertion ouvre la liste des entités, filtrée sur le titre, le slug et les alias, et insère [Libellé](slug.md). La source est déclarée auprès de blink.cmp au setup() — rien à ajouter à ta configuration blink. Pour la déclarer à la main :

sources = {
  per_filetype = { markdown = { inherit_defaults = true, "trace" } },
  providers = { trace = { name = "trace", module = "trace.cmp" } },
}

Sans blink, le repli est natif : completefunc et @ qui déclenche <C-x><C-u>. completion = false dans les options coupe tout.

Équivalents shell

N5 demande que chaque opération ait un équivalent portable documenté : le voici. Il fonctionne sans le paquet, sans nvim, depuis la racine du corpus — c'est le test de N1, « si je désinstalle tout demain ».

# O7 — projets vivants, du plus figé au plus récent
for f in projets/*/*.md; do grep -q '^closed:' "$f" && continue
  printf '%s  %s\n' "$(grep -om1 '^- [0-9-]\{10\}' "$f" | sed 's/^- //')" \
    "$(basename "$f" .md)"; done | sort

# O6 — tout ce qui cite une entité, du plus récent au plus ancien
# (le paquet, lui, groupe ces mêmes lignes par dossier ; ajouter le nom de
#  fichier et trier dessus suffit à retrouver le groupement à la main)
rg --no-filename -N 'bernard-michel' . | grep '^- 2' | sort -r
rg -l 'bernard-michel' .              # juste les fichiers qui la citent

# O8 — les entrées d'une période (2026, 2026-10, un jour entier)
rg --no-filename -N '^- 2026-10' . | sort -r

# O4 — ouvrir une entité, sans connaître son slug
rg --files -g '*.md' . | fzf | xargs -r $EDITOR

# O5 — recherche plein texte
rg --smart-case 'DETR' .

# O13 — les pièces que leur note ne cite nulle part (I8), le plus coûteux à la main
for a in */*/[0-9]*_*; do case "$a" in *.md) continue;; esac
  grep -qr "$(basename "$a")" . || echo "pièce orpheline : $a"; done

Les opérations d'écriture n'ont pas de ligne de shell, et c'est voulu : leur équivalent est ouvrir le fichier et taper la ligne. Tracer (O1), créer une entité (O3), clore (O9) ou attacher une pièce (O10) ne font rien qu'une main ne puisse faire — une ligne - AAAA-MM-JJ — … en tête du journal, un dossier et son <slug>.md, une ligne closed: AAAA-MM-JJ sous le titre, un cp vers AAAAMMJJ_nom.ext et un ![](…) là où l'on veut le voir. Le paquet évite la faute de frappe et la date à retaper ; il ne détient aucun geste.

Découvrir

docs/walkthrough.md — une visite guidée de vingt minutes dans le bac à sable : un signalement trié, lié, devenu projet, photographié, clos, et un corpus cassé exprès pour voir ce que :TraceCheck en dit.

Développement

make test        # toute la suite, en headless (plenary)
make test FILE=tests/spec/log_spec.lua
make check       # les invariants du corpus de référence
make demo        # bac à sable : nvim isolé sur un corpus jetable
make demo-reset  # repartir d'un corpus neuf

make demo ne touche ni la configuration de l'utilisateur ni ses vraies notes. Le script isole XDG_* dans demo/, laisse lazy y installer ses propres dépendances, copie le corpus de référence dans demo/notes/ et monte le paquet depuis le dépôt (dir =). On peut y casser ce qu'on veut.

lua/trace/
  config.lua   racine, kinds, flags
  util.lua     chemins, lecture, écriture (buffer ou disque)
  slug.lua     slugs, préfixes de date, noms de pièces
  entity.lua   parsing d'une note, création, clôture
  index.lua    scan du corpus, cache mémoire (N2 : rien sur disque)
  log.lua      entrées de journal, pièces
  search.lua   rétroliens, périodes
  check.lua    invariants
  ui.lua       vim.ui.*, quickfix, ouverture externe
  follow.lua   ce que « suivre » veut dire selon la cible
  pick.lua     sélection et listes : snacks, telescope ou natif
  api.lua      les opérations O1 → O13
plugin/trace.lua  commandes :Trace*
tests/corpus/     corpus de référence, conforme aux invariants

Chaque module est une fonction pure de ses entrées là où c'est possible : entity.parse, log.insert_entry, check.check_file et slug.* se testent sans disque ni interface.

État

Fait : O1 → O13, sur vim.ui.* et la liste de quickfix — donc sans aucune dépendance obligatoire. snacks ou telescope, s'ils sont présents, habillent les choix ; les listes de lignes restent en quickfix dans tous les cas. blink.cmp porte O2 et se replie sur completefunc.

Reste : O14 (trace-log, le script shell de capture hors nvim) et l'intégration nvim-lint pour que O13 devienne permanent plutôt que demandé.