fix(ai): accents dans les chemins et resolution des chemins prefixes par le vault (BUG-042)
CI / lint (push) Successful in 1m16s
CI / security (push) Successful in 50s
CI / test (push) Successful in 2m24s
CI / build (push) Successful in 47s
CI / e2e (push) Successful in 10m35s

- accents : classes de caracteres Unicode (\p{L}\p{N}\p{M}) pour les motifs
  de liens et _looksLikePath (PATH_NAME_RE) -> les chemins/fichiers accentues
  produisent enfin un lien cliquable ; comparaison de chemins normalisee NFC
  (_normKey), donc une mention decomposee (e + accent combinant, style macOS)
  correspond a une entree d'index precomposee, et inversement.
- prefixe de vault : _splitVaultPrefix() retire un premier segment egal au nom
  d'un vault connu (TestVault/Recettes/Pizza Maison.md) ; _fetchPathsForVault()
  interroge l'index de CE vault sans ecraser le cache du vault actif ;
  _openFileLink/_revealPath recoivent le vault cible ; repli 'retirer le
  premier segment' si le prefixe ne correspond a aucun vault connu.

Tests : frontend IA 62/62 (+5), 9 suites JSDOM, validate-imports 36 modules,
pytest 963 passed / 6 skipped. Verifie aussi contre l'instance live sur donnees
reelles accentuees/espaces (Recettes/Preparation.md, Recettes/Pat... ) : 8/8.
This commit is contained in:
2026-09-14 13:37:23 -04:00
parent 12a53d647e
commit c3e6841293
5 changed files with 224 additions and 43 deletions
+5
View File
@@ -37,6 +37,11 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Les **noms/chemins contenant des espaces** sont pris en charge (`Mon dossier/Ma Les **noms/chemins contenant des espaces** sont pris en charge (`Mon dossier/Ma
note.md`) : liens markdown (y compris cibles `<…>` et `%20`), code inline, et note.md`) : liens markdown (y compris cibles `<…>` et `%20`), code inline, et
mentions brutes liées uniquement si le chemin existe dans l'index du vault. mentions brutes liées uniquement si le chemin existe dans l'index du vault.
Les **caractères accentués** sont reconnus (classes de caractères Unicode
`\p{L}\p{N}\p{M}`, comparaison normalisée NFC : une mention décomposée
`e`+accent correspond à une entrée d'index précomposée) et un chemin
**préfixé par le nom du vault** (`TestVault/Recettes/Pizza Maison.md`) est
résolu puis ouvert dans ce vault, le préfixe étant retiré.
### Sécurité ### Sécurité
+2 -1
View File
@@ -151,7 +151,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🔴 ouvert | P3 | 🔐 sécurité | IA | `backend/auth/router.py:120` | Tenter un login sur un compte verrouillé puis un nom inconnu | Répondre 401 uniforme avec un timing équivalent | Le statut HTTP distingue l'existence d'un compte | | *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🔴 ouvert | P3 | 🔐 sécurité | IA | `backend/auth/router.py:120` | Tenter un login sur un compte verrouillé puis un nom inconnu | Répondre 401 uniforme avec un timing équivalent | Le statut HTTP distingue l'existence d'un compte |
| *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/indexer.py:465` | Démarrer sur un vault contenant de nombreux PDF | Analyser les PDF en tâche de fond / à la demande (lazy) | Ralentit fortement le démarrage et le rebuild d'index | | *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/indexer.py:465` | Démarrer sur un vault contenant de nombreux PDF | Analyser les PDF en tâche de fond / à la demande (lazy) | Ralentit fortement le démarrage et le rebuild d'index |
| *BUG-041* | [🟡 IMPORTANT] Assistant IA : échec sur un répertoire vide (« Aucun fichier markdown trouvé dans ce dossier ») au lieu de répondre | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `backend/bookslm_routes.py`, `backend/bookslm.py`, `frontend/js/bookslm.js` | Ouvrir l'assistant sur un dossier vide puis envoyer une question | `_resolve_system_prompt` dégrade vers le prompt Général + bloc « Dossier vide » (plus de 404) ; contexte applicatif `app_context` enrichi (documents ouverts, répertoire, recherche, fichiers récents) | Le 404 bloquait toute la requête. Feature #88, fiche `docs/features/ai-app-context.md`. Tests : `tests/test_bookslm.py` (+3), `tests/frontend/ai.test.mjs` | | *BUG-041* | [🟡 IMPORTANT] Assistant IA : échec sur un répertoire vide (« Aucun fichier markdown trouvé dans ce dossier ») au lieu de répondre | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `backend/bookslm_routes.py`, `backend/bookslm.py`, `frontend/js/bookslm.js` | Ouvrir l'assistant sur un dossier vide puis envoyer une question | `_resolve_system_prompt` dégrade vers le prompt Général + bloc « Dossier vide » (plus de 404) ; contexte applicatif `app_context` enrichi (documents ouverts, répertoire, recherche, fichiers récents) | Le 404 bloquait toute la requête. Feature #88, fiche `docs/features/ai-app-context.md`. Tests : `tests/test_bookslm.py` (+3), `tests/frontend/ai.test.mjs` |
| *BUG-042* | [🟡 IMPORTANT] Assistant IA : liens de fichiers non fiables (« File not found: ») — pas de règle déterministe nom / dossier / chemin | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Cliquer les liens de fichiers/dossiers dans une réponse de l'assistant (y compris noms avec espaces) | `_classifyPath` distingue `name` (copie presse-papiers) / `dir` (révélation arborescence) / `file` (ouverture) ; `_activatePath()` résout le chemin contre l'index du vault (exact → suffixe → basename unique) avant d'agir ; espaces pris en charge (markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index + plus long suffixe aligné sur un mot) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+6) | | *BUG-042* | [🟡 IMPORTANT] Assistant IA : liens de fichiers non fiables (« File not found: ») — pas de règle déterministe nom / dossier / chemin | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Cliquer les liens de fichiers/dossiers dans une réponse de l'assistant (noms avec espaces et/ou accents, chemin préfixé par le nom du vault) | `_classifyPath` distingue `name` (copie presse-papiers) / `dir` (révélation arborescence) / `file` (ouverture) ; `_activatePath()` résout le chemin contre l'index du vault (exact → suffixe → basename unique) avant d'agir ; espaces + accents pris en charge (classes Unicode `\p{L}\p{N}\p{M}`, comparaison normalisée NFC, markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index) ; `_splitVaultPrefix` retire un préfixe `Vault/…` et ouvre dans ce vault (`_fetchPathsForVault`) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+11) |
### TODOs techniques (améliorations / nouvelles tâches) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -190,6 +190,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-13 | BUG-017 → BUG-020, #83 | Correction + feature | `frontend/js/mobile-editor.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `tests/frontend/mobile-editor.test.mjs`, `tests/e2e/mobile-editor.spec.js` | BUG-017 : cartes de tableau de bord compressibles (`min-width:0`) ; BUG-018 : en-tête éditeur mobile compact + barre IA tactile ; BUG-019 : ancrage du ruban via `visualViewport` ; BUG-020 : suppression de la double scrollbar. #83 : ruban d'édition horizontal défilable style Obsidian (commandes étendues + personnalisation ⚙ persistée). Vérifié : tests frontend 35/35 (mobile) + 50/50 (IA) + 9 JSDOM suites. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-13 | BUG-017 → BUG-020, #83 | Correction + feature | `frontend/js/mobile-editor.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `tests/frontend/mobile-editor.test.mjs`, `tests/e2e/mobile-editor.spec.js` | BUG-017 : cartes de tableau de bord compressibles (`min-width:0`) ; BUG-018 : en-tête éditeur mobile compact + barre IA tactile ; BUG-019 : ancrage du ruban via `visualViewport` ; BUG-020 : suppression de la double scrollbar. #83 : ruban d'édition horizontal défilable style Obsidian (commandes étendues + personnalisation ⚙ persistée). Vérifié : tests frontend 35/35 (mobile) + 50/50 (IA) + 9 JSDOM suites. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-14 | BUG-042 (complément) | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | Prise en charge des **espaces** dans les noms de fichiers et chemins des liens de l'assistant : cibles markdown avec espaces / `<…>` / `%20` décodés, code inline reconnu (`_looksLikePath` élargi, rejette toujours les extraits de code), mentions brutes liées uniquement si présentes dans l'index du vault (`_linkifySpacePaths` + `_confirmPathInCache`, plus long suffixe aligné sur un mot pour ne pas avaler le mot de prose précédent). Vérifié : tests frontend 57/57 (IA) + 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-14 | BUG-042 (complément) | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | Prise en charge des **espaces** dans les noms de fichiers et chemins des liens de l'assistant : cibles markdown avec espaces / `<…>` / `%20` décodés, code inline reconnu (`_looksLikePath` élargi, rejette toujours les extraits de code), mentions brutes liées uniquement si présentes dans l'index du vault (`_linkifySpacePaths` + `_confirmPathInCache`, plus long suffixe aligné sur un mot pour ne pas avaler le mot de prose précédent). Vérifié : tests frontend 57/57 (IA) + 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-14 | BUG-041, BUG-042, #88 | Correction + feature | `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py`, `frontend/locales/{fr,en}.json`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | BUG-041 : contexte de dossier vide → dégradation gracieuse vers le prompt Général + bloc « Dossier vide » (fin du 404). BUG-042 : liens de fichiers déterministes (nom → presse-papiers, dossier → arborescence, chemin → viewer) + résolution du chemin contre l'index du vault avant ouverture. #88 : `app_context` (documents ouverts, répertoire/vault courants, recherche + résultats affichés) et fichiers récemment modifiés injectés dans le prompt Général. Vérifié : pytest 963 passed / 6 skipped, ruff 0, mypy 0, tests frontend 52/52 + validate-imports 36 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-14 | BUG-041, BUG-042, #88 | Correction + feature | `frontend/js/bookslm.js`, `backend/bookslm.py`, `backend/bookslm_routes.py`, `frontend/locales/{fr,en}.json`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | BUG-041 : contexte de dossier vide → dégradation gracieuse vers le prompt Général + bloc « Dossier vide » (fin du 404). BUG-042 : liens de fichiers déterministes (nom → presse-papiers, dossier → arborescence, chemin → viewer) + résolution du chemin contre l'index du vault avant ouverture. #88 : `app_context` (documents ouverts, répertoire/vault courants, recherche + résultats affichés) et fichiers récemment modifiés injectés dans le prompt Général. Vérifié : pytest 963 passed / 6 skipped, ruff 0, mypy 0, tests frontend 52/52 + validate-imports 36 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-14 | BUG-042 (accents + préfixe vault) | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | **Caractères accentués** : classes de caractères Unicode (`\p{L}\p{N}\p{M}`) pour `PATH_WITH_DIR_RE` et `PATH_NAME_RE` (`_looksLikePath`), comparaison normalisée `NFC` via `_normKey()` → une mention décomposée (`e` + accent combinant, style macOS) correspond à une entrée d'index précomposée, et les liens sans espace mais accentués sont enfin produits. **Préfixe de vault** : `_splitVaultPrefix()` retire un premier segment égal à un vault connu (`TestVault/Recettes/Pizza Maison.md`) ; `_fetchPathsForVault()` interroge l'index de ce vault sans écraser le cache du vault actif ; `_openFileLink`/`_revealPath` reçoivent le vault cible ; repli « retirer le premier segment » si le préfixe est inconnu. Vérifié : tests frontend 62/62 (IA) + 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped, et vérification contre l'instance live (données réelles accentuées/espacées : `Recettes/Préparation.md`, `Recettes/Pâtes carbonara.md`, `Recettes/Pizza Maison.md`) — 8/8 contrôles. | 🟢 corrigé (en attente vérif utilisateur) |
--- ---
+20 -7
View File
@@ -64,21 +64,34 @@ Avant d'agir, `_activatePath()` **résout** le chemin contre l'index du vault
(segment de tête omis par le modèle), puis basename unique. Les chemins non (segment de tête omis par le modèle), puis basename unique. Les chemins non
résolus copient le nom au lieu d'ouvrir un lien mort — plus de `File not found`. résolus copient le nom au lieu d'ouvrir un lien mort — plus de `File not found`.
### 4. Espaces dans les noms de fichiers et les chemins (BUG-042) ### 4. Espaces, accents et préfixe de vault dans les chemins (BUG-042)
Les noms de fichiers/dossiers pouvant contenir des espaces, la détection est Les noms de fichiers/dossiers pouvant contenir des espaces et des accents, la
adaptée : détection et la résolution sont adaptées :
- **Liens markdown** : la cible peut contenir des espaces, être encadrée par - **Liens markdown** : la cible peut contenir des espaces, être encadrée par
`<…>` ou porter un `"titre"` ; les URL percent-encodées (`%20`) sont décodées. `<…>` ou porter un `"titre"` ; les URL percent-encodées (`%20`) sont décodées.
- **Code inline** : `` `Ma note.md` `` / `` `Mon dossier/Ma note.md` `` sont - **Code inline** : `` `Ma note.md` `` / `` `Recettes/Préparation.md` `` sont
reconnus (`_looksLikePath` accepte les espaces en gardant un jeu de caractères reconnus (`_looksLikePath` utilise `PATH_NAME_RE`, une classe Unicode
strict, ce qui rejette les extraits de code et commandes shell). `\p{L}\p{N}\p{M}` — les accents sont acceptés, le jeu de caractères reste
strict pour rejeter extraits de code et commandes shell).
- **Mentions brutes** : une mention avec espaces n'est liée **que si elle existe - **Mentions brutes** : une mention avec espaces n'est liée **que si elle existe
réellement dans l'index du vault** (`_linkifySpacePaths` + réellement dans l'index du vault** (`_linkifySpacePaths` +
`_confirmPathInCache`), et l'acceptation se fait sur le **plus long suffixe `_confirmPathInCache`), et l'acceptation se fait sur le **plus long suffixe
aligné sur un mot** — ainsi « Ouvre Mon dossier/Ma note.md » ne crée pas de aligné sur un mot** — ainsi « Ouvre Mon dossier/Ma note.md » ne crée pas de
lien englobant « Ouvre » et la prose ordinaire n'est jamais liée à tort. lien englobant « Ouvre » et la prose ordinaire n'est jamais liée à tort. Les
chemins sans espace mais accentués sont liés par le motif `PATH_WITH_DIR_RE`,
lui aussi Unicode-aware.
- **Normalisation Unicode** : `_normKey()` compare en NFC + minuscules, donc une
mention décomposée (`e` + accent combinant, comme sur macOS) correspond à une
entrée d'index précomposée, et inversement.
- **Préfixe de vault** : `_splitVaultPrefix()` retire un premier segment égal au
nom d'un vault connu (`TestVault/Recettes/Pizza Maison.md` →
`Recettes/Pizza Maison.md`). La résolution interroge l'index **de ce vault**
(`_fetchPathsForVault`, fetch ponctuel sans écraser le cache du vault actif),
puis `_openFileLink`/`_revealPath` reçoivent le vault cible. Si le préfixe ne
correspond à aucun vault connu, `_resolveExistingPath` retente en retirant le
premier segment.
## Tests ## Tests
+139 -34
View File
@@ -22,11 +22,20 @@ export const MODE = Object.freeze({
// in the sidebar tree instead of being opened in the viewer). // in the sidebar tree instead of being opened in the viewer).
const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i; const FILE_EXT_RE = /\.(?:md|markdown|mdx|txt|pdf|excalidraw|canvas|png|jpe?g|gif|svg|webp|bmp|avif|csv|tsv|json|ya?ml|toml|ini|cfg|conf|html?|css|scss|sass|less|jsx?|tsx?|vue|svelte|py|rb|go|rs|java|kt|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|sql|xml|log|tex|org|rtf|docx?|xlsx?|pptx?)$/i;
// A path-like token: optional `dir/` segments followed by a final segment. // A path-like token: optional `dir/` segments followed by a final segment.
const PATH_WITH_DIR_RE = /(^|[\s(>"'])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]{1,8})/g; // Unicode-aware (`\p{L}\p{N}\p{M}`) so accented names — either precomposed
// (é) or decomposed (e + combining acute) — are recognised.
const PATH_CHARS = '[\\p{L}\\p{N}\\p{M}._-]';
const PATH_WITH_DIR_RE = new RegExp(
`(^|[\\s(>"'])((?:${PATH_CHARS}+\\/)+${PATH_CHARS}+\\.[A-Za-z0-9]{1,8})`,
'gu',
);
// Same, but each segment may contain spaces (`Mon dossier/Ma note.md`). Matched // Same, but each segment may contain spaces (`Mon dossier/Ma note.md`). Matched
// only when the candidate resolves against the vault path index so ordinary // only when the candidate resolves against the vault path index so ordinary
// prose is never turned into a link. // prose is never turned into a link.
const PATH_WITH_SPACES_RE = /(^|[\s(>"'\[])((?:[^\s/]+(?:[ \t]+[^\s/]+)*\/)+[^\s/]+(?:[ \t]+[^\s/]+)*\.[A-Za-z0-9]{1,8})/g; const PATH_WITH_SPACES_RE = /(^|[\s(>"'\[])((?:[^\s/]+(?:[ \t]+[^\s/]+)*\/)+[^\s/]+(?:[ \t]+[^\s/]+)*\.[A-Za-z0-9]{1,8})/g;
// Characters allowed in a single file/directory name (accented letters
// included). Used to tell a path from prose or a code snippet.
const PATH_NAME_RE = /^[\p{L}\p{N}\p{M}._/\\ ()'&+-]+$/u;
// Image files that can be attached to a vision-capable model. // Image files that can be attached to a vision-capable model.
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|bmp|avif|ico)$/i; const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|bmp|avif|ico)$/i;
// Resizable panel bounds (px). // Resizable panel bounds (px).
@@ -325,6 +334,7 @@ class BooksLM {
_normalizeLinkPath(rawPath) { _normalizeLinkPath(rawPath) {
return String(rawPath || '') return String(rawPath || '')
.normalize('NFC')
.trim() .trim()
.replace(/^\.\//, '') .replace(/^\.\//, '')
.replace(/\\/g, '/') .replace(/\\/g, '/')
@@ -334,18 +344,69 @@ class BooksLM {
} }
/** /**
* Synchronous lookup of a (possibly space-containing) path in the cached vault * Comparison key for a vault path: NFC-normalised and lowercased, so a model
* path index. Used at render time to decide whether a bare mention is a real * writing a decomposed accent (`e` + combining acute, as macOS stores it)
* vault path before turning it into a link. * still matches a precomposed index entry, and vice versa.
*/
_normKey(value) {
return String(value || '').normalize('NFC').toLowerCase();
}
/** Vault names the client knows about (sidebar list, current context). */
_knownVaultNames() {
const names = new Set();
const add = (entry) => {
const name = typeof entry === 'string' ? entry : (entry && entry.name);
if (name && name !== 'all') names.add(String(name));
};
try {
if (Array.isArray(state.allVaults)) state.allVaults.forEach(add);
} catch { /* not ready */ }
add(this._vault);
try { add(state.currentVault); } catch { /* not ready */ }
return names;
}
/**
* Split an optional leading vault name off a mentioned path, e.g.
* `TestVault/Recettes/Pizza Maison.md` → `{vault: 'TestVault', path: 'Recettes/Pizza Maison.md'}`.
* The prefix is only recognised when it matches a known vault name and is
* followed by at least one more segment.
*/
_splitVaultPrefix(rawPath) {
const clean = this._normalizeLinkPath(rawPath);
if (!clean) return { vault: null, path: clean };
const idx = clean.indexOf('/');
if (idx <= 0) return { vault: null, path: clean };
const head = clean.slice(0, idx);
const rest = clean.slice(idx + 1);
if (!rest) return { vault: null, path: clean };
for (const name of this._knownVaultNames()) {
if (this._normKey(name) === this._normKey(head)) return { vault: name, path: rest };
}
return { vault: null, path: clean };
}
/**
* Synchronous lookup of a (possibly space-containing or vault-prefixed) path
* in the cached vault path index. Used at render time to decide whether a bare
* mention is a real vault path before turning it into a link.
*/ */
_confirmPathInCache(rawPath) { _confirmPathInCache(rawPath) {
const paths = this._pathCache; const paths = this._pathCache;
if (!Array.isArray(paths) || !paths.length) return null; if (!Array.isArray(paths) || !paths.length) return null;
const clean = this._normalizeLinkPath(rawPath).toLowerCase(); const { vault, path } = this._splitVaultPrefix(rawPath);
const active = this._activeVault();
if (vault && active && this._normKey(vault) !== this._normKey(active)) {
// Another vault cannot be validated synchronously: accept the relative
// path and let the click-time resolution fetch that vault's index.
return { path, vault };
}
const clean = this._normKey(path);
if (!clean) return null; if (!clean) return null;
return paths.find((p) => String(p.path || '').toLowerCase() === clean) const hit = paths.find((p) => this._normKey(p.path) === clean)
|| paths.find((p) => String(p.path || '').toLowerCase().endsWith('/' + clean)) || paths.find((p) => this._normKey(p.path).endsWith('/' + clean));
|| null; return hit ? { ...hit, vault: vault || active || null } : null;
} }
/** /**
@@ -378,7 +439,9 @@ class BooksLM {
continue; continue;
} }
out += segment.slice(last, candStart + accepted.offset); out += segment.slice(last, candStart + accepted.offset);
out += put(this._pathLink(accepted.display, accepted.path)); // The accepted text (vault prefix and accents included) is both the link
// label and the path to resolve at click time.
out += put(this._pathLink(accepted.display, accepted.display));
last = candStart + cand.length; last = candStart + cand.length;
re.lastIndex = last; re.lastIndex = last;
} }
@@ -392,35 +455,58 @@ class BooksLM {
while ((w = wordRe.exec(cand)) !== null) { while ((w = wordRe.exec(cand)) !== null) {
const sub = cand.slice(w.index); const sub = cand.slice(w.index);
if (!sub.includes('/')) continue; if (!sub.includes('/')) continue;
const hit = this._confirmPathInCache(sub); if (this._confirmPathInCache(sub)) return { display: sub, offset: w.index };
if (hit) return { path: hit.path, display: sub, offset: w.index };
} }
return null; return null;
} }
/** /**
* Resolve a mentioned path against the vault's indexed paths so links keep * Resolve a mentioned path against a vault's indexed paths so links keep
* working even when the model writes a slightly different path (leading * working even when the model writes a slightly different path — leading
* slash, backslashes, or a bare basename). Returns `{path, name, type}` or * slash or backslashes, a bare basename, a leading vault name
* null when nothing matches. * (`TestVault/Recettes/Pizza.md`) or a differently-normalised accent.
*
* @param {string} rawPath Mentioned path.
* @param {string|null} [vaultOverride] Vault to look into when the mention
* carries no `Vault/…` prefix.
* @returns {Promise<{path: string, name?: string, type?: string, vault: string|null}|null>}
*/ */
async _resolveExistingPath(rawPath) { async _resolveExistingPath(rawPath, vaultOverride) {
const clean = this._normalizeLinkPath(rawPath); const split = this._splitVaultPrefix(rawPath);
const vault = split.vault || vaultOverride || this._activeVault();
const clean = this._normKey(split.path);
if (!clean) return null; if (!clean) return null;
const paths = await this._ensurePaths(); const paths = await this._fetchPathsForVault(vault);
if (!Array.isArray(paths) || !paths.length) return null; if (!Array.isArray(paths) || !paths.length) return null;
const lower = clean.toLowerCase();
const exact = paths.find((p) => String(p.path || '').toLowerCase() === lower); const found = this._matchIndexedPath(paths, clean);
if (found) return { ...found, vault };
// The leading segment may be a vault name the client does not know about
// (or an extra directory the index does not carry): retry one segment down.
const slash = split.path.indexOf('/');
if (slash > 0) {
const reduced = this._normKey(split.path.slice(slash + 1));
if (reduced) {
const fallback = this._matchIndexedPath(paths, reduced);
if (fallback) return { ...fallback, vault };
}
}
return null;
}
/** Match a normalised path against the index (exact → suffix → basename). */
_matchIndexedPath(paths, clean) {
const exact = paths.find((p) => this._normKey(p.path) === clean);
if (exact) return exact; if (exact) return exact;
// Suffix match: the model may omit a leading directory segment. // Suffix match: the model may omit a leading directory segment.
const suffix = paths.find((p) => String(p.path || '').toLowerCase().endsWith('/' + lower)); const suffix = paths.find((p) => this._normKey(p.path).endsWith('/' + clean));
if (suffix) return suffix; if (suffix) return suffix;
// Unique basename match (prefer files over directories). // Unique basename match (prefer files over directories).
const base = lower.split('/').pop(); const base = clean.split('/').pop();
const matches = paths.filter((p) => String(p.name || '').toLowerCase() === base); const matches = paths.filter((p) => this._normKey(p.name || String(p.path || '').split('/').pop()) === base);
if (matches.length === 1) return matches[0]; if (matches.length === 1) return matches[0];
if (matches.length > 1) { if (matches.length > 1) {
const file = matches.find((p) => p.type === 'file'); const file = matches.find((p) => p.type === 'file');
@@ -429,6 +515,22 @@ class BooksLM {
return null; return null;
} }
/**
* Path index for a specific vault. Uses the cached list when it is the one
* already loaded (the `@` menu / render-time checks) and otherwise performs a
* one-off fetch without clobbering that cache.
*/
async _fetchPathsForVault(vault) {
if (!vault) return [];
if (Array.isArray(this._pathCache) && this._pathCacheVault === vault) return this._pathCache;
try {
const data = await api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`);
return Array.isArray(data.results) ? data.results : [];
} catch {
return [];
}
}
/** Copy a mentioned filename to the clipboard (bare-name links). */ /** Copy a mentioned filename to the clipboard (bare-name links). */
_copyLinkName(path) { _copyLinkName(path) {
const name = this._normalizeLinkPath(path).split('/').pop(); const name = this._normalizeLinkPath(path).split('/').pop();
@@ -463,19 +565,21 @@ class BooksLM {
* user still gets something useful instead of "File not found". * user still gets something useful instead of "File not found".
*/ */
async _activatePath(rawPath, kind) { async _activatePath(rawPath, kind) {
const vault = this._activeVault(); const split = this._splitVaultPrefix(rawPath);
const vault = split.vault || this._activeVault();
if (!vault) { if (!vault) {
showToast(t('bookslm.no_vault'), 'error'); showToast(t('bookslm.no_vault'), 'error');
return; return;
} }
const resolved = await this._resolveExistingPath(rawPath); const resolved = await this._resolveExistingPath(rawPath);
if (resolved) { if (resolved) {
if (resolved.type === 'directory') return this._revealPath(resolved.path); const targetVault = resolved.vault || vault;
return this._openFileLink(resolved.path); if (resolved.type === 'directory') return this._revealPath(resolved.path, targetVault);
return this._openFileLink(resolved.path, targetVault);
} }
if (kind === 'dir') { if (kind === 'dir') {
// Unknown directory: still try to reveal it in the tree (best effort). // Unknown directory: still try to reveal it in the tree (best effort).
return this._revealPath(this._normalizeLinkPath(rawPath)); return this._revealPath(split.path, vault);
} }
// Unknown file: copy the name rather than opening a dead link. // Unknown file: copy the name rather than opening a dead link.
this._copyLinkName(rawPath); this._copyLinkName(rawPath);
@@ -543,8 +647,8 @@ class BooksLM {
} }
/** Open a file mentioned in an assistant answer in the main viewer. */ /** Open a file mentioned in an assistant answer in the main viewer. */
async _openFileLink(path) { async _openFileLink(path, vaultOverride) {
const vault = this._activeVault(); const vault = vaultOverride || this._activeVault();
if (!vault || !path) { if (!vault || !path) {
showToast(t('bookslm.no_vault'), 'error'); showToast(t('bookslm.no_vault'), 'error');
return; return;
@@ -568,8 +672,8 @@ class BooksLM {
} }
/** Reveal a directory (or file) path in the sidebar tree. */ /** Reveal a directory (or file) path in the sidebar tree. */
async _revealPath(path) { async _revealPath(path, vaultOverride) {
const vault = this._activeVault(); const vault = vaultOverride || this._activeVault();
if (!vault || !path) { if (!vault || !path) {
showToast(t('bookslm.no_vault'), 'error'); showToast(t('bookslm.no_vault'), 'error');
return; return;
@@ -2325,9 +2429,10 @@ class BooksLM {
if (value == null) return false; if (value == null) return false;
const p = String(value).trim(); const p = String(value).trim();
if (!p || /[\n\r]/.test(p)) return false; if (!p || /[\n\r]/.test(p)) return false;
// Allow spaces inside file/directory names (`Ma note.md`), but keep the // Allow spaces and accented characters inside file/directory names
// character set tight so prose and code snippets are not mistaken for paths. // (`Ma note.md`, `Recettes/Préparation.md`), but keep the character set
if (!/^[\w./\\ ()'&-]+$/.test(p)) return false; // tight so prose and code snippets are not mistaken for paths.
if (!PATH_NAME_RE.test(p)) return false;
return p.includes('/') || FILE_EXT_RE.test(p); return p.includes('/') || FILE_EXT_RE.test(p);
} }
+58 -1
View File
@@ -463,7 +463,7 @@ async function main() {
const revealed = []; const revealed = [];
b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); }; b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); };
b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); }; b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); };
b._ensurePaths = async () => ([ b._fetchPathsForVault = async () => ([
{ path: "Mon dossier/Ma note.md", name: "Ma note.md", type: "file" }, { path: "Mon dossier/Ma note.md", name: "Ma note.md", type: "file" },
{ path: "Mon dossier/Sous dossier", name: "Sous dossier", type: "directory" }, { path: "Mon dossier/Sous dossier", name: "Sous dossier", type: "directory" },
]); ]);
@@ -474,6 +474,63 @@ async function main() {
assert.deepEqual(revealed, ["Mon dossier/Sous dossier"], "spaced directory resolves too"); assert.deepEqual(revealed, ["Mon dossier/Sous dossier"], "spaced directory resolves too");
}); });
// ── 12c. Accents and vault-prefixed paths ──
await test("_looksLikePath accepts accented names", () => {
const b = new BooksLM();
assert.equal(b._looksLikePath("Café.md"), true, "accented filename is a path");
assert.equal(b._looksLikePath("Recettes/Préparation.md"), true, "accented path is a path");
assert.ok(b._renderMarkdown("Voir `Recettes/Préparation.md`.").includes('data-path="Recettes/Préparation.md"'),
"accented inline code becomes a link");
});
await test("bare accented paths become links without the index", () => {
const b = new BooksLM();
const html = b._renderMarkdown("Ouvre Recettes/Préparation.md puis relis.");
assert.ok(html.includes('data-path="Recettes/Préparation.md"'), "accented bare path linkified");
});
await test("decomposed accents match precomposed index entries", async () => {
const b = new BooksLM();
b._activeVault = () => "TestVault";
b._fetchPathsForVault = async () => ([{ path: "Recettes/Préparation.md", name: "Préparation.md", type: "file" }]);
const opened = [];
b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); };
// NFD input (e + combining acute) against an NFC index entry.
const nfd = "Recettes/Pre\u0301paration.md";
await b._activatePath(nfd, "file");
assert.deepEqual(opened, ["Recettes/Préparation.md"], "NFD mention resolves to the NFC path");
});
await test("_splitVaultPrefix strips a leading vault name", () => {
const b = new BooksLM();
b._vault = "TestVault";
assert.deepEqual(
b._splitVaultPrefix("TestVault/Recettes/Pizza Maison.md"),
{ vault: "TestVault", path: "Recettes/Pizza Maison.md" },
"vault prefix split off",
);
assert.deepEqual(
b._splitVaultPrefix("Recettes/Pizza Maison.md"),
{ vault: null, path: "Recettes/Pizza Maison.md" },
"no prefix when the first segment is not a vault",
);
});
await test("a vault-prefixed path opens the file in that vault", async () => {
const b = new BooksLM();
b._vault = "TestVault";
b._activeVault = () => "TestVault";
b._fetchPathsForVault = async (v) => (v === "TestVault"
? [{ path: "Recettes/Pizza Maison.md", name: "Pizza Maison.md", type: "file" }]
: []);
const opened = [];
b._openFileLink = (p, v) => { opened.push([v, p]); return Promise.resolve(); };
await b._activatePath("TestVault/Recettes/Pizza Maison.md", "file");
assert.deepEqual(opened, [["TestVault", "Recettes/Pizza Maison.md"]], "opened in the named vault");
});
await test("_renderMarkdown keeps external links external", () => { await test("_renderMarkdown keeps external links external", () => {
const b = new BooksLM(); const b = new BooksLM();
const html = b._renderMarkdown("[site](https://example.com/page)"); const html = b._renderMarkdown("[site](https://example.com/page)");