fix(ai): accents dans les chemins et resolution des chemins prefixes par le vault (BUG-042)
- 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:
+139
-34
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user