fix(ai): espaces dans les noms de fichiers et chemins des liens (BUG-042)
CI / lint (push) Successful in 1m17s
CI / security (push) Successful in 51s
CI / test (push) Successful in 2m30s
CI / build (push) Successful in 51s
CI / e2e (push) Successful in 10m29s

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.
This commit is contained in:
2026-09-14 13:20:09 -04:00
parent 314e37c433
commit 12a53d647e
5 changed files with 185 additions and 14 deletions
+3
View File
@@ -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'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 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`. 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é ### 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 | `_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) ### 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 | 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-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-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-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) |
--- ---
+19 -1
View File
@@ -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 (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)
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
- `tests/frontend/ai.test.mjs` : classification (`name`/`file`/`dir`), routage - `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` + - `tests/test_bookslm.py` : `build_general_system_prompt` avec `app_context` +
`recent_files` ; prompt inchangé sans contexte ; chat sur dossier vide ne `recent_files` ; prompt inchangé sans contexte ; chat sur dossier vide ne
renvoie plus 404. renvoie plus 404.
+102 -12
View File
@@ -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; 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; 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. // 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).
@@ -321,10 +325,77 @@ class BooksLM {
_normalizeLinkPath(rawPath) { _normalizeLinkPath(rawPath) {
return String(rawPath || '') return String(rawPath || '')
.trim()
.replace(/^\.\//, '') .replace(/^\.\//, '')
.replace(/\\/g, '/') .replace(/\\/g, '/')
.replace(/^\/+/, '') .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>${code}</code>`); return put(`<code>${code}</code>`);
}); });
// Explicit markdown links: [label](target). // Explicit markdown links: [label](target). The target may contain spaces
s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, label, target) => // (`Mon dossier/Ma note.md`), an optional <angle-bracket> wrapper and an
put(this._renderMdLink(label, target))); // 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) => s = s.replace(PATH_WITH_DIR_RE, (_m, pre, path) =>
`${pre}${put(this._pathLink(path, this._decodeEntities(path)))}`); `${pre}${put(this._pathLink(path, this._decodeEntities(path)))}`);
@@ -2213,13 +2297,17 @@ class BooksLM {
if (isExternal) { if (isExternal) {
return `<a href="${target}" target="_blank" rel="noopener noreferrer">${label}</a>`; return `<a href="${target}" target="_blank" rel="noopener noreferrer">${label}</a>`;
} }
const raw = this._decodeEntities(target).replace(/^\.\//, ''); // Links emitted by models are sometimes percent-encoded (spaces as %20).
return this._pathLink(label, raw); 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. */ /** Build a clickable link for a vault-relative file or directory path. */
_pathLink(display, rawPath) { _pathLink(display, rawPath) {
const clean = String(rawPath || '').replace(/^\.\//, ''); const clean = this._normalizeLinkPath(this._decodeEntities(rawPath));
const kind = this._classifyPath(clean); const kind = this._classifyPath(clean);
const attr = this._escapeAttr(clean); const attr = this._escapeAttr(clean);
return `<a href="#" class="bookslm-path-link" data-path="${attr}" data-kind="${kind}" title="${attr}">${display}</a>`; return `<a href="#" class="bookslm-path-link" data-path="${attr}" data-kind="${kind}" title="${attr}">${display}</a>`;
@@ -2234,10 +2322,12 @@ class BooksLM {
} }
_looksLikePath(value) { _looksLikePath(value) {
if (!value) return false; if (value == null) return false;
const p = value.trim(); const p = String(value).trim();
if (!p || /\s/.test(p)) return false; if (!p || /[\n\r]/.test(p)) return false;
if (!/^[\w./\\-]+$/.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); return p.includes('/') || FILE_EXT_RE.test(p);
} }
+59
View File
@@ -415,6 +415,65 @@ async function main() {
assert.equal(b._classifyPath("projets/2026"), "dir", "dir path → reveal"); 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</a>"), "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", () => { 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)");