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
+5
View File
@@ -37,6 +37,11 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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.
Les **caractères accentués** sont reconnus (classes de caractères Unicode
`\p{L}\p{N}\p{M}`, comparaison normalisée NFC : une mention décomposée
`e`+accent correspond à une entrée d'index précomposée) et un chemin
**préfixé par le nom du vault** (`TestVault/Recettes/Pizza Maison.md`) est
résolu puis ouvert dans ce vault, le préfixe étant retiré.
### 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-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-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) |
| *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 (noms avec espaces et/ou accents, chemin préfixé par le nom du vault) | `_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 + accents pris en charge (classes Unicode `\p{L}\p{N}\p{M}`, comparaison normalisée NFC, markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index) ; `_splitVaultPrefix` retire un préfixe `Vault/…` et ouvre dans ce vault (`_fetchPathsForVault`) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+11) |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -190,6 +190,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 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-042 (accents + préfixe vault) | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `docs/features/ai-app-context.md` | **Caractères accentués** : classes de caractères Unicode (`\p{L}\p{N}\p{M}`) pour `PATH_WITH_DIR_RE` et `PATH_NAME_RE` (`_looksLikePath`), comparaison normalisée `NFC` via `_normKey()` → une mention décomposée (`e` + accent combinant, style macOS) correspond à une entrée d'index précomposée, et les liens sans espace mais accentués sont enfin produits. **Préfixe de vault** : `_splitVaultPrefix()` retire un premier segment égal à un vault connu (`TestVault/Recettes/Pizza Maison.md`) ; `_fetchPathsForVault()` interroge l'index de ce vault sans écraser le cache du vault actif ; `_openFileLink`/`_revealPath` reçoivent le vault cible ; repli « retirer le premier segment » si le préfixe est inconnu. Vérifié : tests frontend 62/62 (IA) + 9 suites JSDOM, validate-imports 36 modules, pytest 963 passed / 6 skipped, et vérification contre l'instance live (données réelles accentuées/espacées : `Recettes/Préparation.md`, `Recettes/Pâtes carbonara.md`, `Recettes/Pizza Maison.md`) — 8/8 contrôles. | 🟢 corrigé (en attente vérif utilisateur) |
---
+20 -7
View File
@@ -64,21 +64,34 @@ 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
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)
### 4. Espaces, accents et préfixe de vault dans les chemins (BUG-042)
Les noms de fichiers/dossiers pouvant contenir des espaces, la détection est
adaptée :
Les noms de fichiers/dossiers pouvant contenir des espaces et des accents, la
détection et la résolution sont adaptées :
- **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).
- **Code inline** : `` `Ma note.md` `` / `` `Recettes/Préparation.md` `` sont
reconnus (`_looksLikePath` utilise `PATH_NAME_RE`, une classe Unicode
`\p{L}\p{N}\p{M}` — les accents sont acceptés, le jeu de caractères reste
strict pour rejeter 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.
lien englobant « Ouvre » et la prose ordinaire n'est jamais liée à tort. Les
chemins sans espace mais accentués sont liés par le motif `PATH_WITH_DIR_RE`,
lui aussi Unicode-aware.
- **Normalisation Unicode** : `_normKey()` compare en NFC + minuscules, donc une
mention décomposée (`e` + accent combinant, comme sur macOS) correspond à une
entrée d'index précomposée, et inversement.
- **Préfixe de vault** : `_splitVaultPrefix()` retire un premier segment égal au
nom d'un vault connu (`TestVault/Recettes/Pizza Maison.md` →
`Recettes/Pizza Maison.md`). La résolution interroge l'index **de ce vault**
(`_fetchPathsForVault`, fetch ponctuel sans écraser le cache du vault actif),
puis `_openFileLink`/`_revealPath` reçoivent le vault cible. Si le préfixe ne
correspond à aucun vault connu, `_resolveExistingPath` retente en retirant le
premier segment.
## Tests
+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);
}
+58 -1
View File
@@ -463,7 +463,7 @@ async function main() {
const revealed = [];
b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); };
b._revealPath = (p) => { revealed.push(p); return Promise.resolve(); };
b._ensurePaths = async () => ([
b._fetchPathsForVault = async () => ([
{ path: "Mon dossier/Ma note.md", name: "Ma note.md", type: "file" },
{ path: "Mon dossier/Sous dossier", name: "Sous dossier", type: "directory" },
]);
@@ -474,6 +474,63 @@ async function main() {
assert.deepEqual(revealed, ["Mon dossier/Sous dossier"], "spaced directory resolves too");
});
// ── 12c. Accents and vault-prefixed paths ──
await test("_looksLikePath accepts accented names", () => {
const b = new BooksLM();
assert.equal(b._looksLikePath("Café.md"), true, "accented filename is a path");
assert.equal(b._looksLikePath("Recettes/Préparation.md"), true, "accented path is a path");
assert.ok(b._renderMarkdown("Voir `Recettes/Préparation.md`.").includes('data-path="Recettes/Préparation.md"'),
"accented inline code becomes a link");
});
await test("bare accented paths become links without the index", () => {
const b = new BooksLM();
const html = b._renderMarkdown("Ouvre Recettes/Préparation.md puis relis.");
assert.ok(html.includes('data-path="Recettes/Préparation.md"'), "accented bare path linkified");
});
await test("decomposed accents match precomposed index entries", async () => {
const b = new BooksLM();
b._activeVault = () => "TestVault";
b._fetchPathsForVault = async () => ([{ path: "Recettes/Préparation.md", name: "Préparation.md", type: "file" }]);
const opened = [];
b._openFileLink = (p) => { opened.push(p); return Promise.resolve(); };
// NFD input (e + combining acute) against an NFC index entry.
const nfd = "Recettes/Pre\u0301paration.md";
await b._activatePath(nfd, "file");
assert.deepEqual(opened, ["Recettes/Préparation.md"], "NFD mention resolves to the NFC path");
});
await test("_splitVaultPrefix strips a leading vault name", () => {
const b = new BooksLM();
b._vault = "TestVault";
assert.deepEqual(
b._splitVaultPrefix("TestVault/Recettes/Pizza Maison.md"),
{ vault: "TestVault", path: "Recettes/Pizza Maison.md" },
"vault prefix split off",
);
assert.deepEqual(
b._splitVaultPrefix("Recettes/Pizza Maison.md"),
{ vault: null, path: "Recettes/Pizza Maison.md" },
"no prefix when the first segment is not a vault",
);
});
await test("a vault-prefixed path opens the file in that vault", async () => {
const b = new BooksLM();
b._vault = "TestVault";
b._activeVault = () => "TestVault";
b._fetchPathsForVault = async (v) => (v === "TestVault"
? [{ path: "Recettes/Pizza Maison.md", name: "Pizza Maison.md", type: "file" }]
: []);
const opened = [];
b._openFileLink = (p, v) => { opened.push([v, p]); return Promise.resolve(); };
await b._activatePath("TestVault/Recettes/Pizza Maison.md", "file");
assert.deepEqual(opened, [["TestVault", "Recettes/Pizza Maison.md"]], "opened in the named vault");
});
await test("_renderMarkdown keeps external links external", () => {
const b = new BooksLM();
const html = b._renderMarkdown("[site](https://example.com/page)");