From 12a53d647eaaccd1ad3995ad99f5270576b8d892 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 14 Sep 2026 13:20:04 -0400 Subject: [PATCH] fix(ai): espaces dans les noms de fichiers et chemins des liens (BUG-042) Les noms/paths de l'assistant peuvent contenir des espaces ; la detection et la resolution les prennent desormais en charge de bout en bout : - liens markdown : cible avec espaces, encadree par <...> ou avec un "titre", et URL percent-encodees (%20) decodees ; - code inline : `Ma note.md` / `Mon dossier/Ma note.md` reconnus (_looksLikePath accepte les espaces, jeu de caracteres strict pour rejeter extraits de code et commandes shell) ; - mentions brutes : liees uniquement si presentes dans l'index du vault (_linkifySpacePaths + _confirmPathInCache), avec acceptation du plus long suffixe aligne sur un mot -> le mot de prose precedent n'est pas avale ; - _normalizeLinkPath : trim ; _renderMdLink : decodage %20. Tests : frontend IA 57/57 (+5), 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped. --- CHANGELOG.md | 3 + docs/ISSUES_TODOLIST.md | 3 +- docs/features/ai-app-context.md | 20 +++++- frontend/js/bookslm.js | 114 ++++++++++++++++++++++++++++---- tests/frontend/ai.test.mjs | 59 +++++++++++++++++ 5 files changed, 185 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1c56b..a39091b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). l'arborescence, un chemin de fichier l'ouvre. Le chemin est **résolu contre l'index du vault** (exact → suffixe → basename unique) avant d'agir ; les chemins non résolus copient le nom au lieu d'afficher `File not found`. + 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. ### Sécurité diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 01aa0cf..aac1b1c 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 | `_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 | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+3) | +| *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) | ### TODOs techniques (améliorations / nouvelles tâches) @@ -188,6 +188,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-13 | Revue sécurité statique (BUG-021 → BUG-040, roadmap #84 → #87) | Enregistrement | `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | Revue statique 2026-09-13 consignée : XSS markdown (`escape=False`) + page de partage publique, brute-force MFA, traversal par préfixe `paths.py`, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit, indexation symlinks, recherche O(N), CSP. BUG-021→BUG-040 ouverts au registre ; roadmap #84→#87 (consolidation/sécurité, refonte architecturale, performance, CI/CD). | 🔴 ouvert (à traiter) | | 2026-09-13 | BUG-021 → BUG-034 (#84 phase 1) | Correction | `backend/services/sanitizer.py`, `backend/services/paths.py`, `backend/services/net.py`, `backend/services/regex_safety.py`, `backend/main.py`, `backend/search.py`, `backend/webhooks.py`, `backend/indexer.py`, `backend/ratelimit.py`, `backend/auth/{router,user_store,password,jwt_handler,middleware}.py`, `frontend/js/{auth,admin,sync}.js`, `tests/test_security_hardening.py`, `tests/test_auth_api.py` | Sanitizer XSS serveur (markdown + page de partage), rate-limit/lockout MFA, isolation vaults par segments, caps regex (ReDoS), SSRF webhooks + secrets externalisés, rotation/révocation des jetons, politique de mot de passe + invalidation des sessions, verrous `users.json`, IP réelle dans les audits, rate-limit par compte, symlinks d'index ignorés, recherche simple via inverted index, token en mémoire + cookie HttpOnly + CSP durcie. Vérifié : pytest 961 passed / 6 skipped, ruff 0, mypy 0, tests frontend 36 modules + 9 JSDOM suites verts. | 🟢 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-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) | --- diff --git a/docs/features/ai-app-context.md b/docs/features/ai-app-context.md index f2d3f7e..fd619b5 100644 --- a/docs/features/ai-app-context.md +++ b/docs/features/ai-app-context.md @@ -64,10 +64,28 @@ 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) + +Les noms de fichiers/dossiers pouvant contenir des espaces, la détection est +adaptée : + +- **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). +- **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. + ## Tests - `tests/frontend/ai.test.mjs` : classification (`name`/`file`/`dir`), routage - `_activatePath` (ouvre/révèle/copie), clic d'un lien « nom » → presse-papiers. + `_activatePath` (ouvre/révèle/copie), clic d'un lien « nom » → presse-papiers, + et prise en charge des espaces (code inline, liens markdown `%20`, mentions + brutes liées uniquement si présentes dans l'index, résolution au clic). - `tests/test_bookslm.py` : `build_general_system_prompt` avec `app_context` + `recent_files` ; prompt inchangé sans contexte ; chat sur dossier vide ne renvoie plus 404. diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 607342e..688279a 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -23,6 +23,10 @@ export const MODE = Object.freeze({ 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; +// 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; // 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). @@ -321,10 +325,77 @@ class BooksLM { _normalizeLinkPath(rawPath) { return String(rawPath || '') + .trim() .replace(/^\.\//, '') .replace(/\\/g, '/') .replace(/^\/+/, '') - .replace(/\/+$/, ''); + .replace(/\/+$/, '') + .trim(); + } + + /** + * 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. + */ + _confirmPathInCache(rawPath) { + const paths = this._pathCache; + if (!Array.isArray(paths) || !paths.length) return null; + const clean = this._normalizeLinkPath(rawPath).toLowerCase(); + if (!clean) return null; + return paths.find((p) => String(p.path || '').toLowerCase() === clean) + || paths.find((p) => String(p.path || '').toLowerCase().endsWith('/' + clean)) + || null; + } + + /** + * Linkify bare path mentions that contain spaces (`Mon dossier/Ma note.md`). + * + * A candidate is accepted only when the longest word-aligned suffix exists in + * the vault path index. That keeps ordinary prose unturned-into-links and + * prevents leading sentence words ("Ouvre Mon dossier/…") from being swallowed + * into the link. Returns the segment unchanged when no path index is available. + */ + _linkifySpacePaths(segment, put) { + const paths = this._pathCache; + if (!Array.isArray(paths) || !paths.length) return segment; + // Fresh regex instance: PATH_WITH_SPACES_RE is module-level and its + // lastIndex must not leak between (possibly nested) render calls. + const re = new RegExp(PATH_WITH_SPACES_RE.source, 'g'); + let out = ''; + let last = 0; + let m; + while ((m = re.exec(segment)) !== null) { + const pre = m[1] || ''; + const cand = m[2]; + const candStart = m.index + pre.length; + if (candStart < last) { re.lastIndex = candStart + 1; continue; } + const accepted = this._acceptSpaceCandidate(this._decodeEntities(cand)); + if (!accepted) { + // Rejected: retry from the next character so an over-long candidate + // (leading prose word) does not mask the real path inside it. + re.lastIndex = candStart + 1; + continue; + } + out += segment.slice(last, candStart + accepted.offset); + out += put(this._pathLink(accepted.display, accepted.path)); + last = candStart + cand.length; + re.lastIndex = last; + } + return out ? out + segment.slice(last) : segment; + } + + /** Longest word-aligned suffix of `cand` that exists in the path index. */ + _acceptSpaceCandidate(cand) { + const wordRe = /\S+/g; + let w; + 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 }; + } + return null; } /** @@ -2122,11 +2193,24 @@ class BooksLM { return put(`${code}`); }); - // Explicit markdown links: [label](target). - s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, target) => - put(this._renderMdLink(label, target))); + // Explicit markdown links: [label](target). The target may contain spaces + // (`Mon dossier/Ma note.md`), an optional wrapper and an + // optional "title" — all stripped before resolving. + s = s.replace(/\[([^\]\n]+)\]\(([^)\n]*)\)/g, (_m, label, target) => { + let linkTarget = String(target).trim(); + if (linkTarget.startsWith('<') && linkTarget.endsWith('>')) linkTarget = linkTarget.slice(1, -1).trim(); + linkTarget = linkTarget.replace(/\s+["'][^"']*["']$/, '').trim(); + if (!linkTarget) return _m; + return put(this._renderMdLink(label, linkTarget)); + }); - // Bare `dir/file.ext` mentions. + // Bare mentions whose file/directory names contain spaces. Run BEFORE the + // space-free pass so the full path is captured, and link only when the + // candidate exists in the vault index — ordinary prose is never turned into + // a dead link. + s = this._linkifySpacePaths(s, put); + + // Bare `dir/file.ext` mentions (no spaces). s = s.replace(PATH_WITH_DIR_RE, (_m, pre, path) => `${pre}${put(this._pathLink(path, this._decodeEntities(path)))}`); @@ -2213,13 +2297,17 @@ class BooksLM { if (isExternal) { return `${label}`; } - const raw = this._decodeEntities(target).replace(/^\.\//, ''); - return this._pathLink(label, raw); + // Links emitted by models are sometimes percent-encoded (spaces as %20). + let decoded = this._decodeEntities(target); + if (/%[0-9a-f]{2}/i.test(decoded)) { + try { decoded = decodeURIComponent(decoded); } catch { /* keep as-is */ } + } + return this._pathLink(label, decoded); } /** Build a clickable link for a vault-relative file or directory path. */ _pathLink(display, rawPath) { - const clean = String(rawPath || '').replace(/^\.\//, ''); + const clean = this._normalizeLinkPath(this._decodeEntities(rawPath)); const kind = this._classifyPath(clean); const attr = this._escapeAttr(clean); return `${display}`; @@ -2234,10 +2322,12 @@ class BooksLM { } _looksLikePath(value) { - if (!value) return false; - const p = value.trim(); - if (!p || /\s/.test(p)) return false; - if (!/^[\w./\\-]+$/.test(p)) return false; + 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; return p.includes('/') || FILE_EXT_RE.test(p); } diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index 5bc5a6e..dd2a3e7 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -415,6 +415,65 @@ async function main() { assert.equal(b._classifyPath("projets/2026"), "dir", "dir path → reveal"); }); + // ── 12b. Spaces in file names / paths ── + await test("_looksLikePath accepts spaced names but rejects code snippets", () => { + const b = new BooksLM(); + assert.equal(b._looksLikePath("Ma note.md"), true, "spaced filename is a path"); + assert.equal(b._looksLikePath("Mon dossier/Ma note.md"), true, "spaced path is a path"); + assert.equal(b._looksLikePath("const x = 1;"), false, "code snippet rejected"); + assert.equal(b._looksLikePath("npm run test"), false, "shell command rejected"); + }); + + await test("_renderMarkdown linkifies inline code containing spaces", () => { + const b = new BooksLM(); + const html = b._renderMarkdown("Voir `Mon dossier/Ma note.md` pour le détail."); + assert.ok(html.includes('data-path="Mon dossier/Ma note.md"'), "spaced path kept intact"); + assert.ok(html.includes('data-kind="file"'), "spaced file classified as file"); + }); + + await test("_renderMarkdown linkifies markdown links with spaces and %20", () => { + const b = new BooksLM(); + const spaced = b._renderMarkdown("[Ma note](Mon dossier/Ma note.md)"); + assert.ok(spaced.includes('data-path="Mon dossier/Ma note.md"'), "raw spaced target kept"); + + const encoded = b._renderMarkdown("[Ma note](Mon%20dossier/Ma%20note.md)"); + assert.ok(encoded.includes('data-path="Mon dossier/Ma note.md"'), "%20 decoded to space"); + }); + + await test("bare spaced paths link only when present in the vault index", () => { + const b = new BooksLM(); + + // No index → no link (prose must not become a dead link). + b._pathCache = []; + const noCache = b._renderMarkdown("Ouvre Mon dossier/Ma note.md pour la suite"); + assert.ok(!noCache.includes("bookslm-path-link"), "no link without index"); + + // Indexed path → link, and the leading prose word is not swallowed. + b._pathCache = [{ path: "Mon dossier/Ma note.md", name: "Ma note.md", type: "file" }]; + const html = b._renderMarkdown("Ouvre Mon dossier/Ma note.md pour la suite"); + assert.ok(html.includes('data-path="Mon dossier/Ma note.md"'), "indexed spaced path linkified"); + assert.ok(html.includes(">Mon dossier/Ma note.md"), "link text excludes leading word"); + assert.ok(html.includes("Ouvre Mon") === false || !html.includes(">Ouvre Mon"), "prose word kept outside the link"); + }); + + await test("clicking a spaced file link resolves against the index", async () => { + const b = new BooksLM(); + b._activeVault = () => "TestVault"; + const opened = []; + const revealed = []; + b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); }; + b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); }; + b._ensurePaths = async () => ([ + { path: "Mon dossier/Ma note.md", name: "Ma note.md", type: "file" }, + { path: "Mon dossier/Sous dossier", name: "Sous dossier", type: "directory" }, + ]); + + await b._activatePath("Ma note.md", "file"); + await b._activatePath("Sous dossier", "dir"); + assert.deepEqual(opened, ["Mon dossier/Ma note.md"], "spaced filename resolves to its real path"); + assert.deepEqual(revealed, ["Mon dossier/Sous dossier"], "spaced directory resolves too"); + }); + await test("_renderMarkdown keeps external links external", () => { const b = new BooksLM(); const html = b._renderMarkdown("[site](https://example.com/page)");