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
+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;
// 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>${code}</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 <angle-bracket> 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 `<a href="${target}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
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 `<a href="#" class="bookslm-path-link" data-path="${attr}" data-kind="${kind}" title="${attr}">${display}</a>`;
@@ -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);
}