diff --git a/CHANGELOG.md b/CHANGELOG.md index a39091b..7581832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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. + 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é diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index aac1b1c..70c9c2f 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -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-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-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) @@ -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-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-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) | --- diff --git a/docs/features/ai-app-context.md b/docs/features/ai-app-context.md index fd619b5..70a7c12 100644 --- a/docs/features/ai-app-context.md +++ b/docs/features/ai-app-context.md @@ -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 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 -adaptée : +Les noms de fichiers/dossiers pouvant contenir des espaces et des accents, la +détection et la résolution sont adaptées : - **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. -- **Code inline** : `` `Ma note.md` `` / `` `Mon dossier/Ma note.md` `` sont - reconnus (`_looksLikePath` accepte les espaces en gardant un jeu de caractères - strict, ce qui rejette les extraits de code et commandes shell). +- **Code inline** : `` `Ma note.md` `` / `` `Recettes/Préparation.md` `` sont + reconnus (`_looksLikePath` utilise `PATH_NAME_RE`, une classe Unicode + `\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 réellement dans l'index du vault** (`_linkifySpacePaths` + `_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 - 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 diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 688279a..7ec7f6a 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -22,11 +22,20 @@ export const MODE = Object.freeze({ // 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; // 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 // only when the candidate resolves against the vault path index so ordinary // 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; +// 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. const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|svg|webp|bmp|avif|ico)$/i; // Resizable panel bounds (px). @@ -325,6 +334,7 @@ class BooksLM { _normalizeLinkPath(rawPath) { return String(rawPath || '') + .normalize('NFC') .trim() .replace(/^\.\//, '') .replace(/\\/g, '/') @@ -334,18 +344,69 @@ class BooksLM { } /** - * Synchronous lookup of a (possibly space-containing) 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. + * Comparison key for a vault path: NFC-normalised and lowercased, so a model + * writing a decomposed accent (`e` + combining acute, as macOS stores it) + * 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) { const paths = this._pathCache; 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; - return paths.find((p) => String(p.path || '').toLowerCase() === clean) - || paths.find((p) => String(p.path || '').toLowerCase().endsWith('/' + clean)) - || null; + const hit = paths.find((p) => this._normKey(p.path) === clean) + || paths.find((p) => this._normKey(p.path).endsWith('/' + clean)); + return hit ? { ...hit, vault: vault || active || null } : null; } /** @@ -378,7 +439,9 @@ class BooksLM { continue; } 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; re.lastIndex = last; } @@ -392,35 +455,58 @@ class BooksLM { while ((w = wordRe.exec(cand)) !== null) { const sub = cand.slice(w.index); if (!sub.includes('/')) continue; - const hit = this._confirmPathInCache(sub); - if (hit) return { path: hit.path, display: sub, offset: w.index }; + if (this._confirmPathInCache(sub)) return { display: sub, offset: w.index }; } return null; } /** - * Resolve a mentioned path against the vault's indexed paths so links keep - * working even when the model writes a slightly different path (leading - * slash, backslashes, or a bare basename). Returns `{path, name, type}` or - * null when nothing matches. + * Resolve a mentioned path against a vault's indexed paths so links keep + * working even when the model writes a slightly different path — leading + * slash or backslashes, a bare basename, a leading vault name + * (`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) { - const clean = this._normalizeLinkPath(rawPath); + async _resolveExistingPath(rawPath, vaultOverride) { + const split = this._splitVaultPrefix(rawPath); + const vault = split.vault || vaultOverride || this._activeVault(); + const clean = this._normKey(split.path); if (!clean) return null; - const paths = await this._ensurePaths(); + const paths = await this._fetchPathsForVault(vault); 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; // 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; // Unique basename match (prefer files over directories). - const base = lower.split('/').pop(); - const matches = paths.filter((p) => String(p.name || '').toLowerCase() === base); + const base = clean.split('/').pop(); + 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) { const file = matches.find((p) => p.type === 'file'); @@ -429,6 +515,22 @@ class BooksLM { 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). */ _copyLinkName(path) { const name = this._normalizeLinkPath(path).split('/').pop(); @@ -463,19 +565,21 @@ class BooksLM { * user still gets something useful instead of "File not found". */ async _activatePath(rawPath, kind) { - const vault = this._activeVault(); + const split = this._splitVaultPrefix(rawPath); + const vault = split.vault || this._activeVault(); if (!vault) { showToast(t('bookslm.no_vault'), 'error'); return; } const resolved = await this._resolveExistingPath(rawPath); if (resolved) { - if (resolved.type === 'directory') return this._revealPath(resolved.path); - return this._openFileLink(resolved.path); + const targetVault = resolved.vault || vault; + if (resolved.type === 'directory') return this._revealPath(resolved.path, targetVault); + return this._openFileLink(resolved.path, targetVault); } if (kind === 'dir') { // 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. this._copyLinkName(rawPath); @@ -543,8 +647,8 @@ class BooksLM { } /** Open a file mentioned in an assistant answer in the main viewer. */ - async _openFileLink(path) { - const vault = this._activeVault(); + async _openFileLink(path, vaultOverride) { + const vault = vaultOverride || this._activeVault(); if (!vault || !path) { showToast(t('bookslm.no_vault'), 'error'); return; @@ -568,8 +672,8 @@ class BooksLM { } /** Reveal a directory (or file) path in the sidebar tree. */ - async _revealPath(path) { - const vault = this._activeVault(); + async _revealPath(path, vaultOverride) { + const vault = vaultOverride || this._activeVault(); if (!vault || !path) { showToast(t('bookslm.no_vault'), 'error'); return; @@ -2325,9 +2429,10 @@ class BooksLM { if (value == null) return false; const p = String(value).trim(); if (!p || /[\n\r]/.test(p)) return false; - // Allow spaces inside file/directory names (`Ma note.md`), but keep the - // character set tight so prose and code snippets are not mistaken for paths. - if (!/^[\w./\\ ()'&-]+$/.test(p)) return false; + // Allow spaces and accented characters inside file/directory names + // (`Ma note.md`, `Recettes/Préparation.md`), but keep the character set + // 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); } diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index dd2a3e7..3a08ad4 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -463,7 +463,7 @@ async function main() { const revealed = []; b._openFileLink = (p) => { opened.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/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"); }); + // ── 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", () => { const b = new BooksLM(); const html = b._renderMarkdown("[site](https://example.com/page)");