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.
This commit is contained in:
+102
-12
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user