Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e6a951dd | ||
|
|
3d618eb660 | ||
|
|
2add24a9c1 | ||
|
|
a50227849d | ||
|
|
5ffc9d851a | ||
|
|
1e0e419210 | ||
|
|
2813d546a7 | ||
|
|
19e94dfa51 | ||
|
|
49f97fc26e | ||
|
|
562290d922 | ||
|
|
66a5505965 | ||
|
|
c5c225a68e |
+11
-6
@@ -38,6 +38,7 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: |
|
||||
node tests/frontend/unit.test.mjs
|
||||
node tests/frontend/navfacets.test.mjs
|
||||
node tests/frontend/image-viewer.test.mjs
|
||||
node tests/frontend/pdf-viewer.test.mjs
|
||||
node tests/frontend/forge-completion.test.mjs
|
||||
@@ -58,6 +59,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -78,6 +80,7 @@ jobs:
|
||||
node ai.test.mjs
|
||||
node ai-sidebar.test.mjs
|
||||
node sidebar-filters.test.mjs
|
||||
node search-facets.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
node mobile-editor.test.mjs
|
||||
@@ -174,12 +177,14 @@ jobs:
|
||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||
# pypdf>=6.16.1 (BUG-093). Ces planchers doivent rester *au-dessus*
|
||||
# des versions préinstallées dans la toolcache de l'image du runner :
|
||||
# en dessous, pip répond « already satisfied » et n'aligne jamais
|
||||
# (c'est exactement ce qui a fait échouer ce job). Le garde-fou
|
||||
# tests/test_ci_workflow.py::TestDependencySecurityFloors verrouille
|
||||
# ces planchers.
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# Ces planchers doivent rester *au-dessus* des versions préinstallées
|
||||
# dans la toolcache de l'image du runner : en dessous, pip répond
|
||||
# « already satisfied » et n'aligne jamais (c'est exactement ce qui a
|
||||
# fait échouer ce job). Le garde-fou tests/test_ci_workflow.py::
|
||||
# TestDependencySecurityFloors verrouille ces planchers.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
|
||||
|
||||
+242
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.44.1**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.49.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,247 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.1] — 2026-10-02
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Mobile (BUG-103)** : le bas du sidebar de navigation n'est plus masqué par la barre
|
||||
d'outils du bas — `z-index` du sidebar mobile `200` → `950` (au-dessus de
|
||||
`.mobile-toolbar`, `900`).
|
||||
|
||||
---
|
||||
|
||||
## [2.49.0] — 2026-10-02
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#158 — onglet Accueil.** Le clic sur le titre ouvre désormais la page d'accueil **dans un
|
||||
onglet** (icône Maison) systématiquement épinglé **en tête de barre**, réactivable d'un clic
|
||||
comme un onglet normal (le mode split a son propre onglet Accueil dans le panneau actif).
|
||||
- **#158 — menus contextuels dans la page de navigation.** Clic droit sur les répertoires et
|
||||
les fichiers de la vue : même menu que celui de la sidebar (`ContextMenuManager`), et la
|
||||
ligne d'un vault dans l'arbre ouvre à nouveau la page de navigation (racine du vault).
|
||||
- **#158 — icône des répertoires alignée sur la sidebar.** La couleur du dossier dans la
|
||||
section Répertoires est celle de l'arbre (`var(--accent)`).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-102 — menu contextuel refermé par son propre affichage.** L'auto-scroll déclenché par
|
||||
la reflow des icônes (lucide) à l'ouverture du menu arrivait dans le listener `scroll`
|
||||
(capture) et refermait le menu ~10 ms après son apparition : un décalage de 250 ms est
|
||||
ignoré, les vrais défilements ferment toujours le menu.
|
||||
- **BUG-101 — raccourcis d'onglets déclarés deux fois.** Le bloc « Keyboard shortcuts for
|
||||
tabs » existed en double dans `ui.js` (fin de fichier) : un seul `Ctrl+W` fermait **deux**
|
||||
onglets (le second refermait celui qui venait d'être ré-activé) et `Ctrl+Tab` sautait un
|
||||
onglet. Bloc dupliqué supprimé.
|
||||
- **#158 — clic titre ne repliait plus l'arborescence.** L'effacement du filtre de la sidebar
|
||||
n'est déclenché que s'il contenait quelque chose (sinon `restoreSidebarTree()` repliait tout
|
||||
l'arbre à chaque retour à l'accueil).
|
||||
|
||||
---
|
||||
|
||||
## [2.48.0] — 2026-10-02
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#158 — onglet de navigation.** Chaque clic sur un répertoire de l'arbre (mode focus vault
|
||||
**et** mode All) ouvre un **onglet de navigation** dédié qui coexiste avec les onglets de
|
||||
fichiers : la vue chemin + Récents + fichiers n'est plus écrasée par un document ouvert, et
|
||||
change de répertoire en se mettant à jour dans son onglet. La ligne d'un vault reste un
|
||||
simple expandeur ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette Vaults)
|
||||
ou le sélecteur de vault.
|
||||
- **#158 — contenu de la page de navigation.** La vue liste désormais les **sous-répertoires
|
||||
cliquables** et embarque le mécanisme de la page de recherche : panneau de facettes
|
||||
**Vaults · Tags · Extensions** (repli partagé, filtre local avec pastille active), tri
|
||||
**Pertinence / Date** et bouton **Sauver** (enregistre le répertoire comme recherche
|
||||
sauvegardée). `GET /api/vault/{vault}/files` renvoie les `tags` indexés de chaque fichier
|
||||
(champ `tags` ajouté à `VaultFileEntry`) pour calculer les facettes.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-100 — retour Home incomplet.** Le clic sur le titre perdait la section
|
||||
**Raccourcis & Astuces** (le template de reconstruction de `showWelcome()` ne la contenait
|
||||
pas) et laissait la recherche globale ainsi que le filtre de la sidebar actifs : le bloc
|
||||
`#quick-help` est désormais capturé puis réinjecté, et `goHome()` efface la recherche
|
||||
(chips, barre de résultats) avant d'afficher l'accueil.
|
||||
|
||||
---
|
||||
|
||||
## [2.47.1] — 2026-10-02
|
||||
|
||||
---
|
||||
|
||||
## [2.47.0] — 2026-10-01
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#157 — panneau de facettes repliable.** Le bloc **Vaults · Tags · Extensions** des résultats
|
||||
de recherche se masque/restaure d'un clic sur un bouton discret (chevron), état mémorisé
|
||||
dans `localStorage` et conservé d'une recherche à l'autre.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#157 — facette « Extensions » absente de l'API.** `SearchFacets` (`backend/schemas.py`)
|
||||
ne déclarait que `tags` et `vaults` : la clé `extensions` ajoutée par `backend/search.py`
|
||||
était supprimée par le `response_model` avant d'atteindre le navigateur. Champ ajouté,
|
||||
plus les tests d'advanced-search pointaient la route inexistante `/api/advanced-search`
|
||||
et sautaient en silence — ils ciblent désormais `/api/search/advanced`, avec une assertion
|
||||
stricte sur `facets.extensions`.
|
||||
- **#157 — bouton de repli des facettes maladroit sur mobile.** En dessous de 768 px, le
|
||||
panneau passe en colonne et le bouton s'étirait en pleine largeur d'écran, pivotait
|
||||
entièrement à la vertical au clic. Le bouton reste compact (`align-self: flex-start`) et
|
||||
**seule l'icône chevron pivote** (transition 120 ms), sur mobile comme sur desktop.
|
||||
|
||||
---
|
||||
|
||||
## [2.46.0] — 2026-10-01
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#157 — facette « Extensions » dans les facettes des résultats de recherche.** Le panneau de
|
||||
facettes de la page de résultats (Vaults · Tags) gagne un troisième groupe qui liste les
|
||||
extensions de fichiers présents dans les résultats (`.md`, `.xlsx`, `.pdf`, …) avec leurs
|
||||
compteurs ; cliquer une extension ajoute l'opérateur `ext:` à la requête (en remplaçant un
|
||||
`ext:` déjà actif). Le backend calcule `facets.extensions` dans `backend/search.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.45.2] — 2026-10-01
|
||||
|
||||
---
|
||||
|
||||
## [2.45.1] — 2026-10-01
|
||||
|
||||
---
|
||||
|
||||
## [2.45.0] — 2026-09-30
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#156-A5 — presse-papiers de plage dans l'éditeur Excel.** Copier, couper et coller un
|
||||
**bloc** de cellules (TSV), au clavier (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`) comme par le menu
|
||||
contextuel ; le collage remplit la plage à partir de la cellule active et la laisse
|
||||
sélectionnée. La grille tient son propre presse-papiers (le presse-papiers système est
|
||||
synchronisé quand le navigateur l'autorise), le collage est toujours du **texte** — jamais de
|
||||
HTML — et il est **annulable**. Un bloc plus large que la grille affichée est tronqué et signalé.
|
||||
- **#156-A6 — raccourcis clavier complets.** `Ctrl+S` (enregistrer), `Ctrl+A` (sélectionner la
|
||||
feuille rendue), `Suppr` (vider la sélection), `F2` (édition), `Ctrl+Home`/`Ctrl+End` (coins de
|
||||
la feuille), `Home`/`End` (bords de la ligne), `PgUp`/`PgDn` (un écran), `Ctrl+flèches` (bout de
|
||||
la plage de données) et `Maj+Entrée` (saut de ligne dans la cellule). Une cellule reste en mode
|
||||
« navigation » jusqu'à la saisie (ou `F2`) : `Suppr` et `Home`/`End` ne volent donc jamais une
|
||||
touche du curseur de texte.
|
||||
- **#156-A7 — zone Nom éditable et aide à la saisie.** La zone Nom devient un champ
|
||||
(« Atteindre ») : saisir `B12`, `A1:B3` ou `Feuille!A1` puis `Entrée` sélectionne la cellule ou
|
||||
la plage (avec changement d'onglet si la feuille est nommée) ; une référence inconnue est
|
||||
refusée avec un message et l'adresse précédente restaurée. La barre de formule propose les noms
|
||||
de fonctions courants (liste localisée FR/EN).
|
||||
- **#156-A8 — mise en forme en écriture.** Un bouton **Mise en forme** ouvre un menu qui
|
||||
agit sur la **sélection courante** (une cellule ou une plage) : gras, italique, souligné,
|
||||
effacement de la mise en forme, alignement gauche/centré/droite, couleur de police et de fond,
|
||||
format de nombre (général, nombre, pourcentage, devise, date, texte), fusion/défusion, volets
|
||||
figés/libérés et largeur de colonne / hauteur de ligne. L'écriture passe par une nouvelle route
|
||||
`PUT /api/file/{vault}/xlsx/style`, sur le même modèle que `xlsx/save` et `xlsx/structure` :
|
||||
verrou par fichier, écriture atomique (`.tmp` + `os.replace`), backup automatique, garde de perte
|
||||
(une opération qui effacerait des éléments non préservables est refusée sans `force`) et
|
||||
**concurrence optimiste** (`If-Match`, cf. A12). Les mises en forme ne sont pas proposées sur un
|
||||
`.csv` (le format n'en porte pas) ni sur une grille en lecture seule. Les couleurs viennent du
|
||||
sélecteur natif, jamais d'une palette codée en dur. Tests : `tests/test_xlsx_styles.py` (17) et
|
||||
JSDOM (application d'un format, refus d'un CSV, garde de perte).
|
||||
- **#156-A9 — le calcul est explicitement hors périmètre, et annoncé.** L'éditeur n'embarque
|
||||
aucun moteur de formule : ObsiGate affiche et écrit les formules telles qu'Excel les a
|
||||
enregistrées, les cellules dépendantes ne se recalculent pas à l'écran. Le choix est désormais
|
||||
**dit à l'utilisateur** : un enregistrement contenant des valeurs commençant par `=` ou `@`
|
||||
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé (la garde
|
||||
anti-DDE de BUG-088 reste en place). La pastille « Formules non recalculées » et la fiche #156
|
||||
portent la décision. Tests JSDOM : l'avertissement apparaît, et disparaît une fois `f(x)`
|
||||
activé.
|
||||
- **#156-A10 — historique annuler/rétablir unifié, conservé au re-rendu.** La pile ne couvrait
|
||||
que les éditions de cellules : « Effacer le contenu », le tri, le filtre et les actions de
|
||||
**structure** y entrent désormais, avec des entrées typées (`cell`, `order`, `hidden`,
|
||||
`structure`). Les piles sont **conservées par fichier** (8 fichiers, `_xlsxHistory`) au lieu
|
||||
d'être vidées par un re-rendu ; `Suppr` passe par le même chemin (donc annulable) ; le filtre
|
||||
est **coalescé** en une seule entrée par session de frappe ; un rejeu de structure appelle
|
||||
l'endpoint en mode silencieux (pas de doublon d'historique ni de bandeau parasite) et une
|
||||
action destructrice (suppression de ligne/colonne/feuille) n'est **pas** annulable, faute
|
||||
d'inverse. Tests JSDOM : édition → effacement → tri annulés successivement, insertion de ligne
|
||||
rejouée par l'endpoint, action destructive non annulable, filtre annulé par un seul `Ctrl+Z`.
|
||||
- **#156-A11 — sortie (CSV, Markdown, HTML, impression) et recherche sur tout le classeur.**
|
||||
L'export ne se limite plus à la feuille entière : le bouton **CSV** exporte la **sélection**
|
||||
quand une plage de plusieurs cellules est active (nom de fichier `Feuille-A1B2.csv`), sinon la
|
||||
feuille ; un menu **Exporter** ajoute le **Markdown** (tableau), le **HTML** (document
|
||||
autonome) et l'**impression** (la feuille seule, sans le ruban, via un cadre hors écran). Les
|
||||
quatre formats suivent le **contenu affiché** (la valeur calculée en cache reste une
|
||||
infobulle). L'écart doc/code de #153-A13 (« export de la sélection ») est tranché : la
|
||||
sélection est le défaut quand elle existe. La recherche (`Ctrl+F`) parcourt désormais **toutes
|
||||
les feuilles** : compteur « n/m · k feuilles » et activation automatique de l'onglet de la
|
||||
correspondance atteinte.
|
||||
- **#156-A14 — l'assistant IA couvre les trois formats édités et gagne trois outils.** Les
|
||||
outils existants acceptent `.xlsm` (écriture avec macros préservées) et `.csv` (lecture et
|
||||
écriture, délimiteur conservé) au lieu du seul `.xlsx` ; `update_xlsx_cells` écrit un `.csv`
|
||||
via `save_csv_cells`. Nouveaux outils : `search_workbook` (recherche dans toutes les feuilles,
|
||||
comptée par feuille), `analyze_range` (nombre, somme, moyenne, min, max d'une plage A1, avec
|
||||
échantillon borné) et `edit_xlsx_structure` (ajouter/renommer/dupliquer/supprimer une feuille,
|
||||
insérer/supprimer lignes et colonnes) — les deux premiers en risque READ, le dernier conservant
|
||||
la confirmation obligatoire des outils mutants. Tests `tests/test_spreadsheet_tools.py` (34).
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#156 — Éditeur Excel : audit de complétude, roadmap et 4 défauts enregistrés (documentation
|
||||
seule).** Nouvelle fiche `docs/features/xlsx-editor-completeness.md` (audit par couche, manques
|
||||
fonctionnels, risques, critères d'acceptation) et item **#156** dans `docs/ROADMAP.md`
|
||||
(P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3
|
||||
sortie/robustesse/perf). **BUG-096 → BUG-099** ouverts dans `docs/ISSUES_TODOLIST.md` :
|
||||
enregistrer un `.csv` depuis la visionneuse lève un `TypeError` (reproduit en JSDOM, aucun
|
||||
`PUT …/csv/save` émis) ; « Charger la suite » d'une feuille `.xlsm` tronquée répond **415** ;
|
||||
le délimiteur CSV est figé sur `,` alors que l'export écrit `;` ; la sonde de perte plafonnée à
|
||||
8 Mo peut laisser passer une écriture qui détruit les valeurs calculées en cache. L'audit
|
||||
lui-même ne changeait aucun code ; les correctifs correspondants suivent ci-dessous.
|
||||
- **#156-A12 — concurrence optimiste entre processus (`If-Match`, 409 réparable).** Chaque
|
||||
lecture renvoie la **version** du fichier (`xlsx_revision` : `mtime_ns` + taille) et les trois
|
||||
routes d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête `If-Match` ou
|
||||
le champ `if_match` : si le fichier a changé depuis, l'écriture est refusée en **409**
|
||||
`conflict` (`details.reason = "stale_revision"`, versions attendue et courante) au lieu
|
||||
d'écraser l'autre écrivain. Le bandeau « Réessayer » **relit** le fichier puis rejoue la
|
||||
sauvegarde avec la version courante ; les réponses de succès renvoient la nouvelle version, que
|
||||
le client enchaîne sur les écritures suivantes. Tests : `TestOptimisticConcurrency` (6) et
|
||||
JSDOM (version lue, version rafraîchie par la réponse, relecture avant le retry).
|
||||
- **#156-A13 — cache des métadonnées par `mtime`.** `read_workbook_meta()` rechargeait le
|
||||
classeur entier à chaque fenêtre servie ; les métadonnées (styles, fusions, volets, largeurs)
|
||||
sont désormais mémoïsées par `(chemin, mtime_ns, taille)` (LRU de 8) et invalidées à chaque
|
||||
écriture (cellules, structure, CSV). Charger une fenêtre ne rescanne plus le classeur. L'axe
|
||||
des colonnes au-delà de `MAX_COLS` reste hors périmètre (voir la fiche #156). Tests :
|
||||
`TestMetaCache` (4).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-096 — enregistrer un `.csv` depuis la visionneuse échoue (`TypeError`, aucun
|
||||
`PUT …/csv/save` émis).** Le job de sauvegarde lisait le nom de la feuille dans
|
||||
`xlsx_sheets` — absent du payload d'un CSV — donc `sheets[0].name` levait un `TypeError`
|
||||
**avant** toute requête : aucune modification de CSV ne pouvait être enregistrée. Le nom est
|
||||
désormais résolu avec repli (feuille → nom du fichier) ; l'endpoint `csv/save` est inchangé.
|
||||
Tests : JSDOM (un CSV monté **sans** `xlsx_sheets` émet un unique `PUT …/csv/save`).
|
||||
- **BUG-097 — « Charger la suite » d'une feuille `.xlsm` renvoyait **415**.** Un `.xlsm`
|
||||
s'édite dans la même visionneuse que le `.xlsx`, mais `GET …/xlsx/sheet` refusait tout autre
|
||||
format : les lignes au-delà du plafond de 500 étaient inaccessibles. L'endpoint accepte
|
||||
désormais `.xlsx` **et** `.xlsm` (les autres formats restent en **415**). Tests :
|
||||
`test_xlsx_formats.py` (fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`).
|
||||
- **BUG-098 — un CSV français (`;`) s'affichait en une seule colonne.** Le délimiteur était
|
||||
figé à la virgule, en lecture comme en réécriture. `sniff_csv_delimiter()` détecte `;`, `,`
|
||||
ou tabulation (repli : décompte sur la première ligne, puis `,`) et `save_csv_cells()`
|
||||
**réutilise le délimiteur détecté** — un CSV point-virgule s'affiche en colonnes distinctes et
|
||||
le reste après édition. Tests : `test_xlsx_formats.py` (lecture, écriture, champs entre
|
||||
guillemets contenant le séparateur).
|
||||
- **BUG-099 — perte silencieuse possible des valeurs calculées en cache (sonde plafonnée).**
|
||||
La détection des `<f>…</f><v>…</v>` disposait d'un budget **unique** de 8 Mo partagé entre les
|
||||
feuilles : au-delà, un résultat négatif était pris pour une preuve et l'écriture détruisait
|
||||
les valeurs mises en cache sans avertissement. Le budget est désormais **par feuille** (4 Mo)
|
||||
avec un plafond global (32 Mo), et un budget épuisé est **signalé** (`cached_values_unverified`,
|
||||
libellé FR/EN) : la garde de sauvegarde demande une confirmation au lieu de laisser passer.
|
||||
Tests : `TestXlsxCachedValueProbe` (budget épuisé → signalé, feuille volumineuse → la suivante
|
||||
reste scannée, scan complet propre → aucun faux positif).
|
||||
|
||||
---
|
||||
|
||||
## [2.44.1] — 2026-09-29
|
||||
|
||||
### Sécurité
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -75,7 +75,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **📱 Éditeur mobile natif** — Édition optimisée pour le tactile : barre d'outils Markdown flottante (gras/italique/code/liste/lien), bouton « Coller » persistant (contournement iOS), zoom par pincement et hauteur ajustable, raccourcis swipe (liens entrants / table des matières) et mode lecture plein écran avec navigation entre fichiers ([détail](docs/features/mobile-editor.md))
|
||||
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar ; chaque clic sur un répertoire de l'arbre ouvre un **onglet de navigation** — chemin, récents, sous-répertoires cliquables, facettes Vaults · Tags · Extensions, tri Pertinence/Date et enregistrement du répertoire — qui coexiste avec vos fichiers ouverts
|
||||
- **🔍 Recherche avancée** : Moteur TF-IDF avec stemming français, normalisation des accents, snippets surlignés, facettes, pagination et tri — plus une **recherche sémantique** optionnelle (embeddings `all-MiniLM-L6-v2`, fusion hybride TF-IDF + RRF) activable via le toggle `~` ([détail](docs/features/semantic-search.md))
|
||||
- **💡 Autocomplétion intelligente** : Suggestions de fichiers, tags et historique avec navigation clavier
|
||||
- **🧩 Syntaxe de requête** : Opérateurs `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` avec chips visuels
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation clavier, barre de formule, tri/filtre/recherche, export CSV, édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`. L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, modifier des cellules et ajouter des lignes
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.44.1).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.49.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.44.1 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.49.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -74,7 +74,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **📱 Native Mobile Editor** — Touch-optimised editing: floating Markdown toolbar (bold/italic/code/list/link), persistent Paste button (iOS workaround), pinch-zoom font & adjustable height, swipe shortcuts (backlinks / table of contents) and a full-screen reading mode with page navigation ([details](docs/features/mobile-editor.md))
|
||||
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar; clicking a folder in the tree opens a dedicated **navigation tab** — path, recents, clickable subfolders, Vaults · Tags · Extensions facets, Pertinence/Date sorting and save-as-search — coexisting with your open files
|
||||
- **🔍 Advanced Search** : TF-IDF search engine with French stemming, accent normalization, highlighted snippets, facets, pagination, and sorting — plus an optional **semantic search** (embeddings via `all-MiniLM-L6-v2`, hybrid TF-IDF + RRF fusion) toggled with `~` ([details](docs/features/semantic-search.md))
|
||||
- **💡 Smart Autocomplete** : Suggestions for files, tags, and history with keyboard navigation
|
||||
- **🧩 Query Syntax** : Operators `tag:`, `#`, `vault:`, `title:`, `path:`, `ext:` with visual chips
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation, a formula bar, sort/filter/find, CSV export, sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled. The AI assistant can list sheets, dump a bounded table to its context, update cells and append rows
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.44.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.49.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.44.1 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.49.1 | Last updated: September 2026*
|
||||
|
||||
@@ -185,6 +185,17 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/style"): {
|
||||
"request": {
|
||||
"ops": [
|
||||
{"op": "cell", "sheet": "Budget", "range": "A1:B1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
||||
{"op": "col_width", "sheet": "Budget", "col": "A", "width": 24},
|
||||
],
|
||||
"force": False,
|
||||
"if_match": "18f2c0ab-1f4",
|
||||
},
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 2, "revision": "18f2c0ab-1f6"},
|
||||
},
|
||||
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
|
||||
# invalide) — les paramètres sont documentés par leurs Query().
|
||||
("get", "/api/file/{vault_name}/xlsx/sheet"): {
|
||||
|
||||
@@ -37,3 +37,5 @@ odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
|
||||
@@ -42,6 +42,7 @@ from backend.schemas import (
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
|
||||
@@ -226,7 +227,7 @@ def api_file_xlsx_dashboard(
|
||||
)
|
||||
def api_file_xlsx_sheet(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
|
||||
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
|
||||
limit: int = Query(
|
||||
@@ -234,7 +235,7 @@ def api_file_xlsx_sheet(
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a window of rows of one sheet of an .xlsx workbook (#153 A9).
|
||||
"""Return a window of rows of one sheet of an .xlsx/.xlsm workbook (#153 A9).
|
||||
|
||||
Backs the viewer's lazy loading: instead of every sheet in a single JSON
|
||||
payload, the client asks for the block it is about to display. The row
|
||||
@@ -258,7 +259,7 @@ def api_file_xlsx_sheet(
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
|
||||
415 (not an .xlsx file), 500 (unreadable workbook).
|
||||
415 (not an .xlsx/.xlsm file), 500 (unreadable workbook).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -269,8 +270,12 @@ def api_file_xlsx_sheet(
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() != ".xlsx":
|
||||
raise HTTPException(status_code=415, detail="Le fichier n'est pas un classeur .xlsx")
|
||||
# BUG-097 — a .xlsm rides the same editable viewer (and its lazy loading),
|
||||
# so its row windows must be servable too; other formats stay refused.
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
@@ -368,6 +373,10 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
# #156-A12 — optimistic-concurrency token: the viewer sends it
|
||||
# back as `if_match` so another writer cannot be overwritten in
|
||||
# silence (409 `conflict` instead).
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# #153 A1 — parts a save would drop; the viewer warns and asks
|
||||
# for an explicit confirmation before forcing the write.
|
||||
"xlsx_lossy_features": inspect_workbook(file_path),
|
||||
@@ -507,6 +516,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
# Macros are NOT lossy for .xlsm: keep_vba re-serializes them
|
||||
# (an empty LOSSY probe is what makes the save gate pass).
|
||||
"xlsx_lossy_features": [],
|
||||
@@ -554,6 +564,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
"html": html, "raw_length": len(raw), "extension": ext,
|
||||
"is_markdown": False, "is_csv": True,
|
||||
# #156-A12 — same stale-write guard as the workbooks.
|
||||
"xlsx_revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
# === JSON: syntax-highlighted display ===
|
||||
|
||||
+109
-10
@@ -67,6 +67,9 @@ from backend.services.mutations import (
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_style as service_mutate_xlsx_style,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
@@ -127,7 +130,7 @@ def api_file_xlsx_save(
|
||||
...,
|
||||
description=(
|
||||
'{"sheet": str, "cells": {"A1": value}, '
|
||||
'"allow_formula": false, "force": false}'
|
||||
'"allow_formula": false, "force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -144,6 +147,10 @@ def api_file_xlsx_save(
|
||||
(slicers, form controls, connections, custom XML, signature, cached formula
|
||||
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
|
||||
client asks the user to confirm (#153 A1).
|
||||
* ``if_match`` — revision token returned by the read (#156-A12). When it no
|
||||
longer matches the file on disk the write is refused with **409**
|
||||
``conflict`` (``reason=stale_revision``) instead of overwriting a change
|
||||
made by another writer. Omitted: last writer wins (curl, AI tools).
|
||||
|
||||
A backup is created before the workbook is rewritten, and the new archive
|
||||
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
|
||||
@@ -168,16 +175,22 @@ def api_file_xlsx_save(
|
||||
if not isinstance(raw, bool):
|
||||
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
|
||||
flags[name] = raw
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_edit_xlsx_cells(
|
||||
vault_name, path, sheet, cells, **flags
|
||||
vault_name, path, sheet, cells, expected_revision=if_match, **flags
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
|
||||
@@ -186,7 +199,11 @@ def api_file_csv_save(
|
||||
path: str = Query(..., description="Relative path to the .csv file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description='{"cells": {"A1": value}} — A1-addressed text edits (#153 A16)',
|
||||
description=(
|
||||
'{"cells": {"A1": value}, "if_match": str} — A1-addressed text '
|
||||
'edits (#153 A16). `if_match` is the revision token of the read '
|
||||
'(#156-A12): a stale token fails with 409 `conflict`.'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
@@ -195,6 +212,9 @@ def api_file_csv_save(
|
||||
The grid is re-parsed with :mod:`csv`, patched and re-serialized
|
||||
(RFC 4180 quoting). References beyond the extent grow the grid. Values
|
||||
are stored verbatim as text — a CSV has no formula engine.
|
||||
|
||||
``if_match`` (optional, #156-A12) is the revision the client read: when the
|
||||
file changed in the meantime the write is refused with **409** ``conflict``.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -206,13 +226,20 @@ def api_file_csv_save(
|
||||
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
|
||||
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells)
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||
@@ -224,7 +251,7 @@ def api_file_xlsx_structure(
|
||||
description=(
|
||||
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||
'"force": false}'
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
@@ -239,7 +266,9 @@ def api_file_xlsx_structure(
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced.
|
||||
edits). A backup is created before the archive is replaced. The optional
|
||||
``if_match`` revision (#156-A12) refuses a structural rewrite on a file that
|
||||
changed since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
@@ -258,16 +287,86 @@ def api_file_xlsx_structure(
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force
|
||||
vault_name, path, actions, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"])}
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", '
|
||||
'"style": {"bold": true, "fill_color": "#ffe08a"}}], '
|
||||
'"force": false, "if_match": str}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Write formatting on an .xlsx/.xlsm workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
||||
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
|
||||
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
|
||||
and ``freeze`` (``cell``, empty to release).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
workbook carries features openpyxl cannot rewrite (same gate as the cell
|
||||
edits). A backup is created before the archive is replaced; the optional
|
||||
``if_match`` revision (#156-A12) refuses a rewrite on a file that changed
|
||||
since it was read (**409** ``conflict``).
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx``/``.xlsm`` file.
|
||||
body: JSON body with ``ops`` (1 to 50) and optional ``force``.
|
||||
|
||||
Returns:
|
||||
``FileSaveResponse`` confirming the write.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
ops = body.get("ops")
|
||||
if not isinstance(ops, list) or not ops or len(ops) > 50:
|
||||
raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)")
|
||||
raw_force = body.get("force", False)
|
||||
if not isinstance(raw_force, bool):
|
||||
raise HTTPException(status_code=400, detail="Flag invalide: force")
|
||||
if_match = body.get("if_match")
|
||||
if if_match is not None and not isinstance(if_match, str):
|
||||
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
|
||||
|
||||
result = service_mutate_xlsx_style(
|
||||
vault_name, path, ops, force=raw_force, expected_revision=if_match
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
len(ops),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
|
||||
+18
-1
@@ -301,6 +301,14 @@ class FileContentResponse(BaseModel):
|
||||
"when the sheet exceeds the 500x40 render caps (#153 A8)"
|
||||
),
|
||||
)
|
||||
xlsx_revision: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optimistic-concurrency token of the spreadsheet (#156-A12): the "
|
||||
"client sends it back as the `if_match` of a write so a change made "
|
||||
"elsewhere is refused (409 `conflict`) instead of overwritten"
|
||||
),
|
||||
)
|
||||
xlsx_lossy_features: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
@@ -401,6 +409,12 @@ class FileSaveResponse(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
size: int = Field(description="Size of saved content in characters")
|
||||
# #156-A12 — optimistic-concurrency token of the file AFTER the write, so a
|
||||
# client can chain writes without re-reading (absent on non-spreadsheets).
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
description="Opaque revision of the saved spreadsheet (send it back as `if_match`)",
|
||||
)
|
||||
|
||||
|
||||
class FileDeleteResponse(BaseModel):
|
||||
@@ -632,6 +646,7 @@ class SearchFacets(BaseModel):
|
||||
|
||||
tags: dict[str, int] = Field(default_factory=dict)
|
||||
vaults: dict[str, int] = Field(default_factory=dict)
|
||||
extensions: dict[str, int] = Field(default_factory=dict, description="Counts per file extension, dotless and lowercase (query-ready for the ext: operator)")
|
||||
|
||||
|
||||
class AdvancedSearchResponse(BaseModel):
|
||||
@@ -641,7 +656,7 @@ class AdvancedSearchResponse(BaseModel):
|
||||
total: int = Field(description="Total number of matching results")
|
||||
offset: int = Field(description="Current pagination offset")
|
||||
limit: int = Field(description="Page size")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
|
||||
facets: SearchFacets = Field(description="Faceted counts by tag, vault and file extension")
|
||||
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
|
||||
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
|
||||
|
||||
@@ -652,6 +667,7 @@ class TitleSuggestion(BaseModel):
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(default_factory=list, description="File tags")
|
||||
|
||||
|
||||
class SuggestResponse(BaseModel):
|
||||
@@ -847,6 +863,7 @@ class VaultFileEntry(BaseModel):
|
||||
modified_iso: str | None = None
|
||||
extension: str = ""
|
||||
rel_dir: str | None = None
|
||||
tags: list[str] = Field(default_factory=list, description="Tags de l'index (#158)")
|
||||
|
||||
|
||||
class VaultFilesResponse(BaseModel):
|
||||
|
||||
+16
-3
@@ -373,6 +373,7 @@ class InvertedIndex:
|
||||
self.doc_vault: dict[str, str] = {}
|
||||
self.vault_docs: dict[str, set] = defaultdict(set)
|
||||
self.tag_docs: dict[str, set] = defaultdict(set)
|
||||
self.doc_tags: dict[str, set] = defaultdict(set)
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
@@ -403,6 +404,7 @@ class InvertedIndex:
|
||||
self.doc_vault = {}
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = defaultdict(set)
|
||||
self.doc_tags = defaultdict(set)
|
||||
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
for file_info in vault_data.get("files", []):
|
||||
@@ -417,6 +419,7 @@ class InvertedIndex:
|
||||
# --- Per-document tag index ---
|
||||
for tag in file_info.get("tags", []):
|
||||
self.tag_docs[tag.lower()].add(doc_key)
|
||||
self.doc_tags[file_info['path']].add(tag.lower())
|
||||
|
||||
# --- Title tokens ---
|
||||
title_tokens = tokenize(file_info.get("title", ""))
|
||||
@@ -1324,6 +1327,7 @@ def advanced_search(
|
||||
scored_results: list[tuple[float, dict[str, Any]]] = []
|
||||
facet_tags: dict[str, int] = defaultdict(int)
|
||||
facet_vaults: dict[str, int] = defaultdict(int)
|
||||
facet_extensions: dict[str, int] = defaultdict(int)
|
||||
|
||||
# Pre-compute prefix expansions once per term (avoid repeated binary search)
|
||||
prefix_expansions: dict[str, list[str]] = {}
|
||||
@@ -1423,6 +1427,11 @@ def advanced_search(
|
||||
facet_vaults[result["vault"]] = facet_vaults.get(result["vault"], 0) + 1
|
||||
for tag in result.get("tags", []):
|
||||
facet_tags[tag] = facet_tags.get(tag, 0) + 1
|
||||
# Extension normalised without leading dot ("md", not ".md") so it can be
|
||||
# fed back directly as the `ext:` query operator.
|
||||
ext = str(result.get("extension") or "").lower().lstrip(".")
|
||||
if ext:
|
||||
facet_extensions[ext] = facet_extensions.get(ext, 0) + 1
|
||||
|
||||
total = len(scored_results)
|
||||
page = scored_results[offset: offset + limit]
|
||||
@@ -1436,6 +1445,7 @@ def advanced_search(
|
||||
"facets": {
|
||||
"tags": dict(sorted(facet_tags.items(), key=lambda x: -x[1])[:20]),
|
||||
"vaults": dict(sorted(facet_vaults.items(), key=lambda x: -x[1])),
|
||||
"extensions": dict(sorted(facet_extensions.items(), key=lambda x: -x[1])),
|
||||
},
|
||||
"query_time_ms": elapsed_ms,
|
||||
"semantic_available": semantic_available,
|
||||
@@ -1538,7 +1548,7 @@ def suggest_titles(
|
||||
prefix: str,
|
||||
vault_filter: str = "all",
|
||||
limit: int = SUGGEST_LIMIT,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Suggest file titles matching a prefix (accent-insensitive).
|
||||
|
||||
Args:
|
||||
@@ -1547,7 +1557,7 @@ def suggest_titles(
|
||||
limit: Maximum suggestions.
|
||||
|
||||
Returns:
|
||||
List of ``{"vault", "path", "title"}`` dicts.
|
||||
List of ``{"vault", "path", "title", "tags"}`` dicts.
|
||||
"""
|
||||
if not prefix or len(prefix) < MIN_PREFIX_LENGTH:
|
||||
return []
|
||||
@@ -1565,7 +1575,10 @@ def suggest_titles(
|
||||
key = f"{entry['vault']}::{entry['path']}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(entry)
|
||||
# Add tags from the index
|
||||
entry_with_tags: dict[str, Any] = dict(entry)
|
||||
entry_with_tags["tags"] = list(inv.doc_tags.get(entry["path"], set()))
|
||||
results.append(entry_with_tags)
|
||||
if len(results) >= limit:
|
||||
return results
|
||||
|
||||
|
||||
@@ -278,6 +278,67 @@ def _xlsx_write_lock(key: str) -> Iterator[None]:
|
||||
lock.release()
|
||||
|
||||
|
||||
def _invalidate_meta(file_path: Path) -> None:
|
||||
"""Drop the cached workbook metadata after a write (#156-A13).
|
||||
|
||||
Imported lazily so the module stays importable when the spreadsheet reader
|
||||
is not needed (it pulls openpyxl in).
|
||||
"""
|
||||
try:
|
||||
from backend.xlsx_reader import invalidate_workbook_meta
|
||||
|
||||
invalidate_workbook_meta(file_path)
|
||||
except Exception: # pragma: no cover - cache invalidation is best-effort
|
||||
logger.debug("workbook meta invalidation skipped", exc_info=True)
|
||||
|
||||
|
||||
def file_revision(file_path: Path) -> str:
|
||||
"""Opaque revision token of a file — ``mtime_ns:size`` in hex (#156-A12).
|
||||
|
||||
Cheap by design (one ``stat``) and enough for optimistic concurrency: any
|
||||
writer that replaces the file changes at least one of the two fields. The
|
||||
viewer reads it with the file and sends it back as the ``if_match`` of a
|
||||
write, so an external editor (Excel, the watcher, another worker) can no
|
||||
longer be silently overwritten.
|
||||
|
||||
Returns:
|
||||
The token, or ``""`` when the file cannot be stat'ed.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
return f"{st.st_mtime_ns:x}-{st.st_size:x}"
|
||||
|
||||
|
||||
def _check_revision(file_path: Path, expected: str | None) -> None:
|
||||
"""Refuse a write on a file that changed since it was read (#156-A12).
|
||||
|
||||
``expected`` comes from the read payload (``if_match``): when it is absent
|
||||
the write keeps its pre-A12 behaviour (last writer wins), so curl, the AI
|
||||
tools and the batch uploader are unaffected.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``conflict`` (409, ``reason=stale_revision``) when the
|
||||
file on disk is not the revision the caller read.
|
||||
"""
|
||||
if not expected:
|
||||
return
|
||||
current = file_revision(file_path)
|
||||
if current and current != expected:
|
||||
raise ServiceError(
|
||||
"The file changed on disk since it was read; reload before saving",
|
||||
code="conflict",
|
||||
status=409,
|
||||
details={
|
||||
"reason": "stale_revision",
|
||||
"path": str(file_path),
|
||||
"expected_revision": expected,
|
||||
"current_revision": current,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _coerce_xlsx_value(value: Any) -> Any:
|
||||
"""Turn the string sent by the cell editor back into a scalar (#153 A10).
|
||||
|
||||
@@ -354,6 +415,7 @@ def edit_xlsx_cells(
|
||||
backup: bool = True,
|
||||
allow_formula: bool = False,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||
|
||||
@@ -416,6 +478,9 @@ def edit_xlsx_cells(
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — inside the lock: no writer can slip in between the check
|
||||
# and the load.
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
|
||||
@@ -451,6 +516,9 @@ def edit_xlsx_cells(
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
# #156-A13 — the metadata cache is keyed on (mtime, size); drop it too
|
||||
# so a rewrite that lands on the same tick can never serve stale maps.
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
return {
|
||||
@@ -458,6 +526,7 @@ def edit_xlsx_cells(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -468,6 +537,7 @@ def mutate_xlsx_structure(
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
|
||||
|
||||
@@ -521,6 +591,8 @@ def mutate_xlsx_structure(
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
# #156-A12 — stale-write guard (see edit_xlsx_cells).
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.worksheet.copier import WorksheetCopy
|
||||
|
||||
@@ -636,6 +708,7 @@ def mutate_xlsx_structure(
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(
|
||||
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||
@@ -645,6 +718,306 @@ def mutate_xlsx_structure(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
# #156-A8 — write-side formatting. Every value is data-driven (the colours
|
||||
# come from the caller, never from a hardcoded palette) and the whole batch
|
||||
# rides the same lock, backup and atomic swap as the cell edits.
|
||||
_STYLE_KEYS = {
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"font_color",
|
||||
"fill_color",
|
||||
"align",
|
||||
"number_format",
|
||||
}
|
||||
_STYLE_ALIGNS = {"left", "center", "right"}
|
||||
MAX_STYLE_CELLS = 10_000
|
||||
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _style_color(value: Any) -> str | None:
|
||||
"""Normalise ``#rrggbb`` to openpyxl's ARGB, or ``None`` to clear."""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str) or not _HEX_COLOR_RE.match(value.strip()):
|
||||
raise ServiceError(
|
||||
f"Couleur invalide: {value!r}", code="invalid", status=400
|
||||
)
|
||||
return "FF" + value.strip().lstrip("#").upper()
|
||||
|
||||
|
||||
def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
"""Apply the data-driven style fragment of one ``cell`` operation.
|
||||
|
||||
Only the keys listed in :data:`_STYLE_KEYS` are accepted — an unknown one
|
||||
is a client bug, not something to ignore silently. Font attributes are
|
||||
copied before mutation so the shared style of the other cells is left
|
||||
untouched.
|
||||
"""
|
||||
import copy as copy_mod
|
||||
|
||||
from openpyxl.styles import Alignment, Color, PatternFill
|
||||
|
||||
unknown = set(style) - _STYLE_KEYS
|
||||
if unknown:
|
||||
raise ServiceError(
|
||||
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
|
||||
)
|
||||
if {"bold", "italic", "underline", "font_color"} & set(style):
|
||||
font = copy_mod.copy(cell.font)
|
||||
if "bold" in style:
|
||||
font.bold = bool(style["bold"])
|
||||
if "italic" in style:
|
||||
font.italic = bool(style["italic"])
|
||||
if "underline" in style:
|
||||
font.underline = "single" if style["underline"] else None
|
||||
if "font_color" in style:
|
||||
rgb = _style_color(style["font_color"])
|
||||
font.color = Color(rgb=rgb) if rgb else None
|
||||
cell.font = font
|
||||
if "fill_color" in style:
|
||||
rgb = _style_color(style["fill_color"])
|
||||
cell.fill = (
|
||||
PatternFill(start_color=rgb, end_color=rgb, fill_type="solid")
|
||||
if rgb
|
||||
else PatternFill(fill_type=None)
|
||||
)
|
||||
if "align" in style:
|
||||
align = style["align"]
|
||||
if align not in _STYLE_ALIGNS:
|
||||
raise ServiceError(
|
||||
f"Alignement invalide: {align!r}", code="invalid", status=400
|
||||
)
|
||||
current = cell.alignment
|
||||
cell.alignment = Alignment(
|
||||
horizontal=align,
|
||||
vertical=getattr(current, "vertical", None),
|
||||
wrap_text=getattr(current, "wrap_text", None),
|
||||
)
|
||||
if "number_format" in style:
|
||||
fmt = style["number_format"] or "General"
|
||||
if not isinstance(fmt, str) or len(fmt) > 120:
|
||||
raise ServiceError(
|
||||
"Format de nombre invalide", code="invalid", status=400
|
||||
)
|
||||
cell.number_format = fmt
|
||||
|
||||
|
||||
def mutate_xlsx_style(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
ops: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Write formatting on an ``.xlsx``/``.xlsm`` workbook (#156-A8).
|
||||
|
||||
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
|
||||
|
||||
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
|
||||
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
|
||||
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
|
||||
``number_format`` ;
|
||||
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
|
||||
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
|
||||
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
|
||||
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
|
||||
|
||||
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
||||
``.tmp`` + ``os.replace`` atomic swap, lossy-write 409 and the optional
|
||||
``expected_revision`` concurrency check (#156-A12).
|
||||
"""
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise ServiceError(
|
||||
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
|
||||
)
|
||||
if not ops or len(ops) > 50:
|
||||
raise ServiceError(
|
||||
"Invalid ops (1 to 50 per request)", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force and file_path.suffix.lower() != ".xlsm":
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Restyling this workbook would drop features ObsiGate cannot "
|
||||
"preserve; retry with force=true after confirmation",
|
||||
code="xlsx_lossy_content",
|
||||
status=409,
|
||||
details={"path": path, "features": lossy},
|
||||
)
|
||||
|
||||
def _range(ref: str) -> tuple[int, int, int, int]:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(str(ref).upper())
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Plage invalide: {ref!r}", code="invalid", status=400
|
||||
) from exc
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_STYLE_CELLS:
|
||||
raise ServiceError(
|
||||
f"Plage trop grande (max {MAX_STYLE_CELLS} cellules)",
|
||||
code="invalid",
|
||||
status=400,
|
||||
)
|
||||
return min_col, min_row, max_col, max_row
|
||||
|
||||
def _sheet(wb: Any, action: dict[str, Any]) -> Any:
|
||||
name = str(action.get("sheet", ""))
|
||||
if name not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {name}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
)
|
||||
return wb[name]
|
||||
|
||||
applied: list[str] = []
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
_check_revision(file_path, expected_revision)
|
||||
from openpyxl import load_workbook
|
||||
|
||||
try:
|
||||
wb = load_workbook(
|
||||
file_path, keep_vba=file_path.suffix.lower() == ".xlsm"
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
rel_path = _rel(root, file_path)
|
||||
try:
|
||||
for i, action in enumerate(ops):
|
||||
if not isinstance(action, dict):
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} invalide", code="invalid", status=400
|
||||
)
|
||||
op = action.get("op")
|
||||
try:
|
||||
if op == "cell":
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range") or action.get("cell") or "")
|
||||
min_col, min_row, max_col, max_row = _range(ref)
|
||||
style = action.get("style") or {}
|
||||
if not isinstance(style, dict) or not style:
|
||||
raise ServiceError(
|
||||
"Style vide", code="invalid", status=400
|
||||
)
|
||||
for row in ws.iter_rows(
|
||||
min_row=min_row,
|
||||
max_row=max_row,
|
||||
min_col=min_col,
|
||||
max_col=max_col,
|
||||
):
|
||||
for cell in row:
|
||||
_apply_cell_style(cell, style)
|
||||
applied.append(
|
||||
f"cell:{ws.title}!{ref}:{','.join(sorted(style))}"
|
||||
)
|
||||
elif op in ("merge", "unmerge"):
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("range", ""))
|
||||
_range(ref)
|
||||
if op == "merge":
|
||||
ws.merge_cells(ref)
|
||||
else:
|
||||
ws.unmerge_cells(ref)
|
||||
applied.append(f"{op}:{ws.title}!{ref}")
|
||||
elif op == "col_width":
|
||||
ws = _sheet(wb, action)
|
||||
col = action.get("col")
|
||||
width = action.get("width")
|
||||
if isinstance(col, int):
|
||||
col = get_column_letter(col)
|
||||
if not isinstance(col, str) or not col.strip():
|
||||
raise ServiceError(
|
||||
"Colonne invalide", code="invalid", status=400
|
||||
)
|
||||
if not isinstance(width, (int, float)) or not 0 <= float(width) <= 255:
|
||||
raise ServiceError(
|
||||
"Largeur invalide (0 à 255)", code="invalid", status=400
|
||||
)
|
||||
ws.column_dimensions[col.strip().upper()].width = float(width)
|
||||
applied.append(f"col_width:{ws.title}!{col}={width}")
|
||||
elif op == "row_height":
|
||||
ws = _sheet(wb, action)
|
||||
row = action.get("row")
|
||||
height = action.get("height")
|
||||
if not isinstance(row, int) or row < 1:
|
||||
raise ServiceError("Ligne invalide", code="invalid", status=400)
|
||||
if not isinstance(height, (int, float)) or not 0 <= float(height) <= 409:
|
||||
raise ServiceError(
|
||||
"Hauteur invalide (0 à 409)", code="invalid", status=400
|
||||
)
|
||||
ws.row_dimensions[row].height = float(height)
|
||||
applied.append(f"row_height:{ws.title}!{row}={height}")
|
||||
elif op == "freeze":
|
||||
ws = _sheet(wb, action)
|
||||
cell_ref = str(action.get("cell", ""))
|
||||
if cell_ref:
|
||||
_range(cell_ref)
|
||||
ws.freeze_panes = cell_ref.upper() or None
|
||||
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
)
|
||||
except ServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Action {i + 1} ({op}) a échoué: {exc}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
) from exc
|
||||
except ServiceError:
|
||||
wb.close()
|
||||
raise
|
||||
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
wb.save(tmp_path)
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
wb.close()
|
||||
raise
|
||||
wb.close()
|
||||
_invalidate_meta(file_path)
|
||||
|
||||
logger.info(f"XLSX style: {vault_name}/{rel_path} {applied}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
@@ -654,14 +1027,18 @@ def save_csv_cells(
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
expected_revision: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||
|
||||
The file is re-parsed, patched and re-serialized with :mod:`csv` so
|
||||
quoting follows RFC 4180. References beyond the current extent grow the
|
||||
grid (missing rows/cells are filled with empty strings). Values are
|
||||
stored as text: a CSV has no formula engine, so any string — including
|
||||
ones starting with ``=`` — is written verbatim (the render escapes it).
|
||||
quoting follows RFC 4180. BUG-098 — the delimiter is **sniffed** and the
|
||||
same one is reused on write-back: a `;`-separated French CSV used to be
|
||||
parsed as a single column and rewritten with `,`. References beyond the
|
||||
current extent grow the grid (missing rows/cells are filled with empty
|
||||
strings). Values are stored as text: a CSV has no formula engine, so any
|
||||
string — including ones starting with ``=`` — is written verbatim (the
|
||||
render escapes it).
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||
@@ -670,6 +1047,8 @@ def save_csv_cells(
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
@@ -691,9 +1070,13 @@ def save_csv_cells(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
# #156-A12 — same stale-write guard as the workbook path.
|
||||
_check_revision(file_path, expected_revision)
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
# BUG-098 — parse AND rewrite with the file's own delimiter.
|
||||
delimiter = sniff_csv_delimiter(raw)
|
||||
try:
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw)))
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter))
|
||||
except csv_mod.Error:
|
||||
rows = [[line] for line in raw.splitlines()]
|
||||
|
||||
@@ -721,7 +1104,7 @@ def save_csv_cells(
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
buf = io_mod.StringIO()
|
||||
csv_mod.writer(buf, lineterminator="\n").writerows(rows)
|
||||
csv_mod.writer(buf, delimiter=delimiter, lineterminator="\n").writerows(rows)
|
||||
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
tmp_path.write_text(buf.getvalue(), encoding="utf-8")
|
||||
@@ -736,6 +1119,7 @@ def save_csv_cells(
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
"revision": file_revision(file_path),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,29 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
return {"vault": vault_name, "path": path, "items": items}
|
||||
|
||||
|
||||
def _indexed_tags(vault_name: str, rel_path: str) -> list[str]:
|
||||
"""Tags of a file as stored in the search index (#158).
|
||||
|
||||
Empty list when the file is not indexed yet (binary formats, index still
|
||||
building) — the listing itself comes from the filesystem, tags are a
|
||||
decoration used by the navigation page facets/filters.
|
||||
|
||||
Args:
|
||||
vault_name: Vault name.
|
||||
rel_path: Path of the file relative to the vault root (``/`` separated).
|
||||
|
||||
Returns:
|
||||
The file's tags, or an empty list when unknown.
|
||||
"""
|
||||
try:
|
||||
from backend.search import get_inverted_index
|
||||
|
||||
info = get_inverted_index().doc_info.get(f"{vault_name}::{rel_path}")
|
||||
except Exception:
|
||||
return []
|
||||
return list((info or {}).get("tags") or [])
|
||||
|
||||
|
||||
def list_all_files(
|
||||
vault_name: str,
|
||||
dir: str = "",
|
||||
@@ -189,6 +212,7 @@ def list_all_files(
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"extension": ext.lstrip(".") if ext else "",
|
||||
"tags": _indexed_tags(vault_name, rel_path),
|
||||
}
|
||||
if rel_to_dir and rel_to_dir != ".":
|
||||
file_entry["rel_dir"] = rel_to_dir
|
||||
|
||||
@@ -56,6 +56,9 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"xlsx_to_markdown": ("xlsx_read", "path"),
|
||||
"update_xlsx_cells": ("xlsx_update", "path"),
|
||||
"append_xlsx_rows": ("xlsx_append", "path"),
|
||||
"search_workbook": ("xlsx_search", "query"),
|
||||
"analyze_range": ("xlsx_analyze", "path"),
|
||||
"edit_xlsx_structure": ("xlsx_structure", "path"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
|
||||
@@ -332,12 +332,42 @@ class XlsxToMarkdownInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SearchWorkbookInput(BaseModel):
|
||||
"""Find a text across the sheets of a spreadsheet (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
query: str = Field(..., description="Text to look for")
|
||||
sheet: str = Field("", description="Restrict to one sheet (empty = all sheets)")
|
||||
case_sensitive: bool = Field(False, description="Match case")
|
||||
|
||||
|
||||
class AnalyzeRangeInput(BaseModel):
|
||||
"""Aggregate the values of an A1 range (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(
|
||||
..., description="Vault-relative path of the file (.xlsx, .xlsm or .csv)"
|
||||
)
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active sheet)"
|
||||
)
|
||||
range: str = Field(
|
||||
"",
|
||||
description="A1 range to analyse (e.g. 'B2:B50'); empty = the whole sheet",
|
||||
)
|
||||
|
||||
|
||||
class UpdateXlsxCellsInput(BaseModel):
|
||||
"""Batch-edit cells of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(..., description="Worksheet title to edit")
|
||||
sheet: str = Field(
|
||||
"", description="Worksheet title to edit (ignored for a .csv)"
|
||||
)
|
||||
cells: dict[str, str | int | float | bool | None] = Field(
|
||||
..., description="A1 reference -> new value (max 500 per call)"
|
||||
)
|
||||
@@ -370,6 +400,25 @@ class AppendXlsxRowsInput(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class EditXlsxStructureInput(BaseModel):
|
||||
"""Structural CRUD on an existing workbook (#156 A14)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx/.xlsm file")
|
||||
actions: list[dict[str, Any]] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Ordered structural actions (1-50): sheet_add/sheet_rename/"
|
||||
"sheet_duplicate/sheet_delete, row_insert/row_delete/"
|
||||
"col_insert/col_delete"
|
||||
),
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class CsvInput(BaseModel):
|
||||
"""Create a .csv file in a vault from rows of cells."""
|
||||
|
||||
|
||||
+392
-105
@@ -1,21 +1,30 @@
|
||||
"""Spreadsheet tools (#153 A6) — read and mutate existing ``.xlsx`` workbooks.
|
||||
"""Spreadsheet tools (#153 A6, #156 A14) — read and mutate existing workbooks.
|
||||
|
||||
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||
file; here the assistant can read and edit one that already exists):
|
||||
file; here the assistant can read and edit one that already exists). The same
|
||||
three formats the viewer edits are supported — ``.xlsx``, ``.xlsm`` and
|
||||
``.csv`` (#156-A14 used to be ``.xlsx`` only, which made a workbook the UI
|
||||
edits invisible to the assistant):
|
||||
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (wraps the guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet.
|
||||
* ``list_xlsx_sheets`` — READ, sheet names + dimensions;
|
||||
* ``xlsx_to_markdown`` — READ, bounded markdown table for the LLM context;
|
||||
* ``search_workbook`` — READ, find text across every sheet (#156-A14);
|
||||
* ``analyze_range`` — READ, aggregate stats over an A1 range (#156-A14);
|
||||
* ``update_xlsx_cells`` — WRITE, batch cell edits (guarded service);
|
||||
* ``append_xlsx_rows`` — WRITE, append whole rows at the end of a sheet;
|
||||
* ``edit_xlsx_structure`` — WRITE, structural CRUD (sheets/rows/columns).
|
||||
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`,
|
||||
which already carries the #153 P0 guards: per-file lock, atomic replace,
|
||||
formula neutralisation (``allow_formula`` opt-in) and the lossy-write 409.
|
||||
Mutation tools go through :func:`backend.services.mutations.edit_xlsx_cells`
|
||||
(or ``save_csv_cells`` / ``mutate_xlsx_structure``), which already carry the
|
||||
#153 P0 guards: per-file lock, atomic replace, formula neutralisation
|
||||
(``allow_formula`` opt-in) and the lossy-write 409. Every write is a WRITE-risk
|
||||
tool, so the registry keeps asking for an explicit confirmation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -25,8 +34,11 @@ from backend.services.vaults import get_vault_root
|
||||
from backend.tools.context import ToolContext, ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AnalyzeRangeInput,
|
||||
AppendXlsxRowsInput,
|
||||
EditXlsxStructureInput,
|
||||
ListXlsxSheetsInput,
|
||||
SearchWorkbookInput,
|
||||
UpdateXlsxCellsInput,
|
||||
XlsxToMarkdownInput,
|
||||
)
|
||||
@@ -39,12 +51,28 @@ MAX_MD_ROWS = 100
|
||||
MAX_MD_COLS = 20
|
||||
MAX_MD_CHARS = 20_000
|
||||
|
||||
# #156-A14 — the newer read tools scan more than the markdown table (a search
|
||||
# or an aggregate must not stop at row 100) but stay bounded all the same:
|
||||
# a runaway scan would load a whole ledger into the model's context.
|
||||
MAX_SCAN_ROWS = 5_000
|
||||
MAX_SCAN_COLS = 100
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
MAX_RANGE_CELLS = 10_000
|
||||
MAX_RANGE_VALUES = 200
|
||||
|
||||
def _workbook_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx`` path."""
|
||||
# The formats the spreadsheet editor (and now the assistant) can handle.
|
||||
SPREADSHEET_EXTENSIONS = (".xlsx", ".xlsm", ".csv")
|
||||
|
||||
_NUMBER_RE = re.compile(r"^-?\d+(?:[.,]\d+)?$")
|
||||
|
||||
|
||||
def _spreadsheet_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx``/``.xlsm``/``.csv`` path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(".xlsx"):
|
||||
raise ToolError("Extension attendue : .xlsx", code="invalid_arguments")
|
||||
if not path.lower().endswith(SPREADSHEET_EXTENSIONS):
|
||||
raise ToolError(
|
||||
"Extension attendue : .xlsx, .xlsm ou .csv", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
root = get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
@@ -52,57 +80,175 @@ def _workbook_path(vault: str, path: str) -> Path:
|
||||
return resolve_safe_path(root, path)
|
||||
|
||||
|
||||
def _is_csv(file_path: Path) -> bool:
|
||||
return file_path.suffix.lower() == ".csv"
|
||||
|
||||
|
||||
def _map_service_error(e: ServiceError) -> ToolError:
|
||||
return ToolError(e.message, code=e.code, details=e.details)
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of an .xlsx workbook with their dimensions "
|
||||
"(rows x columns) and whether the display caps truncate them. "
|
||||
"Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS, _sheet_extent
|
||||
def _sheet_titles(file_path: Path) -> list[str]:
|
||||
"""Sheet names of a workbook; a CSV has a single, unnamed “sheet”."""
|
||||
if _is_csv(file_path):
|
||||
return [""]
|
||||
from openpyxl import load_workbook
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
return list(wb.sheetnames)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _read_grid(
|
||||
file_path: Path, sheet: str, max_rows: int, max_cols: int
|
||||
) -> tuple[str, list[list[str]], bool]:
|
||||
"""Read one sheet (or a CSV) as bounded, formatted, trimmed rows.
|
||||
|
||||
Returns ``(title, rows, truncated)``; ``truncated`` is True when real data
|
||||
sits just beyond the row cap (probed one row further) so the caller can say
|
||||
so instead of silently dropping it.
|
||||
"""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
if _is_csv(file_path):
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
stem = file_path.stem
|
||||
if sheet and sheet != stem:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
raw = file_path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=sniff_csv_delimiter(raw))
|
||||
grid: list[list[str]] = []
|
||||
truncated = False
|
||||
for i, row in enumerate(reader):
|
||||
if i >= max_rows:
|
||||
truncated = any(str(c).strip() for c in row)
|
||||
break
|
||||
grid.append([str(c) for c in row][:max_cols])
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return stem, grid, truncated
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
total_rows, total_cols = _sheet_extent(ws)
|
||||
sheets.append(
|
||||
{
|
||||
"name": ws.title,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
if sheet:
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ToolError(f"Feuille introuvable: {sheet}", code="not_found")
|
||||
ws = wb[sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
grid = []
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=max_rows, max_col=max_cols, values_only=True
|
||||
):
|
||||
grid.append([_fmt(v) for v in row])
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=max_rows + 1,
|
||||
max_row=max_rows + 1,
|
||||
max_col=max_cols,
|
||||
values_only=True,
|
||||
)
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
)
|
||||
truncated = any(any(str(v or "").strip() for v in r) for r in probe)
|
||||
finally:
|
||||
wb.close()
|
||||
while grid and not any(c.strip() for c in grid[-1]):
|
||||
grid.pop()
|
||||
return title, grid, truncated
|
||||
|
||||
|
||||
def _to_number(text: str) -> float | None:
|
||||
"""Coerce a displayed cell to a float, or ``None`` when it is not one."""
|
||||
t = text.strip().replace("\u00a0", "").replace(" ", "")
|
||||
if not _NUMBER_RE.match(t):
|
||||
return None
|
||||
try:
|
||||
return float(t.replace(",", "."))
|
||||
except ValueError: # pragma: no cover - regex already guarantees the shape
|
||||
return None
|
||||
|
||||
|
||||
@tool(
|
||||
name="list_xlsx_sheets",
|
||||
description=(
|
||||
"List the sheets of a spreadsheet (.xlsx, .xlsm or .csv) with their "
|
||||
"dimensions (rows x columns) and whether the display caps truncate "
|
||||
"them. Use before editing to pick the right sheet name."
|
||||
),
|
||||
input_model=ListXlsxSheetsInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str, Any]:
|
||||
"""Return sheet names and extents of the workbook (or of the CSV)."""
|
||||
from backend.xlsx_reader import MAX_COLS, MAX_ROWS
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise ToolError(f"Fichier introuvable: {params.path}", code="not_found")
|
||||
|
||||
extents: list[tuple[str, int, int]] = []
|
||||
if _is_csv(file_path):
|
||||
_, rows, _ = _read_grid(file_path, "", MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
extents.append(
|
||||
(
|
||||
file_path.stem,
|
||||
len(rows),
|
||||
max((len(r) for r in rows), default=0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Declared dimensions are enough here (and far cheaper than scanning
|
||||
# every row): the caller just needs a size to decide what to read.
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _sheet_extent
|
||||
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
extents = [
|
||||
(ws.title, *_sheet_extent(ws)) for ws in wb.worksheets
|
||||
]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
sheets = [
|
||||
{
|
||||
"name": name,
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
}
|
||||
for name, total_rows, total_cols in extents
|
||||
]
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
|
||||
|
||||
@tool(
|
||||
name="xlsx_to_markdown",
|
||||
description=(
|
||||
"Read a sheet of an .xlsx workbook as a bounded markdown table "
|
||||
"(up to 100 rows x 20 columns). Use to inspect spreadsheet data "
|
||||
"before answering or editing."
|
||||
"Read a sheet of a spreadsheet (.xlsx, .xlsm or .csv) as a bounded "
|
||||
"markdown table (up to 100 rows x 20 columns). Use to inspect "
|
||||
"spreadsheet data before answering or editing."
|
||||
),
|
||||
input_model=XlsxToMarkdownInput,
|
||||
risk=ToolRisk.READ,
|
||||
@@ -110,49 +256,8 @@ def list_xlsx_sheets(ctx: ToolContext, params: ListXlsxSheetsInput) -> dict[str,
|
||||
)
|
||||
def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str, Any]:
|
||||
"""Render one sheet as a markdown table for the LLM context."""
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from backend.xlsx_reader import _fmt
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
try:
|
||||
if params.sheet:
|
||||
if params.sheet not in wb.sheetnames:
|
||||
raise ToolError(
|
||||
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||
)
|
||||
ws = wb[params.sheet]
|
||||
else:
|
||||
ws = wb.active
|
||||
title = ws.title
|
||||
rows: list[list[str]] = []
|
||||
truncated = False
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=MAX_MD_ROWS, max_col=MAX_MD_COLS, values_only=True
|
||||
):
|
||||
cells = [_fmt(v) for v in row]
|
||||
if not any(c.strip() for c in cells):
|
||||
continue
|
||||
rows.append(cells)
|
||||
# Real tail beyond the caps? Probe one row further.
|
||||
probe = list(
|
||||
ws.iter_rows(
|
||||
min_row=MAX_MD_ROWS + 1,
|
||||
max_row=MAX_MD_ROWS + 1,
|
||||
max_col=MAX_MD_COLS,
|
||||
values_only=True,
|
||||
)
|
||||
)
|
||||
if any(any(str(v or "").strip() for v in r) for r in probe):
|
||||
truncated = True
|
||||
finally:
|
||||
wb.close()
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, truncated = _read_grid(file_path, params.sheet, MAX_MD_ROWS, MAX_MD_COLS)
|
||||
|
||||
lines: list[str] = []
|
||||
if rows:
|
||||
@@ -174,34 +279,166 @@ def xlsx_to_markdown(ctx: ToolContext, params: XlsxToMarkdownInput) -> dict[str,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="search_workbook",
|
||||
description=(
|
||||
"Search a text across every sheet of a spreadsheet (.xlsx, .xlsm or "
|
||||
".csv) and return the matching cells with their sheet and A1 "
|
||||
"reference (max 100 matches). Use it to find where a value lives "
|
||||
"without dumping whole sheets into the context."
|
||||
),
|
||||
input_model=SearchWorkbookInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def search_workbook(ctx: ToolContext, params: SearchWorkbookInput) -> dict[str, Any]:
|
||||
"""Find a needle across all sheets, bounded and counted per sheet."""
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
needle = (params.query or "").strip()
|
||||
if not needle:
|
||||
raise ToolError("Requête vide", code="invalid_arguments")
|
||||
|
||||
titles = _sheet_titles(file_path)
|
||||
if params.sheet:
|
||||
if params.sheet not in titles:
|
||||
raise ToolError(f"Feuille introuvable: {params.sheet}", code="not_found")
|
||||
titles = [params.sheet]
|
||||
|
||||
hay = needle if params.case_sensitive else needle.lower()
|
||||
matches: list[dict[str, Any]] = []
|
||||
by_sheet: dict[str, int] = {}
|
||||
total = 0
|
||||
for title in titles:
|
||||
sheet_title, rows, _ = _read_grid(
|
||||
file_path, title, MAX_SCAN_ROWS, MAX_SCAN_COLS
|
||||
)
|
||||
label = sheet_title or file_path.stem
|
||||
for r_i, row in enumerate(rows, start=1):
|
||||
for c_i, value in enumerate(row, start=1):
|
||||
if not value:
|
||||
continue
|
||||
haystack = value if params.case_sensitive else value.lower()
|
||||
if hay not in haystack:
|
||||
continue
|
||||
total += 1
|
||||
by_sheet[label] = by_sheet.get(label, 0) + 1
|
||||
if len(matches) < MAX_SEARCH_RESULTS:
|
||||
matches.append(
|
||||
{
|
||||
"sheet": label,
|
||||
"cell": f"{get_column_letter(c_i)}{r_i}",
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"query": needle,
|
||||
"total": total,
|
||||
"truncated": total > MAX_SEARCH_RESULTS,
|
||||
"by_sheet": by_sheet,
|
||||
"matches": matches,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="analyze_range",
|
||||
description=(
|
||||
"Aggregate an A1 range of a sheet (.xlsx, .xlsm or .csv): count, sum, "
|
||||
"mean, min and max of the numeric cells, plus a bounded sample of the "
|
||||
"values. Use it to answer a question about a column without reading "
|
||||
"the whole sheet."
|
||||
),
|
||||
input_model=AnalyzeRangeInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
def analyze_range(ctx: ToolContext, params: AnalyzeRangeInput) -> dict[str, Any]:
|
||||
"""Numeric aggregates + value sample over an A1 range of the sheet."""
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
title, rows, _ = _read_grid(file_path, params.sheet, MAX_SCAN_ROWS, MAX_SCAN_COLS)
|
||||
|
||||
label = (params.range or "").strip()
|
||||
if label:
|
||||
try:
|
||||
min_col, min_row, max_col, max_row = range_boundaries(label.upper())
|
||||
except Exception as e:
|
||||
raise ToolError(f"Plage invalide: {label}", code="invalid_arguments") from e
|
||||
if (max_row - min_row + 1) * (max_col - min_col + 1) > MAX_RANGE_CELLS:
|
||||
raise ToolError(
|
||||
f"Plage trop grande (max {MAX_RANGE_CELLS} cellules)",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
selected = [row[min_col - 1 : max_col] for row in rows[min_row - 1 : max_row]]
|
||||
else:
|
||||
selected = rows
|
||||
|
||||
values = [v for row in selected for v in row if isinstance(v, str) and v.strip()]
|
||||
numbers = [n for n in (_to_number(v) for v in values) if n is not None]
|
||||
|
||||
stats: dict[str, Any] = {"count": len(numbers)}
|
||||
if numbers:
|
||||
stats.update(
|
||||
{
|
||||
"sum": round(sum(numbers), 6),
|
||||
"mean": round(sum(numbers) / len(numbers), 6),
|
||||
"min": min(numbers),
|
||||
"max": max(numbers),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"range": params.range or "",
|
||||
"rows": len(selected),
|
||||
"cols": max((len(r) for r in selected), default=0),
|
||||
"cells": len(values),
|
||||
"numeric": stats,
|
||||
"values": values[:MAX_RANGE_VALUES],
|
||||
"truncated": len(values) > MAX_RANGE_VALUES,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="update_xlsx_cells",
|
||||
description=(
|
||||
"Edit cells of an existing .xlsx workbook. ``cells`` maps A1 "
|
||||
"references to new values (max 500). A value starting with '=' or "
|
||||
"'@' is stored as TEXT unless allow_formula is set (DDE guard). "
|
||||
"Editing a workbook carrying features openpyxl cannot rewrite "
|
||||
"requires force=true (cached formula results, slicers…)."
|
||||
"Edit cells of an existing spreadsheet (.xlsx, .xlsm or .csv). "
|
||||
"``cells`` maps A1 references to new values (max 500). A value "
|
||||
"starting with '=' or '@' is stored as TEXT unless allow_formula is "
|
||||
"set (DDE guard). Editing a workbook carrying features openpyxl "
|
||||
"cannot rewrite requires force=true (cached formula results, "
|
||||
"slicers…). A .csv has no sheet: any ``sheet`` value is ignored."
|
||||
),
|
||||
input_model=UpdateXlsxCellsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[str, Any]:
|
||||
"""Wrap the guarded cell-edit service."""
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
"""Wrap the guarded cell-edit service (``.xlsx``/``.xlsm`` or ``.csv``)."""
|
||||
from backend.services.mutations import edit_xlsx_cells, save_csv_cells
|
||||
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if not params.cells:
|
||||
raise ToolError("Aucune cellule fournie", code="invalid_arguments")
|
||||
if not _is_csv(file_path) and not params.sheet:
|
||||
raise ToolError("Feuille requise pour un classeur", code="invalid_arguments")
|
||||
try:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
if _is_csv(file_path):
|
||||
result = save_csv_cells(params.vault, params.path, dict(params.cells))
|
||||
else:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
dict(params.cells),
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
@@ -216,9 +453,10 @@ def update_xlsx_cells(ctx: ToolContext, params: UpdateXlsxCellsInput) -> dict[st
|
||||
@tool(
|
||||
name="append_xlsx_rows",
|
||||
description=(
|
||||
"Append rows at the end of a sheet of an existing .xlsx workbook. "
|
||||
"Values are typed like in the viewer (numbers, TRUE/FALSE, FR dates "
|
||||
"JJ/MM/AAAA). The workbook is rewritten atomically with a backup."
|
||||
"Append rows at the end of a sheet of an existing spreadsheet "
|
||||
"(.xlsx or .xlsm). Values are typed like in the viewer (numbers, "
|
||||
"TRUE/FALSE, FR dates JJ/MM/AAAA). The workbook is rewritten "
|
||||
"atomically with a backup. Not available for .csv."
|
||||
),
|
||||
input_model=AppendXlsxRowsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
@@ -236,7 +474,13 @@ def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str,
|
||||
if len(params.rows) > 500:
|
||||
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
file_path = _spreadsheet_path(params.vault, params.path)
|
||||
if _is_csv(file_path):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de notion de fin de feuille : utilisez "
|
||||
"update_xlsx_cells avec des références A1",
|
||||
code="invalid_arguments",
|
||||
)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
@@ -284,3 +528,46 @@ def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str,
|
||||
"rows": len(params.rows),
|
||||
"first_row": first_free,
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
name="edit_xlsx_structure",
|
||||
description=(
|
||||
"Change the structure of an existing .xlsx/.xlsm workbook: add, "
|
||||
"rename, duplicate or delete a sheet, or insert/delete rows and "
|
||||
"columns. ``actions`` is an ordered list of "
|
||||
'{"op": "sheet_add"|"sheet_rename"|"sheet_duplicate"|"sheet_delete"|'
|
||||
'"row_insert"|"row_delete"|"col_insert"|"col_delete", …} '
|
||||
"(1 to 50). Deletions drop data and cannot be undone from the "
|
||||
"assistant — confirm with the user first."
|
||||
),
|
||||
input_model=EditXlsxStructureInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def edit_xlsx_structure(
|
||||
ctx: ToolContext, params: EditXlsxStructureInput
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of structural changes through the guarded service."""
|
||||
from backend.services.mutations import mutate_xlsx_structure
|
||||
|
||||
if not params.actions:
|
||||
raise ToolError("Aucune action fournie", code="invalid_arguments")
|
||||
if len(params.actions) > 50:
|
||||
raise ToolError("Trop d'actions (max 50)", code="invalid_arguments")
|
||||
if str(params.path or "").lower().endswith(".csv"):
|
||||
raise ToolError(
|
||||
"Un .csv n'a pas de structure modifiable", code="invalid_arguments"
|
||||
)
|
||||
try:
|
||||
result = mutate_xlsx_structure(
|
||||
params.vault, params.path, [dict(a) for a in params.actions], force=params.force
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"actions": len(params.actions),
|
||||
}
|
||||
|
||||
+146
-14
@@ -14,9 +14,11 @@ read-only; :func:`render_csv_table` turns a CSV into the same table shape.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
@@ -65,8 +67,22 @@ LOSSY_PARTS: dict[str, tuple[str, ...]] = {
|
||||
# Excel recalculates.
|
||||
_CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||
_MAX_PROBE_BYTES = 8_000_000
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like
|
||||
# MAX_REPLACE_FILE_BYTES). BUG-099 — the allowance is PER SHEET: a single huge
|
||||
# first sheet used to eat the whole budget and hide a cached formula sitting in
|
||||
# the next one. The total ceiling still bounds the work on a many-sheet archive.
|
||||
# Running out of budget is reported as *unverified* (never as "nothing to lose")
|
||||
# so the write guard stays cautious instead of silently dropping the values.
|
||||
_MAX_PROBE_BYTES_PER_SHEET = 4_000_000
|
||||
_MAX_PROBE_BYTES_TOTAL = 32_000_000
|
||||
|
||||
# #156-A13 — metadata cache. `read_sheet_window()` serves one window at a time
|
||||
# and used to reload the whole workbook (normal mode, data_only=False) for every
|
||||
# window, just to read the style/merge/freeze maps of one sheet. The result is
|
||||
# keyed by (path, mtime_ns, size): any write replaces the file, hence the key.
|
||||
_META_CACHE_MAX = 8
|
||||
_meta_cache: dict[str, tuple[tuple[int, int], dict[str, dict[str, Any]]]] = {}
|
||||
_meta_cache_lock = threading.Lock()
|
||||
|
||||
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
|
||||
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
|
||||
@@ -111,6 +127,10 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-weight:600")
|
||||
if font and font.italic:
|
||||
fragments.append("font-style:italic")
|
||||
# #156-A8 — underline is written from the viewer too, so it is read back
|
||||
# (the toggle in the formatting menu needs to see its own effect).
|
||||
if font and font.underline:
|
||||
fragments.append("text-decoration:underline")
|
||||
fmt = cell.number_format
|
||||
if fmt and fmt not in ("General", "@"):
|
||||
# A custom number format is signalled typographically (mono font)
|
||||
@@ -320,24 +340,65 @@ def _table(
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value."""
|
||||
budget = _MAX_PROBE_BYTES
|
||||
def _is_sheet_xml(name: str) -> bool:
|
||||
"""True for the worksheet XML parts the cached-formula probe scans."""
|
||||
return name.startswith("xl/worksheets/sheet") and name.endswith(".xml")
|
||||
|
||||
|
||||
def _scan_cached_formulas(zf: zipfile.ZipFile) -> tuple[bool, bool]:
|
||||
"""Scan the sheet XML for a formula carrying a non-empty cached result.
|
||||
|
||||
Returns ``(found, unverified)``. ``unverified`` is True when the byte
|
||||
budget stopped the scan before every sheet could be read to the end: a
|
||||
negative result is then **not** proof that the workbook holds no cached
|
||||
value (BUG-099), so callers must not treat it as a licence to write.
|
||||
|
||||
Each sheet gets its own :data:`_MAX_PROBE_BYTES_PER_SHEET` allowance (a
|
||||
single huge sheet can no longer starve the others) while
|
||||
:data:`_MAX_PROBE_BYTES_TOTAL` bounds the whole archive. A hit short-
|
||||
circuits the scan: the answer is already known.
|
||||
"""
|
||||
budget = _MAX_PROBE_BYTES_TOTAL
|
||||
unverified = False
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
|
||||
if not _is_sheet_xml(name):
|
||||
continue
|
||||
sheet_budget = min(_MAX_PROBE_BYTES_PER_SHEET, budget)
|
||||
exhausted = False
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
while budget > 0:
|
||||
chunk = fh.read(65536)
|
||||
while sheet_budget > 0:
|
||||
# Read at most what the sheet's allowance has left, so one
|
||||
# large chunk can never consume the whole total budget.
|
||||
chunk = fh.read(min(65536, sheet_budget))
|
||||
if not chunk:
|
||||
break
|
||||
break # read to the end: this sheet is verified clean
|
||||
sheet_budget -= len(chunk)
|
||||
budget -= len(chunk)
|
||||
if _CACHED_FORMULA_RE.search(chunk):
|
||||
return True
|
||||
return True, False
|
||||
else:
|
||||
# Left the loop on the budget, not on EOF.
|
||||
exhausted = True
|
||||
except (KeyError, OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
return False
|
||||
if exhausted:
|
||||
unverified = True
|
||||
if budget <= 0:
|
||||
unverified = True
|
||||
break
|
||||
return False, unverified
|
||||
|
||||
|
||||
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value.
|
||||
|
||||
Boolean view of :func:`_scan_cached_formulas` for the display path (the
|
||||
second ``data_only=True`` read): a truncated scan simply skips the shadow
|
||||
grid, it never claims the workbook is lossless.
|
||||
"""
|
||||
found, _ = _scan_cached_formulas(zf)
|
||||
return found
|
||||
|
||||
|
||||
def inspect_workbook(file_path: Path) -> list[str]:
|
||||
@@ -349,6 +410,10 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
|
||||
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
|
||||
the cached result, so the workbook stays correct once Excel recalculates it.
|
||||
``cached_values_unverified`` (BUG-099) is the other synthetic key: the
|
||||
cached-value probe ran out of budget, so a negative result is not proof —
|
||||
the entry keeps the write guard cautious (409 + confirmation) rather than
|
||||
promising a lossless round-trip it cannot vouch for.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
@@ -358,13 +423,27 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
for key, prefixes in LOSSY_PARTS.items()
|
||||
if any(name.startswith(prefix) for name in names for prefix in prefixes)
|
||||
}
|
||||
if _has_cached_formulas(zf):
|
||||
cached_found, cached_unverified = _scan_cached_formulas(zf)
|
||||
if cached_found:
|
||||
found.add("cached_values")
|
||||
elif cached_unverified:
|
||||
found.add("cached_values_unverified")
|
||||
return sorted(found)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return []
|
||||
|
||||
|
||||
def invalidate_workbook_meta(file_path: Path | str) -> None:
|
||||
"""Drop the cached metadata of one workbook (#156-A13).
|
||||
|
||||
Called by the write path right after the atomic replace: the (mtime, size)
|
||||
key already changes on a rewrite, this only closes the theoretical window
|
||||
where a same-size write lands on the same timestamp tick.
|
||||
"""
|
||||
with _meta_cache_lock:
|
||||
_meta_cache.pop(str(file_path), None)
|
||||
|
||||
|
||||
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||
|
||||
@@ -372,7 +451,31 @@ def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
fragments are the workbook's own values, a failure yields ``{}`` per sheet
|
||||
so the viewer keeps its plain rendering. Styles are read with
|
||||
``data_only=False`` — the edited value is the formula, not its result.
|
||||
|
||||
#156-A13 — the result is cached on ``(path, mtime_ns, size)``: a truncated
|
||||
sheet is fetched window by window, and each window used to pay a full
|
||||
workbook load for these maps alone. Callers only read from the mapping.
|
||||
"""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
except OSError:
|
||||
return {}
|
||||
key = str(file_path)
|
||||
stamp = (st.st_mtime_ns, st.st_size)
|
||||
with _meta_cache_lock:
|
||||
hit = _meta_cache.get(key)
|
||||
if hit and hit[0] == stamp:
|
||||
return hit[1]
|
||||
out = _read_workbook_meta_uncached(file_path)
|
||||
with _meta_cache_lock:
|
||||
_meta_cache[key] = (stamp, out)
|
||||
while len(_meta_cache) > _META_CACHE_MAX:
|
||||
_meta_cache.pop(next(iter(_meta_cache)))
|
||||
return out
|
||||
|
||||
|
||||
def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Load the workbook once and build the per-sheet metadata maps."""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), data_only=False)
|
||||
except Exception:
|
||||
@@ -781,17 +884,46 @@ def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
|
||||
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str = ",") -> str:
|
||||
# BUG-098 — a CSV written by a French office suite is `;`-separated, and the
|
||||
# delimiter must be *detected* (then reused on write-back), not assumed.
|
||||
CSV_SNIFF_BYTES = 4096
|
||||
_CSV_DELIMITERS = (",", ";", "\t", "|")
|
||||
|
||||
|
||||
def sniff_csv_delimiter(raw: str) -> str:
|
||||
"""Return the most likely field delimiter of *raw* (``,`` as fallback).
|
||||
|
||||
:class:`csv.Sniffer` handles quoted fields and multi-line values; it is
|
||||
unreliable on short or single-column samples, so the first non-empty line
|
||||
is counted as a tie-breaker and a comma remains the last resort.
|
||||
"""
|
||||
sample = raw[:CSV_SNIFF_BYTES]
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters="".join(_CSV_DELIMITERS)).delimiter
|
||||
except csv.Error:
|
||||
pass
|
||||
first = next((line for line in sample.splitlines() if line.strip()), "")
|
||||
counts = {delim: first.count(delim) for delim in _CSV_DELIMITERS}
|
||||
best = max(counts, key=lambda delim: counts[delim])
|
||||
return best if counts[best] else ","
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str | None = None) -> str:
|
||||
"""Render CSV text as the same HTML table shape the xlsx viewer consumes.
|
||||
|
||||
Row numbers replace the A1 column: a CSV has no fixed column count, so
|
||||
the first row is a plain data row like the others (the viewer offers the
|
||||
toolbar either way). Every cell is HTML-escaped at render time.
|
||||
|
||||
``delimiter`` defaults to the sniffed one (:func:`sniff_csv_delimiter`,
|
||||
BUG-098): a `;`-separated file used to render as a single column.
|
||||
"""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter)
|
||||
reader = csv_mod.reader(
|
||||
io_mod.StringIO(raw), delimiter=delimiter or sniff_csv_delimiter(raw)
|
||||
)
|
||||
try:
|
||||
rows = [row for row in reader]
|
||||
except csv_mod.Error:
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.44.1"
|
||||
version = "2.49.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.44.1"
|
||||
version = "2.49.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.44.1",
|
||||
"version": "2.49.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -19,7 +19,8 @@ avec :
|
||||
- **Normalisation des accents** — `resume` trouve `résumé`, `elephant` trouve `éléphant`.
|
||||
- **Stemming français** — les variantes des mots sont rapprochées.
|
||||
- **Snippets surlignés** — les termes trouvés sont mis en `<mark>` dans l'extrait.
|
||||
- **Facettes** — compteurs par vault et par tag sur les résultats.
|
||||
- **Facettes** — compteurs par vault, par tag et par extension sur les résultats, panneau
|
||||
repliable d'un clic (chevron, état mémorisé).
|
||||
- **Pagination** — 50 résultats par page.
|
||||
- **Tri** — par pertinence (TF-IDF) ou par date de modification.
|
||||
- **Chips de filtres** — les filtres actifs apparaissent sous forme de puces retirables.
|
||||
@@ -164,9 +165,11 @@ l'application. Si vous refusez, rien n'est écrit.
|
||||
|
||||
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
|
||||
|
||||
Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate
|
||||
n'interrompt pas votre travail : un bandeau vous propose de **réessayer**
|
||||
l'enregistrement, vos modifications restant en place.
|
||||
Si le classeur est modifié ailleurs entre-temps (autre poste, Excel,
|
||||
synchronisation…), ObsiGate n'interrompt pas votre travail : un bandeau vous
|
||||
propose de **réessayer**. Le bouton **Réessayer** relit d'abord le fichier pour
|
||||
récupérer la version courante, puis rejoue l'enregistrement — vos
|
||||
modifications restent en place pendant tout ce temps.
|
||||
|
||||
### Formules
|
||||
|
||||
@@ -182,8 +185,14 @@ curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx"
|
||||
- `allow_formula` : `true` pour écrire une vraie formule (`=B1*2`).
|
||||
- `force` : `true` pour enregistrer malgré les éléments non préservés
|
||||
(sinon l'API répond **409** `xlsx_lossy_content`).
|
||||
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
|
||||
**409** `conflict` au lieu d'écraser la première.
|
||||
- `if_match` (facultatif) : la **version** du fichier attendue, telle que la
|
||||
lecture la renvoie (`xlsx_revision` ou `revision`). Si le fichier a changé
|
||||
depuis, l'écriture est refusée (**409** `conflict`, `details.reason =
|
||||
"stale_revision"`) au lieu d'écraser le travail de l'autre écrivain ;
|
||||
relisez le fichier puis renvoyez la nouvelle version. Les trois routes
|
||||
d'écriture (`xlsx/save`, `xlsx/structure`, `csv/save`) acceptent l'en-tête
|
||||
`If-Match` ou le champ `if_match` et renvoient la version à jour dans
|
||||
`revision`.
|
||||
|
||||
### Feuilles volumineuses et lecture par fenêtres
|
||||
|
||||
@@ -206,14 +215,35 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
|
||||
lignes (1 à 1 000 par requête).
|
||||
- La réponse porte `total_rows`, `truncated` et `has_more` pour paginer.
|
||||
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
|
||||
n'est pas un `.xlsx`.
|
||||
n'est ni un `.xlsx` ni un `.xlsm`.
|
||||
|
||||
### Fonctions avancées
|
||||
|
||||
**Barre de formule et navigation clavier** — la cellule active est nommée en
|
||||
A1 au-dessus du tableau ; `Tab`/`Maj+Tab` et les flèches circulent entre les
|
||||
cellules, `Entrée` valide, `Maj+Entrée` insère un retour à la ligne, une
|
||||
plage se copie telle quelle vers un tableur.
|
||||
**Barre de formule, zone Nom et navigation clavier** — au-dessus du tableau, la
|
||||
**zone Nom** affiche l'adresse de la cellule active (`B12`) ou de la plage
|
||||
sélectionnée (`A1:B3`) et elle est **éditable** (« Atteindre ») : saisissez une
|
||||
référence puis `Entrée` pour y aller (`B12`, `A1:B3`, `$A$1`, ou `Feuille2!A1`
|
||||
pour changer d'onglet) ; une référence inconnue est refusée avec un message et
|
||||
l'adresse précédente est restaurée. La barre de formule reflète la cellule
|
||||
active et propose les **noms de fonctions** courants pendant la saisie.
|
||||
|
||||
`Tab`/`Maj+Tab` et les flèches circulent entre les cellules, `Maj+flèches` étend
|
||||
la sélection, `Entrée` valide, `Maj+Entrée` insère un saut de ligne **dans** la
|
||||
cellule, `F2` ouvre la cellule en édition, `Suppr` vide la sélection,
|
||||
`Échap` restaure la valeur d'origine ; `Origine`/`Fin` vont au bord de la ligne,
|
||||
`Ctrl+Origine`/`Ctrl+Fin` aux coins de la feuille affichée,
|
||||
`PgPréc`/`PgSuiv` font défiler d'un écran, `Ctrl+flèches` saute au bout de la
|
||||
plage de données, `Ctrl+A` sélectionne toute la feuille affichée et `Ctrl+S`
|
||||
enregistre. Tant que la cellule n'est pas en cours d'édition, `Suppr`
|
||||
efface la sélection plutôt qu'un caractère.
|
||||
|
||||
**Presse-papiers de plage** — copier, couper et coller un **bloc** de cellules
|
||||
(`Ctrl+C`, `Ctrl+X`, `Ctrl+V`, ou les entrées correspondantes du menu
|
||||
contextuel) : coller un bloc copié ici **ou depuis Excel** remplit la plage à
|
||||
partir de la cellule active et la laisse sélectionnée. Le collage est du
|
||||
**texte** (formules et valeurs recopiées telles quelles) et reste **annulable** ;
|
||||
« couper » efface la source après le collage (un collage sur place ne l'efface
|
||||
pas). Un bloc plus large que la grille affichée est tronqué, avec un message.
|
||||
|
||||
**Annuler / rétablir** — `Ctrl+Z` (ou le bouton **Annuler** du ruban) revient
|
||||
sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
|
||||
@@ -221,28 +251,65 @@ sur les dernières éditions de cellules, `Ctrl+Maj+Z` / `Ctrl+Y` les rétablit.
|
||||
**Sélection et menu contextuel** — cliquer une cellule l'active, **glisser**
|
||||
ou `Maj+clic` sélectionne une plage (affichée dans la zone Nom, ex. `A1:B3`),
|
||||
et cliquer un **en-tête** sélectionne toute la ligne ou colonne. Un **clic
|
||||
droit** (ou un **appui long** sur mobile) ouvre un menu : insérer/supprimer
|
||||
une ligne ou une colonne, trier A→Z / Z→A, effacer le contenu.
|
||||
droit** (ou un **appui long** sur mobile) ouvre un menu : copier, couper,
|
||||
coller, insérer/supprimer une ligne ou une colonne, trier A→Z / Z→A, effacer le
|
||||
contenu.
|
||||
|
||||
**Tri, filtre, recherche, export** — le tri (ascendant / descendant) s'applique
|
||||
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent,
|
||||
la recherche (`Ctrl+F` du panneau) va de correspondance en correspondance, et
|
||||
la feuille s'exporte en CSV. Rien de tout cela ne modifie le classeur.
|
||||
depuis le menu contextuel et n'affecte que l'affichage ; les lignes se filtrent et
|
||||
la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteur
|
||||
indique le nombre de feuilles concernées et passer sur une correspondance
|
||||
**active l'onglet** qui la contient.
|
||||
|
||||
La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais
|
||||
sur les valeurs calculées en cache) :
|
||||
|
||||
- **CSV** (bouton `CSV`) — exporte la **sélection** quand une plage de plusieurs
|
||||
cellules est active (le nom du fichier reprend la plage, ex.
|
||||
`Fruits-A1B2.csv`), sinon la feuille entière ;
|
||||
- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document
|
||||
HTML autonome, mêmes règles de sélection ;
|
||||
- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule,
|
||||
sans le ruban ni les panneaux de l'application.
|
||||
|
||||
Rien de tout cela ne modifie le classeur.
|
||||
|
||||
**Structure** — le menu **Structure** de la barre d'outils ajoute,
|
||||
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
||||
colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
||||
automatique et confirmation, comme pour l'édition des cellules).
|
||||
|
||||
**Styles et mise en page** — la lecture restitue couleurs de police et de
|
||||
fond, gras/italique/souligné, alignements, cellules fusionnées et volets
|
||||
figés ; l'ancrage de la zone figée est conservé au défilement.
|
||||
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
|
||||
**sélection courante** (une cellule ou une plage) :
|
||||
|
||||
- **caractère** — gras, italique, souligné, effacer la mise en forme ;
|
||||
- **alignement** — gauche, centré, droite ;
|
||||
- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune
|
||||
palette imposée) ;
|
||||
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
|
||||
- **structure** — fusionner / défusionner les cellules, figer / libérer les
|
||||
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
|
||||
plage).
|
||||
|
||||
L'écriture passe par `PUT …/xlsx/style`, avec les mêmes garanties que l'édition
|
||||
des cellules : backup automatique, écriture atomique, confirmation si
|
||||
l'opération détruirait des éléments non préservables (graphiques, valeurs
|
||||
calculées en cache…) et **détection d'un écrivain externe** (`If-Match` →
|
||||
message « Réessayer »). Un `.csv` ne portant pas de mise en forme, le bouton
|
||||
n'y est pas proposé (il est également absent d'une grille en lecture seule).
|
||||
La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et
|
||||
volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement.
|
||||
Les **commentaires**, liens hypertexte, validation de données, mise en forme
|
||||
conditionnelle et bordures restent hors périmètre.
|
||||
|
||||
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||
**macros sont préservées** à l'enregistrement ; `.xls` et `.ods` s'affichent
|
||||
en **lecture seule** ; un `.csv` s'ouvre dans la même grille et se réécrit
|
||||
conformément à la RFC 4180 (les guillemets et séparateurs sont
|
||||
échappés).
|
||||
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
||||
lignes au-delà du plafond) ; `.xls` et `.ods` s'affichent en **lecture
|
||||
seule** ; un `.csv` s'ouvre dans la même grille et se réécrit conformément à
|
||||
la RFC 4180 (les guillemets et séparateurs sont échappés). Le **séparateur
|
||||
du CSV est détecté** (`;`, `,` ou tabulation) à la lecture et **réutilisé à
|
||||
l'enregistrement** : un fichier exporté par Excel en français (point-virgule)
|
||||
s'affiche donc en colonnes distinctes et le reste après édition.
|
||||
|
||||
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
|
||||
latéral droit** (la grille reste visible à côté) qui liste les plages
|
||||
@@ -253,8 +320,13 @@ numériques) avec quelques chiffres clés. Cliquer une **plage nommée**
|
||||
sélectionne sa première cellule dans la grille, et le panneau est
|
||||
**redimensionnable**. L'en-tête de l'inspecteur offre
|
||||
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
|
||||
plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
`append_xlsx_rows`).
|
||||
plages. Ses outils couvrent désormais les **trois formats édités**
|
||||
(`.xlsx`, `.xlsm`, `.csv`) : `list_xlsx_sheets` et `xlsx_to_markdown` pour lire,
|
||||
`search_workbook` (recherche dans toutes les feuilles, comptée par feuille),
|
||||
`analyze_range` (agrégats — nombre, somme, moyenne, min, max — d'une plage A1),
|
||||
`update_xlsx_cells` et `append_xlsx_rows` pour modifier, et
|
||||
`edit_xlsx_structure` pour la structure (ajouter/renommer/dupliquer/supprimer
|
||||
une feuille, insérer/supprimer des lignes ou des colonnes).
|
||||
|
||||
### Limites
|
||||
|
||||
@@ -262,14 +334,33 @@ plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
sous une feuille plus grande, le bouton **« Charger la suite »** (ou le
|
||||
défilement vers le bas du tableau) ajoute les lignes suivantes par
|
||||
fenêtres de 500 — elles deviennent aussitôt éditables et
|
||||
sauvegardables.
|
||||
sauvegardables. Le chargement paresseux est **vertical uniquement** :
|
||||
l'axe des colonnes reste tronqué à 40 (les colonnes au-delà ne sont ni
|
||||
affichées ni exportées).
|
||||
- Un **format de nombre personnalisé** (devise, pourcentage…) est signalé
|
||||
par une police à chasse fixe, mais la valeur reste affichée brute.
|
||||
- L'application de **styles** depuis la visionneuse (mettre en gras,
|
||||
colorer) n'est pas proposée — seuls les styles existants sont rendus.
|
||||
par une police à chasse fixe à la lecture ; depuis le bouton **Mise en
|
||||
forme**, appliquer un format ne change que le format de la cellule, **pas**
|
||||
la valeur affichée (aucun recalcul n'est fait, cf. les limites d'export
|
||||
ci-dessous).
|
||||
- `.xls` et `.ods` restent en lecture seule (convertir vers `.xlsx` pour
|
||||
éditer) ; les macros d'un `.xlsm` sont conservées mais ne s'exécutent
|
||||
pas dans ObsiGate.
|
||||
- La **poignée de recopie** (fill), la multi-sélection `Ctrl+clic` et le
|
||||
glisser-déposer de lignes/colonnes ne sont pas proposés ; un collage de
|
||||
plusieurs cellules s'annule **cellule par cellule** (`Ctrl+Z` répété).
|
||||
- Les exports (CSV, Markdown, HTML, impression) reflètent ce qui est **affiché** :
|
||||
une feuille tronquée s'exporte tronquée, et les formules sortent telles
|
||||
qu'enregistrées (aucune valeur calculée n'est recalculée). Un classeur reste
|
||||
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
|
||||
plafond.
|
||||
- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles
|
||||
qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie
|
||||
commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE)
|
||||
tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le
|
||||
signale par un message. Excel reste la référence pour les valeurs calculées.
|
||||
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
|
||||
actions de structure ; en revanche une **suppression** (feuille, ligne,
|
||||
colonne) n'est pas annulable, faute d'inverse.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -203,6 +203,14 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-093* | Le job CI `security` échoue : `pip-audit` bloque sur deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910, PYSEC-2026-3911) — et le plancher `pypdf>=4.0` ne les corrigeait pas, car l'image Act du runner embarque 6.16.0 *préinstallé* dans sa toolcache Python (`Requirement already satisfied` ⇒ jamais mis à niveau) | 🟢 corrigé | P0 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | Run Gitea #1660, job `security` : `Found 2 known vulnerabilities, ignored 2 in 1 package` → `pypdf 6.16.0 PYSEC-2026-3910 6.16.1` / `PYSEC-2026-3911 6.16.1` | Plancher `pypdf>=6.16.1` (correctif des deux advisories), commenté pour expliquer la contrainte de la toolcache. Ajout de `tests/test_ci_workflow.py::TestDependencySecurityFloors`, qui verrouille les planchers de sécurité (`pypdf`, `pyjwt`) et interdit qu'ils retombent sous le correctif | Les deux advisories sont des **consommations de ressources non contrôlées** (PDF à outlines multiples ou à nombreux XForm réutilisés) et sont donc **atteignables** par ObsiGate, dont `backend/pdf_reader.py` extrait le texte et parcourt les outlines de PDF fournis par l'utilisateur. Contre-preuve : plancher remis à `>=4.0` → le garde-fou échoue. pip-audit local : 6.16.1, 6.16.2 et 6.19.0 sans vulnérabilité connue. Correction découverte en lisant le log du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log de l'étape Semgrep collé précédemment datait d'un run antérieur |
|
||||
| *BUG-094* | Feuille `.xlsx` vide ou nouvellement ajoutée : impossible d'y saisir une valeur et d'y insérer une ligne/colonne — la feuille s'affiche « Feuille vide » sans aucune cellule | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `frontend/js/viewer.js::renderXlsxViewer` | Ajouter une feuille (`PUT …/xlsx/structure` `sheet_add`) puis tenter de saisir A1 ou d'insérer une ligne/colonne | `render_sheets()` remplace une grille vide par un quadrillage vierge 20×8 (constantes `EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) aux vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`) | Le classeur n'était pas en cause : seule la **représentation HTML** était vide, donc aucun `td` à sélectionner → aucune cellule active → aucune action de structure possible. Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52 |
|
||||
| *BUG-095* | Le job CI `security` échoue : `pip-audit` bloque sur CVE-2026-102274 dans `pyjwt` 2.13.0 (correctif 2.14.0) — le plancher `>=2.13.0` (BUG-091) est désormais sous le dernier correctif | 🟢 corrigé | P1 | CI / sécurité | IA | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | Run Gitea du push v2.44.0, job `security` : `Found 1 known vulnerability, ignored 2 in 1 package` → `pyjwt 2.13.0 CVE-2026-102274 2.14.0` | Plancher `pyjwt[crypto]>=2.14.0` (au-dessus de la version préinstallée de la toolcache du runner, sinon `pip` répond « already satisfied »). Garde-fou `TestDependencySecurityFloors` : `FLOORS["pyjwt"]` porté à `(2, 14, 0)` — contre-preuve : plancher remis à `2.13.0` → test rouge. Commentaire de la job `security` mis à jour | Le plancher de BUG-091 (2.13.0) corrigeait PYSEC-2026-178 mais est lui-même vulnérable depuis. Même mécanisme que BUG-093 (pypdf) : un plancher de sécurité doit rester au-dessus du dernier correctif. Vérifié : `test_ci_workflow.py` 9 passed ; pip-audit local OK (pyjwt 2.15.1 ≥ 2.14.0) |
|
||||
| *BUG-096* | [🟡 IMPORTANT] Enregistrer un `.csv` depuis la visionneuse échoue : `TypeError` sur `sheets[…].name` (aucun `PUT …/csv/save` émis) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`renderXlsxViewer`, construction des jobs de sauvegarde) | Ouvrir un `.csv` dans ObsiGate, modifier une cellule, cliquer **Enregistrer** | `frontend/js/viewer.js` : le job de sauvegarde ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV — l'origine du `TypeError`) — repli feuille → `data.title`/`data.path`. Test JSDOM « saving an edited csv PUTs /csv/save (payload without xlsx_sheets) ». Vérifié : suite 1490 passed / 6 skipped, `xlsx-viewer.test.mjs` 61/61 | Défaut #156 A1. En mode CSV la réponse de lecture n'a pas de `xlsx_sheets` (`backend/routers/files_read.py`) : `sheets` est vide et `sheets[Number(panel.dataset.sheet)].name` lève un `TypeError` **avant** le `try`, donc aucun `PUT /api/file/{vault}/csv/save` n'est émis (le service `save_csv_cells()` et l'endpoint sont, eux, corrects). **Reproduit en JSDOM le 2026-09-29** (harnais de `tests/frontend/xlsx-viewer.test.mjs`) : `Cannot read properties of undefined (reading 'name')`, 0 requête d'API enregistrée. Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-097* | [🟡 IMPORTANT] Feuille `.xlsm` tronquée : « Charger la suite » échoue en **415** (`GET …/xlsx/sheet` n'accepte que `.xlsx`) alors que le `.xlsm` est éditable | 🟢 corrigé | P1 | ⚙️ backend + 🔌 api | IA | `backend/routers/files_read.py` (`api_file_xlsx_sheet`), `frontend/js/viewer.js` (`wireLazyRows`) | Ouvrir un `.xlsm` de plus de 500 lignes puis cliquer le pied « Charger la suite » (ou approcher du bas du tableau) | `backend/routers/files_read.py` : `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (autres formats → 415 inchangé). Tests `TestXlsmEditable::test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats` | Défaut #156 A2. `.xlsm` est servi éditable (`files_read.py:491-512`, pas de `xlsx_readonly`) donc la visionneuse câble le chargement paresseux, mais `api_file_xlsx_sheet` refuse tout ce qui n'est pas `.xlsx` (`files_read.py:272`). Analyse et critères : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-098* | [🟡 IMPORTANT] Délimiteur CSV figé `,` : un `.csv` français (`;`) s'affiche en une seule colonne et se réécrit dans un autre format | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`render_csv_table`, `delimiter=","` par défaut), `backend/routers/files_read.py:551`, `backend/services/mutations.py` (`save_csv_cells`) | Ouvrir un CSV `;` (export Excel FR) dans ObsiGate : une seule colonne ; éditer une cellule puis enregistrer : le fichier est réécrit en `,` | `backend/xlsx_reader.py` (`sniff_csv_delimiter`) + `backend/services/mutations.py::save_csv_cells` : délimiteur détecté et **réutilisé**. Tests `TestCsvDelimiter` + lecture/écriture/guillemets point-virgule | Défaut #156 A3. `render_csv_table(raw)` est appelé sans délimiteur et `save_csv_cells()` re-parse en `,`, alors que l'export CSV de la visionneuse écrit en `;`. Traitement prévu : détection `;`/`,`/tab partagée lecture/écriture/export. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-099* | [🔵 MINEUR] Sonde de perte plafonnée (8 Mo, budget global) : risque de perte **silencieuse** des valeurs calculées en cache au-delà du budget | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/xlsx_reader.py` (`_MAX_PROBE_BYTES`, `_has_cached_formulas`, `inspect_workbook`) | Constituer un classeur dont le XML de feuille dépasse 8 Mo **avant** la première formule cachée, puis l'éditer : la garde 409 `xlsx_lossy_content` ne se déclenche pas | `backend/xlsx_reader.py` : budget **par feuille** (4 Mo) + plafond global (32 Mo) ; `_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute `cached_values_unverified` (libellé i18n FR/EN). Contre-preuve : budget épuisé → signalé au lieu de `[]`. Tests `TestXlsxCachedValueProbe` (3) | Défaut #156 A4, **analyse statique (non reproduit)**. Le budget est partagé entre toutes les feuilles : au-delà, `cached_values` n'est pas détecté et l'écriture détruit ces valeurs sans avertissement (risque n°1 de #153). Traitement prévu : budget par feuille + signal d'incertitude pour que la garde reste prudente. Analyse : `docs/features/xlsx-editor-completeness.md` |
|
||||
| *BUG-100* | Retour à l'accueil incomplet : le clic sur le titre perd la section « Raccourcis & Astuces » et laisse la recherche globale ainsi que le filtre de la sidebar actifs | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/viewer.js` (`showWelcome`), `frontend/js/legacy.js` (`goHome`) | Ouvrir un fichier (le dashboard est détruit), puis cliquer le titre ObsiGate | `viewer.js` : `#quick-help` capturé au chargement du module et réinjecté dans le template reconstruit de `showWelcome()` ; `legacy.js::goHome` clique `#search-clear-btn` (chips + barre de résultats) et `#sidebar-filter-clear-btn` avant d'afficher l'accueil | **Cause racine :** `showWelcome()` reconstruit `#dashboard-home` depuis un template qui n'embarque pas le bloc `#quick-help` présent dans `index.html` — il ne disparaissait qu'après la première reconstruction (ouverture de fichier, puis retour). Livré avec #158 A3 |
|
||||
| *BUG-101* | Raccourcis d'onglets déclarés deux fois : un seul `Ctrl+W` fermait deux onglets (le second fermait celui ré-activé par la fermeture précédente) et `Ctrl+Tab` sautait un onglet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (bloc « Keyboard shortcuts for tabs » dupliqué en fin de fichier) | Ouvrir deux onglets puis `Ctrl+W` une fois | Suppression du second bloc `document.addEventListener("keydown", …)` dupliqué (fin de `ui.js`) — un seul listener reste | Exposé par #158 : le second onglet n'était plus jamais vide auparavant (la 2ᵉ fermeture tombait sur `_activeTabId === null` et devenait sans effet). Vérifié : repro Playwright (`closes: ["fichier", "nav"]` avant → `["fichier"]` après) + suite E2E |
|
||||
| *BUG-102* | Menu contextuel qui se referme ~10 ms après son ouverture : la reflow des icônes (remplacement `<i>` → `<svg>` par lucide) déclenche un événement `scroll` capturé par `ContextMenuManager` qui referme le menu | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/ui.js` (`ContextMenuManager.init/show`) | Clic droit sur un fichier de la page de navigation (ou de l'arbre) juste après un rendu d'icônes | `show()` mémorise `_shownAt` ; le listener `scroll` (capture) ignore un déclenchement dans les 250 ms suivant l'ouverture | Exposé par #158 A5 (test E2E `nav-tab.spec.js` : menu résolu « hidden » alors que 6 items étaient construits). Vérifié : instrumentation Playwright (display block conservé après le scroll) + suite E2E |
|
||||
| *BUG-103* | Mobile : le bas du sidebar de navigation est masqué par la barre d'outils du bas (`z-index` 200 < 900) | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` (règle `.sidebar` du bloc `@media (max-width: 768px)`) | Vue mobile (≤768 px), sidebar ouvert depuis « Explorateur » | Passé à `z-index: 950` (au-dessus de `.mobile-toolbar`, 900) | Exposé après #158 (retour utilisateur). Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`) |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -301,6 +309,15 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-094 | Correction | `backend/xlsx_reader.py`, `frontend/js/viewer.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-094 — une feuille vide ou nouvellement ajoutée devient éditable et manipulable.** `render_sheets()` substitue une grille vierge 20×8 (`EMPTY_SHEET_ROWS`/`EMPTY_SHEET_COLS`) quand la feuille ne porte aucune cellule, avec de vraies coordonnées A1 ; la visionneuse retombe sur `parseRef(activeRef) || {row:1,col:1}` pour que le menu Structure propose toujours insérer/supprimer ligne et colonne. Contre-preuve : `TestXlsxDisplay::test_empty_sheet_renders_an_editable_blank_grid` (sans le correctif : « Feuille vide » sans `data-cell`). Vérifié : `test_xlsx_viewer.py` 59 passed, `xlsx-viewer.test.mjs` 52/52. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-095 | Correction CI / sécurité | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Le job `security` repasse au vert** : `pyjwt` 2.13.0 est vulnérable (CVE-2026-102274, correctif 2.14.0) et le plancher de BUG-091 (`>=2.13.0`) était donc sous le dernier correctif. Plancher porté à `pyjwt[crypto]>=2.14.0` (au-dessus de la toolcache du runner, sinon `pip` répond « already satisfied »), garde-fou `TestDependencySecurityFloors` mis à jour (contre-preuve : plancher remis à 2.13.0 → test rouge), commentaire de la job `security` aligné. Vérifié : `test_ci_workflow.py` 9 passed, pip-audit local OK (pyjwt 2.15.1). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-096 → BUG-099 (#156 A1-A4) | Correction | `frontend/js/viewer.js`, `frontend/locales/{fr,en}.json`, `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/test_xlsx_viewer.py`, `tests/test_xlsx_formats.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `docs/ISSUES_TODOLIST.md` | **Les 4 défauts P0 du tableur sont corrigés.** (BUG-096) l'enregistrement d'un `.csv` depuis la visionneuse ne lit plus le nom de feuille dans `xlsx_sheets` (absent du payload CSV) : le `TypeError` partait **avant** tout appel réseau, aucun `PUT …/csv/save` n'était émis. (BUG-097) `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` — le chargement des lignes au-delà du plafond fonctionne pour les classeurs macro. (BUG-098) `sniff_csv_delimiter()` détecte `;`/`,`/tabulation et `save_csv_cells()` réutilise le délimiteur : un CSV français s'affiche en colonnes distinctes et **le reste** après édition. (BUG-099) sonde de perte **par feuille** (4 Mo) + plafond global (32 Mo) et clé `cached_values_unverified` quand le budget est épuisé — plus de perte silencieuse possible des valeurs calculées. Tests : `TestXlsxCachedValueProbe` (3, contre-preuve : budget épuisé signalé au lieu de `[]`), `TestCsvDelimiter` + 3 tests CSV, fenêtre `.xlsm` (+ refus `.csv`), 1 test JSDOM CSV. Vérifié : suite **1490 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), `xlsx-viewer.test.mjs` 61/61, validate-imports 40 modules, unit 12/12, i18n parity. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | #156 A5-A7 (P1) | Fonctionnalité (sans nouveau défaut) | `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` | **P1 livré** — (A5) presse-papiers de plage : copier/couper/coller un bloc TSV au clavier et au menu contextuel, presse-papiers interne + miroir système, remplissage multi-cellules, insertion **texte** échappée, débordement signalé, annulable ; (A6) clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) ; (A7) zone Nom éditable (« Atteindre ») + liste de fonctions. Vérifié : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux), E2E Playwright. | ✅ livré |
|
||||
| 2026-09-30 | #156 A8-A14 (P2 + P3) — clôture du backlog | Fonctionnalité (sans nouveau défaut) | `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/xlsx_reader.py`, `backend/openapi_docs.py`, `backend/tools/{spreadsheets,schemas,labels}.py`, `frontend/js/viewer.js`, `frontend/js/xlsx/command-bar.js`, `frontend/locales/{fr,en}.json`, `frontend/style.css`, `tests/test_xlsx_styles.py`, `tests/test_xlsx_viewer.py`, `tests/test_spreadsheet_tools.py`, `tests/test_xlsx_formats.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **P2 + P3 livrés, backlog #156 clôturé** — (A8) **mise en forme en écriture** : bouton Mise en forme (gras/italique/souligné, alignements, couleurs via sélecteur natif, formats de nombre, fusion/défusion, volets figés, largeur/hauteur) et nouvelle route `PUT …/xlsx/style` (`mutate_xlsx_style` : verrou, backup, swap atomique, garde de perte, `If-Match`) ; (A9) décision « pas de moteur de formule » **annoncée dans l'UI** ; (A10) undo/redo unifié avec piles par fichier conservées au re-rendu ; (A11) export de la sélection + Markdown + HTML + impression et recherche sur **toutes** les feuilles (`n/m · k feuilles`) ; (A12) concurrence optimiste `If-Match` → **409** `conflict`/`stale_revision`, retry qui relit ; (A13) cache de `read_workbook_meta()` par `(chemin, mtime_ns, taille)` (LRU 8) ; (A14) outils IA `.xlsm`/`.csv` + `search_workbook`, `analyze_range`, `edit_xlsx_structure` (confirmation conservée). Vérifié : suite **1525 passed / 6 skipped**, ruff 0, mypy 0 (102 fichiers), JSDOM `xlsx-viewer.test.mjs` 107/107, validate-imports + unit, i18n parity 2355/2355, E2E `xlsx` 10/10 + `Split View` 37 + `XSS` 2. | ✅ livré |
|
||||
| 2026-09-29 | #156 (audit), BUG-096 → BUG-099 | Enregistrement (audit statique + 1 repro JSDOM) | `docs/ROADMAP.md`, `docs/features/xlsx-editor-completeness.md` (nouveau), `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Audit de complétude de l'éditeur Excel → ouverture de l'item #156** (P0 défauts · P1 presse-papiers/clavier · P2 mise en forme/calcul/undo · P3 sortie/robustesse/perf) avec fiche dédiée. **4 défauts** enregistrés : BUG-096 (enregistrement `.csv` en `TypeError`, **reproduit en JSDOM** — `Cannot read properties of undefined (reading 'name')`, aucun `PUT …/csv/save`), BUG-097 (lazy-load `.xlsm` → 415), BUG-098 (délimiteur CSV `,` figé vs export `;`), BUG-099 (sonde de perte plafonnée à 8 Mo, risque théorique). Manques fonctionnels recensés : presse-papiers de plage, clavier complet, zone Nom éditable, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste, cache des métadonnées, outils IA `.xlsm`/`.csv`. **Aucun code modifié** (documentation seule). | 🔴 ouvert (à traiter) |
|
||||
| 2026-10-02 | #158 (A1-A3), BUG-100 | Fonctionnalité + correction | `frontend/js/navfacets.js` (nouveau), `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `frontend/js/legacy.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/services/vaults.py`, `backend/schemas.py`, `tests/test_nav_files.py` (nouveau), `tests/frontend/navfacets.test.mjs` (nouveau), `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **A1** : `TabManager.openNav(vault, dir)` — un onglet de navigation par vault, ouvert à chaque clic répertoire de l'arbre (mode focus et mode All) sans fermer les onglets de fichiers ; la ligne vault reste un expandeur. **A2** : la vue liste les sous-répertoires cliquables et embarque le panneau de facettes **Vaults · Tags · Extensions** (mêmes classes que la page de recherche), les tris **Pertinence / Date** et le bouton **Sauver** ; `GET /api/vault/{v}/files` expose désormais les `tags` indexés (facettes calculées côté client). **A3 / BUG-100** : `showWelcome()` réinjecte `#quick-help` et `goHome()` efface recherche globale + filtre sidebar. Tests : `test_nav_files.py` 3 passed, `navfacets.test.mjs` 10/10, `unit.test.mjs` 12/12, `validate-imports` 41 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | #158 (A4-A6) | Fonctionnalité | `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/pane-manager.js`, `frontend/js/legacy.js`, `frontend/js/sidebar.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/e2e/nav-tab.spec.js` (nouveau), `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/features/navigation-tab-158.md` | **A4** : `TabManager.openHome()` — le clic sur le titre ouvre la page d'accueil dans un **onglet Accueil** (icône Maison, `common.home` FR/EN) remonté en position 0 à chaque fois ; `deactivate()` conserve cet onglet actif, `pane-manager.js` a son propre `openHome` pour le split. **A5** : menus contextuels répertoires **et** fichiers de la page de navigation via `ContextMenuManager.show()` (avec `stopPropagation()`, le handler global referme sinon le menu) — listes vérifiées identiques à la sidebar ; la ligne d'un vault rouvre la page de navigation. **A6** : icône des répertoires à `var(--accent)` (couleur de l'arbre). **Correctif annexé** : l'effacement du filtre sidebar au retour à l'accueil n'est déclenché que s'il était actif (sinon `restoreSidebarTree()` repliait l'arbre). Vérifié : Playwright jetable 16/16, `nav-tab.spec.js` 4 tests, validate-imports/unit/i18n parity verts. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-101 | Correction | `frontend/js/ui.js` | **BUG-101 — le bloc « Keyboard shortcuts for tabs » existait en double dans `ui.js`** (fin de fichier) : un seul `Ctrl+W` déclenchait **deux** `close()`, le second refermant l'onglet ré-activé par le premier — observé avec #158 quand un onglet navigation survit à la fermeture du fichier ; `Ctrl+Tab` sautait également un onglet. Bloc dupliqué supprimé (un seul listener). Vérifié : repro Playwright (`close()` appelé 2× puis 1×) et suite E2E. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-102 | Correction | `frontend/js/ui.js` | **BUG-102 — menu contextuel refermé par sa propre ouverture** : la reflow des icônes lucide à l'affichage déclenche un `scroll` capturé qui refermait le menu ~10 ms après son apparition (constaté sur les fichiers de la page de navigation, #158 A5). `show()` mémorise `_shownAt` et le listener `scroll` ignore un déclenchement dans les 250 ms. Vérifié : instrumentation Playwright + suite E2E (`nav-tab.spec.js`). | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-02 | BUG-103 | Correction | `frontend/style.css` | **BUG-103 — sidebar mobile sous la barre d'outils du bas** : en mobile le `.sidebar` (`z-index: 200`) passait sous `.mobile-toolbar` (`z-index: 900`, 64 px de haut), masquant les dernières entrées de l'arbre ; passé à `950`. Vérifié : `elementFromPoint` Playwright (393×851) + test source `mobile sidebar over toolbar` (`unit.test.mjs`). | 🟢 corrigé |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+91
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.44.1 | **Dernière mise à jour :** 2026-09-29
|
||||
> **Version :** 2.49.1 | **Dernière mise à jour :** 2026-10-02
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -137,6 +137,89 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Navigation
|
||||
|
||||
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré** — 2026-10-02 (A1 onglet, A2 contenu/filtres/tris, A3 retour Home +
|
||||
**BUG-100**)
|
||||
- **Détail de conception :** [features/navigation-tab-158.md](./features/navigation-tab-158.md)
|
||||
- **Description :** la vue « chemin + Récents + fichiers du répertoire » (vault home) n'apparaît
|
||||
qu'en mode focus vault, disparaît dès qu'un fichier est ouvert, n'expose ni sous-répertoires ni
|
||||
filtres, et le clic sur le titre ne redonne pas la vraie page d'accueil (section
|
||||
**Raccourcis & Astuces** manquante, recherche/filtres non effacés).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1 — onglet de navigation.** Tout clic sur un **répertoire** de l'arbre (en mode focus
|
||||
vault **et** en mode All) ouvre/actualise un **onglet de navigation** dédié, qui
|
||||
coexiste avec les onglets de fichiers ouverts. La ligne d'un vault dans l'arbre garde son
|
||||
rôle d'expansion ; la racine du vault s'atteint depuis la vue (fil d'Ariane, facette
|
||||
Vaults) ou le sélecteur de vault.
|
||||
- [x] **A2 — contenu de la vue.** Liste des **sous-répertoires cliquables**, panneau de
|
||||
**facettes Vaults · Tags · Extensions** (même mécanisme/visuel que la page de recherche),
|
||||
boutons de tri **Pertinence / Date** et bouton **Sauver**.
|
||||
- [x] **A3 — retour Home complet.** Clic sur le titre = page d'accueil d'un refresh
|
||||
(section **Raccourcis & Astuces** restaurée) + effacement de la recherche globale et de
|
||||
la barre de filtre de la sidebar gauche.
|
||||
- [x] **A4 — onglet Accueil.** Le clic sur le titre affiche la page d'accueil dans un onglet
|
||||
(icône Maison) épinglé **en tête de barre**, réactivable à tout moment.
|
||||
- [x] **A5 — menus contextuels de la page de navigation.** Clic droit identique à la sidebar
|
||||
sur les répertoires **et** les fichiers de la vue ; la ligne d'un vault dans l'arbre
|
||||
ouvre la page de navigation (racine du vault).
|
||||
- [x] **A6 — icône des répertoires** de la section Répertoires à la couleur de l'arbre
|
||||
(`var(--accent)`).
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Tableur Excel (complétude)
|
||||
|
||||
### 156. Éditeur Excel — complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
- **Effort :** 15-22 jours (P0 2-3 j ✅ · P1 4-6 j ✅ · P2 5-7 j ✅ · P3 4-6 j ✅) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-09-30** — **P0** (4 défauts **BUG-096 → BUG-099** corrigés),
|
||||
**P1** (**A5-A7** : presse-papiers de plage, clavier complet, zone Nom éditable), **P2**
|
||||
(**A8** mise en forme en écriture, **A9** décision « pas de moteur de formule, annoncée dans
|
||||
l'UI », **A10** undo/redo unifié conservé au re-rendu) et **P3** (**A11** export sélection/
|
||||
Markdown/HTML/impression + recherche multi-feuilles, **A12** concurrence optimiste `If-Match`,
|
||||
**A13** cache des métadonnées, **A14** outils IA `.xlsm`/`.csv` + recherche/analyse/structure)
|
||||
- **Analyse, défauts, risques et critères d'acceptation :** [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md)
|
||||
- **Description :** #153 a rendu l'éditeur **correct** sur la grille de valeurs, #154/#155 l'ont
|
||||
rendu **convivial** (ruban, inspecteur, undo/redo des cellules, sélection et menu contextuel
|
||||
type Excel). Il reste l'écart avec un vrai éditeur tableur : coller une **plage**, écrire la
|
||||
**mise en forme**, **calculer**, **sortir** le résultat, et détecter un écrivain concurrent.
|
||||
La fiche porte l'audit ; les cases ci-dessous sont le **suivi de référence**.
|
||||
- **Défauts corrigés le 2026-09-29 (cf. [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md)) :**
|
||||
- ✅ BUG-096 — enregistrer un `.csv` depuis la visionneuse levait un `TypeError`
|
||||
(`sheet: sheets[…].name` alors qu'un CSV n'a pas de `xlsx_sheets`) : aucun `PUT …/csv/save`
|
||||
n'était émis (reproduit en JSDOM, corrigé + test) ;
|
||||
- ✅ BUG-097 — feuille `.xlsm` tronquée : « Charger la suite » échouait en **415** —
|
||||
`GET …/xlsx/sheet` accepte désormais `.xlsx` **et** `.xlsm` ;
|
||||
- ✅ BUG-098 — délimiteur CSV détecté (`;`/`,`/tabulation) et réutilisé à l'écriture :
|
||||
un CSV français s'affiche en colonnes et le reste après édition ;
|
||||
- ✅ BUG-099 — sonde de perte **par feuille** + signal `cached_values_unverified` quand le
|
||||
budget est épuisé : plus de perte silencieuse possible des valeurs calculées.
|
||||
- **Sous-tâches :**
|
||||
- **P0 — défauts (🔴, 2-3 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A1** Sauvegarde `.csv` depuis la visionneuse — BUG-096 (test JSDOM : `PUT …/csv/save` émis)
|
||||
- [x] **A2** Chargement paresseux des `.xlsm` — BUG-097 (`GET …/xlsx/sheet` accepte `.xlsx`/`.xlsm`)
|
||||
- [x] **A3** Délimiteur CSV détecté et réutilisé à l'écriture — BUG-098 (`sniff_csv_delimiter`)
|
||||
- [x] **A4** Sonde de perte par feuille + signal `cached_values_unverified` — BUG-099
|
||||
- **P1 — presse-papiers & clavier (🟡, 4-6 j) — ✅ livré le 2026-09-29**
|
||||
- [x] **A5** Presse-papiers de plage — copier/couper/coller un bloc TSV (`Ctrl+C`/`Ctrl+X`/`Ctrl+V`, menu contextuel), presse-papiers interne + système, remplissage multi-cellules (tests JSDOM : `A1:B2` → `D5:E6`)
|
||||
- [x] **A6** Clavier complet — `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` (saut de ligne en cellule)
|
||||
- [x] **A7** Zone Nom éditable (« Atteindre » : `B12`, `A1:B3`, `Feuille!A1`) + aide à la saisie des fonctions
|
||||
- **P2 — mise en forme, calcul, undo (🟡, 5-7 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A8** Mise en forme en écriture — bouton **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur) via `PUT …/xlsx/style` (verrou, backup, garde de perte, `If-Match`)
|
||||
- [x] **A9** Calcul — **décision documentée** : pas de moteur de formule, l'enregistrement annonce « enregistrée comme texte » (la garde anti-DDE reste)
|
||||
- [x] **A10** Undo/redo unifié (cellules, effacement, tri/filtre, structure) et conservé au re-rendu (`_xlsxHistory` par fichier)
|
||||
- **P3 — sortie, robustesse, performances (🟢, 4-6 j) — ✅ livré le 2026-09-30**
|
||||
- [x] **A11** Sortie & recherche : export de la sélection / Markdown / HTML / impression, recherche sur toutes les feuilles (compteur `n/m · k feuilles`)
|
||||
- [x] **A12** Concurrence optimiste inter-processus (`ETag`/`If-Match`, **409** réparable, bouton « Réessayer » qui relit)
|
||||
- [x] **A13** Performances : cache des métadonnées par `(chemin, mtime, taille)` (LRU 8), invalidé à chaque écriture (axe des colonnes > `MAX_COLS` : hors périmètre)
|
||||
- [x] **A14** Outils IA étendus — `.xlsm`/`.csv` acceptés, `search_workbook`, `analyze_range`, `edit_xlsx_structure`
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -187,6 +270,7 @@
|
||||
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
|
||||
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
|
||||
| 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
|
||||
@@ -254,6 +338,8 @@
|
||||
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
|
||||
| 85 | Refonte architecturale — découpage du monolithe (14 routers, `main.py` 4 827 → ~750 lignes), stores JSON verrouillés, rate-limit SQLite optionnel | 2.27.2→2.27.13 | [features/archi-refonte-85.md](./features/archi-refonte-85.md) |
|
||||
| 157 | Recherche — facette « Extensions » dans les résultats (3ᵉ filtre avec Vaults et Tags) + panneau repliable | 2.46.0→2.47.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 158 | Navigation — clic répertoire → **onglet de navigation** (sous-répertoires, facettes Vaults/Tags/Extensions, tri Pertinence/Date, Sauver), **onglet Accueil**, menus contextuels + retour Home complet (**BUG-100** → **BUG-102**) | 2.48.0→2.49.0 | [features/navigation-tab-158.md](./features/navigation-tab-158.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -268,6 +354,7 @@
|
||||
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
|
||||
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.43.1) | 0 jour restant |
|
||||
| ✅ Terminé | #155 Ergonomie tableur — menu contextuel & sélection type Excel | 0 jour restant |
|
||||
| ✅ Terminé | #156 Éditeur Excel — complétude — **P0-P3 ✅ livrés le 2026-09-30** (BUG-096 → BUG-099, A5-A14) | 0 jour restant |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||
|
||||
---
|
||||
@@ -278,6 +365,9 @@
|
||||
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
|
||||
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
|
||||
- **Ajout 2026-09-29 :** #155 livré — menu contextuel (clic droit / appui long) et sélection type Excel sur la grille ([features/xlsx-context-menu.md](./features/xlsx-context-menu.md)) ; au passage **BUG-094** corrigé (feuille vide/nouvelle désormais éditable, quadrillage vierge 20×8).
|
||||
- **Clôture 2026-09-30 :** #156 **livré (P0 → P3)** — **A8** mise en forme en écriture (bouton **Mise en forme** : gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — via `PUT …/xlsx/style`), **A9** décision « pas de moteur de formule, annoncée dans l'UI », **A10** undo/redo unifié conservé au re-rendu, **A11** export de la sélection / Markdown / HTML / impression + recherche sur toutes les feuilles, **A12** concurrence optimiste (`If-Match`, **409** réparable), **A13** cache des métadonnées par `mtime`, **A14** outils IA `.xlsm`/`.csv` + `search_workbook`/`analyze_range`/`edit_xlsx_structure` — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Ajout 2026-09-29 :** #156 **P1 livré** — **A5** presse-papiers de plage (copier/couper/coller un bloc, presse-papiers interne + système, entrées du menu contextuel, remplissage multi-cellules), **A6** clavier complet (`Ctrl+S`/`Ctrl+A`/`Suppr`/`F2`/`Ctrl+Home|End`/`PgUp|PgDn`/`Ctrl+flèches`/`Maj+Entrée`) et **A7** zone Nom éditable + aide à la saisie ; restent P2 (mise en forme, calcul, undo unifié) et P3 (sortie, concurrence optimiste, performances, outils IA).
|
||||
- **Ajout 2026-09-29 :** #156 ouvert — audit de complétude de l'éditeur Excel : **4 défauts recensés** (BUG-096 enregistrement `.csv`, BUG-097 lazy-load `.xlsm`, BUG-098 délimiteur CSV, BUG-099 sonde de perte), **corrigés le jour même (P0 ✅)** avec tests de non-régression ; restent P1-P3 (presse-papiers, clavier, mise en forme, calcul, export, concurrence optimiste) puis presse-papiers de plage, clavier complet, mise en forme en écriture, calcul, undo/redo unifié, export/impression, concurrence optimiste — détail dans [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md).
|
||||
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
|
||||
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
|
||||
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
|
||||
|
||||
@@ -419,6 +419,24 @@ première modification et téléchargement du fichier d'origine.
|
||||
|
||||
---
|
||||
|
||||
## #157 — Recherche : facette « Extensions » ✅ TERMINÉ
|
||||
|
||||
Le panneau de facettes de la page de résultats de recherche affiche désormais **trois** groupes :
|
||||
**Vaults**, **Tags** et **Extensions**. Le troisième liste les extensions de fichiers présents
|
||||
dans le résultat courant (`.md`, `.xlsx`, `.pdf`, …) avec leur compteur ; un clic ajoute
|
||||
l'opérateur `ext:<type>` à la requête (un `ext:` déjà actif est remplacé, jamais dupliqué).
|
||||
L'ensemble du panneau se replie/rouvre d'un clic sur un bouton discret (chevron), état
|
||||
mémorisé dans `localStorage`.
|
||||
|
||||
| Aspect | Détail |
|
||||
|---|---|
|
||||
| Backend | `backend/search.py::advanced_search` — `facets.extensions` : compteurs par extension normalisée (sans point, en minuscules), triés par fréquence · **`backend/schemas.py::SearchFacets`** expose le champ (sinon le `response_model` le supprimait — corrigé après livraison de 2.46.0) |
|
||||
| Frontend | `frontend/js/search.js::renderAdvancedSearchResults` — groupe `[data-facet="extensions"]`, items montés en `textContent` (les suffixes viennent de noms de fichiers non fiables) ; panneau repliable via `.search-facets__toggle` + `data-collapsed` (état `obsigate_facets_collapsed`) |
|
||||
| i18n / aide | `help.desc_facet_extensions`, `search.facets_collapse`/`_expand` (FR/EN), guide `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md` |
|
||||
| Tests | `tests/test_search.py::test_facets` (route réelle `/api/search/advanced`, assertion stricte `facets.extensions`), `tests/frontend/search-facets.test.mjs` (5 tests JSDOM, ajoutés au CI), e2e `obsigate.spec.ts` (facette visible + repli/rouverture) |
|
||||
|
||||
---
|
||||
|
||||
## Grosses fonctionnalités — fiches dédiées
|
||||
|
||||
| # | Feature | Version | Fiche |
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# #158 — Navigation : vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
|
||||
> **Statut :** livré le 2026-10-02 · **ID :** `#158` (correctif associé : `BUG-100`)
|
||||
> **Fichiers :** `frontend/js/vaulthome.js`, `frontend/js/ui.js`, `frontend/js/sidebar.js`,
|
||||
> `frontend/js/navfacets.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/legacy.js`,
|
||||
> `backend/services/vaults.py`, `backend/schemas.py`, `frontend/style.css`,
|
||||
> `frontend/locales/{fr,en}.json`
|
||||
|
||||
## Problème
|
||||
|
||||
La vue « chemin + Récents + fichiers du répertoire » (vault home) ne s'affichait qu'en mode
|
||||
focus vault, disparaissait dès qu'un fichier était ouvert (aucun onglet), ne listait pas les
|
||||
sous-répertoires, n'avait ni filtre ni tri, et le clic sur le titre ne rendait pas la vraie
|
||||
page d'accueil.
|
||||
|
||||
## Conception
|
||||
|
||||
**Un onglet de navigation par vault.** `TabManager.openNav(vault, dir)` (`frontend/js/ui.js`)
|
||||
crée/rétarget l'onglet `nav::<vault>` (icône dossier) ; `TabManager.activate()` branche sur
|
||||
`cache.nav` et rend `showVaultHome(vault, dir)` au lieu de fetcher un fichier. Les onglets de
|
||||
fichiers ne sont jamais fermés. Le dashboard appelle `TabManager.deactivate()` : aucun onglet
|
||||
ne reste « actif », donc un clic suivant ré-éffectue réellement l'onglet (avant : early-return
|
||||
et vue figée).
|
||||
|
||||
Points d'entrée des clics (`frontend/js/sidebar.js`) : clic **répertoire** dans l'arbre (2 sites,
|
||||
mode focus et mode All), clic sur la **racine d'un vault** (2 sites, expansion + navigation),
|
||||
changement de contexte vault (sélecteur), facettes/filtres et fil d'Ariane de la vue
|
||||
(`vaulthome.js` route tout par `openNav`, plus aucune délégation sur l'arbre).
|
||||
|
||||
**Menus contextuels** : les répertoires et les fichiers de la vue appellent le même
|
||||
`ContextMenuManager.show()` que la sidebar, avec `stopPropagation()` — le handler global de
|
||||
`ContextMenuManager` referme le menu sur tout clic droit hors `.tree-item`.
|
||||
|
||||
**Onglet Accueil** (`ui.js::openHome` + `pane-manager.js::openHome`) : le clic sur le titre
|
||||
crée (ou remonte en position 0) l'onglet `home` — icône Maison, libellé i18n `common.home` —
|
||||
et l'active ; `activate()` rend la page d'accueil via `showWelcome()` et `deactivate()`
|
||||
conserve l'onglet Accueil actif (il *est* la page d'accueil). En mode split, l'onglet vit dans
|
||||
le panneau actif (délégation `getActiveTabManager()`, libellé transmis par l'appelant pour ne
|
||||
pas d'importer `t` dans `pane-manager.js`, qui déclare déjà des variables locales `t`).
|
||||
|
||||
**Contenu de la vue** (`vaulthome.js`) : fil d'Ariane · barre **Pertinence / Date** + **Sauver**
|
||||
· panneau de facettes · **Répertoires** cliquables (`GET /api/browse`) · Récents (racine) ·
|
||||
fichiers (`GET /api/vault/{v}/files?recursive=false`). Les facettes réutilisent les classes de
|
||||
la page de recherche (`search-facets`, `search-sort`, `search-save-btn`) — même visuel, même
|
||||
repli `obsigate_facets_collapsed`, filtrage **local** (pas de nouvelle requête).
|
||||
|
||||
**Facettes** (`frontend/js/navfacets.js`, pur, sans DOM) : `buildNavFacets`,
|
||||
`filterNavFiles`, `sortNavFiles`. Le listing embarque les tags indexés :
|
||||
`GET /api/vault/{v}/files` renvoie `tags` (`_indexed_tags()` dans
|
||||
`backend/services/vaults.py`, lookup `vault::path` dans l'index inversé, `[]` si non indexé).
|
||||
|
||||
## Décisions / limites connues
|
||||
|
||||
- **Un seul onglet de navigation par vault** : cliquer un autre répertoire re-cible le même
|
||||
onglet (pas d'empilement d'onglets). En mode split, la vue s'affiche dans la zone principale.
|
||||
- **« Pertinence » = tri alphabétique** (`ponytail:` commenté) : un listing de répertoire n'a
|
||||
aucun signal de pertinence tant que la vue n'a pas sa propre requête — voir
|
||||
`navfacets.js::sortNavFiles`.
|
||||
- Facettes calculées sur la liste courante (répertoire non récursif, 200 fichiers max) : les
|
||||
sous-répertoires non listés n'apportent aucune compte.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_nav_files.py` (3) : `tags` exposés + survivent au `response_model`, tags en
|
||||
sous-répertoire, miss d'index → `[]`.
|
||||
- `tests/frontend/navfacets.test.mjs` (10) : comptages, filtres tag/ext (insensible au point),
|
||||
combinaison ET, tris, non-mutation.
|
||||
- `tests/frontend/unit.test.mjs` : `navfacets.js` ajouté à la liste des modules purs.
|
||||
- `tests/e2e/nav-tab.spec.js` (4) : racine vault → page de navigation, icône + menus
|
||||
contextuels identiques à la sidebar, onglet Accueil en tête de barre (avec `#quick-help`).
|
||||
- Vérification manuelle Playwright (jetable) : 22/22 puis 16/16 checks sur les lots A1-A3 et
|
||||
A4-A6.
|
||||
@@ -0,0 +1,322 @@
|
||||
# #156 — Éditeur Excel : complétude fonctionnelle (presse-papiers, mise en forme, calcul, robustesse)
|
||||
|
||||
> **Item de roadmap :** [#156 — Éditeur Excel — complétude fonctionnelle](../ROADMAP.md)
|
||||
> **Origine :** audit de complétude demandé le 2026-09-29, après #153 (backlog tableur),
|
||||
> #154 (refonte UI/UX) et #155 (menu contextuel & sélection)
|
||||
> **Statut :** ✅ **livré le 2026-09-30** — P0 ✅ (BUG-096 → BUG-099), P1 ✅ (A5, A6, A7),
|
||||
> P2 ✅ (A8, A9, A10) et P3 ✅ (A11, A12, A13, A14) ; ouverture 2026-09-29 (audit statique,
|
||||
> A1 reproduit en JSDOM)
|
||||
> **Effort estimé :** 15-22 jours (P0 2-3 j · P1 4-6 j · P2 5-7 j · P3 4-6 j) — **réalisé dans
|
||||
> l'enveloppe annoncée**
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, les défauts, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
#153 a rendu l'éditeur XLSX **correct sur le périmètre « grille de valeurs »** (lecture/écriture
|
||||
gardées, styles en lecture, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation, outils IA,
|
||||
tableau de bord). #154/#155 l'ont rendu **convivial** (ruban, inspecteur, undo/redo des cellules,
|
||||
sélection et menu contextuel type Excel).
|
||||
|
||||
Reste l'écart avec ce qu'un utilisateur attend d'un **éditeur tableur** : coller une plage,
|
||||
penser la mise en forme, calculer, sortir le résultat, et se protéger d'un autre écrivain.
|
||||
Cet item recense **4 défauts** (A1 reproduit, A4 théorique) et les **manques fonctionnels**
|
||||
correspondants.
|
||||
|
||||
## 2. Périmètre (couches touchées)
|
||||
|
||||
| Couche | Fichier | Rôle dans cet item |
|
||||
|---|---|---|
|
||||
| Lecture XLSX | `backend/xlsx_reader.py` | Fenêtres de lignes, délimiteur CSV, sonde de perte, métadonnées |
|
||||
| Écriture XLSX | `backend/services/mutations.py` | Cellules, structure, **style (A8)**, coercion, `revision` (A12) |
|
||||
| Endpoints | `backend/routers/files_read.py`, `files_write.py` | `xlsx/sheet`, `xlsx/save`, `xlsx/structure`, **`xlsx/style` (A8)**, `csv/save` |
|
||||
| Outils IA | `backend/tools/spreadsheets.py` | Lecture/édition par l'assistant (`.xlsx`/`.xlsm`/`.csv`, recherche, analyse, structure — A14) |
|
||||
| Visionneuse | `frontend/js/viewer.js` + `frontend/js/xlsx/*` | Presse-papiers, clavier, barre de formule, undo/redo, export |
|
||||
| Styles | `frontend/style.css` | Affordances presse-papiers/mise en forme (variables CSS) |
|
||||
| i18n | `frontend/locales/{fr,en}.json` | Tout libellé nouveau, FR **et** EN |
|
||||
| Tests | `tests/test_xlsx_*.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js` | Non-régression par sous-tâche |
|
||||
|
||||
## 3. Audit
|
||||
|
||||
### 3.1 Défauts recensés (P0) — ✅ corrigés le 2026-09-29
|
||||
|
||||
#### ✅ A1 → BUG-096 — Enregistrer un `.csv` depuis la visionneuse échoue
|
||||
|
||||
`frontend/js/viewer.js` construit l'unique job de sauvegarde ainsi :
|
||||
|
||||
```js
|
||||
const jobs = panelEls.map((panel) => {
|
||||
const cells = {};
|
||||
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
|
||||
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells }; // ← ligne 1757
|
||||
}).filter((job) => Object.keys(job.cells).length);
|
||||
```
|
||||
|
||||
En mode CSV, la réponse de lecture (`backend/routers/files_read.py:548-556`) **ne contient pas
|
||||
`xlsx_sheets`** : `sheets` est donc vide et `sheets[...].name` lève un `TypeError`. Vérifié le
|
||||
2026-09-29 avec le harnais JSDOM (`tests/frontend/xlsx-viewer.test.mjs`) : après édition d'une
|
||||
cellule CSV et clic sur **Enregistrer**, `TypeError: Cannot read properties of undefined
|
||||
(reading 'name')` et **aucun** `PUT /api/file/{vault}/csv/save` n'est émis. Le service backend
|
||||
`save_csv_cells()` et l'endpoint sont pourtant corrects : le défaut est purement côté visionneuse.
|
||||
|
||||
*Cause racine :* le mode CSV réutilise un panneau unique (`data-sheet="0"`) sans feuille
|
||||
correspondante dans `sheets` ; le chemin d'enregistrement n'a pas été adapté comme l'ont été les
|
||||
onglets, la structure et le tableau de bord (qui, eux, testent `isCsv`).
|
||||
|
||||
**Correctif (2026-09-29) :** le nom de feuille est résolu avec repli
|
||||
(`sheets[idx]?.name || sheets[0]?.name || data.title || data.path`). Test JSDOM : un CSV monté
|
||||
**sans** `xlsx_sheets` émet un unique `PUT …/csv/save`.
|
||||
|
||||
#### ✅ A2 → BUG-097 — `.xlsm` éditable : « Charger la suite » renvoie 415 au-delà de 500 lignes
|
||||
|
||||
Un `.xlsm` est servi **éditable** (`backend/routers/files_read.py:491-512`, pas de
|
||||
`xlsx_readonly`) : la visionneuse câble donc le chargement paresseux. Mais
|
||||
`GET /api/file/{vault}/xlsx/sheet` refuse **tout ce qui n'est pas `.xlsx`** (`files_read.py:272`,
|
||||
**415**). Une feuille `.xlsm` de plus de 500 lignes affiche donc le pied « Charger la suite » dont
|
||||
chaque clic échoue (toast d'erreur), alors que la limitation n'est pas annoncée comme telle pour
|
||||
ce format.
|
||||
|
||||
**Correctif (2026-09-29) :** `GET …/xlsx/sheet` accepte `.xlsx` **et** `.xlsm` (les autres
|
||||
formats restent en 415). Tests : fenêtre servie pour un `.xlsm`, refus maintenu pour un `.csv`.
|
||||
|
||||
#### ✅ A3 → BUG-098 — Délimiteur CSV figé : un `.csv` français s'ouvre en une seule colonne
|
||||
|
||||
`render_csv_table(raw)` utilise le délimiteur par défaut `,` (`backend/xlsx_reader.py:784`),
|
||||
appelé sans argument (`backend/routers/files_read.py:551`) — tandis que l'**export** CSV de la
|
||||
visionneuse écrit du `;` et un BOM UTF-8. Un CSV produit par Excel/Sheets en locale française
|
||||
(`;`) s'affiche donc en une colonne. Le même `,` est utilisé par `save_csv_cells()` pour re-parser
|
||||
et réécrire le fichier : l'édition peut re-sérialiser dans un format différent de la source.
|
||||
|
||||
**Correctif (2026-09-29) :** `sniff_csv_delimiter()` (Sniffer, repli par décompte sur la
|
||||
première ligne, puis `,`) est partagé par le rendu et la réécriture — `save_csv_cells()`
|
||||
réutilise le délimiteur détecté. Tests : lecture, écriture et champs entre guillemets contenant
|
||||
le séparateur.
|
||||
|
||||
#### ✅ A4 → BUG-099 — Sonde de perte plafonnée à 8 Mo : perte silencieuse possible
|
||||
|
||||
`inspect_workbook()` détecte les valeurs calculées en cache via un budget global
|
||||
`_MAX_PROBE_BYTES = 8_000_000` (`backend/xlsx_reader.py:69`) partagé entre toutes les feuilles
|
||||
(`_has_cached_formulas`). Sur un classeur dont le XML des feuilles dépasse ce budget **avant** la
|
||||
première formule cachée, `xlsx_lossy_features` ne contient pas `cached_values` : le `409`
|
||||
`xlsx_lossy_content` n'est pas déclenché et l'enregistrement détruit ces valeurs **sans
|
||||
avertissement** — exactement le risque n°1 de #153. À ce stade, risque **théorique** (non
|
||||
reproduit).
|
||||
|
||||
**Correctif (2026-09-29) :** budget **par feuille** (4 Mo) + plafond global (32 Mo) ;
|
||||
`_scan_cached_formulas()` renvoie `(found, unverified)` et `inspect_workbook()` ajoute
|
||||
`cached_values_unverified` (libellé i18n FR/EN) : la sauvegarde demande une confirmation au lieu
|
||||
de passer en silence. Trucs de test : budgets monkeypatchés. Contre-preuve : budget épuisé →
|
||||
signalé au lieu de `[]` (comportement antérieur).
|
||||
|
||||
### 3.2 Fonctions manquantes (écart vs Excel / Google Sheets)
|
||||
|
||||
**Presse-papiers et sélection** — ✅ traité en P1 (A5)
|
||||
|
||||
- ✅ Coller une **plage** : le handler forcait tout sur une ligne
|
||||
(`viewer.js` : `replace(/\r?\n/g, " ")`) — coller un bloc TSV/CSV depuis Excel écrasait une seule
|
||||
cellule. Le collage découpe désormais le bloc (`parseMatrix`) et remplit la plage à partir de la
|
||||
cellule active (`fillFrom`), 1×1 restant un remplacement de cellule.
|
||||
- ✅ Copier / Couper / Coller de plage côté application : `Ctrl+C`/`Ctrl+X`/`Ctrl+V` (événements
|
||||
`copy`/`cut`/`paste`) **et** entrées du menu contextuel ; une sélection de texte dans la cellule
|
||||
éditée garde le comportement natif.
|
||||
- Toujours absents : poignée de recopie (fill), multi-sélection `Ctrl+clic`, glisser-déposer de
|
||||
lignes/colonnes, copie d'une image/du HTML.
|
||||
|
||||
**Clavier / navigation** — ✅ traité en P1 (A6, A7)
|
||||
|
||||
- ✅ `Ctrl+S`, `Suppr`, `F2`, `Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Ctrl+A`
|
||||
sont câblés dans un handler unique de la grille ; une cellule reste en mode « navigation »
|
||||
jusqu'à la saisie (ou `F2`), sinon `Suppr`/`Home`/`End` voleraient une touche du curseur.
|
||||
- ✅ `Maj+Entrée` insère un saut de ligne **dans** la cellule (texte uniquement).
|
||||
- ✅ La **zone Nom** est un champ éditable (« Atteindre ») : `B12`, `A1:B3`, `Feuille!A1`.
|
||||
- ✅ Aide à la saisie : la barre de formule propose les noms de fonctions courants (`<datalist>`,
|
||||
liste localisée FR/EN). L'autocomplétion contextuelle reste à faire si un moteur de formules
|
||||
arrive (A9).
|
||||
|
||||
**Mise en forme en écriture** — ✅ traitée (A8, 2026-09-30)
|
||||
|
||||
- ✅ Un bouton **Mise en forme** (`#xlsx-format-btn`, classeurs éditables non-CSV) agit sur la
|
||||
**sélection** via `PUT …/xlsx/style` : gras / italique / souligné / effacer, alignement
|
||||
gauche-centré-droite, couleur de police et de fond (`<input type="color">`, donc aucune
|
||||
palette codée en dur), formats de nombre (général, nombre, pourcentage, devise, date, texte),
|
||||
fusion/défusion, volets figés/libérés, largeur de colonne et hauteur de ligne. La fusion exige
|
||||
une vraie plage (`xlsx.format_merge_needs_range`).
|
||||
- Écriture sur le modèle de `xlsx/save` / `xlsx/structure` : verrou par fichier, backup, écriture
|
||||
atomique (`.tmp` + `os.replace`), garde de perte (refus sans `force`), `If-Match` (A12), garde
|
||||
anti-formule de BUG-088, plafond `MAX_STYLE_CELLS = 10 000`, invalidation du cache méta (A13).
|
||||
- Toujours **hors périmètre** : commentaires, liens hypertexte, validation de données, mise en
|
||||
forme conditionnelle, protection de cellules (lecture seule), bordures et palette Excel.
|
||||
|
||||
**Calcul** — ✅ décision documentée (A9, 2026-09-30)
|
||||
|
||||
- Aucun moteur de formule n'est livré : ObsiGate lit et écrit les formules telles qu'Excel les a
|
||||
enregistrées, sans recalcul (pas de références inter-feuilles, pas d'agrégats). Le choix est
|
||||
**annoncé à l'utilisateur** : un enregistrement contenant une valeur commençant par `=` ou `@`
|
||||
affiche « enregistrée comme texte » tant que le bouton `f(x)` n'est pas activé ; la garde
|
||||
anti-DDE de BUG-088 reste en place.
|
||||
|
||||
**Sortie et recherche** — ✅ traité (A11, 2026-09-30)
|
||||
|
||||
- ✅ Export de la **sélection** (CSV, nom `Feuille-A1B2.csv`) quand une plage de plusieurs cellules
|
||||
est active, sinon la feuille entière ; l'écart doc/code de #153-A13 est tranché dans ce sens.
|
||||
- ✅ Menu **Exporter** : Markdown (tableau), HTML (document autonome) et **impression** (iframe
|
||||
hors écran, cadre précédent détruit). Les quatre formats suivent le **contenu affiché**.
|
||||
- ✅ La recherche (`Ctrl+F`) parcourt **toutes les feuilles** : compteur `n/m · k feuilles` et
|
||||
activation automatique de l'onglet contenant la correspondance.
|
||||
|
||||
**Robustesse / multi-postes** — ✅ traitée (A10, A12, 2026-09-30)
|
||||
|
||||
- ✅ Concurrence optimiste : `revision` (`mtime_ns` + taille) renvoyée à la lecture et acceptée en
|
||||
`If-Match` sur `xlsx/save`, `xlsx/structure`, `xlsx/style` et `csv/save` ; un écrivain externe
|
||||
détecté donne un **409** `conflict` (`details.reason = "stale_revision"`) au lieu d'un
|
||||
écrasement, et le bandeau « Réessayer » **relit** le fichier avant de rejouer.
|
||||
- ✅ Piles undo/redo **unifiées et conservées au re-rendu** (`_xlsxHistory` par fichier) : édition,
|
||||
effacement, tri/filtre et structure ; action destructive (suppression) non annulable faute
|
||||
d'inverse, et lecture du filtre **coalescée** (une entrée par session de frappe).
|
||||
- Le tri/filtre restent **d'affichage** : après un tri, un enregistrement ne persiste pas l'ordre
|
||||
(seules les cellules sales sont écrites) — le libellé `xlsx.sort_applied` le dit.
|
||||
|
||||
**Outils IA** — ✅ traités (A14, 2026-09-30)
|
||||
|
||||
- ✅ `_spreadsheet_path()` accepte `.xlsx`, `.xlsm` (macros préservées) et `.csv` (délimiteur
|
||||
conservé) ; `update_xlsx_cells` délègue à `save_csv_cells` pour un CSV.
|
||||
- ✅ Nouveaux outils : `search_workbook` (recherche multi-feuilles, comptée par feuille),
|
||||
`analyze_range` (nombre, somme, moyenne, min, max d'une plage A1), `edit_xlsx_structure`
|
||||
(feuilles, lignes, colonnes — risque WRITE, **confirmation conservée**).
|
||||
- Bornes explicites : `MAX_SCAN_ROWS/MAX_SCAN_COLS`, `MAX_SEARCH_RESULTS = 100`,
|
||||
`MAX_RANGE_VALUES = 200`, `MAX_RANGE_CELLS = 10 000`.
|
||||
|
||||
### 3.3 Limites techniques relevées
|
||||
|
||||
- `MAX_COLS = 40` sans pagination de colonnes : le chargement paresseux ne concerne que les
|
||||
lignes (`read_sheet_window`), l'axe des colonnes reste tronqué.
|
||||
- Coût de lecture : `render_sheets()` charge le classeur pour les formules, une seconde fois si
|
||||
des valeurs cachées existent, puis `read_workbook_meta()` (troisième chargement, scan
|
||||
`500 × 40` par feuille). ✅ **A13** : `read_workbook_meta()` est mémoïsé par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8) et invalidé à chaque écriture — le reload complet à chaque
|
||||
fenêtre demandée a disparu ; `render_sheets()` reste hors cache (il doit relire les valeurs).
|
||||
- `allow_formula` (bouton `f(x)`) reste actif pour toute la session une fois activé : rien ne le
|
||||
remet à zéro après un enregistrement.
|
||||
- `role="grid"` posé (#154-A4) mais sans `aria-rowcount`/`aria-colcount`/`aria-selected`/
|
||||
`aria-activedescendant` ni annonce des changements de cellule active.
|
||||
|
||||
## 4. Backlog #156 — sous-tâches
|
||||
|
||||
Légende : 🔴 P0 (défaut) · 🟡 P1 (productivité immédiate) · 🟢 P2 (fidélité/finitions) ·
|
||||
effort en jours-homme (développement + tests).
|
||||
|
||||
### P0 — défauts (2-3 j)
|
||||
|
||||
- [x] **A1 — Sauvegarde `.csv` depuis la visionneuse (BUG-096).** Nom de feuille résolu avec
|
||||
repli (feuille → `data.title`/`data.path`), le chemin d'enregistrement ne dépend plus de la
|
||||
structure du classeur. *Vérifié :* test JSDOM « saving an edited csv PUTs /csv/save
|
||||
(payload without xlsx_sheets) » (contre-preuve : échouait avec le `TypeError`).
|
||||
- [x] **A2 — Chargement paresseux des `.xlsm` (BUG-097).** `GET …/xlsx/sheet` accepte `.xlsx`
|
||||
**et** `.xlsm` ; les autres formats restent refusés (415). *Vérifié :*
|
||||
`test_sheet_window_is_served_for_xlsm` + `test_sheet_window_still_refuses_other_formats`.
|
||||
- [x] **A3 — Détection du délimiteur CSV (BUG-098).** `sniff_csv_delimiter()` partagé par la
|
||||
lecture et la réécriture, qui conserve le délimiteur détecté. *Vérifié :* `TestCsvDelimiter`
|
||||
(3 séparateurs + repli) et 3 tests client (lecture, écriture, guillemets).
|
||||
- [x] **A4 — Sonde de perte par feuille (BUG-099).** Budget par feuille + plafond global ;
|
||||
budget épuisé → clé `cached_values_unverified` (i18n FR/EN) au lieu d'un silence. *Vérifié :*
|
||||
`TestXlsxCachedValueProbe` (3), dont la contre-preuve du silence antérieur.
|
||||
|
||||
### P1 — presse-papiers & clavier (4-6 j)- [x] **A5 — Presse-papiers de plage (livré le 2026-09-29).** Copier / couper / coller un bloc
|
||||
(presse-papiers interne TSV, miroir système best-effort) via `Ctrl+C`/`Ctrl+X`/`Ctrl+V` et les
|
||||
entrées du menu contextuel ; remplissage multi-cellules depuis la cellule active, bloc laissé
|
||||
sélectionné, collage **texte** uniquement (R4) et annulable cellule par cellule ; un bloc plus
|
||||
grand que la grille rendue est tronqué et signalé (`xlsx.paste_out_of_grid`). Couper efface la
|
||||
source au collage, sauf en collage sur place.
|
||||
*Vérifié :* JSDOM — `A1:B2` copié puis collé en `D5` donne `D5:E6`, bloc TSV Excel + retour
|
||||
ligne final, 1×1 qui remplace la cellule, coupe → collage, menu contextuel, débordement
|
||||
signalé, `E2E` (Playwright : copie/collage par le menu contextuel sur la fixture 520 lignes).
|
||||
- [x] **A6 — Clavier complet (livré le 2026-09-29).** `Ctrl+S`, `Ctrl+A`, `Suppr`, `F2`,
|
||||
`Ctrl+Home/End`, `Home`/`End`, `PgUp/PgDn`, `Ctrl+flèches`, `Maj+Entrée` ; undo/redo déplacés
|
||||
dans le même handler priorisé et `Suppr` passe par `clearRange()` (donc **annulable**).
|
||||
*Vérifié :* JSDOM — un test par raccourci, dont la contre-épreuve « `Suppr` pendant la saisie
|
||||
supprime un caractère, pas la sélection » ; `E2E` (Ctrl+A, Ctrl+Home/End, Suppr).
|
||||
- [x] **A7 — Zone Nom éditable & aide à la saisie (livré le 2026-09-29).** La zone Nom est un
|
||||
`<input>` (`#xlsx-active-cell`) : `B12`, `A1:B3`, `$A$1`, `Feuille!A1` (changement d'onglet) ;
|
||||
référence inconnue → message `xlsx.name_box_invalid` et adresse précédente restaurée. Le champ
|
||||
de formule porte la liste des fonctions (`<datalist id="xlsx-function-list">`, `xlsx.fn_suggestions`).
|
||||
*Vérifié :* JSDOM (atteindre, plage normalisée, refus d'une référence inconnue, bascule
|
||||
d'onglet, liste de fonctions) ; `E2E` (zone Nom → `B12`).
|
||||
|
||||
### P2 — mise en forme, calcul, undo (5-7 j)
|
||||
|
||||
- [x] **A8 — Mise en forme en écriture (livré le 2026-09-30).** Bouton **Mise en forme** :
|
||||
gras/italique/souligné/effacer, alignements, couleurs de police et de fond (sélecteur natif),
|
||||
formats de nombre (général/nombre/pourcentage/devise/date/texte), fusion/défusion, volets
|
||||
figés/libérés, largeur de colonne et hauteur de ligne, sur la sélection courante. Nouvelle route
|
||||
`PUT …/xlsx/style` (`mutate_xlsx_style`) sur le modèle de `PUT …/xlsx/structure` : verrou,
|
||||
`.tmp`/`os.replace`, backup, garde de perte (`force`), `If-Match`, garde anti-formule BUG-088,
|
||||
plafond `MAX_STYLE_CELLS`. i18n FR/EN. *Vérifié :* `tests/test_xlsx_styles.py` (17 : rechargement,
|
||||
fusions/tailles/volets, effacement, entrées invalides, CSV refusé, `if_match`, backup) et JSDOM
|
||||
(application d'un format, refus d'un CSV, garde de perte).
|
||||
- [x] **A9 — Calcul : décision documentée (livré le 2026-09-30).** Pas de moteur de formule ; le
|
||||
gap est **annoncé dans l'UI** (toast « enregistrée comme texte » à l'enregistrement si une valeur
|
||||
commence par `=` ou `@` sans le bouton `f(x)`), la garde anti-DDE reste. *Vérifié :* JSDOM
|
||||
(l'avertissement apparaît, disparaît une fois `f(x)` activé).
|
||||
- [x] **A10 — Undo/redo unifié (livré le 2026-09-30).** Entrées typées `cell|order|hidden|structure`,
|
||||
piles **par fichier** (`_xlsxHistory`, 8 fichiers) conservées au re-rendu, filtre coalescé,
|
||||
rejeu de structure silencieux, action destructive non annulable. *Vérifié :* JSDOM (édition →
|
||||
effacement → tri annulés successivement, insertion de ligne rejouée, filtre annulé en un `Ctrl+Z`).
|
||||
|
||||
### P3 — sortie, robustesse, performances (4-6 j)
|
||||
|
||||
- [x] **A11 — Sortie & recherche (livré le 2026-09-30).** Bouton **Exporter** : CSV de la
|
||||
**sélection** (sinon de la feuille, nom `Feuille-A1B2.csv`), Markdown, HTML et impression
|
||||
(iframe hors écran) ; écart #153-A13 tranché. `runFind` parcourt **toutes** les feuilles avec
|
||||
compteur `n/m · k feuilles` et activation de l'onglet correspondant. *Vérifié :* JSDOM
|
||||
(sélection multi-cellules, feuille entière, 4 formats, compteur multi-feuilles) et E2E.
|
||||
- [x] **A12 — Concurrence optimiste (livré le 2026-09-30).** `revision` (`mtime_ns` + taille)
|
||||
renvoyée à la lecture (`xlsx_revision`) et acceptée en `If-Match`/`if_match` sur `xlsx/save`,
|
||||
`xlsx/structure`, `xlsx/style` et `csv/save` ; unconflict produit un **409** `conflict`
|
||||
(`details.reason = "stale_revision"`) et le bandeau « Réessayer » relit avant de rejouer.
|
||||
*Vérifié :* `TestOptimisticConcurrency` (6) + JSDOM (version rafraîchie par la réponse, relecture
|
||||
avant le retry).
|
||||
- [x] **A13 — Performances & plafonds (livré le 2026-09-30).** Cache de `read_workbook_meta()` par
|
||||
`(chemin, mtime_ns, taille)` (LRU 8), invalidé à chaque écriture : charger une fenêtre ne
|
||||
rescanne plus le classeur. *Hors périmètre :* la pagination de l'axe des colonnes au-delà de
|
||||
`MAX_COLS = 40` (documenté dans les limites du guide). *Vérifié :* `TestMetaCache` (4).
|
||||
- [x] **A14 — Outils IA étendus (livré le 2026-09-30).** `SPREADSHEET_EXTENSIONS` =
|
||||
`.xlsx`/`.xlsm`/`.csv` (`_spreadsheet_path`, `_read_grid`), `update_xlsx_cells`/`append_xlsx_rows`
|
||||
adaptés, nouveaux outils `search_workbook` (READ), `analyze_range` (READ) et `edit_xlsx_structure`
|
||||
(WRITE, **confirmation conservée**, refus d'un CSV). *Vérifié :*
|
||||
`tests/test_spreadsheet_tools.py` (34, dont `TestXlsmSupport`, `TestCsvSupport`,
|
||||
`TestSearchWorkbook`, `TestAnalyzeRange`, `TestEditXlsxStructure`).
|
||||
|
||||
## 5. Risques et sécurité
|
||||
|
||||
| # | Risque | Où | Traitement |
|
||||
|---|---|---|---|
|
||||
| R1 | Perte silencieuse de valeurs calculées (sonde plafonnée) | `xlsx_reader.inspect_workbook` | ✅ A4 — budget par feuille + clé `cached_values_unverified` (BUG-099) |
|
||||
| R2 | Écrasement par un écrivain externe | `mutations`, routes write | ✅ A12 — `If-Match` + **409** `conflict`, retry qui relit |
|
||||
| R3 | Réécriture CSV dans un délimiteur différent de la source | `xlsx_reader`, `mutations.save_csv_cells` | ✅ A3 — `sniff_csv_delimiter` partagé (BUG-098) |
|
||||
| R4 | Collage d'un bloc : ne jamais injecter de HTML/`contenteditable` brut | `viewer.js` | ✅ A5 — insertion **texte** uniquement, échappée |
|
||||
| R5 | Mise en forme en écriture : garder la garde anti-formule (BUG-088) et la garde de perte (BUG-085) | `mutations` | ✅ A8 — `mutate_xlsx_style` réutilise verrou, backup, swap atomique, garde de perte et `If-Match` |
|
||||
|
||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||
|
||||
- Chaque sous-tâche démarre par son **ID stable** (`#156-A<n>`) ; un **défaut** est ouvert comme
|
||||
`BUG-NNN` dans `docs/ISSUES_TODOLIST.md` (A1→BUG-096, A2→BUG-097, A3→BUG-098, A4→BUG-099).
|
||||
- Tout correctif arrive avec son **test de non-régression** (contre-preuve quand c'est possible).
|
||||
- Frontend : `vanilla JS`, **zéro build**, `safeCreateIcons()`, **variables CSS**, **i18n FR+EN**
|
||||
(`test_i18n_parity.py` vert).
|
||||
- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans
|
||||
`backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`.
|
||||
- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée + index), cette fiche,
|
||||
guide utilisateur i18n + README si impact utilisateur.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Audit de complétude de l'éditeur Excel → ouverture de #156 et de **BUG-096 → BUG-099** ; défaut A1 reproduit en JSDOM (`TypeError` sur l'enregistrement d'un CSV, aucun `PUT …/csv/save` émis) |
|
||||
| 2026-09-29 | **P1 livré** — A5 (presse-papiers de plage), A6 (clavier complet) et A7 (zone Nom éditable + aide à la saisie) : presse-papiers interne TSV (miroir système best-effort), collage texte/annulable, `Suppr`/`F2`/`Ctrl+flèches`/`PgUp-PgDn`/`Ctrl+A`/`Maj+Entrée`, zone Nom `<input>` et liste de fonctions i18n FR/EN. Tests : JSDOM `xlsx-viewer.test.mjs` 84/84 (20 nouveaux) + E2E Playwright |
|
||||
| 2026-09-30 | **P2 livré** — A8 (mise en forme en écriture : bouton Mise en forme, `PUT …/xlsx/style`, verrou/backup/garde de perte/`If-Match`), A9 (décision « pas de moteur de formule », annoncée dans l'UI), A10 (undo/redo unifié, piles conservées au re-rendu). Tests : `test_xlsx_styles.py` 17 + JSDOM `xlsx-viewer.test.mjs` |
|
||||
| 2026-09-30 | **P3 livré** — A11 (export sélection / Markdown / HTML / impression, recherche multi-feuilles), A12 (`If-Match` + 409 réparable), A13 (cache des métadonnées par `mtime`), A14 (outils IA `.xlsm`/`.csv`, recherche, analyse de plage, structure). Tests : `test_spreadsheet_tools.py` 34, `test_xlsx_viewer.py` (`TestOptimisticConcurrency`, `TestMetaCache`), JSDOM |
|
||||
| 2026-09-30 | **Backlog #156 clôturé** — P0 → P3 livrés (A1 → A14, dont 4 défauts **BUG-096 → BUG-099**). Suite complète : `pytest tests/` 1525 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 107/107, E2E `xlsx` 10/10 |
|
||||
| 2026-09-29 | **P0 livré** — A1-A4 corrigés et testés (BUG-096 → BUG-099) : sauvegarde `.csv` depuis la visionneuse, fenêtres `.xlsm`, délimiteur CSV détecté/réutilisé, sonde de perte par feuille + signal `cached_values_unverified`. Suite complète : 1490 passed / 6 skipped, ruff/mypy 0, JSDOM `xlsx-viewer.test.mjs` 61/61 |
|
||||
+7
-4
@@ -1196,16 +1196,15 @@
|
||||
>Ctrl+Shift+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de commandes</span
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
<span class="qh-kbd"
|
||||
>Ctrl+Alt+Space</span
|
||||
>
|
||||
<span class="qh-desc"
|
||||
>Palette de fichiers (nav.
|
||||
rapide)</span
|
||||
<span class="qh-desc">Palette de commandes</span
|
||||
>
|
||||
</div>
|
||||
<div class="qh-row">
|
||||
@@ -3695,6 +3694,10 @@
|
||||
<strong>Facettes vaults</strong><span data-i18n="help.desc_facet_vaults">: Compteurs
|
||||
par vault</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Facettes extensions</strong><span data-i18n="help.desc_facet_extensions">:
|
||||
Compteurs par extension de fichier dans les résultats</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tri par pertinence</strong><span data-i18n="help.desc_9fec424d"> : Score
|
||||
TF-IDF (défaut)</span></li>
|
||||
|
||||
+14
-1
@@ -288,6 +288,14 @@ function initSearch() {
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
// #158 — a refresh-equivalent home: wipe the global search (chips, result
|
||||
// bar, filters) and the sidebar filter bar before showing the dashboard.
|
||||
const searchClear = document.getElementById("search-clear-btn");
|
||||
if (searchClear) searchClear.click();
|
||||
const sideInput = document.getElementById("sidebar-filter-input");
|
||||
const sideClear = document.getElementById("sidebar-filter-clear-btn");
|
||||
if (sideClear && sideInput && sideInput.value) sideClear.click();
|
||||
|
||||
const searchInput = document.getElementById("search-input");
|
||||
if (searchInput) searchInput.value = "";
|
||||
|
||||
@@ -299,7 +307,12 @@ function goHome() {
|
||||
state.cachedRawSource = null;
|
||||
|
||||
closeMobileSidebar();
|
||||
showWelcome();
|
||||
// #158 — la page d'accueil s'ouvre dans un onglet épinglé en tête de barre
|
||||
if (window.TabManager && window.TabManager.openHome) {
|
||||
window.TabManager.openHome();
|
||||
} else {
|
||||
showWelcome();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Pure helpers for the navigation page facets / filters (#158).
|
||||
*
|
||||
* No DOM, no imports — usable straight from Node tests.
|
||||
* Mirrors the search page's facet mechanism (Vaults / Tags / Extensions)
|
||||
* over a plain directory listing instead of a search result set.
|
||||
*/
|
||||
|
||||
/** Strip the leading dot and lowercase a file extension. */
|
||||
function normExt(extension) {
|
||||
return String(extension || "").toLowerCase().replace(/^\./, "");
|
||||
}
|
||||
|
||||
/** Display name used for labels and alphabetical sorting (.md removed). */
|
||||
export function navDisplayName(file) {
|
||||
const name = file.name || String(file.path || "").split("/").pop() || "";
|
||||
return name.endsWith(".md") ? name.slice(0, -3) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count vaults / tags / extensions over a file listing.
|
||||
* @param {Array<object>} files
|
||||
* @returns {{vaults: object, tags: object, extensions: object}} counts sorted desc
|
||||
*/
|
||||
export function buildNavFacets(files) {
|
||||
const facets = { vaults: {}, tags: {}, extensions: {} };
|
||||
for (const f of files || []) {
|
||||
if (f.vault) facets.vaults[f.vault] = (facets.vaults[f.vault] || 0) + 1;
|
||||
const ext = normExt(f.extension);
|
||||
if (ext) facets.extensions[ext] = (facets.extensions[ext] || 0) + 1;
|
||||
for (const tag of f.tags || []) {
|
||||
facets.tags[tag] = (facets.tags[tag] || 0) + 1;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(facets)) {
|
||||
facets[key] = Object.fromEntries(
|
||||
Object.entries(facets[key]).sort((a, b) => b[1] - a[1]),
|
||||
);
|
||||
}
|
||||
return facets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the files matching the active tag / extension filters.
|
||||
* @param {Array<object>} files
|
||||
* @param {{tag?: string, ext?: string}} filters
|
||||
*/
|
||||
export function filterNavFiles(files, filters) {
|
||||
const tag = (filters && filters.tag) || "";
|
||||
const ext = normExt((filters && filters.ext) || "");
|
||||
if (!tag && !ext) return files || [];
|
||||
return (files || []).filter((f) => {
|
||||
if (ext && normExt(f.extension) !== ext) return false;
|
||||
if (tag && !(f.tags || []).includes(tag)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the listing: `modified` (newest first, server default) or
|
||||
* `relevance` (A→Z).
|
||||
*
|
||||
* ponytail: a directory listing carries no relevance signal, `relevance` is an
|
||||
* alphabetical stand-in — upgrade path: sort by scoped search score once the
|
||||
* navigation page owns a query.
|
||||
*/
|
||||
export function sortNavFiles(files, mode) {
|
||||
const out = (files || []).slice();
|
||||
if (mode === "relevance") {
|
||||
out.sort((a, b) =>
|
||||
navDisplayName(a).localeCompare(navDisplayName(b), undefined, { sensitivity: "base" }),
|
||||
);
|
||||
} else {
|
||||
out.sort((a, b) => (b.modified || 0) - (a.modified || 0));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+36
-7
@@ -14,17 +14,26 @@ function getCurrentFile() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Query parser: +word = required, -word = exclude, word = required ──
|
||||
// ── Query parser: +word = required, -word = exclude, word = required, ext:xxx, vault:xxx, tag:xxx ──
|
||||
function parseQuery(raw) {
|
||||
const positive = [];
|
||||
const negative = [];
|
||||
const filters = { ext: null, vault: null, tag: null };
|
||||
const parts = (raw || '').split(/\s+/).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
const lower = p.toLowerCase();
|
||||
if (lower.startsWith('ext:')) {
|
||||
filters.ext = lower.slice(4).trim().replace(/^\./, '').toLowerCase();
|
||||
} else if (lower.startsWith('vault:')) {
|
||||
filters.vault = lower.slice(6).trim();
|
||||
} else if (lower.startsWith('tag:') || (lower.startsWith('#') && p.length > 1)) {
|
||||
const tag = lower.startsWith('tag:') ? lower.slice(4) : lower.slice(1);
|
||||
if (tag) filters.tag = tag.trim();
|
||||
} else if (p.startsWith('-') && p.length > 1) negative.push(p.slice(1).toLowerCase());
|
||||
else if (p.startsWith('+')) positive.push(p.slice(1).toLowerCase());
|
||||
else positive.push(p.toLowerCase());
|
||||
}
|
||||
return { positive, negative };
|
||||
return { positive, negative, filters };
|
||||
}
|
||||
|
||||
// ── DOM / state ──
|
||||
@@ -407,8 +416,8 @@ function createDOM() {
|
||||
_overlay.className = 'command-palette-overlay';
|
||||
_overlay.tabIndex = -1;
|
||||
_overlay.innerHTML = `<div class="command-palette">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-header"><input type="text" class="cp-input" placeholder="Rechercher un fichier... (+inclure -exclure ext:md vault:nom tag:monTag)" spellcheck="false" autocomplete="off"><div class="cp-breadcrumb" id="cp-breadcrumb"></div><span class="cp-hint">Ctrl+Shift+Espace</span></div>
|
||||
<input type="text" class="cp-browse-filter" id="cp-browse-filter" placeholder="Filtrer (+mot -mot ext:xxx vault:nom tag:xxx) — recherche globale..." spellcheck="false" autocomplete="off" style="display:none">
|
||||
<div class="cp-results"></div>
|
||||
<div class="cp-footer"><span>↑↓ naviguer</span><span>↵ ouvrir/entrer</span><span>⌫ monter</span><span>Esc fermer</span></div></div>`;
|
||||
_input = _overlay.querySelector('.cp-input');
|
||||
@@ -488,11 +497,14 @@ async function searchFiles(rawQ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { positive, negative } = parseQuery(rawQ);
|
||||
const { positive, negative, filters } = parseQuery(rawQ);
|
||||
|
||||
try {
|
||||
const q = positive.join(' ');
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=all`);
|
||||
let vaultFilter = 'all';
|
||||
if (filters.vault) vaultFilter = filters.vault;
|
||||
|
||||
const d = await api(`/api/suggest?q=${encodeURIComponent(q)}&vault=${encodeURIComponent(vaultFilter)}`);
|
||||
let sug = d.suggestions || [];
|
||||
|
||||
// Client-side negative filter
|
||||
@@ -506,6 +518,23 @@ async function searchFiles(rawQ) {
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by extension if specified
|
||||
if (filters.ext) {
|
||||
sug = sug.filter(s => {
|
||||
const name = s.path.split('/').pop() || '';
|
||||
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
||||
return ext === filters.ext;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by tag if specified
|
||||
if (filters.tag) {
|
||||
sug = sug.filter(s => {
|
||||
const tags = s.tags || [];
|
||||
return tags.some(t => t.toLowerCase() === filters.tag.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
if (sug.length === 0) {
|
||||
_items = [{ type:'_browse', label:'📂 Parcourir les répertoires...', sublabel:'Aucun résultat — naviguer manuellement', act:()=>startBrowse('open-file') }];
|
||||
} else {
|
||||
|
||||
@@ -105,6 +105,20 @@ function createPaneTabManager(paneId) {
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
async openHome(name = "Home") {
|
||||
const tabId = "home";
|
||||
const idx = this._tabs.findIndex((x) => x.id === tabId);
|
||||
if (idx > 0) {
|
||||
const [tab] = this._tabs.splice(idx, 1);
|
||||
this._tabs.unshift(tab);
|
||||
} else if (idx === -1) {
|
||||
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
|
||||
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
|
||||
}
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
async openPersistent(vault, path) {
|
||||
const tabId = `${vault}::${path}`;
|
||||
const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
|
||||
@@ -166,6 +180,15 @@ function createPaneTabManager(paneId) {
|
||||
const cache = this._tabCache[tabId];
|
||||
if (!cache) return;
|
||||
|
||||
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
|
||||
if (cache.home) {
|
||||
state.currentVault = null;
|
||||
state.currentPath = null;
|
||||
this._showDashboard();
|
||||
if (history.pushState) history.pushState(null, '', '#');
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path;
|
||||
// syncActiveFileTreeItem is imported below to avoid circular deps
|
||||
|
||||
+50
-4
@@ -1034,8 +1034,27 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
}
|
||||
|
||||
// Facets panel
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0)) {
|
||||
const facetsDiv = el("div", { class: "search-facets" });
|
||||
if (data.facets && (Object.keys(data.facets.tags || {}).length > 0 || Object.keys(data.facets.vaults || {}).length > 0 || Object.keys(data.facets.extensions || {}).length > 0)) {
|
||||
const collapsed = localStorage.getItem("obsigate_facets_collapsed") === "1";
|
||||
const facetsDiv = el("div", { class: "search-facets", "data-collapsed": collapsed ? "true" : "false" });
|
||||
|
||||
// Discreet collapse toggle for the whole panel (vaults + tags + extensions)
|
||||
const toggleBtn = el("button", {
|
||||
class: "search-facets__toggle",
|
||||
type: "button",
|
||||
"aria-expanded": String(!collapsed),
|
||||
title: collapsed ? t("search.facets_expand") : t("search.facets_collapse"),
|
||||
});
|
||||
toggleBtn.innerHTML = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const nowCollapsed = facetsDiv.getAttribute("data-collapsed") !== "true";
|
||||
facetsDiv.setAttribute("data-collapsed", nowCollapsed ? "true" : "false");
|
||||
toggleBtn.setAttribute("aria-expanded", String(!nowCollapsed));
|
||||
toggleBtn.title = nowCollapsed ? t("search.facets_expand") : t("search.facets_collapse");
|
||||
localStorage.setItem("obsigate_facets_collapsed", nowCollapsed ? "1" : "0");
|
||||
});
|
||||
facetsDiv.appendChild(toggleBtn);
|
||||
const facetsBody = el("div", { class: "search-facets__body" });
|
||||
|
||||
// Vault facets
|
||||
const vaultFacets = data.facets.vaults || {};
|
||||
@@ -1056,7 +1075,7 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
// Tag facets
|
||||
@@ -1075,9 +1094,36 @@ export function renderAdvancedSearchResults(data, query, tagFilter) {
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsDiv.appendChild(group);
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
// Extension facets
|
||||
const extFacets = data.facets.extensions || {};
|
||||
if (Object.keys(extFacets).length > 0) {
|
||||
const group = el("div", { class: "search-facets__group", "data-facet": "extensions" });
|
||||
const label = el("span", { class: "search-facets__label" });
|
||||
label.textContent = "Extensions";
|
||||
group.appendChild(label);
|
||||
for (const [extName, count] of Object.entries(extFacets)) {
|
||||
const item = el("span", { class: "search-facets__item" });
|
||||
// textContent: extension suffixes come from filenames (untrusted), never innerHTML
|
||||
item.appendChild(document.createTextNode(`.${extName} `));
|
||||
const facetCount = el("span", { class: "facet-count" });
|
||||
facetCount.textContent = String(count);
|
||||
item.appendChild(facetCount);
|
||||
item.addEventListener("click", () => {
|
||||
const input = document.getElementById("search-input");
|
||||
// Replace any existing ext: operator, then trigger a new search
|
||||
const current = input.value.replace(/ext:\S+\s*/gi, "").trim();
|
||||
input.value = current + " ext:" + extName;
|
||||
_triggerAdvancedSearch(input.value);
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
facetsBody.appendChild(group);
|
||||
}
|
||||
|
||||
facetsDiv.appendChild(facetsBody);
|
||||
area.appendChild(facetsDiv);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,12 @@ async function setSelectedVaultContext(vaultName, options) {
|
||||
loadRecentFiles(vaultName === "all" ? null : vaultName);
|
||||
}
|
||||
|
||||
showWelcome();
|
||||
// If a specific vault is selected, switch to vault home page
|
||||
// Onglet de navigation (#158) si un vault précis est sélectionné,
|
||||
// sinon la page d'accueil. Les onglets de fichiers restent ouverts.
|
||||
if (vaultName !== "all") {
|
||||
import('./vaulthome.js').then(m => m.showVaultHome(vaultName, ''));
|
||||
TabManager.openNav(vaultName, "");
|
||||
} else {
|
||||
showWelcome();
|
||||
}
|
||||
if (options && options.focusVault && vaultName !== "all") {
|
||||
await focusVaultInSidebar(vaultName);
|
||||
@@ -142,7 +144,7 @@ async function refreshSidebarForContext() {
|
||||
|
||||
vaultsToShow.forEach((v) => {
|
||||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
|
||||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||||
vaultItem.addEventListener("click", () => { toggleVault(vaultItem, v.name); TabManager.openNav(v.name, ""); });
|
||||
|
||||
vaultItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -239,7 +241,7 @@ async function loadVaults() {
|
||||
vaults.forEach((v) => {
|
||||
// Sidebar tree entry
|
||||
const vaultItem = el("div", { class: "tree-item vault-item", "data-vault": v.name }, [icon("chevron-right", 14), getVaultIcon(v.name, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(v.name)]), smallBadge(v.file_count)]);
|
||||
vaultItem.addEventListener("click", () => toggleVault(vaultItem, v.name));
|
||||
vaultItem.addEventListener("click", () => { toggleVault(vaultItem, v.name); TabManager.openNav(v.name, ""); });
|
||||
|
||||
vaultItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -341,6 +343,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
fragment.appendChild(subContainer);
|
||||
|
||||
dirItem.addEventListener("click", async () => {
|
||||
TabManager.openNav(vaultName, item.path);
|
||||
scrollTreeItemIntoView(dirItem, false);
|
||||
if (subContainer.classList.contains("collapsed")) {
|
||||
if (subContainer.children.length === 0) {
|
||||
@@ -635,6 +638,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
fragment.appendChild(subContainer);
|
||||
|
||||
dirItem.addEventListener("click", async () => {
|
||||
TabManager.openNav(vaultName, item.path);
|
||||
scrollTreeItemIntoView(dirItem, false);
|
||||
if (subContainer.classList.contains("collapsed")) {
|
||||
if (subContainer.children.length === 0) {
|
||||
|
||||
+94
-29
@@ -1776,7 +1776,12 @@ export const ContextMenuManager = {
|
||||
if (e.key === 'Escape') this.hide();
|
||||
});
|
||||
|
||||
document.addEventListener('scroll', () => this.hide(), true);
|
||||
document.addEventListener('scroll', () => {
|
||||
// Un scroll déclenché juste après l'ouverture vient de la reflow des
|
||||
// icônes (lucide) : on ne referme pas le menu qui vient d'apparaître.
|
||||
if (performance.now() - (this._shownAt || 0) < 250) return;
|
||||
this.hide();
|
||||
}, true);
|
||||
},
|
||||
|
||||
show(x, y, vault, path, type, isReadonly) {
|
||||
@@ -1822,6 +1827,7 @@ export const ContextMenuManager = {
|
||||
}
|
||||
|
||||
this._menu.classList.add('active');
|
||||
this._shownAt = performance.now();
|
||||
|
||||
const rect = this._menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
@@ -2105,6 +2111,70 @@ export const TabManager = {
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Open (or retarget) the navigation tab for a vault directory (#158).
|
||||
* One navigation tab per vault: clicking another directory reuses it and
|
||||
* updates its directory, so open file tabs are never displaced. */
|
||||
async openNav(vault, dir = "") {
|
||||
if (!vault || vault === "all") return;
|
||||
dir = dir || "";
|
||||
const tabId = `nav::${vault}`;
|
||||
const name = dir ? dir.split("/").pop() : vault;
|
||||
|
||||
const existing = this._tabs.find(t => t.id === tabId);
|
||||
if (existing) {
|
||||
existing.path = dir;
|
||||
existing.name = name;
|
||||
this._tabCache[tabId].path = dir;
|
||||
this._tabCache[tabId].title = name;
|
||||
// Force a re-render when it is already the active tab
|
||||
if (this._activeTabId === tabId) this._activeTabId = null;
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
this._tabs.push({ id: tabId, vault, path: dir, name, icon: "folder", nav: true });
|
||||
this._tabCache[tabId] = { vault, path: dir, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "folder", nav: true };
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Onglet Accueil (#158) : la page d'accueil vit dans un onglet épinglé tout
|
||||
* à gauche de la barre. Recréé (et remonté en position 0) à chaque clic sur
|
||||
* le titre. */
|
||||
async openHome() {
|
||||
// En split, l'onglet appartient au panneau actif
|
||||
if (window.getActiveTabManager && window.PaneManager && window.PaneManager.isSplit()) {
|
||||
return window.getActiveTabManager().openHome(t("common.home"));
|
||||
}
|
||||
const tabId = "home";
|
||||
const idx = this._tabs.findIndex(t => t.id === tabId);
|
||||
if (idx > 0) {
|
||||
const [tab] = this._tabs.splice(idx, 1);
|
||||
this._tabs.unshift(tab); // toujours en tête de barre
|
||||
} else if (idx === -1) {
|
||||
const name = t("common.home");
|
||||
this._tabs.unshift({ id: tabId, vault: null, path: "", name, icon: "home", home: true });
|
||||
this._tabCache[tabId] = { home: true, vault: null, path: "", title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon: "home" };
|
||||
}
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Drop the active-tab marker — the dashboard/home page is showing.
|
||||
* Keeps the tabs open; clicking one re-activates (and re-renders) it.
|
||||
* L'onglet Accueil, lui, reste l'onglet actif quand la page d'accueil
|
||||
* s'affiche (il EST la page d'accueil). */
|
||||
deactivate() {
|
||||
const active = this._activeTabId && this._tabCache[this._activeTabId];
|
||||
if (active && active.home) {
|
||||
this._renderTabs();
|
||||
return;
|
||||
}
|
||||
this._activeTabId = null;
|
||||
this._renderTabs();
|
||||
},
|
||||
|
||||
/** Activate a specific tab */
|
||||
async activate(tabId) {
|
||||
if (this._activeTabId === tabId && this._tabs.length > 0) return;
|
||||
@@ -2125,6 +2195,29 @@ export const TabManager = {
|
||||
const cache = this._tabCache[tabId];
|
||||
if (!cache) return;
|
||||
|
||||
// Onglet Accueil (#158) — la page d'accueil vit dans l'onglet
|
||||
if (cache.home) {
|
||||
state.currentVault = null;
|
||||
state.currentPath = null;
|
||||
showWelcome(); // conserve l'onglet Accueil actif (deactivate())
|
||||
if (history.pushState) {
|
||||
history.pushState(null, "", "#");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation tab — render the directory view instead of fetching a file
|
||||
if (cache.nav) {
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path || null;
|
||||
const navMod = await import("./vaulthome.js");
|
||||
await navMod.showVaultHome(cache.vault, cache.path);
|
||||
if (history.pushState) {
|
||||
history.pushState(null, "", `#/nav/${encodeURIComponent(cache.vault)}/${encodeURIComponent(cache.path)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update global state
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path;
|
||||
@@ -2509,32 +2602,4 @@ export const TabManager = {
|
||||
|
||||
|
||||
|
||||
// ---- Keyboard shortcuts for tabs ----
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "w" || e.key === "W") {
|
||||
e.preventDefault();
|
||||
if (TabManager._activeTabId) {
|
||||
TabManager.close(TabManager._activeTabId);
|
||||
}
|
||||
} else if (e.key === "Tab" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const tabs = TabManager._tabs;
|
||||
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
|
||||
if (currentIdx >= 0 && tabs.length > 1) {
|
||||
const nextIdx = (currentIdx + 1) % tabs.length;
|
||||
TabManager.activate(tabs[nextIdx].id);
|
||||
}
|
||||
} else if (e.key === "Tab" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const tabs = TabManager._tabs;
|
||||
const currentIdx = tabs.findIndex(t => t.id === TabManager._activeTabId);
|
||||
if (currentIdx >= 0 && tabs.length > 1) {
|
||||
const prevIdx = (currentIdx - 1 + tabs.length) % tabs.length;
|
||||
TabManager.activate(tabs[prevIdx].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Modify init to include TabManager ----
|
||||
|
||||
+430
-215
@@ -1,24 +1,27 @@
|
||||
/**
|
||||
* Vault Home Page module for ObsiGate.
|
||||
* Vault Home / navigation page module for ObsiGate (#158).
|
||||
*
|
||||
* When a specific vault is selected (not "All"), replaces the dashboard
|
||||
* with a non-recursive file listing sorted by modification time (newest first).
|
||||
* Only files from the selected directory are shown (not subdirectories)
|
||||
* to keep the listing fast and lightweight.
|
||||
* The listing updates automatically when browsing into subdirectories.
|
||||
* The view is mounted by `TabManager.openNav(vault, dir)` as a dedicated
|
||||
* **navigation tab**: it opens on every directory click (vault root included,
|
||||
* vault focus mode *and* "All" mode), coexists with open file tabs, and
|
||||
* re-renders when the user drills into a subdirectory or the breadcrumb.
|
||||
*
|
||||
* The visual style mirrors the search results (.search-result-item) for
|
||||
* consistency.
|
||||
* Content: breadcrumb, sort/save toolbar, facets panel (Vaults · Tags ·
|
||||
* Extensions — same mechanism and look as the search page), subdirectory list,
|
||||
* recent files (root only) and the non-recursive file listing of the current
|
||||
* directory.
|
||||
*
|
||||
* Usage:
|
||||
* import { showVaultHome, updateVaultHomeDir, isVaultHomeActive } from './vaulthome.js';
|
||||
* import { showVaultHome, isVaultHomeActive } from './vaulthome.js';
|
||||
* // …but in practice: TabManager.openNav(vault, dirPath)
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { el } from './viewer.js';
|
||||
import { getFileIcon, safeCreateIcons } from './utils.js';
|
||||
import { TabManager } from './ui.js';
|
||||
import { TabManager, ContextMenuManager } from './ui.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from './navfacets.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
@@ -27,25 +30,35 @@ import { t, getLocale } from './i18n.js';
|
||||
const state = {
|
||||
active: false,
|
||||
currentVault: null,
|
||||
currentDir: '', // empty = vault root
|
||||
currentDir: '', // empty = vault root
|
||||
sort: 'modified', // 'modified' (Date) | 'relevance' (Pertinence)
|
||||
filterTag: '',
|
||||
filterExt: '',
|
||||
files: [], // last listing, unfiltered
|
||||
dirs: [], // subdirectories of the current directory
|
||||
};
|
||||
|
||||
// Handles of the mounted view (only one navigation view exists at a time).
|
||||
let _view = null;
|
||||
let _renderSeq = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if vault home is currently displayed. */
|
||||
/** Check if the navigation view is currently displayed. */
|
||||
export function isVaultHomeActive() {
|
||||
return state.active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the vault home page for a vault directory.
|
||||
* Display the navigation view for a vault directory.
|
||||
* Called by TabManager when the navigation tab is activated.
|
||||
* @param {string} vaultName - Vault name
|
||||
* @param {string} dirPath - Directory path (empty for root)
|
||||
*/
|
||||
export async function showVaultHome(vaultName, dirPath = '') {
|
||||
if (vaultName === 'all' || !vaultName) {
|
||||
if (!vaultName || vaultName === 'all') {
|
||||
state.active = false;
|
||||
return;
|
||||
}
|
||||
@@ -53,6 +66,9 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
state.active = true;
|
||||
state.currentVault = vaultName;
|
||||
state.currentDir = dirPath || '';
|
||||
state.filterTag = '';
|
||||
state.filterExt = '';
|
||||
state.sort = 'modified';
|
||||
|
||||
const area = document.getElementById('content-area');
|
||||
if (!area) return;
|
||||
@@ -61,7 +77,7 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
const home = document.getElementById('dashboard-home');
|
||||
if (home) home.remove();
|
||||
|
||||
// Build vault home container
|
||||
const seq = ++_renderSeq;
|
||||
area.innerHTML = '';
|
||||
|
||||
const container = el('div', { class: 'vault-home', id: 'vault-home' });
|
||||
@@ -70,117 +86,412 @@ export async function showVaultHome(vaultName, dirPath = '') {
|
||||
// Breadcrumb
|
||||
renderBreadcrumb(container, vaultName, state.currentDir);
|
||||
|
||||
// Sort + save toolbar (same classes as the search results header)
|
||||
container.appendChild(buildToolbar());
|
||||
|
||||
// Facets host (filled once the listing is loaded)
|
||||
const facetsHost = el('div', { id: 'vault-home-facets' });
|
||||
container.appendChild(facetsHost);
|
||||
|
||||
// Results container
|
||||
const resultsArea = el('div', { class: 'vault-home-results', id: 'vault-home-results' });
|
||||
container.appendChild(resultsArea);
|
||||
|
||||
// Show loading
|
||||
resultsArea.innerHTML = `
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:24px;height:24px;border-width:3px;margin:0 auto"></div>
|
||||
<p data-i18n="vaulthome.loading" style="margin-top:0.75rem;color:var(--text-muted);font-size:0.85rem">${t('vaulthome.loading')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
|
||||
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
||||
_view = { container, facetsHost, resultsArea, vault: vaultName, dirs: [] };
|
||||
|
||||
// Also load recent files for this vault (only at root level)
|
||||
if (!state.currentDir) {
|
||||
loadRecentSection(vaultName, container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the vault home directory without rebuilding the whole page.
|
||||
* Called when navigating into a subdirectory.
|
||||
* @param {string} vaultName
|
||||
* @param {string} dirPath
|
||||
*/
|
||||
export async function updateVaultHomeDir(vaultName, dirPath) {
|
||||
if (!state.active || state.currentVault !== vaultName) return;
|
||||
|
||||
state.currentDir = dirPath || '';
|
||||
|
||||
const container = document.getElementById('vault-home');
|
||||
if (!container) return;
|
||||
|
||||
// Update breadcrumb
|
||||
const bc = container.querySelector('.vault-home-breadcrumb');
|
||||
if (bc) {
|
||||
bc.innerHTML = '';
|
||||
buildBreadcrumbHTML(bc, vaultName, state.currentDir);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// Remove recent section when navigating into subdirectory
|
||||
const recentSection = container.querySelector('.vault-home-recent');
|
||||
if (recentSection) recentSection.remove();
|
||||
|
||||
// Reload results
|
||||
const resultsArea = document.getElementById('vault-home-results');
|
||||
if (resultsArea) {
|
||||
// Listing + subdirectories
|
||||
let files;
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(state.currentDir)}&limit=200&recursive=false`,
|
||||
);
|
||||
files = data.files || [];
|
||||
} catch (err) {
|
||||
if (seq !== _renderSeq) return;
|
||||
const errMsg = escapeHtml(
|
||||
t('vaulthome.error_loading') + ' ' + (err?.detail || err?.message || t('common.unknown')),
|
||||
);
|
||||
resultsArea.innerHTML = `
|
||||
<div class="tree-loading" style="padding:2rem;text-align:center">
|
||||
<div class="loading-spinner" style="width:20px;height:20px;border-width:2px;margin:0 auto"></div>
|
||||
<div class="vault-home-error">
|
||||
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
|
||||
</div>`;
|
||||
await loadAndRender(vaultName, state.currentDir, resultsArea);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree click delegation — update vault home on directory clicks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _treeDelegationInstalled = false;
|
||||
|
||||
function _initTreeDelegation() {
|
||||
if (_treeDelegationInstalled) return;
|
||||
|
||||
const tree = document.getElementById('vault-tree');
|
||||
if (!tree) {
|
||||
// Retry once when DOM might not be ready — use MutationObserver as fallback
|
||||
const observer = new MutationObserver(() => {
|
||||
const t = document.getElementById('vault-tree');
|
||||
if (t) {
|
||||
observer.disconnect();
|
||||
_attachTreeListener(t);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return;
|
||||
}
|
||||
|
||||
_attachTreeListener(tree);
|
||||
try {
|
||||
const browse = await api(
|
||||
`/api/browse/${encodeURIComponent(vaultName)}?path=${encodeURIComponent(state.currentDir)}`,
|
||||
);
|
||||
state.dirs = (browse.items || []).filter((i) => i.type === 'directory');
|
||||
} catch {
|
||||
state.dirs = []; // no subdirectory list — the file listing still renders
|
||||
}
|
||||
if (seq !== _renderSeq) return;
|
||||
|
||||
state.files = files;
|
||||
_view.dirs = state.dirs;
|
||||
const facets = buildFacets();
|
||||
if (facets) facetsHost.appendChild(facets);
|
||||
renderResults();
|
||||
|
||||
// Recent files for this vault (root level only)
|
||||
if (!state.currentDir) {
|
||||
loadRecentSection(vaultName, container, resultsArea);
|
||||
}
|
||||
}
|
||||
|
||||
function _attachTreeListener(tree) {
|
||||
if (_treeDelegationInstalled) return;
|
||||
_treeDelegationInstalled = true;
|
||||
|
||||
tree.addEventListener('click', (e) => {
|
||||
if (!state.active) return;
|
||||
|
||||
// Find the closest tree-item that is a directory
|
||||
const item = e.target.closest('.tree-item');
|
||||
if (!item || !item.dataset.vault || item.dataset.path === undefined) return;
|
||||
|
||||
const vault = item.dataset.vault;
|
||||
const path = item.dataset.path;
|
||||
|
||||
// Only react to directory clicks (items that have a child container)
|
||||
const hasChildContainer = item.nextElementSibling
|
||||
&& item.nextElementSibling.classList.contains('tree-children');
|
||||
if (!hasChildContainer) return;
|
||||
|
||||
// Update vault home dir
|
||||
updateVaultHomeDir(vault, path);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize delegation when module loads
|
||||
_initTreeDelegation();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// Sort + save toolbar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildToolbar() {
|
||||
const header = el('div', { class: 'search-results-header' });
|
||||
|
||||
const sortDiv = el('div', { class: 'search-sort' });
|
||||
const btnRelevance = el('button', {
|
||||
class: 'search-sort__btn' + (state.sort === 'relevance' ? ' active' : ''),
|
||||
type: 'button',
|
||||
});
|
||||
btnRelevance.textContent = 'Pertinence';
|
||||
btnRelevance.addEventListener('click', () => {
|
||||
state.sort = 'relevance';
|
||||
btnRelevance.classList.add('active');
|
||||
btnDate.classList.remove('active');
|
||||
renderResults();
|
||||
});
|
||||
const btnDate = el('button', {
|
||||
class: 'search-sort__btn' + (state.sort === 'modified' ? ' active' : ''),
|
||||
type: 'button',
|
||||
});
|
||||
btnDate.textContent = 'Date';
|
||||
btnDate.addEventListener('click', () => {
|
||||
state.sort = 'modified';
|
||||
btnDate.classList.add('active');
|
||||
btnRelevance.classList.remove('active');
|
||||
renderResults();
|
||||
});
|
||||
sortDiv.appendChild(btnRelevance);
|
||||
sortDiv.appendChild(btnDate);
|
||||
header.appendChild(sortDiv);
|
||||
|
||||
// Save the current directory as a saved search — same payload as the search
|
||||
// page's "save directory" action (query vide + include_paths).
|
||||
const saveBtn = el('button', { class: 'search-save-btn', type: 'button', title: t('search.save_search') });
|
||||
saveBtn.innerHTML = '<i data-lucide="bookmark-plus" style="width:14px;height:14px"></i> Sauver';
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/saved-searches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: '',
|
||||
vault: state.currentVault,
|
||||
case_sensitive: false,
|
||||
whole_word: false,
|
||||
regex: false,
|
||||
include_paths: state.currentDir ? state.currentDir + '/**' : '**',
|
||||
exclude_paths: '',
|
||||
}),
|
||||
});
|
||||
flashSaveBtn(saveBtn, false);
|
||||
import('./viewer.js').then((m) => m.loadSavedSearches());
|
||||
} catch (err) {
|
||||
flashSaveBtn(saveBtn, true, err);
|
||||
}
|
||||
});
|
||||
header.appendChild(saveBtn);
|
||||
safeCreateIcons();
|
||||
return header;
|
||||
}
|
||||
|
||||
/** Tiny inline feedback on the save button (no toast module dependency). */
|
||||
function flashSaveBtn(btn, isError, err) {
|
||||
const original = btn.innerHTML;
|
||||
if (isError) {
|
||||
btn.title = String(err?.detail || err?.message || err);
|
||||
}
|
||||
btn.textContent = isError ? '✕' : '✓';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = original;
|
||||
safeCreateIcons();
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Facets panel — same mechanism and visuals as the search page (#157/#158)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildFacets() {
|
||||
const facets = buildNavFacets(state.files);
|
||||
// Parité avec la page de recherche : pas de panneau vide (listing sans fichier).
|
||||
if (!Object.keys(facets.vaults).length && !Object.keys(facets.tags).length
|
||||
&& !Object.keys(facets.extensions).length) {
|
||||
return null;
|
||||
}
|
||||
const collapsed = localStorage.getItem('obsigate_facets_collapsed') === '1';
|
||||
const facetsDiv = el('div', { class: 'search-facets', 'data-collapsed': collapsed ? 'true' : 'false' });
|
||||
|
||||
const toggleBtn = el('button', {
|
||||
class: 'search-facets__toggle',
|
||||
type: 'button',
|
||||
'aria-expanded': String(!collapsed),
|
||||
title: collapsed ? t('search.facets_expand') : t('search.facets_collapse'),
|
||||
});
|
||||
toggleBtn.innerHTML = '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const nowCollapsed = facetsDiv.getAttribute('data-collapsed') !== 'true';
|
||||
facetsDiv.setAttribute('data-collapsed', nowCollapsed ? 'true' : 'false');
|
||||
toggleBtn.setAttribute('aria-expanded', String(!nowCollapsed));
|
||||
toggleBtn.title = nowCollapsed ? t('search.facets_expand') : t('search.facets_collapse');
|
||||
localStorage.setItem('obsigate_facets_collapsed', nowCollapsed ? '1' : '0');
|
||||
});
|
||||
facetsDiv.appendChild(toggleBtn);
|
||||
|
||||
const body = el('div', { class: 'search-facets__body' });
|
||||
|
||||
// Vaults — the view is scoped to one vault; clicking the chip jumps to the
|
||||
// vault root (never a dead control).
|
||||
if (Object.keys(facets.vaults).length > 0) {
|
||||
const group = el('div', { class: 'search-facets__group' });
|
||||
group.appendChild(facetLabel('Vaults'));
|
||||
for (const [vaultName, count] of Object.entries(facets.vaults)) {
|
||||
const item = facetItem(vaultName, count, false);
|
||||
item.addEventListener('click', () => TabManager.openNav(vaultName, ''));
|
||||
group.appendChild(item);
|
||||
}
|
||||
body.appendChild(group);
|
||||
}
|
||||
|
||||
// Tags — filter the listing client-side (toggle).
|
||||
if (Object.keys(facets.tags).length > 0) {
|
||||
const group = el('div', { class: 'search-facets__group' });
|
||||
group.appendChild(facetLabel('Tags'));
|
||||
for (const [tagName, count] of Object.entries(facets.tags).slice(0, 12)) {
|
||||
const item = facetItem('#' + tagName, count, state.filterTag === tagName);
|
||||
item.addEventListener('click', () => {
|
||||
state.filterTag = state.filterTag === tagName ? '' : tagName;
|
||||
refreshView();
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
body.appendChild(group);
|
||||
}
|
||||
|
||||
// Extensions — filter the listing client-side (toggle).
|
||||
if (Object.keys(facets.extensions).length > 0) {
|
||||
const group = el('div', { class: 'search-facets__group', 'data-facet': 'extensions' });
|
||||
group.appendChild(facetLabel('Extensions'));
|
||||
for (const [extName, count] of Object.entries(facets.extensions)) {
|
||||
const item = facetItem('.' + extName, count, state.filterExt === extName);
|
||||
item.addEventListener('click', () => {
|
||||
state.filterExt = state.filterExt === extName ? '' : extName;
|
||||
refreshView();
|
||||
});
|
||||
group.appendChild(item);
|
||||
}
|
||||
body.appendChild(group);
|
||||
}
|
||||
|
||||
facetsDiv.appendChild(body);
|
||||
return facetsDiv;
|
||||
}
|
||||
|
||||
function facetLabel(text) {
|
||||
const label = el('span', { class: 'search-facets__label' });
|
||||
label.textContent = text;
|
||||
return label;
|
||||
}
|
||||
|
||||
function facetItem(label, count, active) {
|
||||
// textContent: facet labels come from user data (names, tags, extensions),
|
||||
// never innerHTML.
|
||||
const item = el('span', { class: 'search-facets__item' + (active ? ' active' : '') });
|
||||
item.appendChild(document.createTextNode(label + ' '));
|
||||
const countEl = el('span', { class: 'facet-count' });
|
||||
countEl.textContent = String(count);
|
||||
item.appendChild(countEl);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Re-render facets + listing after a filter change (no refetch). */
|
||||
function refreshView() {
|
||||
if (!_view) return;
|
||||
_view.facetsHost.replaceChildren();
|
||||
const facets = buildFacets();
|
||||
if (facets) _view.facetsHost.appendChild(facets);
|
||||
renderResults();
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Listing: subdirectories + files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderResults() {
|
||||
if (!_view) return;
|
||||
const container = _view.resultsArea;
|
||||
container.innerHTML = '';
|
||||
|
||||
const files = filterNavFiles(state.files, { tag: state.filterTag, ext: state.filterExt });
|
||||
const isEmptyListing = state.files.length === 0;
|
||||
|
||||
if (isEmptyListing && state.dirs.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-empty">
|
||||
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
|
||||
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Header: count + directory label
|
||||
const header = el('div', { class: 'vault-home-header' });
|
||||
const shown = isEmptyListing ? state.dirs.length : files.length;
|
||||
const countKey = shown > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count';
|
||||
header.appendChild(el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [
|
||||
document.createTextNode(t(countKey, { count: shown })),
|
||||
]));
|
||||
header.appendChild(el('span', { class: 'vault-home-dir-info' }, [
|
||||
document.createTextNode(state.currentDir ? `/${state.currentDir}` : ` ${t('common.root')}`),
|
||||
]));
|
||||
container.appendChild(header);
|
||||
|
||||
// Active filter chips (same badges as the search page)
|
||||
if (state.filterTag || state.filterExt) {
|
||||
const activeFilters = el('div', { class: 'search-results-active-tags' });
|
||||
const addChip = (label, onRemove) => {
|
||||
const removeBtn = el('button', { class: 'search-results-active-tag-remove', title: label }, [
|
||||
document.createTextNode('×'),
|
||||
]);
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
});
|
||||
activeFilters.appendChild(el('span', { class: 'search-results-active-tag' }, [
|
||||
document.createTextNode(label), removeBtn,
|
||||
]));
|
||||
};
|
||||
if (state.filterTag) addChip('#' + state.filterTag, () => { state.filterTag = ''; refreshView(); });
|
||||
if (state.filterExt) addChip('.' + state.filterExt, () => { state.filterExt = ''; refreshView(); });
|
||||
container.appendChild(activeFilters);
|
||||
}
|
||||
|
||||
// Subdirectories — clickable, navigate deeper
|
||||
if (state.dirs.length > 0) {
|
||||
const section = el('div', { class: 'vault-home-recent vault-home-dirs' });
|
||||
const secHeader = el('div', { class: 'vault-home-recent-header' });
|
||||
secHeader.innerHTML = `
|
||||
<i data-lucide="folders" style="width:14px;height:14px"></i>
|
||||
<span data-i18n="vaulthome.directories">${t('vaulthome.directories')}</span>`;
|
||||
section.appendChild(secHeader);
|
||||
const list = el('div', { class: 'vault-home-recent-list' });
|
||||
state.dirs.forEach((d) => {
|
||||
const item = el('div', { class: 'vault-home-recent-item', 'data-path': d.path });
|
||||
// Même couleur d'icône que l'arbre de la sidebar (.tree-item .icon)
|
||||
item.innerHTML = `
|
||||
<i data-lucide="folder" class="icon" style="width:16px;height:16px;flex-shrink:0"></i>
|
||||
<span class="vault-home-recent-title">${escapeHtml(d.name)}</span>
|
||||
<span class="vault-home-recent-time">(${d.children_count})</span>`;
|
||||
item.addEventListener('click', () => TabManager.openNav(state.currentVault, d.path));
|
||||
// Menu contextuel identique à celui de la sidebar (#158)
|
||||
item.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // sinon le handler global de ContextMenuManager referme le menu
|
||||
ContextMenuManager.show(e.clientX, e.clientY, state.currentVault, d.path, 'directory', false);
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
section.appendChild(list);
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
// File list — search-result-item style
|
||||
if (files.length === 0) {
|
||||
if (!isEmptyListing) {
|
||||
container.appendChild(el('p', { style: 'color:var(--text-muted);margin-top:20px' }, [
|
||||
document.createTextNode(t('search.no_results')),
|
||||
]));
|
||||
}
|
||||
safeCreateIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('div', { class: 'search-results' });
|
||||
const locale = getLocale();
|
||||
sortNavFiles(files, state.sort).forEach((f) => {
|
||||
list.appendChild(buildFileItem(f, locale));
|
||||
});
|
||||
container.appendChild(list);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
function buildFileItem(f, locale) {
|
||||
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
|
||||
const ext = f.extension || '';
|
||||
|
||||
const titleDiv = el('div', { class: 'search-result-title' }, [
|
||||
document.createTextNode(navDisplayName(f)),
|
||||
]);
|
||||
|
||||
const modDate = new Date(f.modified * 1000);
|
||||
const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
const metaDiv = el('div', { class: 'search-result-vault' }, [
|
||||
document.createTextNode([dateStr, formatSize(f.size)].join(' · ')),
|
||||
]);
|
||||
|
||||
const item = el('div', {
|
||||
class: 'search-result-item vault-home-item',
|
||||
'data-vault': f.vault,
|
||||
'data-path': f.path,
|
||||
}, [
|
||||
el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]),
|
||||
el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }),
|
||||
titleDiv,
|
||||
metaDiv,
|
||||
]);
|
||||
|
||||
// Tags — clicking one applies the tag filter (like the search page)
|
||||
if (f.tags && f.tags.length > 0) {
|
||||
const tagsDiv = el('div', { class: 'search-result-tags' });
|
||||
f.tags.forEach((tag) => {
|
||||
const tagEl = el('span', { class: 'file-tag' }, [document.createTextNode('#' + tag)]);
|
||||
tagEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
state.filterTag = tag;
|
||||
refreshView();
|
||||
});
|
||||
tagsDiv.appendChild(tagEl);
|
||||
});
|
||||
item.appendChild(tagsDiv);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
|
||||
item.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
TabManager.openPersistent(f.vault, f.path);
|
||||
});
|
||||
// Menu contextuel identique à celui de la sidebar (#158)
|
||||
item.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ContextMenuManager.show(e.clientX, e.clientY, f.vault, f.path, 'file', false);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breadcrumb
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderBreadcrumb(container, vaultName, dirPath) {
|
||||
@@ -190,12 +501,13 @@ function renderBreadcrumb(container, vaultName, dirPath) {
|
||||
}
|
||||
|
||||
function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
|
||||
// Vault icon + name (root)
|
||||
// Vault icon + name (root) — routed through the navigation tab so the tab
|
||||
// cache and the view never drift apart.
|
||||
const vaultLink = el('span', { class: 'vault-home-breadcrumb-item vault-home-breadcrumb-root' }, [
|
||||
el('i', { 'data-lucide': 'database', style: 'width:14px;height:14px' }),
|
||||
el('span', {}, [document.createTextNode(vaultName)]),
|
||||
]);
|
||||
vaultLink.addEventListener('click', () => updateVaultHomeDir(vaultName, ''));
|
||||
vaultLink.addEventListener('click', () => TabManager.openNav(vaultName, ''));
|
||||
vaultLink.style.cursor = 'pointer';
|
||||
parentEl.appendChild(vaultLink);
|
||||
|
||||
@@ -205,126 +517,31 @@ function buildBreadcrumbHTML(parentEl, vaultName, dirPath) {
|
||||
let cumulative = '';
|
||||
for (const seg of segments) {
|
||||
cumulative = cumulative ? cumulative + '/' + seg : seg;
|
||||
const sep = el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]);
|
||||
parentEl.appendChild(sep);
|
||||
parentEl.appendChild(el('span', { class: 'vault-home-breadcrumb-sep' }, [document.createTextNode(' / ')]));
|
||||
const target = cumulative;
|
||||
const segLink = el('span', { class: 'vault-home-breadcrumb-item' }, [
|
||||
el('i', { 'data-lucide': 'folder', style: 'width:14px;height:14px' }),
|
||||
el('span', {}, [document.createTextNode(seg)]),
|
||||
]);
|
||||
segLink.addEventListener('click', () => updateVaultHomeDir(vaultName, cumulative));
|
||||
segLink.addEventListener('click', () => TabManager.openNav(vaultName, target));
|
||||
segLink.style.cursor = 'pointer';
|
||||
parentEl.appendChild(segLink);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRender(vaultName, dirPath, container) {
|
||||
try {
|
||||
const url = `/api/vault/${encodeURIComponent(vaultName)}/files?dir=${encodeURIComponent(dirPath)}&limit=200&recursive=false`;
|
||||
const data = await api(url);
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-empty">
|
||||
<i data-lucide="folder-open" style="width:32px;height:32px;color:var(--text-muted)"></i>
|
||||
<p data-i18n="vaulthome.empty">${t('vaulthome.empty')}</p>
|
||||
</div>`;
|
||||
safeCreateIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Header
|
||||
const header = el('div', { class: 'vault-home-header' });
|
||||
const countKey = data.files.length > 1 ? 'vaulthome.file_count_plural' : 'vaulthome.file_count';
|
||||
const countStr = t(countKey, { count: data.files.length });
|
||||
const count = el('span', { class: 'vault-home-count', 'data-i18n': countKey }, [
|
||||
document.createTextNode(countStr),
|
||||
]);
|
||||
header.appendChild(count);
|
||||
|
||||
// Directory label
|
||||
const dirLabel = dirPath ? `/${dirPath}` : ` ${t('common.root')}`;
|
||||
const dirInfo = el('span', { class: 'vault-home-dir-info' }, [
|
||||
document.createTextNode(dirLabel),
|
||||
]);
|
||||
header.appendChild(dirInfo);
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(header);
|
||||
|
||||
// File list — using search-result-item style
|
||||
const list = el('div', { class: 'search-results' });
|
||||
const locale = getLocale();
|
||||
|
||||
data.files.forEach((f) => {
|
||||
const fileIconName = getFileIcon ? getFileIcon(f.name) : 'file';
|
||||
const displayName = f.name.endsWith('.md') ? f.name.slice(0, -3) : f.name;
|
||||
const ext = f.extension || '';
|
||||
|
||||
const titleDiv = el('div', { class: 'search-result-title' }, [
|
||||
document.createTextNode(displayName),
|
||||
]);
|
||||
|
||||
// Format date using current locale
|
||||
const modDate = new Date(f.modified * 1000);
|
||||
const dateStr = modDate.toLocaleDateString(locale === 'fr' ? 'fr-CA' : 'en-CA', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
|
||||
// Build metadata line: date + size
|
||||
const metaParts = [];
|
||||
metaParts.push(dateStr);
|
||||
metaParts.push(formatSize(f.size));
|
||||
|
||||
const metaDiv = el('div', { class: 'search-result-vault' }, [
|
||||
document.createTextNode(metaParts.join(' · ')),
|
||||
]);
|
||||
|
||||
const item = el('div', {
|
||||
class: 'search-result-item vault-home-item',
|
||||
'data-vault': f.vault,
|
||||
'data-path': f.path,
|
||||
}, [
|
||||
el('span', { class: 'search-result-ext' }, [document.createTextNode(ext)]),
|
||||
el('i', { 'data-lucide': fileIconName, style: 'width:14px;height:14px;margin-right:4px;opacity:0.6' }),
|
||||
titleDiv,
|
||||
metaDiv,
|
||||
]);
|
||||
|
||||
item.addEventListener('click', () => TabManager.openPreview(f.vault, f.path));
|
||||
item.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
TabManager.openPersistent(f.vault, f.path);
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
container.appendChild(list);
|
||||
safeCreateIcons();
|
||||
} catch (err) {
|
||||
const errMsg = t('vaulthome.error_loading') + ' ' + escapeHtml(err?.detail || err?.message || t('common.unknown'));
|
||||
container.innerHTML = `
|
||||
<div class="vault-home-error">
|
||||
<p data-i18n="vaulthome.error_loading">${errMsg}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent files section (loaded at vault root only)
|
||||
// Recent files section (root only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadRecentSection(vaultName, container) {
|
||||
async function loadRecentSection(vaultName, container, resultsArea) {
|
||||
try {
|
||||
const data = await api(`/api/recent?mode=opened&vault=${encodeURIComponent(vaultName)}&limit=5`);
|
||||
const files = data.files || [];
|
||||
if (files.length === 0) return;
|
||||
// The view may have been replaced while fetching
|
||||
if (!container.isConnected) return;
|
||||
|
||||
// Insert recent section BEFORE the results area
|
||||
const resultsArea = document.getElementById('vault-home-results');
|
||||
const section = el('div', { class: 'vault-home-recent' });
|
||||
|
||||
const header = el('div', { class: 'vault-home-recent-header' });
|
||||
header.innerHTML = `
|
||||
<i data-lucide="clock" style="width:14px;height:14px"></i>
|
||||
@@ -351,16 +568,14 @@ async function loadRecentSection(vaultName, container) {
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
section.appendChild(list);
|
||||
|
||||
// Insert before the results area
|
||||
if (resultsArea) {
|
||||
if (resultsArea && resultsArea.parentNode === container) {
|
||||
container.insertBefore(section, resultsArea);
|
||||
} else {
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
safeCreateIcons();
|
||||
} catch {
|
||||
// Silently skip if recent files can't be loaded
|
||||
|
||||
+1123
-94
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,9 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
}
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
|
||||
<button class="btn-action" id="xlsx-format-btn" title="${escapeHtml(t("xlsx.format_btn"))}">
|
||||
<i data-lucide="paintbrush" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
@@ -68,6 +71,16 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<span class="export-dropdown xlsx-export-dropdown">
|
||||
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}">
|
||||
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
|
||||
</button>
|
||||
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none">
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="md">${escapeHtml(t("xlsx.export_md"))}</button>
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="html">${escapeHtml(t("xlsx.export_html"))}</button>
|
||||
<button class="export-menu-item" type="button" data-xlsx-export="print">${escapeHtml(t("xlsx.print"))}</button>
|
||||
</div>
|
||||
</span>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||
</span>`);
|
||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"common.previous": "Previous",
|
||||
"common.refresh": "Refresh",
|
||||
"common.rename": "Rename",
|
||||
"common.home": "Home",
|
||||
"common.root": "(root)",
|
||||
"common.save": "Save",
|
||||
"common.search": "Search",
|
||||
@@ -1101,6 +1102,7 @@
|
||||
"help.desc_f952db17": "Refresh tokens : Cookies HttpOnly avec rotation automatique",
|
||||
"help.desc_fa126609": "Converts the document structure into an Obsidian Canvas (visual cards).",
|
||||
"help.desc_facet_vaults": ": Counters by vault",
|
||||
"help.desc_facet_extensions": ": Counters by file extension in the results",
|
||||
"help.desc_fc9391f3": "ObsiGate sends an HTTP POST to your webhook URL with event details.",
|
||||
"help.desc_fdeaf803": ": Each file type has its icon (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": File title suggestions",
|
||||
@@ -1608,6 +1610,8 @@
|
||||
"search.exclude_placeholder": "Exclude: vendor/*, *.lock",
|
||||
"search.facet_tags": "Tags",
|
||||
"search.facet_vaults": "Vaults",
|
||||
"search.facets_collapse": "Collapse facets",
|
||||
"search.facets_expand": "Expand facets",
|
||||
"search.files_filtered": "{count} file(s) with filters",
|
||||
"search.filter_path_title": "Path filters (Alt-F)",
|
||||
"search.filters": "Filters",
|
||||
@@ -1803,6 +1807,7 @@
|
||||
"vault.add_dynamic": "Add vault",
|
||||
"vault.reindex": "Reindex vault",
|
||||
"vault.remove_dynamic": "Remove vault",
|
||||
"vaulthome.directories": "Folders",
|
||||
"vaulthome.empty": "No files in this directory",
|
||||
"vaulthome.error_loading": "Loading error:",
|
||||
"vaulthome.file_count": "{count} file",
|
||||
@@ -1848,13 +1853,59 @@
|
||||
"xlsx.load_error": "Could not load the remaining rows",
|
||||
"xlsx.formula_bar_placeholder": "Active cell content",
|
||||
"xlsx.active_cell": "Cell",
|
||||
"xlsx.find_placeholder": "Search in the sheet…",
|
||||
"xlsx.name_box_label": "Name Box — jump to a cell or a range",
|
||||
"xlsx.name_box_hint": "Type a reference (B12, A1:B3) then Enter to jump there",
|
||||
"xlsx.name_box_invalid": "Unknown reference: {ref}",
|
||||
"xlsx.copy": "Copy",
|
||||
"xlsx.cut": "Cut",
|
||||
"xlsx.paste": "Paste",
|
||||
"xlsx.paste_empty": "Clipboard empty or unreadable",
|
||||
"xlsx.paste_out_of_grid": "Part of the pasted block is outside the displayed grid and was skipped",
|
||||
"xlsx.fn_suggestions": "SUM;AVERAGE;COUNT;COUNTA;MIN;MAX;IF;VLOOKUP;XLOOKUP;SUMIF;SUMIFS;COUNTIF;CONCAT;LEFT;RIGHT;MID;TODAY;NOW;ROUND;ABS;SQRT",
|
||||
"xlsx.formula_stored_as_text": "{n} value(s) starting with “=” or “@” saved as text — turn on the f(x) button to write real formulas",
|
||||
"xlsx.find_placeholder": "Search in the workbook…",
|
||||
"xlsx.find_prev": "Previous",
|
||||
"xlsx.find_next": "Next",
|
||||
"xlsx.find_case": "Match case",
|
||||
"xlsx.find_no_match": "No match",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Export the sheet as CSV",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
|
||||
"xlsx.csv_export": "Export the sheet or the selection as CSV",
|
||||
"xlsx.export_btn": "Export",
|
||||
"xlsx.export_title": "Export the selection or the sheet",
|
||||
"xlsx.export_md": "Export as Markdown",
|
||||
"xlsx.export_html": "Export as HTML",
|
||||
"xlsx.print": "Print",
|
||||
"xlsx.export_scope_sheet": "the sheet",
|
||||
"xlsx.export_scope_selection": "the selection",
|
||||
"xlsx.export_done": "Exported {scope}",
|
||||
"xlsx.format_btn": "Formatting",
|
||||
"xlsx.format_bold": "Bold",
|
||||
"xlsx.format_italic": "Italic",
|
||||
"xlsx.format_underline": "Underline",
|
||||
"xlsx.format_clear": "Clear formatting",
|
||||
"xlsx.format_align_left": "Align left",
|
||||
"xlsx.format_align_center": "Center",
|
||||
"xlsx.format_align_right": "Align right",
|
||||
"xlsx.format_font_color": "Text colour",
|
||||
"xlsx.format_fill_color": "Fill colour",
|
||||
"xlsx.format_num_general": "General format",
|
||||
"xlsx.format_num_number": "Number (2 decimals)",
|
||||
"xlsx.format_num_percent": "Percentage",
|
||||
"xlsx.format_num_currency": "Currency (€)",
|
||||
"xlsx.format_num_date": "Date (DD/MM/YYYY)",
|
||||
"xlsx.format_num_text": "Text",
|
||||
"xlsx.format_merge": "Merge cells",
|
||||
"xlsx.format_unmerge": "Unmerge cells",
|
||||
"xlsx.format_merge_needs_range": "Select at least two cells to merge",
|
||||
"xlsx.format_freeze": "Freeze panes here",
|
||||
"xlsx.format_unfreeze": "Unfreeze panes",
|
||||
"xlsx.format_col_width": "Column width…",
|
||||
"xlsx.format_col_prompt": "Width of column {col} (0 to 255)",
|
||||
"xlsx.format_row_height": "Row height…",
|
||||
"xlsx.format_row_prompt": "Height of row {row} (0 to 409)",
|
||||
"xlsx.format_applied": "Formatting saved",
|
||||
"xlsx.format_error": "Formatting failed",
|
||||
"xlsx.sort_asc": "Sort column A→Z",
|
||||
"xlsx.sort_desc": "Sort column Z→A",
|
||||
"xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged",
|
||||
@@ -1905,6 +1956,7 @@
|
||||
"xlsx.dashboard_nr_ref": "Reference",
|
||||
"xlsx.dashboard_hint": "Select a range or open the AI assistant to analyse this data.",
|
||||
"xlsx.feature_cached_values": "cached values",
|
||||
"xlsx.feature_cached_values_unverified": "cached values could not be verified (workbook too large)",
|
||||
"xlsx.feature_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
"xlsx.feature_connections": "connections and queries",
|
||||
@@ -2080,6 +2132,9 @@
|
||||
"ai.step.xlsx_read": "Workbook read: {value}",
|
||||
"ai.step.xlsx_update": "Cells edited: {value}",
|
||||
"ai.step.xlsx_append": "Rows appended: {value}",
|
||||
"ai.step.xlsx_search": "Workbook searched: {value}",
|
||||
"ai.step.xlsx_analyze": "Range analysed: {value}",
|
||||
"ai.step.xlsx_structure": "Structure changed: {value}",
|
||||
"ai.step.docx_create": "Word document proposed: {value}",
|
||||
"ai.step.csv_create": "CSV file proposed: {value}",
|
||||
"ai.step.pdf_create": "PDF document proposed: {value}",
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"common.previous": "Précédent",
|
||||
"common.refresh": "Rafraîchir",
|
||||
"common.rename": "Renommer",
|
||||
"common.home": "Accueil",
|
||||
"common.root": "(racine)",
|
||||
"common.save": "Enregistrer",
|
||||
"common.search": "Rechercher",
|
||||
@@ -1101,6 +1102,7 @@
|
||||
"help.desc_f952db17": "Refresh tokens : Cookies\n HttpOnly avec rotation automatique",
|
||||
"help.desc_fa126609": "🔷\n Convert to canvas :\n Convertit en diagramme Mermaid.js",
|
||||
"help.desc_facet_vaults": ": Compteurs par vault",
|
||||
"help.desc_facet_extensions": ": Compteurs par extension de fichier dans les résultats",
|
||||
"help.desc_fc9391f3": "# Exemple avec curl + jq dans un script bash\nWEBHOOK_URL=\"https://discord.com/api/webhooks/...\"\nOBSIGATE_SECRET=\"mon-secret\"\n\n# Lire le payload depuis stdin\nread -r payload\nevent=$(echo \"$payload\" | jq -r '.event')\npath=$(echo \"$payload\" | jq -r '.data.path')\n\ncurl -H \"Content-Type: application/json\" -d \"{\n \\\"content\\\": \\\"📝 **$event** — $path\\\"\n}\" \"$WEBHOOK_URL\"\n\n Tester avec curl\n # Simuler ce qu'ObsiGate envoie (pour tester votre endpoint)\ncurl -X POST https://votre-serveur.com/webhook \\\n -H \"Content-Type: application/json\" \\\n -H \"X-ObsiGate-Event: file_created\" \\\n -H \"X-ObsiGate-Signature: sha256=$(echo -n '{\"event\":\"file_created\",...}' | openssl dgst -sha256 -hmac 'mon-secret' | cut -d' ' -f2)\" \\\n -d '{\"event\":\"file_created\",\"timestamp\":\"2026-06-03T14:30:00Z\",\"data\":{\"vault\":\"IT\",\"path\":\"notes/test.md\"}}'\n\n Limitations actuelles\n \n \n Timeout de 5 secondes par\n requête — les webhooks sont \"fire and\n forget\" (pas de retry)\n \n \n Les modifications détectées par le watcher\n filesystem (watchdog)\n ne déclenchent pas de\n webhooks — seules les opérations via l'API\n REST sont prises en compte\n \n \n file_modified n'est pas encore\n dispatché (réservé pour version future)\n \n Pas de retry automatique en cas d'échec\n \n Pas de filtrage par vault — un webhook\n écoute toutes les vaults\n \n \n \n \n 💾 Sauvegardes et Audits\n\n Backup automatique\n \n ObsiGate crée automatiquement des sauvegardes\n avant chaque modification destructive.",
|
||||
"help.desc_fdeaf803": ": Chaque type de\n fichier a son icône (markdown, code, etc.)",
|
||||
"help.desc_file_suggest": ": Suggestions de titres de fichiers",
|
||||
@@ -1608,6 +1610,8 @@
|
||||
"search.exclude_placeholder": "Exclure: vendor/*, *.lock",
|
||||
"search.facet_tags": "Tags",
|
||||
"search.facet_vaults": "Vaults",
|
||||
"search.facets_collapse": "Réduire les facettes",
|
||||
"search.facets_expand": "Afficher les facettes",
|
||||
"search.files_filtered": "{count} fichier(s) avec filtres",
|
||||
"search.filter_path_title": "Filtres de chemin (Alt-F)",
|
||||
"search.filters": "Filtres",
|
||||
@@ -1803,6 +1807,7 @@
|
||||
"vault.add_dynamic": "Ajouter une vault",
|
||||
"vault.reindex": "Réindexer la vault",
|
||||
"vault.remove_dynamic": "Retirer la vault",
|
||||
"vaulthome.directories": "Répertoires",
|
||||
"vaulthome.empty": "Aucun fichier dans ce repertoire",
|
||||
"vaulthome.error_loading": "Erreur de chargement:",
|
||||
"vaulthome.file_count": "{count} fichier",
|
||||
@@ -1848,13 +1853,59 @@
|
||||
"xlsx.load_error": "Chargement de la suite impossible",
|
||||
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
|
||||
"xlsx.active_cell": "Cellule",
|
||||
"xlsx.find_placeholder": "Rechercher dans la feuille…",
|
||||
"xlsx.name_box_label": "Zone Nom — atteindre une cellule ou une plage",
|
||||
"xlsx.name_box_hint": "Saisissez une référence (B12, A1:B3) puis Entrée pour y aller",
|
||||
"xlsx.name_box_invalid": "Référence inconnue : {ref}",
|
||||
"xlsx.copy": "Copier",
|
||||
"xlsx.cut": "Couper",
|
||||
"xlsx.paste": "Coller",
|
||||
"xlsx.paste_empty": "Presse-papiers vide ou illisible",
|
||||
"xlsx.paste_out_of_grid": "Une partie du collage dépasse la grille affichée et a été ignorée",
|
||||
"xlsx.fn_suggestions": "SOMME;MOYENNE;NB;NBVAL;MIN;MAX;SI;RECHERCHEV;RECHERCHEX;SOMME.SI;SOMME.SI.ENS;NB.SI;CONCAT;GAUCHE;DROITE;STXT;AUJOURDHUI;MAINTENANT;ARRONDI;ABS;RACINE",
|
||||
"xlsx.formula_stored_as_text": "{n} valeur(s) commençant par « = » ou « @ » enregistrée(s) comme texte — activez le bouton f(x) pour écrire de vraies formules",
|
||||
"xlsx.find_placeholder": "Rechercher dans le classeur…",
|
||||
"xlsx.find_prev": "Précédent",
|
||||
"xlsx.find_next": "Suivant",
|
||||
"xlsx.find_case": "Respecter la casse",
|
||||
"xlsx.find_no_match": "Aucune correspondance",
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.csv_export": "Exporter la feuille en CSV",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
|
||||
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
|
||||
"xlsx.export_btn": "Exporter",
|
||||
"xlsx.export_title": "Exporter la sélection ou la feuille",
|
||||
"xlsx.export_md": "Exporter en Markdown",
|
||||
"xlsx.export_html": "Exporter en HTML",
|
||||
"xlsx.print": "Imprimer",
|
||||
"xlsx.export_scope_sheet": "la feuille",
|
||||
"xlsx.export_scope_selection": "la sélection",
|
||||
"xlsx.export_done": "Export de {scope} terminé",
|
||||
"xlsx.format_btn": "Mise en forme",
|
||||
"xlsx.format_bold": "Gras",
|
||||
"xlsx.format_italic": "Italique",
|
||||
"xlsx.format_underline": "Souligné",
|
||||
"xlsx.format_clear": "Effacer la mise en forme",
|
||||
"xlsx.format_align_left": "Aligner à gauche",
|
||||
"xlsx.format_align_center": "Centrer",
|
||||
"xlsx.format_align_right": "Aligner à droite",
|
||||
"xlsx.format_font_color": "Couleur du texte",
|
||||
"xlsx.format_fill_color": "Couleur de fond",
|
||||
"xlsx.format_num_general": "Format général",
|
||||
"xlsx.format_num_number": "Nombre (2 décimales)",
|
||||
"xlsx.format_num_percent": "Pourcentage",
|
||||
"xlsx.format_num_currency": "Monétaire (€)",
|
||||
"xlsx.format_num_date": "Date (JJ/MM/AAAA)",
|
||||
"xlsx.format_num_text": "Texte",
|
||||
"xlsx.format_merge": "Fusionner les cellules",
|
||||
"xlsx.format_unmerge": "Annuler la fusion",
|
||||
"xlsx.format_merge_needs_range": "Sélectionnez au moins deux cellules pour fusionner",
|
||||
"xlsx.format_freeze": "Figer les volets ici",
|
||||
"xlsx.format_unfreeze": "Libérer les volets",
|
||||
"xlsx.format_col_width": "Largeur de colonne…",
|
||||
"xlsx.format_col_prompt": "Largeur de la colonne {col} (0 à 255)",
|
||||
"xlsx.format_row_height": "Hauteur de ligne…",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)",
|
||||
"xlsx.format_applied": "Mise en forme enregistrée",
|
||||
"xlsx.format_error": "Échec de la mise en forme",
|
||||
"xlsx.sort_asc": "Trier la colonne A→Z",
|
||||
"xlsx.sort_desc": "Trier la colonne Z→A",
|
||||
"xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié",
|
||||
@@ -1905,6 +1956,7 @@
|
||||
"xlsx.dashboard_nr_ref": "Référence",
|
||||
"xlsx.dashboard_hint": "Sélectionnez une plage ou ouvrez l'assistant IA pour analyser ces données.",
|
||||
"xlsx.feature_cached_values": "valeurs calculées",
|
||||
"xlsx.feature_cached_values_unverified": "valeurs calculées non vérifiables (classeur trop volumineux)",
|
||||
"xlsx.feature_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
"xlsx.feature_connections": "connexions et requêtes",
|
||||
@@ -2080,6 +2132,9 @@
|
||||
"ai.step.xlsx_read": "Classeur lu : {value}",
|
||||
"ai.step.xlsx_update": "Cellules modifiées : {value}",
|
||||
"ai.step.xlsx_append": "Lignes ajoutées : {value}",
|
||||
"ai.step.xlsx_search": "Recherche dans le classeur : {value}",
|
||||
"ai.step.xlsx_analyze": "Plage analysée : {value}",
|
||||
"ai.step.xlsx_structure": "Structure modifiée : {value}",
|
||||
"ai.step.docx_create": "Document Word proposé : {value}",
|
||||
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
||||
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
||||
|
||||
+127
-2
@@ -4038,7 +4038,9 @@ body.resizing-v {
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
/* Par-dessus la barre d'outils mobile (.mobile-toolbar, z-index 900)
|
||||
pour ne pas masquer le bas du sidebar — BUG-103 */
|
||||
z-index: 950;
|
||||
width: 280px !important;
|
||||
min-width: 280px !important;
|
||||
max-width: 85vw !important;
|
||||
@@ -5472,10 +5474,55 @@ body.resizing-v {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
/* Facette actuellement appliquée (filtre local — recherche et navigation) */
|
||||
.search-facets__item.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.search-facets__item .facet-count {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-facets__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-start; /* mobile (colonne) : ne jamais s'étirer en pleine largeur */
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 120ms,
|
||||
color 120ms,
|
||||
background 120ms;
|
||||
}
|
||||
.search-facets__toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Seule l'icône pivote — la boîte du bouton reste fixe */
|
||||
.search-facets__toggle i,
|
||||
.search-facets__toggle svg {
|
||||
transition: transform 120ms;
|
||||
}
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle i,
|
||||
.search-facets[data-collapsed="true"] .search-facets__toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.search-facets__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-facets[data-collapsed="true"] .search-facets__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Advanced Search — Pagination
|
||||
@@ -5860,6 +5907,11 @@ body.resizing-v {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Icône des répertoires : même couleur que l'arbre de la sidebar (.tree-item .icon) */
|
||||
.vault-home-dirs .icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.vault-home-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
@@ -11172,8 +11224,11 @@ body.desktop-mode .editor-container {
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
/* #156-A7 — the Name Box is editable (“Atteindre”): a plain input styled like
|
||||
the read-only badge it replaced. */
|
||||
.xlsx-active-cell {
|
||||
min-width: 52px;
|
||||
width: 84px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
@@ -11184,6 +11239,10 @@ body.desktop-mode .editor-container {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.xlsx-active-cell:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-formula-sep {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -11253,6 +11312,72 @@ body.desktop-mode .editor-container {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* #156-A11 — export menu (CSV stays a button; MD/HTML/Print live here). */
|
||||
.xlsx-export-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.xlsx-export-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
padding: 4px;
|
||||
background: var(--bg, #fff);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.xlsx-export-menu .export-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
.xlsx-export-menu .export-menu-item:hover {
|
||||
background: var(--surface-hover, rgba(127, 127, 127, 0.12));
|
||||
}
|
||||
.xlsx-print-frame {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* #156-A8 — formatting menu: the colour rows pair a label with the native
|
||||
picker (data-driven colours, no hardcoded swatch). */
|
||||
.xlsx-format-menu {
|
||||
min-width: 230px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.xlsx-format-colour {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-format-colour input[type="color"] {
|
||||
width: 34px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* #153 A15 — freeze panes: frozen rows sit UNDER the sticky thead, frozen
|
||||
columns stay left. z-index mirrors thead (3) without covering it. */
|
||||
.xlsx-table tr.xlsx-frozen-row td,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.44.1",
|
||||
"version": "2.49.1",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* E2E — Page de navigation & onglet Accueil (#158).
|
||||
*
|
||||
* Couvre :
|
||||
* - clic sur la racine d'un vault -> page de navigation (onglet dédié)
|
||||
* - icône des répertoires à la couleur de l'arbre de la sidebar
|
||||
* - menu contextuel des répertoires et des fichiers identique à la sidebar
|
||||
* - clic sur le titre -> onglet Accueil (icône Maison) épinglé en tête de barre
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
async function boot(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
|
||||
}
|
||||
|
||||
const menuItems = (page) =>
|
||||
page.$$eval('#context-menu .context-menu-item', (els) => els.map((e) => e.textContent.trim()))
|
||||
.catch(() => []);
|
||||
|
||||
test.describe('Page de navigation (#158)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await boot(page);
|
||||
});
|
||||
|
||||
test("clic sur la racine d'un vault ouvre la page de navigation", async ({ page }) => {
|
||||
const vault = await page.locator('.vault-item').first().getAttribute('data-vault');
|
||||
await page.locator('.vault-item').first().click();
|
||||
|
||||
await page.waitForSelector('#vault-home .vault-home-breadcrumb', { timeout: 15000 });
|
||||
await expect(page.locator(`#tab-list .tab-item[data-tab-id="nav::${vault}"]`)).toHaveCount(1);
|
||||
// Le fil d'Ariane est à la racine du vault
|
||||
await expect(page.locator('#vault-home .vault-home-breadcrumb-item')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("répertoires de la vue : icône à la couleur de la sidebar + menu contextuel", async ({ page }) => {
|
||||
await page.locator('.vault-item').first().click();
|
||||
await page.waitForSelector('#vault-home .vault-home-dirs .vault-home-recent-item', { timeout: 15000 });
|
||||
|
||||
// Couleur identique à l'icône de l'arbre (.tree-item .icon -> var(--accent))
|
||||
const colors = await page.evaluate(() => {
|
||||
const cs = (el) => (el ? getComputedStyle(el).color : null);
|
||||
return {
|
||||
nav: cs(document.querySelector('#vault-home .vault-home-dirs .vault-home-recent-item svg')),
|
||||
side: cs(document.querySelector('.tree-item[data-type="directory"] svg')),
|
||||
};
|
||||
});
|
||||
expect(colors.nav).toBe(colors.side);
|
||||
expect(colors.nav).toBeTruthy();
|
||||
|
||||
// Menu contextuel : page de navigation vs sidebar
|
||||
await page.locator('#vault-home .vault-home-dirs .vault-home-recent-item').first().click({ button: 'right' });
|
||||
await page.waitForSelector('#context-menu', { timeout: 5000 });
|
||||
const navMenu = await menuItems(page);
|
||||
expect(navMenu.length).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('.tree-item[data-type="directory"]').first().click({ button: 'right' });
|
||||
await page.waitForSelector('#context-menu', { timeout: 5000 });
|
||||
const sideMenu = await menuItems(page);
|
||||
await page.keyboard.press('Escape');
|
||||
expect(navMenu).toEqual(sideMenu);
|
||||
});
|
||||
|
||||
test('fichiers de la vue : menu contextuel identique à la sidebar', async ({ page }) => {
|
||||
await page.locator('.vault-item').first().click();
|
||||
await page.waitForSelector('#vault-home .search-result-item', { timeout: 15000 });
|
||||
|
||||
await page.locator('#vault-home .search-result-item').first().click({ button: 'right' });
|
||||
await page.waitForSelector('#context-menu', { timeout: 5000 });
|
||||
const navMenu = await menuItems(page);
|
||||
expect(navMenu.length).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('.tree-item[data-type="file"]').first().click({ button: 'right' });
|
||||
await page.waitForSelector('#context-menu', { timeout: 5000 });
|
||||
const sideMenu = await menuItems(page);
|
||||
await page.keyboard.press('Escape');
|
||||
expect(navMenu).toEqual(sideMenu);
|
||||
});
|
||||
|
||||
test('titre -> onglet Accueil (Maison) épinglé en tête de barre', async ({ page }) => {
|
||||
// Un fichier ouvert pour vérifier que l'onglet Accueil passe devant
|
||||
await page.locator('.vault-item').first().click();
|
||||
await page.waitForSelector('.tree-item[data-type="file"]', { timeout: 15000 });
|
||||
await page.locator('.tree-item[data-type="file"]').first().dblclick();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.locator('#header-logo').click();
|
||||
await page.waitForSelector('#dashboard-home', { timeout: 10000 });
|
||||
|
||||
const ids = await page.$$eval('#tab-list .tab-item', (els) => els.map((e) => e.dataset.tabId));
|
||||
expect(ids[0]).toBe('home');
|
||||
expect(ids.length).toBeGreaterThan(1);
|
||||
await expect(page.locator('#tab-list .tab-item.active')).toHaveAttribute('data-tab-id', 'home');
|
||||
// Icône Maison + libellé traduit
|
||||
const iconClass = await page.locator('#tab-list .tab-item').first().locator('.tab-icon').getAttribute('class');
|
||||
expect(iconClass).toContain('home');
|
||||
const label = (await page.locator('#tab-list .tab-item').first().locator('.tab-name').textContent()) || '';
|
||||
expect(['Accueil', 'Home']).toContain(label.trim());
|
||||
// La page d'accueil est affichée (avec sa section Raccourcis & Astuces)
|
||||
await expect(page.locator('#dashboard-home #quick-help')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,33 @@ test.describe('Full-Text Search', () => {
|
||||
// Not always present — depends on vault content
|
||||
});
|
||||
|
||||
test('should show the extensions facet and collapse the facets panel (#157)', async ({ page }) => {
|
||||
await goHome(page);
|
||||
await searchFor(page, 'test');
|
||||
const extFacet = page.locator('.search-facets__group[data-facet="extensions"]');
|
||||
await expect(extFacet).toBeVisible();
|
||||
await expect(extFacet.locator('.search-facets__item').first()).toBeVisible();
|
||||
// discreet collapse/expand of the whole vaults+tags+extensions section
|
||||
await page.locator('.search-facets__toggle').click();
|
||||
await expect(page.locator('.search-facets__body')).toBeHidden();
|
||||
await page.locator('.search-facets__toggle').click();
|
||||
await expect(page.locator('.search-facets__body')).toBeVisible();
|
||||
|
||||
// mobile: the toggle stays compact (no full-width stretch) and only the
|
||||
// icon rotates — the button box itself never spins
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const toggle = page.locator('.search-facets__toggle');
|
||||
const box = await toggle.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeLessThan(150);
|
||||
await toggle.click();
|
||||
expect(await toggle.evaluate((el) => getComputedStyle(el).transform)).toBe('none');
|
||||
const icon = toggle.locator('svg');
|
||||
expect(await icon.evaluate((el) => getComputedStyle(el).transform)).not.toBe('none');
|
||||
await toggle.click();
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
test('should navigate results with floating bar arrows', async ({ page }) => {
|
||||
await goHome(page);
|
||||
await searchFor(page, 'test');
|
||||
|
||||
@@ -160,8 +160,9 @@ test.describe('Split View Matrix — Boutons & menu contextuel', () => {
|
||||
await flyout.click();
|
||||
await page.waitForTimeout(600);
|
||||
// Pane 0 (single) holds both files now; empty source pane auto-collapsed
|
||||
// (+ onglet navigation ouvert au clic sur la racine du vault — #158)
|
||||
await expect(page.locator('.pane-container')).toHaveCount(0);
|
||||
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
|
||||
await expect(page.locator('#tab-list .tab-item')).toHaveCount(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,10 +206,10 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
test('X button on tab closes it', async ({ page }) => {
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
await openFile(page, 'TestVault', 'note2.md');
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
|
||||
await page.locator('.tab-item').first().locator('.tab-close').click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(1);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('double-click on tab closes it', async ({ page }) => {
|
||||
@@ -216,12 +217,15 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await openFile(page, 'TestVault', 'note2.md');
|
||||
await page.locator('.tab-item').first().dblclick();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(1);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2); // reste : onglet navigation (#158)
|
||||
});
|
||||
|
||||
test('Ctrl+W closes active tab (single pane)', async ({ page }) => {
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
await page.keyboard.press('Control+w');
|
||||
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
|
||||
await page.keyboard.press('Control+w'); // ferme l'onglet fichier
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(1);
|
||||
await page.keyboard.press('Control+w'); // ferme l'onglet navigation
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(0);
|
||||
await expect(page.locator('#dashboard-home')).toBeVisible();
|
||||
@@ -233,7 +237,7 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await page.locator('.tab-item').first().click({ button: 'right' });
|
||||
await page.locator('#tab-context-menu [data-action="close"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(1);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2); // reste navigation + fichier (#158)
|
||||
});
|
||||
|
||||
test('context menu Fermer les autres keeps one', async ({ page }) => {
|
||||
@@ -243,7 +247,7 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await openFile(page, 'TestVault', 'Accueil.md');
|
||||
await page.waitForTimeout(300);
|
||||
const tabs = page.locator('.tab-item');
|
||||
await expect(tabs).toHaveCount(3);
|
||||
await expect(tabs).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
|
||||
await tabs.nth(1).click({ button: 'right' });
|
||||
await page.locator('#tab-context-menu [data-action="closeOthers"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
@@ -256,7 +260,7 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await page.locator('#header-logo').click(); await page.waitForTimeout(300);
|
||||
await openFile(page, 'TestVault', 'Accueil.md');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(3);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(5); // accueil + navigation + 3 fichiers (#158)
|
||||
await page.locator('.tab-item').first().click({ button: 'right' });
|
||||
await page.locator('#tab-context-menu [data-action="closeRight"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
@@ -286,8 +290,8 @@ test.describe('Split View Matrix — Fermeture', () => {
|
||||
await pane1.locator('.tab-item').first().locator('.tab-close').click({ force: true });
|
||||
await page.waitForTimeout(700);
|
||||
await expect(page.locator('.pane-container')).toHaveCount(0);
|
||||
// pane 0 file is still open in single mode
|
||||
await expect(page.locator('#tab-list .tab-item')).toHaveCount(1);
|
||||
// pane 0 file + onglet navigation still open in single mode (#158)
|
||||
await expect(page.locator('#tab-list .tab-item')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('closing pane 0 of 2 focuses neighbour then collapses correctly', async ({ page }) => {
|
||||
@@ -376,7 +380,7 @@ test.describe('Split View Matrix — Edge cases', () => {
|
||||
// dblclick note1 again
|
||||
await page.locator('.tree-item[data-path="note1.md"]').dblclick();
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(2);
|
||||
await expect(page.locator('.tab-item')).toHaveCount(3); // + onglet navigation (#158)
|
||||
// it should be the active one
|
||||
const active = page.locator('.tab-item.active');
|
||||
await expect(active).toHaveCount(1);
|
||||
|
||||
@@ -79,7 +79,7 @@ test.describe('Split View — Basic Operations', () => {
|
||||
const tabBar = page.locator('#tab-bar');
|
||||
await expect(tabBar).toBeVisible();
|
||||
const tabs = page.locator('.tab-item');
|
||||
await expect(tabs).toHaveCount(1);
|
||||
await expect(tabs).toHaveCount(2); // navigation (racine vault) + fichier (#158)
|
||||
// Content area should show the file
|
||||
const content = page.locator('#content-area');
|
||||
await expect(content).not.toBeEmpty();
|
||||
@@ -91,7 +91,7 @@ test.describe('Split View — Basic Operations', () => {
|
||||
await page.waitForTimeout(300);
|
||||
await openFile(page, 'TestVault', 'note2.md');
|
||||
const tabs = page.locator('.tab-item');
|
||||
await expect(tabs).toHaveCount(2);
|
||||
await expect(tabs).toHaveCount(4); // accueil + navigation + 2 fichiers (#158)
|
||||
});
|
||||
|
||||
test('split right via keyboard shortcut', async ({ page }) => {
|
||||
@@ -192,7 +192,7 @@ test.describe('Split View — Tab Drag & Drop', () => {
|
||||
// Source pane should have 1 tab, target should have 1 tab
|
||||
const pane0Tabs = page.locator('.pane-container').nth(0).locator('.tab-item');
|
||||
const pane1Tabs = page.locator('.pane-container').nth(1).locator('.tab-item');
|
||||
await expect(pane0Tabs).toHaveCount(1);
|
||||
await expect(pane0Tabs).toHaveCount(2); // navigation + fichier (#158)
|
||||
await expect(pane1Tabs).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -343,12 +343,13 @@ test.describe('Split View — Regression', () => {
|
||||
});
|
||||
|
||||
test('closing last tab shows dashboard', async ({ page }) => {
|
||||
await openFile(page, 'TestVault', 'note1.md');
|
||||
await openFile(page, 'TestVault', 'note1.md'); // ouvre aussi l'onglet navigation (#158)
|
||||
|
||||
// Close tab via middle-click
|
||||
const tab = page.locator('.tab-item').first();
|
||||
await tab.click({ button: 'middle' });
|
||||
await page.waitForTimeout(500);
|
||||
// Close tabs via middle-click until none remains (dernier onglet -> dashboard)
|
||||
while (await page.locator('.tab-item').count()) {
|
||||
await page.locator('.tab-item').first().click({ button: 'middle' });
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Dashboard should be visible
|
||||
const dashboard = page.locator('#dashboard-home');
|
||||
|
||||
@@ -241,17 +241,18 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// Un clic sur une cellule active la barre avec son nom et son contenu.
|
||||
// (#156-A7 : la zone Nom est un input, donc on lit sa valeur.)
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('B2');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B2');
|
||||
const bar = page.locator('#xlsx-formula-input');
|
||||
await expect(bar).toHaveValue(/Operation 1/);
|
||||
|
||||
// Les flèches déplacent la cellule active.
|
||||
await cell.press('ArrowDown');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('B3');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('B3');
|
||||
await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('C3');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveValue('C3');
|
||||
|
||||
// Éditer depuis la barre marque la cellule dirty, Échap annule.
|
||||
await bar.fill('Operation 2 modifiee');
|
||||
@@ -260,3 +261,56 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #156 P1 — presse-papiers de plage, clavier complet, zone Nom ────────────
|
||||
|
||||
test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)', () => {
|
||||
test('coller une plage, la navigation clavier et la zone Nom (#156 A5/A6/A7)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const nameBox = page.locator('#xlsx-active-cell');
|
||||
const cellOf = (ref) => page.locator(`#content-area td[data-cell="${ref}"]`);
|
||||
const textOf = async (ref) => (await cellOf(ref).innerText()).trim();
|
||||
|
||||
// — A7 : la zone Nom est éditable et sert à « Atteindre ».
|
||||
await nameBox.fill('B12');
|
||||
await nameBox.press('Enter');
|
||||
await expect(nameBox).toHaveValue('B12');
|
||||
await expect(cellOf('B12')).toHaveClass(/xlsx-active/);
|
||||
|
||||
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
|
||||
await cellOf('B12').click();
|
||||
await page.keyboard.press('Control+a');
|
||||
await expect(nameBox).toHaveValue('A1:C500');
|
||||
await page.keyboard.press('Control+Home');
|
||||
await expect(nameBox).toHaveValue('A1');
|
||||
await page.keyboard.press('Control+End');
|
||||
await expect(nameBox).toHaveValue('C500');
|
||||
|
||||
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
|
||||
// utilise le presse-papiers interne de la grille : aucune permission
|
||||
// navigateur n'est requise.
|
||||
const src = { a1: await textOf('A1'), b1: await textOf('B1'), b2: await textOf('B2'), c10: await textOf('C10') };
|
||||
await cellOf('A1').click();
|
||||
await cellOf('B2').click({ modifiers: ['Shift'] });
|
||||
await expect(nameBox).toHaveValue('A1:B2');
|
||||
await cellOf('A1').click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'Copier' }).click();
|
||||
await cellOf('A10').click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'Coller' }).click();
|
||||
expect(await textOf('A10')).toBe(src.a1);
|
||||
expect(await textOf('B10')).toBe(src.b1);
|
||||
expect(await textOf('B11')).toBe(src.b2);
|
||||
expect(await textOf('C10')).toBe(src.c10);
|
||||
|
||||
// — A6 : Suppr vide la sélection (et seulement elle).
|
||||
await cellOf('A10').click();
|
||||
await cellOf('B11').click({ modifiers: ['Shift'] });
|
||||
await expect(nameBox).toHaveValue('A10:B11');
|
||||
await page.keyboard.press('Delete');
|
||||
expect(await textOf('A10')).toBe('');
|
||||
expect(await textOf('B11')).toBe('');
|
||||
expect(await textOf('C10')).toBe(src.c10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — pure tests for the navigation page facets/filters (#158):
|
||||
* `frontend/js/navfacets.js` (no DOM, real module import).
|
||||
*
|
||||
* Usage: node tests/frontend/navfacets.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { buildNavFacets, filterNavFiles, sortNavFiles, navDisplayName } from "../../frontend/js/navfacets.js";
|
||||
|
||||
const files = [
|
||||
{ name: "note2.md", path: "note2.md", vault: "V1", extension: "md", modified: 100, tags: ["docker"] },
|
||||
{ name: "note1.md", path: "note1.md", vault: "V1", extension: "md", modified: 300, tags: ["python", "docker"] },
|
||||
{ name: "photo.png", path: "sub/photo.png", vault: "V1", extension: ".PNG", modified: 200, tags: [] },
|
||||
{ name: "sans-titre.md", path: "sans-titre.md", vault: "V1", extension: "md", modified: 400 },
|
||||
];
|
||||
|
||||
let pass = 0;
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
pass++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
test("buildNavFacets counts vaults/tags/extensions, sorted desc", () => {
|
||||
const f = buildNavFacets(files);
|
||||
assert.deepStrictEqual(f.vaults, { V1: 4 });
|
||||
assert.deepStrictEqual(f.tags, { docker: 2, python: 1 });
|
||||
// leading dot stripped + lowercased, untagged file contributes nothing
|
||||
assert.deepStrictEqual(f.extensions, { md: 3, png: 1 });
|
||||
});
|
||||
|
||||
test("buildNavFacets tolerates empty/missing input", () => {
|
||||
assert.deepStrictEqual(buildNavFacets([]), { vaults: {}, tags: {}, extensions: {} });
|
||||
assert.deepStrictEqual(buildNavFacets(undefined), { vaults: {}, tags: {}, extensions: {} });
|
||||
});
|
||||
|
||||
test("filterNavFiles: no filter returns the input untouched", () => {
|
||||
assert.strictEqual(filterNavFiles(files, {}), files);
|
||||
assert.strictEqual(filterNavFiles(files, { tag: "", ext: "" }), files);
|
||||
});
|
||||
|
||||
test("filterNavFiles: tag filter keeps files carrying the tag", () => {
|
||||
const out = filterNavFiles(files, { tag: "docker" });
|
||||
assert.deepStrictEqual(out.map((f) => f.name).sort(), ["note1.md", "note2.md"]);
|
||||
});
|
||||
|
||||
test("filterNavFiles: extension filter is dot-insensitive", () => {
|
||||
assert.deepStrictEqual(filterNavFiles(files, { ext: "png" }).map((f) => f.name), ["photo.png"]);
|
||||
assert.deepStrictEqual(filterNavFiles(files, { ext: ".png" }).map((f) => f.name), ["photo.png"]);
|
||||
});
|
||||
|
||||
test("filterNavFiles: tag + ext combine (AND) and can empty the list", () => {
|
||||
assert.deepStrictEqual(filterNavFiles(files, { tag: "docker", ext: "md" }).map((f) => f.name).sort(), ["note1.md", "note2.md"]);
|
||||
assert.deepStrictEqual(filterNavFiles(files, { tag: "python", ext: "png" }), []);
|
||||
});
|
||||
|
||||
test("sortNavFiles 'modified' = newest first", () => {
|
||||
assert.deepStrictEqual(sortNavFiles(files, "modified").map((f) => f.modified), [400, 300, 200, 100]);
|
||||
});
|
||||
|
||||
test("sortNavFiles 'relevance' = alphabetical, .md stripped, case-insensitive", () => {
|
||||
const names = sortNavFiles(files, "relevance").map(navDisplayName);
|
||||
assert.deepStrictEqual(names, ["note1", "note2", "photo.png", "sans-titre"]);
|
||||
});
|
||||
|
||||
test("sortNavFiles does not mutate its input", () => {
|
||||
const before = files.map((f) => f.name);
|
||||
sortNavFiles(files, "relevance");
|
||||
sortNavFiles(files, "modified");
|
||||
assert.deepStrictEqual(files.map((f) => f.name), before);
|
||||
});
|
||||
|
||||
test("navDisplayName strips only a trailing .md", () => {
|
||||
assert.strictEqual(navDisplayName({ name: "a.md" }), "a");
|
||||
assert.strictEqual(navDisplayName({ name: "a.markdown" }), "a.markdown");
|
||||
assert.strictEqual(navDisplayName({ path: "dir/b.md" }), "b");
|
||||
});
|
||||
|
||||
console.log(`\n${pass} passed`);
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate - JSDOM tests for the search results facets panel (#157):
|
||||
* vault, tag and file-extension facets rendered by
|
||||
* `renderAdvancedSearchResults()` (frontend/js/search.js).
|
||||
*
|
||||
* Covers:
|
||||
* - the three facet groups are rendered (Vaults / Tags / Extensions)
|
||||
* - the extensions facet appears even when tags and vaults are empty
|
||||
* - clicking an extension replaces any `ext:` operator in the search
|
||||
* input and triggers a new search
|
||||
*
|
||||
* Usage: node tests/frontend/search-facets.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html><body>
|
||||
<input id="search-input" value="" />
|
||||
<select id="vault-filter"><option value="all">all</option></select>
|
||||
<div id="results-bar" style="display:none"></div>
|
||||
<span id="rb-count"></span>
|
||||
<div id="content-area"></div>
|
||||
</body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.KeyboardEvent = w.KeyboardEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
globalThis.MutationObserver = w.MutationObserver;
|
||||
globalThis.getComputedStyle = w.getComputedStyle.bind(w);
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Backend payload shape for /api/search/advanced (facets only matter here).
|
||||
const PAYLOAD = {
|
||||
results: [],
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
query_time_ms: 1,
|
||||
facets: {
|
||||
vaults: { IT: 4, Recettes: 2 },
|
||||
tags: { linux: 3 },
|
||||
extensions: { md: 5, pdf: 2, xlsx: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => PAYLOAD,
|
||||
});
|
||||
|
||||
const { renderAdvancedSearchResults } = await import(
|
||||
pathToFileURL(path.join(JS_DIR, "search.js")).href
|
||||
);
|
||||
|
||||
const facetGroups = () =>
|
||||
Array.from(document.querySelectorAll(".search-facets__group"));
|
||||
const extGroup = () =>
|
||||
document.querySelector('.search-facets__group[data-facet="extensions"]');
|
||||
const extItems = () =>
|
||||
Array.from(extGroup()?.querySelectorAll(".search-facets__item") || []);
|
||||
|
||||
console.log("Search facets (#157) — Vaults / Tags / Extensions");
|
||||
|
||||
await test("renders the three facet groups", async () => {
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
assert.equal(facetGroups().length, 3, "vault, tag and extension groups");
|
||||
const labels = facetGroups().map((g) =>
|
||||
g.querySelector(".search-facets__label").textContent,
|
||||
);
|
||||
assert.deepEqual(labels, ["Vaults", "Tags", "Extensions"]);
|
||||
});
|
||||
|
||||
await test("lists extensions with their counts", async () => {
|
||||
const texts = extItems().map((i) => i.textContent.trim());
|
||||
assert.deepEqual(texts, [".md 5", ".pdf 2", ".xlsx 1"]);
|
||||
assert.equal(
|
||||
extItems()[0].querySelector(".facet-count").textContent,
|
||||
"5",
|
||||
"count lives in .facet-count",
|
||||
);
|
||||
});
|
||||
|
||||
await test("extension facet shows even with empty tags and vaults", async () => {
|
||||
renderAdvancedSearchResults(
|
||||
{ ...PAYLOAD, facets: { vaults: {}, tags: {}, extensions: { md: 1 } } },
|
||||
"",
|
||||
null,
|
||||
);
|
||||
assert.ok(extGroup(), "extensions group rendered alone");
|
||||
assert.equal(extItems().length, 1);
|
||||
});
|
||||
|
||||
await test("clicking an extension adds the ext: operator", async () => {
|
||||
document.getElementById("search-input").value = "python ext:md";
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
extItems()[1].click(); // .pdf
|
||||
assert.equal(
|
||||
document.getElementById("search-input").value,
|
||||
"python ext:pdf",
|
||||
"existing ext: operator replaced, not duplicated",
|
||||
);
|
||||
});
|
||||
|
||||
await test("facets panel collapses and restores via the toggle", async () => {
|
||||
localStorage.removeItem("obsigate_facets_collapsed");
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
const panel = document.querySelector(".search-facets");
|
||||
const toggle = panel.querySelector(".search-facets__toggle");
|
||||
assert.ok(toggle, "collapse toggle rendered");
|
||||
assert.equal(panel.getAttribute("data-collapsed"), "false", "expanded by default");
|
||||
assert.ok(panel.querySelector(".search-facets__body"), "groups live in the body");
|
||||
|
||||
toggle.click();
|
||||
assert.equal(panel.getAttribute("data-collapsed"), "true", "click collapses");
|
||||
assert.equal(
|
||||
localStorage.getItem("obsigate_facets_collapsed"),
|
||||
"1",
|
||||
"collapsed state persisted",
|
||||
);
|
||||
|
||||
renderAdvancedSearchResults(PAYLOAD, "python", null);
|
||||
const panel2 = document.querySelector(".search-facets");
|
||||
assert.equal(
|
||||
panel2.getAttribute("data-collapsed"),
|
||||
"true",
|
||||
"collapsed state survives a re-render",
|
||||
);
|
||||
panel2.querySelector(".search-facets__toggle").click();
|
||||
assert.equal(panel2.getAttribute("data-collapsed"), "false", "toggle restores");
|
||||
localStorage.removeItem("obsigate_facets_collapsed");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -109,6 +109,7 @@ function testModulesHaveImports() {
|
||||
{ file: 'pane-manager.js', reason: 'standalone — no deps' },
|
||||
{ file: 'editor-inline.js', reason: 'standalone — DOM-only helpers for the inline editor host (#93)' },
|
||||
{ file: 'themes.js', reason: 'standalone — no deps' },
|
||||
{ file: 'navfacets.js', reason: 'pure helpers — no deps, imported by vaulthome.js' },
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
||||
];
|
||||
@@ -306,6 +307,22 @@ function testSyntaxHighlightTheme() {
|
||||
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
|
||||
}
|
||||
|
||||
// ── Test mobile : le sidebar passe par-dessus la barre d'outils du bas (BUG-103) ──
|
||||
function testMobileSidebarOverToolbar() {
|
||||
const css = readFileSync(join(JS_DIR, '..', 'style.css'), 'utf-8');
|
||||
const zOf = (selector) => {
|
||||
const blocks = css.match(new RegExp('\\' + selector + ' \\{[^}]*\\}', 'g')) || [];
|
||||
return Math.max(0, ...blocks.map((b) => Number((b.match(/z-index: (\d+)/) || [])[1] || 0)));
|
||||
};
|
||||
const sidebarZ = zOf('.sidebar');
|
||||
const toolbarZ = zOf('.mobile-toolbar');
|
||||
assert.ok(
|
||||
sidebarZ > toolbarZ,
|
||||
`.sidebar z-index (${sidebarZ}) doit être supérieur à .mobile-toolbar (${toolbarZ})`,
|
||||
);
|
||||
console.log(' ✓ mobile sidebar over toolbar');
|
||||
}
|
||||
|
||||
// ── Run all tests ──────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
let passed = 0, failed = 0;
|
||||
@@ -323,6 +340,7 @@ async function main() {
|
||||
['config TOC icons', testConfigTocIcons],
|
||||
['sidebar user section', testSidebarUserSection],
|
||||
['syntax highlight theme', testSyntaxHighlightTheme],
|
||||
['mobile sidebar over toolbar', testMobileSidebarOverToolbar],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||
@@ -108,6 +108,9 @@ async function test(name, fn) {
|
||||
// #154-A2 — themed dialogs live in document.body; clear any left behind by a
|
||||
// previous test so the helpers always reach the current one.
|
||||
document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove());
|
||||
// #156-A10 — the undo/redo stacks are keyed by file and every helper mounts
|
||||
// the same vault/path: reset them so tests stay independent.
|
||||
resetXlsxHistory();
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
@@ -119,7 +122,7 @@ async function test(name, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
const { renderXlsxViewer } = await import(
|
||||
const { renderXlsxViewer, resetXlsxHistory } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href
|
||||
);
|
||||
// Load the FR catalog so t() resolves the real strings.
|
||||
@@ -140,7 +143,7 @@ const sheetHtml = (value) =>
|
||||
// successive windows a lazy-loading sheet will request.
|
||||
let apiQueue = [];
|
||||
|
||||
function mount({ lossy = [], sheet = {} } = {}) {
|
||||
function mount({ lossy = [], sheet = {}, revision = null } = {}) {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
@@ -149,6 +152,8 @@ function mount({ lossy = [], sheet = {} } = {}) {
|
||||
is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }],
|
||||
xlsx_lossy_features: lossy,
|
||||
// #156-A12 — optimistic-concurrency token returned by the read.
|
||||
...(revision ? { xlsx_revision: revision } : {}),
|
||||
});
|
||||
return area;
|
||||
}
|
||||
@@ -160,6 +165,19 @@ function mountTruncated({ total = 520, rows = 1 } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a CSV exactly as the backend serves it: a single `html` table and NO
|
||||
* `xlsx_sheets` (the shape that used to crash the save path, BUG-096).
|
||||
*/
|
||||
function mountCsv(html = sheetHtml("100")) {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "budget.csv", is_csv: true, is_markdown: false, html,
|
||||
});
|
||||
return area;
|
||||
}
|
||||
|
||||
/** Mark a cell dirty the way a user edit would. */
|
||||
function editCell(area, ref, text) {
|
||||
const td = area.querySelector(`td[data-cell="${ref}"]`);
|
||||
@@ -334,7 +352,10 @@ await test("a 409 conflict shows a non-blocking retry banner and keeps the edits
|
||||
nextResponse = () => ({ ok: true, status: 200, body: {} });
|
||||
banner.querySelector(".xlsx-banner-retry").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
// #156-A12 — the retry re-reads the file first (to pick up the current
|
||||
// revision), then re-sends the save: one extra request.
|
||||
assert.equal(calls.length, 3);
|
||||
assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/);
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry");
|
||||
});
|
||||
@@ -479,7 +500,7 @@ await test("a failed window fetch keeps the footnote and shows an error toast",
|
||||
|
||||
await test("the formula bar starts empty and disabled", () => {
|
||||
const area = mount();
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "—");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "");
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").disabled, true);
|
||||
});
|
||||
|
||||
@@ -490,7 +511,7 @@ await test("focusing a cell shows its name and content in the bar", () => {
|
||||
// JSDOM does not run the default focus behaviour on dispatchEvent, so go
|
||||
// through the real API:
|
||||
td.focus();
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1");
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").value, "100");
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").disabled, false);
|
||||
});
|
||||
@@ -517,13 +538,13 @@ await test("Tab and arrows move to the neighbour cell", () => {
|
||||
const a1 = area.querySelector('td[data-cell="A1"]');
|
||||
a1.focus();
|
||||
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B1");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "B1");
|
||||
const b1 = area.querySelector('td[data-cell="B1"]');
|
||||
b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B2");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "B2");
|
||||
const b2 = area.querySelector('td[data-cell="B2"]');
|
||||
b2.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A2");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A2");
|
||||
});
|
||||
|
||||
await test("Enter commits and Shift+Tab goes backwards", () => {
|
||||
@@ -533,7 +554,7 @@ await test("Enter commits and Shift+Tab goes backwards", () => {
|
||||
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
|
||||
const b1 = area.querySelector('td[data-cell="B1"]');
|
||||
b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1");
|
||||
});
|
||||
|
||||
await test("a saved edit from the bar resets the dirty flag and orig value", async () => {
|
||||
@@ -551,7 +572,7 @@ await test("a saved edit from the bar resets the dirty flag and orig value", asy
|
||||
|
||||
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
|
||||
|
||||
const mountGrid = () => {
|
||||
const mountGrid = (revision = null) => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const grid =
|
||||
@@ -565,6 +586,7 @@ const mountGrid = () => {
|
||||
vault: "V", path: "data.xlsx", is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "Fruits", html: grid, rows: 3, cols: 2, total_rows: 3, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false }],
|
||||
xlsx_lossy_features: [],
|
||||
...(revision ? { xlsx_revision: revision } : {}),
|
||||
});
|
||||
return area;
|
||||
};
|
||||
@@ -834,6 +856,22 @@ await test("a csv shows no tab strip and no + button", () => {
|
||||
assert.equal(area.querySelector("#xlsx-tab-add"), null);
|
||||
});
|
||||
|
||||
await test("saving an edited csv PUTs /csv/save (payload without xlsx_sheets)", async () => {
|
||||
// BUG-096 — a CSV read payload has no `xlsx_sheets`; building the save job
|
||||
// from `sheets[0].name` used to throw a TypeError before any request, so a
|
||||
// CSV edit could never be saved from the viewer.
|
||||
const area = mountCsv();
|
||||
editCell(area, "A1", "modifié");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 1, "exactly one save request");
|
||||
assert.match(calls[0].url, /\/api\/file\/V\/csv\/save\?path=budget\.csv/);
|
||||
assert.deepEqual(calls[0].body.cells, { A1: "modifié" });
|
||||
// Committed: the cell is no longer dirty and the button is disabled again.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, true);
|
||||
});
|
||||
|
||||
await test("the + button adds a sheet through the structure endpoint", async () => {
|
||||
const area = mount();
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
||||
@@ -967,7 +1005,7 @@ await test("clicking a named range reveals its first cell in the grid", async ()
|
||||
const row = area.querySelector(".xlsx-range-row[data-ref]");
|
||||
assert.ok(row, "the named range row is rendered");
|
||||
row.click();
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1", "the anchor cell becomes active");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1", "the anchor cell becomes active");
|
||||
assert.ok(area.querySelector('td[data-cell="A1"]').classList.contains("xlsx-active"));
|
||||
});
|
||||
|
||||
@@ -1014,7 +1052,7 @@ await test("clear contents empties the selected cells (local edit)", () => {
|
||||
await test("clicking a column header selects the whole column", () => {
|
||||
const area = mount();
|
||||
area.querySelector(".xlsx-table thead th:nth-child(2)").click(); // column A
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:A2");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:A2");
|
||||
const selected = [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort();
|
||||
assert.deepEqual(selected, ["A1", "A2"]);
|
||||
});
|
||||
@@ -1022,7 +1060,7 @@ await test("clicking a column header selects the whole column", () => {
|
||||
await test("clicking a row header selects the whole row", () => {
|
||||
const area = mount();
|
||||
area.querySelector("th.xlsx-rownum").click(); // row 1
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B1");
|
||||
});
|
||||
|
||||
await test("dragging from a cell to another selects the range", () => {
|
||||
@@ -1033,7 +1071,7 @@ await test("dragging from a cell to another selects the range", () => {
|
||||
a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
||||
b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 }));
|
||||
viewer.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B2");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B2");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4);
|
||||
});
|
||||
|
||||
@@ -1042,7 +1080,7 @@ await test("Shift+Arrow extends the selection from the anchor", () => {
|
||||
const a1 = area.querySelector('td[data-cell="A1"]');
|
||||
a1.focus();
|
||||
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true }));
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B1");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 2);
|
||||
});
|
||||
|
||||
@@ -1057,6 +1095,778 @@ await test("the context menu module exposes open/close helpers", async () => {
|
||||
assert.equal(mod.isContextMenuOpen(), false);
|
||||
});
|
||||
|
||||
// ── #156 (A5/A6/A7) — presse-papiers, clavier complet, zone Nom ────────────
|
||||
|
||||
/** #156-A5 — a 6×6 grid (A1:F6) so a pasted block has room to land. */
|
||||
const mountWide = () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const cols = ["A", "B", "C", "D", "E", "F"];
|
||||
const head = '<thead><tr><th class="xlsx-corner"></th>' +
|
||||
cols.map((c) => `<th>${c}</th>`).join("") + "</tr></thead>";
|
||||
let body = "<tbody>";
|
||||
for (let r = 1; r <= 6; r++) {
|
||||
body += `<tr><th class="xlsx-rownum">${r}</th>` +
|
||||
cols.map((c) => `<td data-cell="${c}${r}">${c}${r}</td>`).join("") + "</tr>";
|
||||
}
|
||||
body += "</tbody>";
|
||||
const html = `<div class="csv-table-wrapper"><table class="csv-table xlsx-table">${head}${body}</table></div>`;
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.xlsx", is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "Grille", html, rows: 6, cols: 6, total_rows: 6, total_cols: 6, max_rows: 500, max_cols: 40, truncated: false }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
return area;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatch a copy/cut/paste event carrying a plain-text payload (JSDOM has no
|
||||
* real clipboard, so `clipboardData` is a minimal stub).
|
||||
*/
|
||||
const clipboardEvent = (type, area, ref, text) => {
|
||||
// A leftover document selection would make the handler think the user is
|
||||
// editing a cell: start from a clean selection.
|
||||
const sel = w.getSelection();
|
||||
if (sel && sel.removeAllRanges) sel.removeAllRanges();
|
||||
const td = area.querySelector(`td[data-cell="${ref}"]`);
|
||||
assert.ok(td, `cell ${ref} exists`);
|
||||
const store = { text };
|
||||
const ev = new w.Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "clipboardData", {
|
||||
value: {
|
||||
getData: (kind) => (kind === "text/plain" || kind === "text" ? store.text : ""),
|
||||
setData: (kind, value) => {
|
||||
if (kind === "text/plain" || kind === "text") store.text = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
td.dispatchEvent(ev);
|
||||
return { td, store, ev };
|
||||
};
|
||||
|
||||
/** Select A1:B2 by dragging (the gesture the grid listens to). */
|
||||
const dragSelect = (area, from, to) => {
|
||||
area.querySelector(`td[data-cell="${from}"]`).dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
||||
area.querySelector(`td[data-cell="${to}"]`).dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 }));
|
||||
area.querySelector(".xlsx-viewer").dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
|
||||
};
|
||||
|
||||
const selectedRefs = (area) =>
|
||||
[...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort();
|
||||
|
||||
const cellTexts = (area, refs) => refs.map((r) => area.querySelector(`td[data-cell="${r}"]`).textContent);
|
||||
|
||||
const toastCount = () => document.querySelectorAll("#toast-container .toast").length;
|
||||
|
||||
const pressKey = (target, key, mods = {}) =>
|
||||
target.dispatchEvent(new w.KeyboardEvent("keydown", { key, bubbles: true, ...mods }));
|
||||
|
||||
// ── A5 — presse-papiers de plage ────────────────────────────────────────────
|
||||
|
||||
await test("copying a range exports TSV and pasting it fills the target block", () => {
|
||||
const area = mountWide();
|
||||
dragSelect(area, "A1", "B2");
|
||||
const copied = clipboardEvent("copy", area, "A1", "");
|
||||
assert.equal(copied.ev.defaultPrevented, true, "the range copy is handled by the grid");
|
||||
assert.equal(copied.store.text, "A1\tB1\nA2\tB2", "TSV block on the clipboard");
|
||||
|
||||
const pasted = clipboardEvent("paste", area, "D5", copied.store.text);
|
||||
assert.equal(pasted.ev.defaultPrevented, true);
|
||||
assert.deepEqual(cellTexts(area, ["D5", "E5", "D6", "E6"]), ["A1", "B1", "A2", "B2"]);
|
||||
assert.equal(area.querySelector('td[data-cell="F6"]').textContent, "F6", "the rest of the grid is untouched");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "D5:E6", "the pasted block stays selected");
|
||||
assert.deepEqual(selectedRefs(area), ["D5", "D6", "E5", "E6"]);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false, "the paste is unsaved work");
|
||||
});
|
||||
|
||||
await test("a block copied in Excel pastes as a range (trailing newline ignored)", () => {
|
||||
const area = mountWide();
|
||||
clipboardEvent("paste", area, "A3", "1\t2\n3\t4\n");
|
||||
assert.deepEqual(cellTexts(area, ["A3", "B3", "A4", "B4"]), ["1", "2", "3", "4"]);
|
||||
assert.equal(area.querySelector('td[data-cell="A5"]').textContent, "A5", "no phantom fifth row");
|
||||
});
|
||||
|
||||
await test("a single-cell paste replaces the cell content", () => {
|
||||
const area = mountWide();
|
||||
const td = area.querySelector('td[data-cell="C4"]');
|
||||
td.focus();
|
||||
clipboardEvent("paste", area, "C4", "remplacé");
|
||||
assert.equal(td.textContent, "remplacé");
|
||||
assert.equal(td.classList.contains("xlsx-dirty"), true);
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").value, "remplacé", "the bar follows");
|
||||
});
|
||||
|
||||
await test("cutting a range clears the source once it is pasted elsewhere", () => {
|
||||
const area = mountWide();
|
||||
area.querySelector("th.xlsx-rownum").click(); // row 1 of a 6-column grid
|
||||
const cut = clipboardEvent("cut", area, "A1", "");
|
||||
assert.equal(cut.store.text, "A1\tB1\tC1\tD1\tE1\tF1");
|
||||
clipboardEvent("paste", area, "A5", cut.store.text);
|
||||
assert.deepEqual(cellTexts(area, ["A5", "B5"]), ["A1", "B1"]);
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "", "the cut source is cleared");
|
||||
assert.equal(area.querySelector('td[data-cell="B1"]').textContent, "");
|
||||
});
|
||||
|
||||
await test("cut then paste in place keeps the pasted values", () => {
|
||||
const area = mountWide();
|
||||
area.querySelector("th.xlsx-rownum").click(); // A1:F1
|
||||
const cut = clipboardEvent("cut", area, "A1", "");
|
||||
clipboardEvent("paste", area, "A1", cut.store.text);
|
||||
assert.deepEqual(cellTexts(area, ["A1", "B1"]), ["A1", "B1"], "no self-erase");
|
||||
});
|
||||
|
||||
await test("the context menu copies and pastes a block", () => {
|
||||
const area = mountWide();
|
||||
openMenuOn(area, "A1");
|
||||
assert.ok(menuItem(FR["xlsx.copy"]), "Copier");
|
||||
assert.ok(menuItem(FR["xlsx.cut"]), "Couper");
|
||||
assert.ok(menuItem(FR["xlsx.paste"]), "Coller");
|
||||
area.querySelector("th.xlsx-rownum").click();
|
||||
openMenuOn(area, "A1");
|
||||
menuItem(FR["xlsx.copy"]).click();
|
||||
openMenuOn(area, "A4");
|
||||
menuItem(FR["xlsx.paste"]).click();
|
||||
assert.equal(area.querySelector('td[data-cell="A4"]').textContent, "A1");
|
||||
assert.equal(area.querySelector('td[data-cell="B4"]').textContent, "B1");
|
||||
});
|
||||
|
||||
await test("pasting with a stale clipboard is a no-op with a hint", async () => {
|
||||
const area = mountWide();
|
||||
const before = toastCount();
|
||||
openMenuOn(area, "A1");
|
||||
menuItem(FR["xlsx.paste"]).click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(toastCount(), before + 1, "the user is told the clipboard is unusable");
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "A1", "nothing was written");
|
||||
});
|
||||
|
||||
await test("a block that overflows the rendered grid is trimmed and signalled", () => {
|
||||
const area = mountWide();
|
||||
const before = toastCount();
|
||||
clipboardEvent("paste", area, "E6", "1\t2\t3\n4\t5\t6");
|
||||
assert.deepEqual(cellTexts(area, ["E6", "F6"]), ["1", "2"]);
|
||||
assert.equal(area.querySelector('td[data-cell="F7"]'), null);
|
||||
assert.equal(toastCount(), before + 1, "the overflow is announced");
|
||||
});
|
||||
|
||||
await test("a pasted block is undoable cell by cell", () => {
|
||||
const area = mountWide();
|
||||
clipboardEvent("paste", area, "D5", "x\ty");
|
||||
area.querySelector("#xlsx-undo-btn").click();
|
||||
assert.equal(area.querySelector('td[data-cell="E5"]').textContent, "E5", "the last pasted cell is back");
|
||||
area.querySelector("#xlsx-undo-btn").click();
|
||||
assert.equal(area.querySelector('td[data-cell="D5"]').textContent, "D5");
|
||||
});
|
||||
|
||||
await test("the cached-value shadow never leaks into a copied block", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const html = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
|
||||
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">=SOMME(B1:B2)' +
|
||||
'<span class="xlsx-cached" data-cached-value="42">42</span></td></tr>' +
|
||||
"</tbody></table></div>";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.xlsx", is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "F", html }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
area.querySelector('td[data-cell="A1"]').focus();
|
||||
const copied = clipboardEvent("copy", area, "A1", "");
|
||||
assert.equal(copied.store.text, "=SOMME(B1:B2)", "the formula, not Excel's shadow value");
|
||||
});
|
||||
|
||||
// ── A6 — clavier complet ────────────────────────────────────────────────────
|
||||
|
||||
await test("Ctrl+S saves the pending edits", async () => {
|
||||
const area = mountGrid();
|
||||
editCell(area, "A1", "Ctrl+S");
|
||||
pressKey(area.querySelector(".xlsx-viewer"), "s", { ctrlKey: true });
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls.length, 1, "one save request");
|
||||
assert.match(calls[0].url, /\/xlsx\/save/);
|
||||
assert.deepEqual(calls[0].body.cells, { A1: "Ctrl+S" });
|
||||
});
|
||||
|
||||
await test("Suppr clears the selected range and stays undoable", () => {
|
||||
const area = mountGrid();
|
||||
area.querySelector("th.xlsx-rownum").click(); // A1:B1
|
||||
pressKey(area.querySelector(".xlsx-viewer"), "Delete");
|
||||
assert.deepEqual(cellTexts(area, ["A1", "B1"]), ["", ""]);
|
||||
assert.equal(area.querySelector('td[data-cell="A2"]').textContent, "Abricot", "only the selection");
|
||||
area.querySelector("#xlsx-undo-btn").click();
|
||||
assert.equal(area.querySelector('td[data-cell="B1"]').textContent, "3");
|
||||
area.querySelector("#xlsx-undo-btn").click();
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "Banane");
|
||||
});
|
||||
|
||||
await test("Suppr while typing in a cell deletes text instead of the selection", () => {
|
||||
const area = mountGrid();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.focus();
|
||||
pressKey(td, "x"); // typing switches the cell to edit mode
|
||||
assert.equal(td.dataset.editing, "1", "typing enters edit mode");
|
||||
pressKey(td, "Delete");
|
||||
assert.equal(td.textContent, "Banane", "the caret key was not hijacked");
|
||||
});
|
||||
|
||||
await test("F2 enters the cell for text editing", () => {
|
||||
const area = mountGrid();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.focus();
|
||||
assert.equal(td.dataset.editing, "", "navigation mode after a plain focus");
|
||||
pressKey(td, "F2");
|
||||
assert.equal(td.dataset.editing, "1");
|
||||
});
|
||||
|
||||
await test("Maj+Entrée inserts a line break inside the cell", () => {
|
||||
const area = mountGrid();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.focus();
|
||||
pressKey(td, "Enter", { shiftKey: true });
|
||||
assert.ok(td.textContent.includes("\n"), `expected a newline in ${JSON.stringify(td.textContent)}`);
|
||||
assert.equal(td.classList.contains("xlsx-dirty"), true);
|
||||
});
|
||||
|
||||
await test("Ctrl+A selects the rendered sheet, Ctrl+Home/End walk its corners", () => {
|
||||
const area = mountGrid();
|
||||
const viewer = area.querySelector(".xlsx-viewer");
|
||||
area.querySelector('td[data-cell="B2"]').focus();
|
||||
pressKey(viewer, "a", { ctrlKey: true });
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1:B3");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 6);
|
||||
pressKey(viewer, "Home", { ctrlKey: true });
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1");
|
||||
pressKey(viewer, "End", { ctrlKey: true });
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "B3");
|
||||
});
|
||||
|
||||
await test("Home/End reach the row edges, Ctrl+flèches the data run", () => {
|
||||
const area = mountWide();
|
||||
const viewer = area.querySelector(".xlsx-viewer");
|
||||
area.querySelector('td[data-cell="C3"]').focus();
|
||||
pressKey(viewer, "End");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "F3");
|
||||
pressKey(viewer, "Home");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A3");
|
||||
pressKey(viewer, "ArrowDown", { ctrlKey: true });
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A6", "end of the filled column");
|
||||
});
|
||||
|
||||
await test("PageDown/PageUp move by a screenful", () => {
|
||||
const area = mountWide();
|
||||
const viewer = area.querySelector(".xlsx-viewer");
|
||||
area.querySelector('td[data-cell="A1"]').focus();
|
||||
pressKey(viewer, "PageDown");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A6", "clamped to the last rendered row");
|
||||
pressKey(viewer, "PageUp");
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").value, "A1");
|
||||
});
|
||||
|
||||
// ── A7 — zone Nom éditable & aide à la saisie ──────────────────────────────
|
||||
|
||||
await test("typing a reference in the Name Box activates that cell", () => {
|
||||
const area = mountGrid();
|
||||
const box = area.querySelector("#xlsx-active-cell");
|
||||
box.value = "B3";
|
||||
pressKey(box, "Enter");
|
||||
assert.equal(box.value, "B3");
|
||||
assert.equal(area.querySelector('td[data-cell="B3"]').classList.contains("xlsx-active"), true);
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").value, "2");
|
||||
});
|
||||
|
||||
await test("the Name Box accepts a range and normalises it", () => {
|
||||
const area = mountGrid();
|
||||
const box = area.querySelector("#xlsx-active-cell");
|
||||
box.value = "$a1:$b2";
|
||||
pressKey(box, "Enter");
|
||||
assert.equal(box.value, "A1:B2");
|
||||
assert.deepEqual(selectedRefs(area), ["A1", "A2", "B1", "B2"]);
|
||||
});
|
||||
|
||||
await test("an unknown reference is refused and the previous address restored", () => {
|
||||
const area = mountGrid();
|
||||
area.querySelector('td[data-cell="A1"]').focus();
|
||||
const box = area.querySelector("#xlsx-active-cell");
|
||||
const before = toastCount();
|
||||
box.value = "XYZ";
|
||||
pressKey(box, "Enter");
|
||||
assert.equal(box.value, "A1");
|
||||
assert.equal(toastCount(), before + 1, "the user is warned");
|
||||
});
|
||||
|
||||
await test("a reference naming another sheet switches tab", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const other = '<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
|
||||
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">Autre</td></tr></tbody></table></div>';
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.xlsx", is_xlsx: true,
|
||||
xlsx_sheets: [
|
||||
{ name: "Feuille1", html: sheetHtml("100") },
|
||||
{ name: "Autre", html: other },
|
||||
],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
const box = area.querySelector("#xlsx-active-cell");
|
||||
box.value = "Autre!A1";
|
||||
pressKey(box, "Enter");
|
||||
assert.equal(box.value, "A1");
|
||||
assert.equal(area.querySelector('.xlsx-tab[data-sheet="1"]').classList.contains("active"), true);
|
||||
assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "", "the target sheet is shown");
|
||||
});
|
||||
|
||||
await test("the formula bar offers function-name suggestions", () => {
|
||||
const area = mountGrid();
|
||||
const list = area.querySelector("#xlsx-function-list");
|
||||
assert.ok(list, "the datalist exists");
|
||||
const names = [...list.querySelectorAll("option")].map((o) => o.value);
|
||||
assert.ok(names.length >= 10, `expected a list of functions, got ${names.length}`);
|
||||
assert.ok(names.includes("SOMME"), names.join(","));
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").getAttribute("list"), "xlsx-function-list");
|
||||
});
|
||||
|
||||
// ── #156-A9 — calcul : pas de moteur, l'écart est annoncé ───────────────────
|
||||
|
||||
await test("saving a value starting with '=' warns it is stored as text (#156-A9)", async () => {
|
||||
const area = mountGrid();
|
||||
const before = toastCount();
|
||||
editCell(area, "A1", "=B1*2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls.length, 1, "the save still goes out");
|
||||
assert.equal(calls[0].body.allow_formula, false);
|
||||
const added = [...document.querySelectorAll("#toast-container .toast")].slice(before);
|
||||
assert.ok(added.some((n) => n.textContent.includes("f(x")), "the f(x) hint is shown");
|
||||
});
|
||||
|
||||
// ── #156-A10 — undo/redo unifié ─────────────────────────────────────────────
|
||||
|
||||
await test("undo replays an edit, a clear, then a sort (#156-A10)", () => {
|
||||
const area = mountGrid();
|
||||
const undoBtn = area.querySelector("#xlsx-undo-btn");
|
||||
const colA = () => [...area.querySelectorAll('td[data-cell^="A"]')].map((td) => td.textContent);
|
||||
|
||||
const a1 = area.querySelector('td[data-cell="A1"]');
|
||||
a1.focus();
|
||||
editCell(area, "A1", "modifié");
|
||||
a1.dispatchEvent(new w.Event("blur"));
|
||||
area.querySelector("th.xlsx-rownum").click();
|
||||
pressKey(area.querySelector(".xlsx-viewer"), "Delete");
|
||||
openMenuOn(area, "B1");
|
||||
menuItem(FR["xlsx.sort_desc"]).click();
|
||||
assert.deepEqual(colA(), ["Cerise", "Abricot", ""], "sorted descending with the cleared row last");
|
||||
|
||||
undoBtn.click(); // the sort
|
||||
assert.deepEqual(colA(), ["", "Abricot", "Cerise"], "the row order is restored");
|
||||
undoBtn.click(); // the clear (B1)
|
||||
assert.equal(area.querySelector('td[data-cell="B1"]').textContent, "3");
|
||||
undoBtn.click(); // the clear (A1)
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "modifié");
|
||||
undoBtn.click(); // the edit
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "Banane");
|
||||
assert.equal(undoBtn.disabled, true, "the whole history is consumed");
|
||||
});
|
||||
|
||||
await test("a row insertion is undoable through the structure endpoint (#156-A10)", async () => {
|
||||
const area = mountGrid();
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } });
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] },
|
||||
});
|
||||
openMenuOn(area, "A2");
|
||||
menuItem(FR["xlsx.insert_row_above"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "row_insert", sheet: "Fruits", at: 2, count: 1 }]);
|
||||
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, false, "the history survived the re-render");
|
||||
|
||||
area.querySelector("#xlsx-undo-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.match(calls[2].url, /\/xlsx\/structure/);
|
||||
assert.deepEqual(calls[2].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }], "the inverse batch");
|
||||
});
|
||||
|
||||
await test("a destructive structure action is not undoable (#156-A10)", async () => {
|
||||
const area = mountGrid();
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } });
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] },
|
||||
});
|
||||
openMenuOn(area, "A2");
|
||||
menuItem(FR["xlsx.delete_rows"].replace("{n}", "1")).click();
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "row_delete", sheet: "Fruits", at: 2, count: 1 }]);
|
||||
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true, "a delete cannot be replayed");
|
||||
});
|
||||
|
||||
await test("the filter is recorded once and cleared by a single undo (#156-A10)", () => {
|
||||
const area = mountGrid();
|
||||
const input = area.querySelector("#xlsx-find-input");
|
||||
const hidden = () => [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display === "none").length;
|
||||
input.value = "ab";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
input.value = "abr";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.equal(hidden(), 2, "two rows are hidden");
|
||||
area.querySelector("#xlsx-undo-btn").click();
|
||||
assert.equal(hidden(), 0, "one undo clears the whole typing session");
|
||||
assert.equal(input.value, "", "the field follows");
|
||||
});
|
||||
|
||||
// ── #156-A12 — concurrence optimiste (ETag / If-Match) ──────────────────────
|
||||
|
||||
await test("a save carries the read revision and refreshes it from the response (#156-A12)", async () => {
|
||||
const area = mount({ revision: "rev-1" });
|
||||
editCell(area, "A1", "42");
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls[0].body.if_match, "rev-1", "the read token is sent back");
|
||||
|
||||
// A second save chains on the revision the first response returned.
|
||||
editCell(area, "A2", "43");
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-3" } });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls[1].body.if_match, "rev-2");
|
||||
});
|
||||
|
||||
await test("a stale revision is repaired by re-reading before the retry (#156-A12)", async () => {
|
||||
const area = mount({ revision: "rev-1" });
|
||||
editCell(area, "A1", "42");
|
||||
// 1) the save is refused as stale, 2) the retry re-reads the file, 3) the
|
||||
// re-send succeeds — all queued up front so the re-read really answers.
|
||||
apiQueue.push({
|
||||
ok: false, status: 409,
|
||||
body: { detail: "changed on disk", code: "conflict", details: { reason: "stale_revision", current_revision: "rev-9" } },
|
||||
});
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const banner = area.querySelector(".xlsx-banner-conflict");
|
||||
assert.ok(banner, "the conflict banner is shown");
|
||||
assert.equal(calls[0].body.if_match, "rev-1");
|
||||
|
||||
// The retry re-reads the file, then re-sends with the CURRENT revision.
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_revision: "rev-9", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [] },
|
||||
});
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-10" } });
|
||||
banner.querySelector(".xlsx-banner-retry").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/);
|
||||
assert.equal(calls[2].body.if_match, "rev-9", "the retry uses the revision just read");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
});
|
||||
|
||||
await test("a structural write carries the revision too (#156-A12)", async () => {
|
||||
const area = mountGrid("rev-1");
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } });
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }], xlsx_lossy_features: [] },
|
||||
});
|
||||
openMenuOn(area, "A2");
|
||||
menuItem(FR["xlsx.insert_row_above"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls[0].body.if_match, "rev-1");
|
||||
});
|
||||
|
||||
// ── #156-A8 — mise en forme en écriture ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Make `ref` the ACTIVE cell: the grid wires setActiveCell() on the cell's
|
||||
* focus event, and td.focus() runs it in JSDOM (a synthetic click would not).
|
||||
*/
|
||||
const activateCell = (area, ref) => {
|
||||
const td = area.querySelector(`td[data-cell="${ref}"]`);
|
||||
td.focus();
|
||||
return td;
|
||||
};
|
||||
|
||||
const openFormatMenu = (area) => {
|
||||
area.querySelector("#xlsx-format-btn").click();
|
||||
return area.querySelector(".xlsx-format-menu");
|
||||
};
|
||||
|
||||
const formatItem = (area, label) => {
|
||||
const b = [...area.querySelectorAll(".xlsx-format-menu .xlsx-structure-item")]
|
||||
.find((item) => item.textContent === label);
|
||||
assert.ok(b, `format item "${label}" is present`);
|
||||
return b;
|
||||
};
|
||||
|
||||
/** Queue the PUT then the re-read the formatting flow performs. */
|
||||
const queueStyleWrite = () => {
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } });
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: {
|
||||
is_xlsx: true, vault: "V", path: "data.xlsx",
|
||||
xlsx_sheets: [{ name: "Fruits", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await test("the formatting menu is offered on an editable workbook only (#156-A8)", () => {
|
||||
const area = mountGrid();
|
||||
assert.ok(area.querySelector("#xlsx-format-btn"), "the button is there");
|
||||
assert.equal(area.querySelector("#xlsx-format-btn").getAttribute("title"), FR["xlsx.format_btn"]);
|
||||
const csv = mountCsv();
|
||||
assert.equal(csv.querySelector("#xlsx-format-btn"), null, "no formatting on a csv");
|
||||
});
|
||||
|
||||
await test("bold applied to a selection goes to /xlsx/style and re-renders (#156-A8)", async () => {
|
||||
const area = mountGrid();
|
||||
dragSelect(area, "A1", "B2");
|
||||
openFormatMenu(area);
|
||||
queueStyleWrite();
|
||||
formatItem(area, FR["xlsx.format_bold"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.match(calls[0].url, /\/xlsx\/style\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.ops, [
|
||||
{ op: "cell", sheet: "Fruits", range: "A1:B2", style: { bold: true } },
|
||||
]);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.match(calls[1].url, /\/api\/file\/V\?path=data\.xlsx/, "the viewer re-reads the file");
|
||||
});
|
||||
|
||||
await test("the bold toggle removes an existing bold (#156-A8)", async () => {
|
||||
const area = mountGrid();
|
||||
activateCell(area, "A1").style.fontWeight = "600";
|
||||
openFormatMenu(area);
|
||||
queueStyleWrite();
|
||||
formatItem(area, FR["xlsx.format_bold"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(calls[0].body.ops, [
|
||||
{ op: "cell", sheet: "Fruits", range: "A1", style: { bold: false } },
|
||||
]);
|
||||
});
|
||||
|
||||
await test("alignment, underline and number formats are written (#156-A8)", async () => {
|
||||
const area = mountGrid();
|
||||
activateCell(area, "B2");
|
||||
openFormatMenu(area);
|
||||
queueStyleWrite();
|
||||
formatItem(area, FR["xlsx.format_align_center"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(calls[0].body.ops[0].style, { align: "center" });
|
||||
|
||||
// `calls` keeps accumulating (same test): the second write is calls[2].
|
||||
const area2 = mountGrid();
|
||||
activateCell(area2, "B2");
|
||||
openFormatMenu(area2);
|
||||
queueStyleWrite();
|
||||
formatItem(area2, FR["xlsx.format_num_percent"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 4, "PUT + re-read for each formatting write");
|
||||
assert.deepEqual(calls[2].body.ops[0].style, { number_format: "0.0%" });
|
||||
assert.equal(calls[2].body.ops[0].range, "B2");
|
||||
});
|
||||
|
||||
await test("merging needs a real range (#156-A8)", async () => {
|
||||
const area = mountGrid();
|
||||
activateCell(area, "A1");
|
||||
const before = toastCount();
|
||||
openFormatMenu(area);
|
||||
formatItem(area, FR["xlsx.format_merge"]).click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls.length, 0, "nothing is written for a single cell");
|
||||
assert.equal(toastCount(), before + 1, "the user is told why");
|
||||
});
|
||||
|
||||
await test("the merge action carries the selected range (#156-A8)", async () => {
|
||||
const area = mountGrid();
|
||||
dragSelect(area, "A1", "B1");
|
||||
openFormatMenu(area);
|
||||
queueStyleWrite();
|
||||
formatItem(area, FR["xlsx.format_merge"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(calls[0].body.ops, [{ op: "merge", sheet: "Fruits", range: "A1:B1" }]);
|
||||
});
|
||||
|
||||
await test("freezing uses the active cell, unfreezing sends an empty one (#156-A8)", async () => {
|
||||
const area = mountGrid();
|
||||
activateCell(area, "B3");
|
||||
openFormatMenu(area);
|
||||
queueStyleWrite();
|
||||
formatItem(area, FR["xlsx.format_freeze"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(calls[0].body.ops, [{ op: "freeze", sheet: "Fruits", cell: "B3" }]);
|
||||
|
||||
const area2 = mountGrid();
|
||||
openFormatMenu(area2);
|
||||
queueStyleWrite();
|
||||
formatItem(area2, FR["xlsx.format_unfreeze"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(calls[2].body.ops, [{ op: "freeze", sheet: "Fruits", cell: "" }]);
|
||||
});
|
||||
|
||||
await test("the column width prompt writes one op per selected column (#156-A8)", async () => {
|
||||
const area = mountGrid();
|
||||
dragSelect(area, "A1", "B2");
|
||||
openFormatMenu(area);
|
||||
formatItem(area, FR["xlsx.format_col_width"]).click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const input = lastDialog()?.querySelector('[data-dialog="input"]');
|
||||
assert.ok(input, "a themed prompt asks for the width");
|
||||
input.value = "30";
|
||||
clickDialog("confirm");
|
||||
queueStyleWrite();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(calls[0].body.ops, [
|
||||
{ op: "col_width", sheet: "Fruits", col: "A", width: 30 },
|
||||
{ op: "col_width", sheet: "Fruits", col: "B", width: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
await test("a lossy refusal asks once then retries the style with force (#156-A8)", async () => {
|
||||
const area = mount({ lossy: ["cached_values"] });
|
||||
activateCell(area, "A1");
|
||||
openFormatMenu(area);
|
||||
apiQueue.push(lossyError); // 1) the style write is refused as lossy
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok", revision: "rev-2" } }); // 2) forced retry
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: {
|
||||
is_xlsx: true, vault: "V", path: "data.xlsx",
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
},
|
||||
}); // 3) the re-read
|
||||
formatItem(area, FR["xlsx.format_italic"]).click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 1, "nothing is retried before the answer");
|
||||
const dialog = lastDialog();
|
||||
assert.ok(dialog, "a themed confirmation is shown");
|
||||
// The 409 payload lists the features (slicers), not the mount's list.
|
||||
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const retry = calls.find((c) => c.body && c.body.force === true);
|
||||
assert.ok(retry, "the retry goes out with force");
|
||||
assert.match(retry.url, /\/xlsx\/style\?path=data\.xlsx/);
|
||||
});
|
||||
|
||||
// ── #156-A11 — sortie (CSV/MD/HTML/impression) + recherche classeur ────────
|
||||
|
||||
/**
|
||||
* Click an export control and capture the file name and the payload it built.
|
||||
* The real <a>/Blob constructors are swapped out because JSDOM has neither
|
||||
* downloads nor object URLs.
|
||||
*/
|
||||
const captureExport = (area, selector) => {
|
||||
const realCreate = document.createElement.bind(document);
|
||||
const realBlob = globalThis.Blob;
|
||||
const anchor = realCreate("a");
|
||||
let href = "";
|
||||
let text = "";
|
||||
Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href });
|
||||
document.createElement = (tag) => (tag === "a" ? anchor : realCreate(tag));
|
||||
URL.createObjectURL = () => "blob:x";
|
||||
URL.revokeObjectURL = () => {};
|
||||
globalThis.Blob = class extends realBlob {
|
||||
constructor(parts, opts) {
|
||||
super(parts, opts);
|
||||
text = parts.join("");
|
||||
}
|
||||
};
|
||||
try {
|
||||
area.querySelector(selector).click();
|
||||
} finally {
|
||||
document.createElement = realCreate;
|
||||
globalThis.Blob = realBlob;
|
||||
}
|
||||
return { anchor, text, href };
|
||||
};
|
||||
|
||||
await test("the CSV export carries the selection when a range is active (#156-A11)", () => {
|
||||
const area = mountGrid();
|
||||
dragSelect(area, "A1", "B2");
|
||||
const { anchor, text } = captureExport(area, "#xlsx-csv-btn");
|
||||
assert.equal(anchor.download, "Fruits-A1B2.csv", "the file name pins the range");
|
||||
assert.equal(text, "\uFEFFBanane;3\nAbricot;10", text);
|
||||
});
|
||||
|
||||
await test("without a selection the export still carries the whole sheet (#156-A11)", () => {
|
||||
const area = mountGrid();
|
||||
const { anchor, text } = captureExport(area, "#xlsx-csv-btn");
|
||||
assert.equal(anchor.download, "Fruits.csv");
|
||||
assert.equal(text.split("\n").length, 3, text);
|
||||
assert.ok(text.includes("Cerise"), text);
|
||||
});
|
||||
|
||||
await test("the export menu offers Markdown and writes a table (#156-A11)", () => {
|
||||
const area = mountGrid();
|
||||
area.querySelector("#xlsx-export-btn").click();
|
||||
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "block");
|
||||
const { anchor, text } = captureExport(area, '[data-xlsx-export="md"]');
|
||||
assert.equal(anchor.download, "Fruits.md");
|
||||
assert.equal(text.split("\n")[0], "| Banane | 3 |");
|
||||
assert.equal(text.split("\n")[1], "|---|---|");
|
||||
assert.equal(area.querySelector("#xlsx-export-menu").style.display, "none", "the menu closes");
|
||||
});
|
||||
|
||||
await test("the export menu writes a standalone HTML document (#156-A11)", () => {
|
||||
const area = mountGrid();
|
||||
area.querySelector("#xlsx-export-btn").click();
|
||||
const { anchor, text } = captureExport(area, '[data-xlsx-export="html"]');
|
||||
assert.equal(anchor.download, "Fruits.html");
|
||||
assert.ok(text.startsWith("<!DOCTYPE html>"), text.slice(0, 40));
|
||||
assert.ok(text.includes("<td>Banane</td>"), text);
|
||||
assert.ok(text.includes("<td>Cerise</td>"), text);
|
||||
});
|
||||
|
||||
await test("printing builds an off-screen frame and never touches the app chrome (#156-A11)", () => {
|
||||
const area = mountGrid();
|
||||
area.querySelector("#xlsx-export-btn").click();
|
||||
area.querySelector('[data-xlsx-export="print"]').click();
|
||||
const frame = document.querySelector("body > iframe.xlsx-print-frame");
|
||||
assert.ok(frame, "a print frame is created");
|
||||
const doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document);
|
||||
assert.ok(doc, "the frame carries its own document");
|
||||
assert.ok(doc.body.textContent.includes("Banane"), doc.body.textContent);
|
||||
assert.equal(document.body.classList.contains("xlsx-print"), false, "the print class is cleared");
|
||||
// A second print does not pile frames up.
|
||||
area.querySelector("#xlsx-export-btn").click();
|
||||
area.querySelector('[data-xlsx-export="print"]').click();
|
||||
assert.equal(document.querySelectorAll("body > iframe.xlsx-print-frame").length, 1);
|
||||
});
|
||||
|
||||
await test("the find scans every sheet and steps across tabs (#156-A11)", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const oneCell = (value) =>
|
||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
||||
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td><td data-cell="B1">x</td></tr>` +
|
||||
"</tbody></table></div>";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.xlsx", is_xlsx: true,
|
||||
xlsx_sheets: [
|
||||
{ name: "Un", html: oneCell("pomme") },
|
||||
{ name: "Deux", html: oneCell("pomme") },
|
||||
],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
const input = area.querySelector("#xlsx-find-input");
|
||||
input.value = "pomme";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 2, "both sheets are scanned");
|
||||
assert.ok(area.querySelector("#xlsx-find-count").textContent.includes("2 feuilles"), area.querySelector("#xlsx-find-count").textContent);
|
||||
assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "none", "sheet 2 stays hidden while its hit is not focused");
|
||||
area.querySelector("#xlsx-find-next").click();
|
||||
assert.equal(area.querySelector('.xlsx-tab[data-sheet="1"]').classList.contains("active"), true, "the tab of the hit is activated");
|
||||
assert.equal(area.querySelector('.xlsx-panel[data-sheet="1"]').style.display, "", "the sheet holding the hit is shown");
|
||||
assert.equal(area.querySelectorAll("mark.xlsx-find-current").length, 1, "exactly one hit is current");
|
||||
assert.ok(area.querySelector('.xlsx-panel[data-sheet="1"] mark.xlsx-find-current'), "the current hit sits in sheet 2");
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
|
||||
@@ -254,6 +254,7 @@ class TestDependencySecurityFloors:
|
||||
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
|
||||
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
|
||||
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
|
||||
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
|
||||
}
|
||||
|
||||
def test_security_floors_are_declared(self):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# tests/test_nav_files.py — Navigation page listing (#158)
|
||||
#
|
||||
# The vault home / navigation page lists a directory with its indexed tags so
|
||||
# the Vaults · Tags · Extensions facets can filter the listing client-side.
|
||||
# The tags must survive the endpoint's response_model.
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_vault_files_expose_indexed_tags(client):
|
||||
"""Every entry carries a `tags` list; markdown tags come from the index."""
|
||||
resp = client.get("/api/vault/TestVault/files", params={"recursive": False})
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
files = data["files"]
|
||||
assert files, "expected a non-empty listing"
|
||||
|
||||
# The field is always present (response_model must not strip it)
|
||||
assert all("tags" in f for f in files)
|
||||
|
||||
by_name = {f["name"]: f for f in files}
|
||||
assert "note1.md" in by_name
|
||||
assert sorted(by_name["note1.md"]["tags"]) == ["python", "tutorial"]
|
||||
|
||||
# Unindexed / tagless files keep an empty list, never null
|
||||
assert sorted(by_name["note2.md"]["tags"]) == ["devops", "docker"]
|
||||
for f in files:
|
||||
assert isinstance(f["tags"], list)
|
||||
|
||||
|
||||
def test_vault_files_tags_in_subdirectory(client):
|
||||
"""Tags are resolved per directory listing, not only at the vault root."""
|
||||
resp = client.get(
|
||||
"/api/vault/TestVault/files",
|
||||
params={"dir": "Projets", "recursive": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
files = resp.json()["files"]
|
||||
by_name = {f["name"]: f for f in files}
|
||||
assert "projet.md" in by_name
|
||||
assert sorted(by_name["projet.md"]["tags"]) == ["projet", "python"]
|
||||
|
||||
|
||||
def test_indexed_tags_unknown_vault_returns_empty():
|
||||
"""Index miss (unknown vault/file) degrades to an empty tag list."""
|
||||
from backend.services.vaults import _indexed_tags
|
||||
|
||||
assert _indexed_tags("NoSuchVault", "ghost.md") == []
|
||||
+15
-5
@@ -204,7 +204,7 @@ class TestAdvancedSearchAPI:
|
||||
pytest.skip(f"Advanced search non-JSON response: {resp.text[:200]}")
|
||||
|
||||
def test_basic(self, client):
|
||||
resp = client.get("/api/advanced-search?q=python&vault=all")
|
||||
resp = client.get("/api/search/advanced?q=python&vault=all")
|
||||
data = self._check(resp)
|
||||
assert data["total"] >= 1
|
||||
assert len(data["results"]) > 0
|
||||
@@ -217,17 +217,27 @@ class TestAdvancedSearchAPI:
|
||||
assert "path" in r
|
||||
|
||||
def test_pagination(self, client):
|
||||
resp = client.get("/api/advanced-search?q=python&limit=1&offset=0")
|
||||
resp = client.get("/api/search/advanced?q=python&limit=1&offset=0")
|
||||
data = self._check(resp)
|
||||
assert len(data["results"]) <= 1
|
||||
|
||||
def test_facets(self, client):
|
||||
resp = client.get("/api/advanced-search?q=python&vault=all")
|
||||
data = self._check(resp)
|
||||
resp = client.get("/api/search/advanced?q=python&vault=all")
|
||||
# Hard assertion: the facet contract must hold through the response_model
|
||||
# (regression #157: `extensions` used to be stripped by SearchFacets).
|
||||
assert resp.status_code == 200, resp.text[:200]
|
||||
data = resp.json()
|
||||
assert "facets" in data
|
||||
assert "extensions" in data["facets"]
|
||||
exts = data["facets"]["extensions"]
|
||||
assert isinstance(exts, dict)
|
||||
assert all(not e.startswith(".") for e in exts)
|
||||
if data["total"] > 0:
|
||||
assert len(exts) >= 1
|
||||
assert sum(exts.values()) <= data["total"]
|
||||
|
||||
def test_empty_query(self, client):
|
||||
resp = client.get("/api/advanced-search?q=")
|
||||
resp = client.get("/api/search/advanced?q=")
|
||||
data = self._check(resp)
|
||||
# Empty query should return 0 results
|
||||
assert data["total"] == 0
|
||||
|
||||
@@ -223,3 +223,197 @@ class TestAppendXlsxRows:
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "rows": [],
|
||||
})
|
||||
|
||||
|
||||
# ── #156-A14 — formats étendus, recherche, analyse de plage, structure ──────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notes_csv(vault: Path) -> Path:
|
||||
"""A `;`-separated CSV — the locale the viewer sniffs (BUG-098)."""
|
||||
path = vault / "notes.csv"
|
||||
path.write_text("Poste;Montant\nLoyer;900\nCourses;250\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class TestRegistryExtended:
|
||||
def test_read_tools_include_search_and_analyze(self):
|
||||
for name in ("search_workbook", "analyze_range"):
|
||||
spec = get_tool(name)
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.READ
|
||||
|
||||
def test_structure_tool_requires_confirmation(self, vault, workbook):
|
||||
spec = get_tool("edit_xlsx_structure")
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.WRITE
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
|
||||
})
|
||||
|
||||
|
||||
class TestXlsmSupport:
|
||||
def test_reads_and_edits_an_xlsm(self, vault):
|
||||
path = vault / "macro.xlsm"
|
||||
wb = openpyxl.Workbook()
|
||||
wb.active.title = "Donnees"
|
||||
wb.active.append(["A", "B"])
|
||||
wb.active.append([1, 2])
|
||||
wb.save(path)
|
||||
|
||||
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
|
||||
assert out.ok and out.data["sheets"][0]["name"] == "Donnees"
|
||||
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "macro.xlsm"})
|
||||
assert "| A | B |" in md.data["markdown"]
|
||||
|
||||
edit = call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "macro.xlsm", "sheet": "Donnees",
|
||||
"cells": {"A2": "9"},
|
||||
}, confirm=True)
|
||||
assert edit.ok
|
||||
wb2 = openpyxl.load_workbook(path)
|
||||
assert wb2["Donnees"]["A2"].value == 9
|
||||
wb2.close()
|
||||
|
||||
|
||||
class TestCsvSupport:
|
||||
def test_lists_reads_and_searches_a_csv(self, vault, notes_csv):
|
||||
out = call_tool("list_xlsx_sheets", _ctx(), {"vault": "Vault", "path": "notes.csv"})
|
||||
assert out.ok
|
||||
assert out.data["sheets"] == [
|
||||
{"name": "notes", "total_rows": 3, "total_cols": 2, "truncated": False}
|
||||
]
|
||||
md = call_tool("xlsx_to_markdown", _ctx(), {"vault": "Vault", "path": "notes.csv"})
|
||||
assert "| Poste | Montant |" in md.data["markdown"]
|
||||
found = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv", "query": "Loyer",
|
||||
})
|
||||
assert found.ok
|
||||
assert found.data["total"] == 1
|
||||
assert found.data["matches"][0] == {"sheet": "notes", "cell": "A2", "value": "Loyer"}
|
||||
assert found.data["by_sheet"] == {"notes": 1}
|
||||
|
||||
def test_updates_a_csv_keeping_its_delimiter(self, vault, notes_csv):
|
||||
out = call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv", "cells": {"B2": "1000"},
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
assert "Loyer;1000" in notes_csv.read_text(encoding="utf-8-sig")
|
||||
|
||||
def test_append_rows_refuses_a_csv(self, vault, notes_csv):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("append_xlsx_rows", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv",
|
||||
"sheet": "notes", "rows": [["x"]],
|
||||
}, confirm=True)
|
||||
|
||||
def test_structure_refuses_a_csv(self, vault, notes_csv):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "notes.csv",
|
||||
"actions": [{"op": "sheet_add", "name": "X"}],
|
||||
}, confirm=True)
|
||||
|
||||
def test_wrong_extension_is_still_refused(self, vault):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "note.md", "query": "a",
|
||||
})
|
||||
|
||||
|
||||
class TestSearchWorkbook:
|
||||
def test_scans_every_sheet_with_counts(self, vault, workbook):
|
||||
out = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "o",
|
||||
})
|
||||
assert out.ok
|
||||
assert out.data["by_sheet"]["Budget"] > 0
|
||||
assert out.data["by_sheet"]["Notes"] == 1 # « bonjour » : une seule cellule
|
||||
assert out.data["truncated"] is False
|
||||
assert out.data["total"] == sum(out.data["by_sheet"].values())
|
||||
|
||||
def test_case_sensitive_and_sheet_scope(self, vault, workbook):
|
||||
loose = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
|
||||
})
|
||||
assert loose.data["total"] == 1
|
||||
strict = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "LOYER",
|
||||
"case_sensitive": True,
|
||||
})
|
||||
assert strict.data["total"] == 0
|
||||
scoped = call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": "bonjour",
|
||||
"sheet": "Budget",
|
||||
})
|
||||
assert scoped.data["total"] == 0
|
||||
|
||||
def test_empty_query_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("search_workbook", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "query": " ",
|
||||
})
|
||||
|
||||
|
||||
class TestAnalyzeRange:
|
||||
def test_aggregates_a_column(self, vault, workbook):
|
||||
out = call_tool("analyze_range", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "range": "B2:B3",
|
||||
})
|
||||
assert out.ok
|
||||
stats = out.data["numeric"]
|
||||
assert stats["count"] == 2
|
||||
assert stats["sum"] == 1150
|
||||
assert stats["mean"] == 575
|
||||
assert stats["min"] == 250 and stats["max"] == 900
|
||||
|
||||
def test_whole_sheet_and_fr_separators(self, vault):
|
||||
path = vault / "prix.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["Article", "Prix"])
|
||||
ws.append(["A", "1 234,5"])
|
||||
ws.append(["B", 10])
|
||||
wb.save(path)
|
||||
out = call_tool("analyze_range", _ctx(), {"vault": "Vault", "path": "prix.xlsx"})
|
||||
assert out.ok
|
||||
assert out.data["numeric"]["count"] == 2
|
||||
assert out.data["numeric"]["max"] == 1234.5
|
||||
assert out.data["cells"] == 6 # 2 en-têtes + 4 valeurs
|
||||
|
||||
def test_bad_range_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("analyze_range", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "range": "pas-une-plage",
|
||||
})
|
||||
|
||||
|
||||
class TestEditXlsxStructure:
|
||||
def test_inserts_a_row_and_survives_a_reload(self, vault, workbook):
|
||||
out = call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "row_insert", "sheet": "Budget", "at": 2}],
|
||||
}, confirm=True)
|
||||
assert out.ok and out.data["actions"] == 1
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
assert wb["Budget"]["A3"].value == "Loyer" # la ligne 2 a été poussée
|
||||
wb.close()
|
||||
|
||||
def test_adds_a_sheet(self, vault, workbook):
|
||||
out = call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"actions": [{"op": "sheet_add", "name": "Trois"}],
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
assert "Trois" in wb.sheetnames
|
||||
wb.close()
|
||||
|
||||
def test_empty_actions_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("edit_xlsx_structure", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "actions": [],
|
||||
})
|
||||
|
||||
@@ -95,9 +95,32 @@ class TestXlsmEditable:
|
||||
with zipfile.ZipFile(_vault_file(client, xlsm_book)) as zf:
|
||||
assert "xl/vbaProject.bin" in zf.namelist()
|
||||
|
||||
def test_lossy_gate_is_skipped_for_xlsm(self, formats_vault, xlsm_book):
|
||||
def test_sheet_window_is_served_for_xlsm(self, client, xlsm_book):
|
||||
"""BUG-097 — a .xlsm is editable, so its lazy row windows must work."""
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/sheet",
|
||||
params={"path": xlsm_book, "sheet": "Data", "offset": 0, "limit": 100},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["sheet"] == "Data"
|
||||
assert 'data-cell="A2"' in body["html"]
|
||||
assert body["has_more"] is False
|
||||
|
||||
def test_sheet_window_still_refuses_other_formats(self, client, formats_vault):
|
||||
(formats_vault / "plain.csv").write_text("a,b\n", encoding="utf-8")
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/sheet",
|
||||
params={"path": "plain.csv", "sheet": "Feuille1"},
|
||||
)
|
||||
assert resp.status_code == 415
|
||||
|
||||
def test_lossy_gate_is_skipped_for_xlsm(self, client, formats_vault, xlsm_book):
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
|
||||
# `client` is required: the service resolves the vault through the app
|
||||
# registry (the test used to pass only thanks to state leaked by an
|
||||
# earlier test in this class).
|
||||
# No force flag: the save must succeed despite the vbaProject part
|
||||
# (it would raise xlsx_lossy_content on a plain .xlsx).
|
||||
result = edit_xlsx_cells(VAULT, xlsm_book, "Data", {"A2": "Alan"})
|
||||
@@ -149,6 +172,22 @@ class TestLegacyReadOnly:
|
||||
assert sheet["rows"] == 0
|
||||
|
||||
|
||||
class TestCsvDelimiter:
|
||||
"""BUG-098 — the delimiter is detected, not assumed to be a comma."""
|
||||
|
||||
def test_sniffing_handles_the_common_separators(self):
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
assert sniff_csv_delimiter("a;b\n1;2\n") == ";"
|
||||
assert sniff_csv_delimiter("a,b\n1,2\n") == ","
|
||||
assert sniff_csv_delimiter("a\tb\n1\t2\n") == "\t"
|
||||
|
||||
def test_single_column_falls_back_to_a_comma(self):
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
assert sniff_csv_delimiter("juste\nune\ncolonne\n") == ","
|
||||
|
||||
|
||||
class TestCsvEditable:
|
||||
def test_read_renders_the_xlsx_shaped_table(self, client, formats_vault):
|
||||
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
@@ -197,6 +236,42 @@ class TestCsvEditable:
|
||||
text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8")
|
||||
assert '"Martin, Pierre"' in text
|
||||
|
||||
def test_semicolon_csv_renders_multiple_columns(self, client, formats_vault):
|
||||
"""BUG-098 — a French (semicolon) CSV is no longer a single column."""
|
||||
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
|
||||
html = client.get(
|
||||
f"/api/file/{VAULT}", params={"path": "fr.csv"}
|
||||
).json()["html"]
|
||||
assert 'data-cell="B1"' in html and 'data-cell="B2"' in html
|
||||
assert "Paris" in html
|
||||
|
||||
def test_semicolon_is_preserved_when_saving(self, client, formats_vault):
|
||||
"""BUG-098 — the write-back keeps the file's own delimiter."""
|
||||
(formats_vault / "fr.csv").write_text("Nom;Ville\nAda;Paris\n", encoding="utf-8")
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "fr.csv"},
|
||||
json={"cells": {"B2": "Lyon"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
text = _vault_file(client, "fr.csv").read_text(encoding="utf-8")
|
||||
assert "Nom;Ville" in text
|
||||
assert "Ada;Lyon" in text
|
||||
assert "," not in text # the source delimiter is not switched to a comma
|
||||
|
||||
def test_quoted_semicolon_survives_a_round_trip(self, client, formats_vault):
|
||||
(formats_vault / "quote-fr.csv").write_text(
|
||||
'Nom;Note\n"Dupont; Jean";ok\n', encoding="utf-8"
|
||||
)
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "quote-fr.csv"},
|
||||
json={"cells": {"B2": "modifié"}},
|
||||
)
|
||||
text = _vault_file(client, "quote-fr.csv").read_text(encoding="utf-8")
|
||||
assert '"Dupont; Jean"' in text
|
||||
assert ";modifié" in text
|
||||
|
||||
def test_bad_reference_is_refused(self, client, formats_vault):
|
||||
(formats_vault / "liste.csv").write_text("a\n", encoding="utf-8")
|
||||
resp = client.put(
|
||||
|
||||
@@ -35,6 +35,20 @@ def styled_book(test_vault_dir: str) -> str:
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_book(test_vault_dir: str) -> str:
|
||||
"""An unstyled workbook — the write side starts from a blank slate."""
|
||||
path = Path(test_vault_dir) / "plain.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws.append(["Nom", "Valeur"])
|
||||
ws.append(["a", 1])
|
||||
ws.append(["b", 2])
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
class TestStyleReading:
|
||||
def test_bold_italic_and_font_color(self, styled_book):
|
||||
from backend.xlsx_reader import read_sheet_styles
|
||||
@@ -82,6 +96,160 @@ class TestStyleReading:
|
||||
assert read_sheet_styles(bad, "Data") == {}
|
||||
|
||||
|
||||
class TestStyleWriting:
|
||||
"""#156-A8 — the viewer can now WRITE formatting, not just read it.
|
||||
|
||||
One locked, atomic rewrite through ``PUT …/xlsx/style``: styles (bold,
|
||||
italic, underline, colours, alignment, number format), merges, column
|
||||
widths, row heights and frozen panes.
|
||||
"""
|
||||
|
||||
def _style(self, client, path, ops, **extra):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/style",
|
||||
params={"path": path},
|
||||
json={"ops": ops, **extra},
|
||||
)
|
||||
|
||||
def test_styles_survive_a_reload(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{
|
||||
"op": "cell", "sheet": "Data", "range": "A1:B1",
|
||||
"style": {
|
||||
"bold": True, "italic": True, "underline": True,
|
||||
"font_color": "#cc0000", "fill_color": "#ffe08a",
|
||||
"align": "center", "number_format": "0.00",
|
||||
},
|
||||
},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["revision"]
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
ws = wb["Data"]
|
||||
assert ws["A1"].font.bold is True and ws["A1"].font.italic is True
|
||||
assert ws["A1"].font.underline == "single"
|
||||
assert ws["A1"].font.color.rgb == "FFCC0000"
|
||||
assert ws["A1"].fill.start_color.rgb == "FFFFE08A"
|
||||
assert ws["A1"].alignment.horizontal == "center"
|
||||
assert ws["B1"].number_format == "0.00"
|
||||
assert ws["A1"].value == "Nom", "the value is untouched"
|
||||
assert not ws["A2"].font.bold, "a neighbour outside the range is untouched"
|
||||
wb.close()
|
||||
|
||||
def test_the_written_style_is_read_back_by_the_viewer(self, client, plain_book):
|
||||
self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "fill_color": "#ffe08a"}},
|
||||
])
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
assert resp.status_code == 200
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
style = sheet["styles"]["A1"]
|
||||
assert "font-weight:600" in style and "background:#ffe08a" in style
|
||||
|
||||
def test_merges_sizes_and_freeze(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "merge", "sheet": "Data", "range": "A1:B1"},
|
||||
{"op": "col_width", "sheet": "Data", "col": "A", "width": 24},
|
||||
{"op": "row_height", "sheet": "Data", "row": 2, "height": 30},
|
||||
{"op": "freeze", "sheet": "Data", "cell": "A2"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
ws = wb["Data"]
|
||||
assert [str(r) for r in ws.merged_cells.ranges] == ["A1:B1"]
|
||||
assert ws.column_dimensions["A"].width == 24
|
||||
assert ws.row_dimensions[2].height == 30
|
||||
assert str(ws.freeze_panes) == "A2"
|
||||
wb.close()
|
||||
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "unmerge", "sheet": "Data", "range": "A1:B1"},
|
||||
{"op": "freeze", "sheet": "Data", "cell": ""},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
ws = wb["Data"]
|
||||
assert not list(ws.merged_cells.ranges)
|
||||
assert ws.freeze_panes is None
|
||||
wb.close()
|
||||
|
||||
def test_a_style_can_be_cleared(self, client, plain_book):
|
||||
self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True, "font_color": "#cc0000", "fill_color": "#ffe08a"}},
|
||||
])
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": False, "font_color": "", "fill_color": "", "number_format": "General"}},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
ws = wb["Data"]
|
||||
assert ws["A1"].font.bold in (None, False)
|
||||
assert ws["A1"].font.color is None
|
||||
assert ws["A1"].fill.fill_type is None
|
||||
assert ws["A1"].number_format == "General"
|
||||
wb.close()
|
||||
|
||||
def test_invalid_inputs_are_refused(self, client, plain_book):
|
||||
bad_style = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"blink": True}},
|
||||
])
|
||||
assert bad_style.status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {}},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"fill_color": "rouge"}},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "pas-une-plage", "style": {"bold": True}},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Nope", "range": "A1", "style": {"bold": True}},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [{"op": "spin", "sheet": "Data"}]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", []).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "col_width", "sheet": "Data", "col": "A", "width": 900},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"align": "diagonal"}},
|
||||
]).status_code == 400
|
||||
|
||||
def test_a_csv_is_refused(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "style.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
resp = self._style(client, "style.csv", [
|
||||
{"op": "cell", "sheet": "x", "range": "A1", "style": {"bold": True}},
|
||||
])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_a_stale_revision_is_refused_and_the_fresh_one_accepted(self, client, plain_book):
|
||||
first = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
|
||||
])
|
||||
assert first.status_code == 200
|
||||
stale = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
|
||||
], if_match="deadbeef-1")
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["details"]["reason"] == "stale_revision"
|
||||
fresh = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A2", "style": {"bold": True}},
|
||||
], if_match=first.json()["revision"])
|
||||
assert fresh.status_code == 200
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A3", "style": {"bold": True}},
|
||||
], if_match=42).status_code == 400
|
||||
|
||||
def test_a_backup_is_written_before_the_rewrite(self, client, plain_book):
|
||||
from backend.services.backups import get_backup_dir
|
||||
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": {"bold": True}},
|
||||
]).status_code == 200
|
||||
backups = list(get_backup_dir(VAULT, "plain.xlsx").glob("plain.xlsx.*.bak"))
|
||||
assert backups, "the rewrite is backed up first"
|
||||
|
||||
|
||||
class TestEndpointWiring:
|
||||
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
||||
|
||||
@@ -474,6 +474,64 @@ class TestXlsxLossyGuard:
|
||||
assert inspect_workbook(bad) == []
|
||||
|
||||
|
||||
# ── BUG-099 — the cached-value probe must never fail silently ──────────────
|
||||
|
||||
|
||||
def _probe_zip(tmp_path: Path, sheets: list[bytes]) -> Path:
|
||||
"""Build a minimal archive holding the given worksheet XML parts."""
|
||||
path = tmp_path / "probe.xlsx"
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for i, blob in enumerate(sheets, start=1):
|
||||
zf.writestr(f"xl/worksheets/sheet{i}.xml", blob)
|
||||
return path
|
||||
|
||||
|
||||
class TestXlsxCachedValueProbe:
|
||||
"""BUG-099 — an exhausted probe is reported, never mistaken for clean."""
|
||||
|
||||
def test_exhausted_budget_is_reported_as_unverified(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from backend import xlsx_reader
|
||||
|
||||
# Budget smaller than the only sheet: the scan cannot reach its end.
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 64)
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 64)
|
||||
book = _probe_zip(tmp_path, [b"<x/>" + b"filler" * 1024])
|
||||
|
||||
# Contre-preuve: the old code returned [] here — an interrupted scan was
|
||||
# indistinguishable from a completed one, so the write guard stayed
|
||||
# silent and the cached values could be dropped without warning.
|
||||
assert xlsx_reader.inspect_workbook(book) == ["cached_values_unverified"]
|
||||
|
||||
def test_a_huge_first_sheet_does_not_starve_the_next_one(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from backend import xlsx_reader
|
||||
|
||||
# Per-sheet cap (1 KiB) well under the total allowance (4 KiB): a single
|
||||
# shared budget would spend everything on sheet 1 and never look at
|
||||
# sheet 2, where the only cached formula lives.
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 1024)
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 4096)
|
||||
cached = b'<sheetData><c r="A1"><f>1+1</f><v>2</v></c></sheetData>'
|
||||
book = _probe_zip(tmp_path, [b"filler" * 2048, cached])
|
||||
|
||||
assert "cached_values" in xlsx_reader.inspect_workbook(book)
|
||||
|
||||
def test_a_completed_clean_scan_reports_nothing(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from backend import xlsx_reader
|
||||
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_PER_SHEET", 4096)
|
||||
monkeypatch.setattr(xlsx_reader, "_MAX_PROBE_BYTES_TOTAL", 8192)
|
||||
book = _probe_zip(tmp_path, [b"<x/>", b"<x/>"])
|
||||
|
||||
# No cached value and no budget issue: no false positive either.
|
||||
assert xlsx_reader.inspect_workbook(book) == []
|
||||
|
||||
|
||||
# ── #153 A2 — atomic write ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -741,3 +799,164 @@ class TestXlsxFormulaGuard:
|
||||
ws = openpyxl.load_workbook(xlsx_file)["Budget"]
|
||||
assert ws["B3"].value == 42 and isinstance(ws["B3"].value, int)
|
||||
assert ws["B4"].value == -3.5
|
||||
|
||||
|
||||
class TestMetaCache:
|
||||
"""#156-A13 — ``read_workbook_meta`` is cached on (path, mtime_ns, size).
|
||||
|
||||
A truncated sheet is fetched window by window: without the cache every
|
||||
window paid a full normal-mode workbook load just for the style / merge /
|
||||
freeze maps of one sheet.
|
||||
"""
|
||||
|
||||
def test_second_call_is_served_from_the_cache(self, xlsx_file, monkeypatch):
|
||||
from backend import xlsx_reader
|
||||
|
||||
path = Path(xlsx_file)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
real_load = xlsx_reader.load_workbook
|
||||
loads = []
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
loads.append(1)
|
||||
return real_load(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
|
||||
first = xlsx_reader.read_workbook_meta(path)
|
||||
assert len(loads) == 1, "la première lecture charge le classeur"
|
||||
second = xlsx_reader.read_workbook_meta(path)
|
||||
assert len(loads) == 1, "la seconde lecture ne recharge pas le classeur"
|
||||
assert second is first, "la même carte est servie (lecture seule côté appelant)"
|
||||
assert "Budget" in first
|
||||
|
||||
def test_invalidation_forces_a_reload(self, xlsx_file, monkeypatch):
|
||||
from backend import xlsx_reader
|
||||
|
||||
path = Path(xlsx_file)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
real_load = xlsx_reader.load_workbook
|
||||
loads = []
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
loads.append(1)
|
||||
return real_load(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
|
||||
xlsx_reader.read_workbook_meta(path)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
xlsx_reader.read_workbook_meta(path)
|
||||
assert len(loads) == 2, "l'invalidation explicite force un rechargement"
|
||||
|
||||
def test_rewriting_the_file_invalidates_the_cache(self, xlsx_file):
|
||||
from backend import xlsx_reader
|
||||
|
||||
path = Path(xlsx_file)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == []
|
||||
wb = openpyxl.load_workbook(xlsx_file)
|
||||
wb["Budget"].merge_cells("A1:B1")
|
||||
wb.save(xlsx_file)
|
||||
wb.close()
|
||||
# (mtime_ns, size) changed: the cached maps cannot be served any more.
|
||||
assert xlsx_reader.read_workbook_meta(path)["Budget"]["merges"] == ["A1:B1"]
|
||||
|
||||
def test_two_windows_share_one_metadata_load(self, xlsx_file, monkeypatch):
|
||||
from backend import xlsx_reader
|
||||
|
||||
path = Path(xlsx_file)
|
||||
xlsx_reader.invalidate_workbook_meta(path)
|
||||
real_load = xlsx_reader.load_workbook
|
||||
normal = []
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
if not kwargs.get("read_only"):
|
||||
normal.append(1)
|
||||
return real_load(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(xlsx_reader, "load_workbook", counting)
|
||||
assert xlsx_reader.read_sheet_window(path, "Budget", 0, 1) is not None
|
||||
assert xlsx_reader.read_sheet_window(path, "Budget", 1, 1) is not None
|
||||
assert len(normal) == 1, "les deux fenêtres partagent une seule lecture de métadonnées"
|
||||
|
||||
|
||||
class TestOptimisticConcurrency:
|
||||
"""#156-A12 — the read exposes a revision, a write may require it.
|
||||
|
||||
A stale ``if_match`` (the file changed since it was read: Excel, the
|
||||
watcher, another worker) is refused with 409 ``conflict`` instead of
|
||||
silently overwriting the other writer.
|
||||
"""
|
||||
|
||||
def _save(self, client, body, path="budget.xlsx"):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body
|
||||
)
|
||||
|
||||
def _revision(self, client, path="budget.xlsx"):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": path})
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["xlsx_revision"]
|
||||
|
||||
def test_the_read_exposes_a_revision_and_the_write_refreshes_it(self, client, xlsx_file):
|
||||
rev = self._revision(client)
|
||||
assert isinstance(rev, str) and rev
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["revision"] and resp.json()["revision"] != rev
|
||||
|
||||
def test_a_stale_if_match_is_refused_and_writes_nothing(self, client, xlsx_file):
|
||||
rev = self._revision(client)
|
||||
assert self._save(
|
||||
client, {"sheet": "Budget", "cells": {"A3": "un"}, "if_match": rev}
|
||||
).status_code == 200
|
||||
# The token is stale now: the client must re-read before writing again.
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "deux"}, "if_match": rev})
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == "conflict"
|
||||
assert resp.json()["details"]["reason"] == "stale_revision"
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value is None
|
||||
|
||||
def test_omitting_if_match_keeps_last_writer_wins(self, client, xlsx_file):
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A4": "sans-jeton"}})
|
||||
assert resp.status_code == 200
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].value == "sans-jeton"
|
||||
|
||||
def test_an_invalid_if_match_is_rejected(self, client, xlsx_file):
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A3": "x"}, "if_match": 42})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_the_structure_endpoint_honours_if_match(self, client, xlsx_file):
|
||||
rev = self._revision(client)
|
||||
actions = [{"op": "row_insert", "sheet": "Budget", "at": 1}]
|
||||
ok = client.put(
|
||||
f"/api/file/{VAULT}/xlsx/structure",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"actions": actions, "if_match": rev},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
stale = client.put(
|
||||
f"/api/file/{VAULT}/xlsx/structure",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"actions": actions, "if_match": rev},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert stale.json()["code"] == "conflict"
|
||||
|
||||
def test_the_csv_endpoint_honours_if_match(self, client, test_vault_dir):
|
||||
csv_path = Path(test_vault_dir) / "notes.csv"
|
||||
csv_path.write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
rev = self._revision(client, "notes.csv")
|
||||
ok = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "notes.csv"},
|
||||
json={"cells": {"A1": "x"}, "if_match": rev},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["revision"] != rev
|
||||
stale = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "notes.csv"},
|
||||
json={"cells": {"A1": "y"}, "if_match": rev},
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
assert csv_path.read_text(encoding="utf-8").startswith("x")
|
||||
|
||||
Reference in New Issue
Block a user