fix(ai): accents dans les chemins et resolution des chemins prefixes par le vault (BUG-042)
CI / lint (push) Successful in 1m16s
CI / security (push) Successful in 50s
CI / test (push) Successful in 2m24s
CI / build (push) Successful in 47s
CI / e2e (push) Successful in 10m35s

- accents : classes de caracteres Unicode (\p{L}\p{N}\p{M}) pour les motifs
  de liens et _looksLikePath (PATH_NAME_RE) -> les chemins/fichiers accentues
  produisent enfin un lien cliquable ; comparaison de chemins normalisee NFC
  (_normKey), donc une mention decomposee (e + accent combinant, style macOS)
  correspond a une entree d'index precomposee, et inversement.
- prefixe de vault : _splitVaultPrefix() retire un premier segment egal au nom
  d'un vault connu (TestVault/Recettes/Pizza Maison.md) ; _fetchPathsForVault()
  interroge l'index de CE vault sans ecraser le cache du vault actif ;
  _openFileLink/_revealPath recoivent le vault cible ; repli 'retirer le
  premier segment' si le prefixe ne correspond a aucun vault connu.

Tests : frontend IA 62/62 (+5), 9 suites JSDOM, validate-imports 36 modules,
pytest 963 passed / 6 skipped. Verifie aussi contre l'instance live sur donnees
reelles accentuees/espaces (Recettes/Preparation.md, Recettes/Pat... ) : 8/8.
This commit is contained in:
2026-09-14 13:37:23 -04:00
parent 12a53d647e
commit c3e6841293
5 changed files with 224 additions and 43 deletions
+139 -34
View File
@@ -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);
}