Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72da123a51 | ||
|
|
e94af0369b | ||
|
|
8da65611cb | ||
|
|
605060c51d | ||
|
|
856e654306 | ||
|
|
4de9ee038c | ||
|
|
290d62da4e | ||
|
|
140e9a679d | ||
|
|
267a33d43b | ||
|
|
435a0687d7 | ||
|
|
dbf935bec0 | ||
|
|
d6d081c0e9 | ||
|
|
c72f852a55 | ||
|
|
99779ecc08 | ||
|
|
c4b8e66206 | ||
|
|
ca6407e0c0 | ||
|
|
dff32a97ee | ||
|
|
d5c528fead | ||
|
|
38f39a10ae | ||
|
|
48e023ba25 | ||
|
|
011ec84f23 | ||
|
|
472ea9d309 | ||
|
|
6ba04c4381 | ||
|
|
06f8e63d06 |
+31
-6
@@ -131,8 +131,13 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
# setuptools / pip sont mis à jour : l'image de base peut embarquer
|
||||
# une version couverte par un advisory fraîchement publié
|
||||
# (PYSEC-2026-3447 / PYSEC-2026-3721).
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: |
|
||||
pip install bandit pip-audit semgrep
|
||||
pip install -U pip setuptools
|
||||
pip install bandit pip-audit
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Bandit (SAST, bloquant — #87)
|
||||
@@ -141,10 +146,20 @@ jobs:
|
||||
# vrais positifs restants portent un `# nosec` justifié inline.
|
||||
run: bandit -r backend/ --skip B101,B105,B110,B310
|
||||
|
||||
- name: Semgrep (SAST local, bloquant — #87)
|
||||
# Règles 100 % locales (semgrep-rules/, 8 règles) : aucun
|
||||
# téléchargement de registre (runner au réseau fragile).
|
||||
run: semgrep --config semgrep-rules/ backend/
|
||||
- name: Semgrep (SAST local) — DÉSACTIVÉ (BUG-091)
|
||||
# Les règles locales (semgrep-rules/, 8 règles) ne sont plus exécutées
|
||||
# en CI : semgrep-core est un exécutable natif que le runner actuel ne
|
||||
# peut pas lancer (exit 127, sans message exploitable) — les releases
|
||||
# récentes exigent un CPU x86-64-v2, et la dernière version compatible
|
||||
# (1.157.0, core statique vérifié en baseline v1) échoue aussi. Les
|
||||
# règles restent applicables en local : `semgrep --config semgrep-rules/
|
||||
# backend/`. À réactiver dès que le runner dispose d'un CPU x86-64-v2
|
||||
# (ou d'une image de runner plus récente). Bandit et pip-audit, eux,
|
||||
# restent bloquants dans ce job.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "::warning::SAST semgrep non exécutée (runner incompatible — BUG-091). Bandit et pip-audit restent bloquants."
|
||||
|
||||
- name: Pip-audit (bloquant — #87)
|
||||
# Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3,
|
||||
@@ -153,7 +168,17 @@ jobs:
|
||||
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
|
||||
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
|
||||
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
|
||||
# s'exécutent jamais.
|
||||
# s'exécutent jamais. PYSEC-2026-178 (pyjwt) est, lui, corrigé par le
|
||||
# plancher pyjwt>=2.13.0 de backend/requirements.txt (BUG-091).
|
||||
# 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.
|
||||
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
|
||||
run: pip-audit --ignore-vuln PYSEC-2026-1325
|
||||
|
||||
|
||||
+348
-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.29.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.43.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,353 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.43.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 4) : undo/redo, défilement et accessibilité.**
|
||||
Les éditions de cellules peuvent être **annulées / rétablies** (boutons dans le ruban,
|
||||
raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`). Les fenêtres de lignes d'une feuille tronquée se
|
||||
chargent via **`IntersectionObserver`** (repli sur l'écouteur de défilement quand l'API est
|
||||
absente). La grille expose désormais une sémantique **ARIA** (`role="grid"` / `row` /
|
||||
`gridcell` / `columnheader` / `rowheader`). Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
|
||||
(47) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts. Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.42.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 3) : inspecteur droit.**
|
||||
Le **Tableau de bord** quitte le flux de la grille pour un **panneau latéral droit repliable**
|
||||
(`.xlsx-inspector`) : la grille reste visible à côté. L'en-tête de l'inspecteur porte le titre,
|
||||
une entrée **Assistant IA** (ouvre le panneau latéral global existant) et un bouton de fermeture.
|
||||
Sous 900 px, l'inspecteur se place sous la grille. Tests JSDOM
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (44) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
|
||||
Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.41.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 2) : dialogues thémés et conflits non
|
||||
bloquants.**
|
||||
Les `confirm()` / `prompt()` natifs sont remplacés par des **dialogues intégrés au thème**
|
||||
(`showConfirm()` / `showPrompt()` dans `frontend/js/ui.js`, promise-based, réutilisant
|
||||
`.obsigate-modal-*`) pour toutes les actions de structure du classeur (ajouter / renommer /
|
||||
dupliquer / supprimer une feuille, insérer / supprimer une ligne ou une colonne) et la
|
||||
confirmation de perte (`409 xlsx_lossy_content`). Un **conflit d'écriture** (`409 conflict`)
|
||||
n'interrompt plus l'utilisateur : un **bandeau non bloquant** propose de réessayer en
|
||||
conservant les modifications. Le bouton **Enregistrer** et l'onglet de la feuille concernée
|
||||
signalent les modifications non sauvegardées. Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
|
||||
(42) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) adaptés. Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.40.0] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 1) : ruban de commandes groupé,
|
||||
onglets de feuilles permanents avec bouton « + », badges d'état.**
|
||||
La vue tableur gagne une barre de commandes segmentée (Formules · Insertion · Vue ·
|
||||
Fichier) avec un bouton **Enregistrer** primaire. La barre d'onglets est désormais
|
||||
toujours affichée (même à une seule feuille) et un bouton « + » y ajoute une feuille
|
||||
(même pipeline `PUT …/xlsx/structure`, re-rendu depuis le serveur). Deux pastilles
|
||||
d'état annoncent les limites de la vue : **lecture seule** (`.xls`/`.ods` — plus de
|
||||
« + », ni de structure, ni de tableau de bord, ni d'édition) et **formules non
|
||||
recalculées**. Des tokens de grille dédiés (`--grid-bg`, `--grid-header-bg`,
|
||||
`--grid-header-text`, `--grid-border`, `--grid-zebra`, déclinés dark/light) rendent les
|
||||
en-têtes clairement distincts des cellules, avec zébrage, survol et cellule active
|
||||
renforcée. Audit UX, architecture cible et plan par lots :
|
||||
[docs/features/xlsx-ui-redesign.md](docs/features/xlsx-ui-redesign.md). Tests JSDOM
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (41, dont 6 nouveaux). Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.10] — 2026-09-29
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **BUG-093 — deux DoS de ressources dans `pypdf` 6.16.0 (PYSEC-2026-3910,
|
||||
PYSEC-2026-3911) corrigés par le plancher `pypdf>=6.16.1`.**
|
||||
Un PDF peut provoquer un temps de calcul et une consommation mémoire
|
||||
arbitraires, soit via de nombreux contours (*outlines*), soit via une page
|
||||
portant beaucoup d'objets XForm réutilisés. ObsiGate est **directement
|
||||
exposé** : `backend/pdf_reader.py` extrait le texte et parcourt les contours
|
||||
de PDF fournis par l'utilisateur. Le plancher `pypdf>=4.0` ne protégeait
|
||||
rien en pratique — l'image du runner Act embarque 6.16.0 *préinstallé* dans
|
||||
sa toolcache Python, donc `pip` répondait « already satisfied » et
|
||||
n'alignait jamais la version. Tout plancher de sécurité doit désormais rester
|
||||
au-dessus de la version préinstallée.
|
||||
|
||||
### Correction
|
||||
|
||||
- **Le job CI `security` n'est plus rouge : la désactivation de semgrep
|
||||
fonctionne, et `pip-audit` est désormais réparé pour de bon.**
|
||||
Le garde-fou `tests/test_ci_workflow.py::TestSemgrepStep`, en régression
|
||||
depuis la désactivation (il exigeait encore l'exécution de semgrep),
|
||||
vérifie maintenant que l'étape n'exécute que son `::warning::` et que
|
||||
**bandit et pip-audit restent bloquants**. Nouveau garde-fou
|
||||
`TestDependencySecurityFloors` : les planchers de sécurité (`pypdf`,
|
||||
`pyjwt`) ne peuvent plus retomber sous leur correctif — contre-preuve
|
||||
vérifiée (plancher remis à `>=4.0` → test rouge).
|
||||
|
||||
---
|
||||
|
||||
## [2.39.9] — 2026-09-29
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-091 — l'étape Semgrep est désactivée dans le job CI `security`.**
|
||||
Le core de semgrep est un exécutable natif que le runner actuel ne peut
|
||||
pas lancer (exit 127, sans message exploitable) : les versions récentes
|
||||
exigent un CPU x86-64-v2 et la dernière version compatible (1.157.0, core
|
||||
statique vérifié en baseline v1) échoue également. Son installation
|
||||
(230 Mo sur un runner au réseau fragile) échouait en prime en amont de
|
||||
l'analyse. **Bandit et pip-audit restent bloquants** ; les 8 règles
|
||||
locales semgrep restent applicables en local et l'étape sera réactivable
|
||||
telle quelle sur un runner x86-64-v2.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.8] — 2026-09-29
|
||||
|
||||
---
|
||||
|
||||
## [2.39.7] — 2026-09-29
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-091 — l'étape Semgrep ne bloque plus la CI quand le runner ne peut
|
||||
pas exécuter le core.** Le binaire natif de semgrep sort en 127 sur le
|
||||
runner Gitea quelle que soit sa version : les releases récentes exigent un
|
||||
CPU x86-64-v2, et la dernière version compatible (1.157.0, core statique
|
||||
vérifié en baseline v1) échoue également, sans message. L'étape teste
|
||||
désormais l'exécutabilité du core avant de lancer l'analyse : **si
|
||||
l'analyse a lieu elle bloque comme auparavant**, sinon elle émet un
|
||||
avertissement explicite et le job se poursuit. Bandit et pip-audit
|
||||
restent bloquants — la barrière de sécurité est conservée sur ce que le
|
||||
runner sait exécuter, et semgrep redeviendra bloquant automatiquement sur
|
||||
un runner x86-64-v2. Une étape de diagnostic (CPU, options de montage,
|
||||
taille et permissions du core, exécution brute) reste dans le job pour
|
||||
lever la cause exacte le jour où les logs du runner seront lisibles.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.6] — 2026-09-29
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-091 (suite) — semgrep-core s'exécutait depuis un venv sous `/tmp`.**
|
||||
Le binaire natif de semgrep sortait en 127 sans message, alors que sa
|
||||
version était bien compatible avec le CPU du runner (core statique,
|
||||
baseline x86-64 v1) : le filesystem `/tmp` du runner est monté `noexec`
|
||||
et le noyau refuse l'exécution sans message exploitable. Le venv isolé
|
||||
est donc créé dans `$HOME`, et l'étape de diagnostic du job security
|
||||
trace désormais CPU, options de montage, taille/permissions du core et
|
||||
exécution brute.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.5] — 2026-09-29
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-092 — les tests réseau ne dépendaient plus du DNS réel.** Trois tests
|
||||
de `fetch_url` mockaient `httpx` mais laissaient le garde SSRF résoudre
|
||||
`example.com` pour de vrai : sur un runner au DNS instable, le job CI
|
||||
`test` échouait en `dns_error` au lieu d'atteindre la couche testée. Les
|
||||
tests isolent désormais le garde — y compris la référence importée dans
|
||||
`webrender`, qui échappait au premier correctif — et les tests de garde
|
||||
SSRF continuent de traverser le vrai chemin. Contre-preuve : DNS coupé
|
||||
globalement, la suite passe (1474 tests).
|
||||
|
||||
---
|
||||
|
||||
## [2.39.4] — 2026-09-29
|
||||
|
||||
---
|
||||
|
||||
## [2.39.3] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.39.2] — 2026-09-28
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-091 — le job CI `security` refusait de démarrer semgrep, puis
|
||||
échouait à l'audit des dépendances.** Depuis 1.158.0, semgrep ne publie
|
||||
plus que des wheels `manylinux_2_34`/`2_35` dont les bibliothèques
|
||||
natives exigent un CPU x86-64-v2 : le runner Gitea les refuse (« CPU ISA
|
||||
level is lower than required », exit 127). semgrep est désormais isolé
|
||||
dans un venv jetable du job, épinglé à **1.157.0** (dernière publication
|
||||
`manylinux2014`, baseline v1) — un venv, aussi, parce que ses
|
||||
dépendances contredisent l'environnement principal (`tomli~=2.0.1` vs
|
||||
pip-audit ≥ 2.10, `pyjwt~=2.12.0` vulnérable). Dans la foulée :
|
||||
plancher `pyjwt[crypto]>=2.13.0` dans `backend/requirements.txt`
|
||||
(PYSEC-2026-178, pyjwt est transitif de mcp) et mise à jour de
|
||||
pip/setuptools dans le job (PYSEC-2026-3721 / PYSEC-2026-3447, apparus
|
||||
récemment dans la base d'advisories). Validé en environnement frais :
|
||||
résolution sans conflit, pip-audit et semgrep verts.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.1] — 2026-09-28
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#153 A6 — l'assistant IA sait lire et modifier les classeurs existants.**
|
||||
Quatre nouveaux outils dans `backend/tools/spreadsheets.py` :
|
||||
`list_xlsx_sheets` (noms de feuilles + dimensions), `xlsx_to_markdown`
|
||||
(tableau plafonné injecté au contexte du modèle), `update_xlsx_cells`
|
||||
(édition par lots passant par le service gardé) et `append_xlsx_rows`
|
||||
(ajout de lignes en fin de feuille). Les mutations demandent confirmation
|
||||
et rafraîchissent la visionneuse (`obsigate:file-written`).
|
||||
- **#153 A7 — navigation clavier et barre de formule dans la visionneuse.**
|
||||
`Tab`/`Maj+Tab` circulent entre les cellules, flèches et `Entrée`/
|
||||
`Maj+Entrée` (multiligne) fonctionnent comme dans un tableur, la cellule
|
||||
active est nommée en A1 dans la barre de formule, une plage se copie,
|
||||
et le focus reste visible et tactile (≥ 44 px, couvert par les E2E mobiles).
|
||||
- **#153 A13 — tri, filtre, recherche et export CSV dans la feuille.**
|
||||
Tri ascendant/descendant par colonne, filtre de lignes, recherche
|
||||
suivant/précédent (respect de casse optionnel) et export CSV de la feuille :
|
||||
toutes des opérations d'**affichage**, le classeur n'est jamais réécrit.
|
||||
- **#153 A14 — structure du classeur éditable depuis la visionneuse.**
|
||||
Ajout, renommage, duplication et suppression de feuilles ; insertion et
|
||||
suppression de lignes/colonnes autour de la cellule active, via le menu
|
||||
Structure et `PUT /api/file/{vault}/xlsx/structure` — mêmes garde-fous
|
||||
(backup atomique, verrou, confirmation) que l'édition de cellules.
|
||||
- **#153 A15 — styles, fusions et volets figés affichés fidèlement.**
|
||||
La lecture rend les couleurs de police et de fond, le gras/italique/
|
||||
souligné, les alignements, les plages fusionnées et l'ancre des volets
|
||||
figés ; un format de nombre personnalisé est signalé par une police à
|
||||
chasse fixe. Une seule charge du classeur (mode normal) suffit pour toutes
|
||||
les feuilles, y compris celles rendues par fenêtres.
|
||||
- **#153 A16 — formats tableur additionnels.** `.xlsm` éditable avec
|
||||
**macros préservées** (`keep_vba`), `.xls` et `.ods` en **lecture seule**
|
||||
(xlrd / odfpy), `.csv` édité comme un tableur et réécrit au format
|
||||
RFC 4180 (`PUT …/csv/save`). Dépendances : `xlrd==2.0.2`,
|
||||
`odfpy==1.4.1` dans `backend/requirements.txt`.
|
||||
- **#153 A17 — tableau de bord du classeur.** Un panneau de la visionneuse
|
||||
liste les plages nommées (portée classeur ou feuille), signale la présence
|
||||
de graphiques et de tableaux croisés (analyse des parties OPC, sans
|
||||
recharger le fichier), donne les statistiques par feuille (cellules,
|
||||
lignes, colonnes, formules, valeurs numériques) et huit KPI extraits de la
|
||||
première zone de données — endpoint `GET /api/file/{vault}/xlsx/dashboard`.
|
||||
|
||||
---
|
||||
|
||||
## [2.39.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.38.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.37.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.36.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.35.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.34.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.33.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.32.0] — 2026-09-28
|
||||
|
||||
---
|
||||
|
||||
## [2.31.0] — 2026-09-28
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-090 — troncature silencieuse d'une feuille `.xlsx` au-delà de
|
||||
500 lignes × 40 colonnes.** `render_sheets()` renvoie les dimensions
|
||||
déclarées par la feuille (`total_rows`/`total_cols`), les plafonds du
|
||||
moteur (`max_rows`/`max_cols`) et un flag `truncated` : la visionneuse
|
||||
affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 »
|
||||
(i18n FR/EN) au lieu de présenter une table courte comme complète. La
|
||||
ligne d'en-têtes est désormais figée au défilement vertical (`thead`
|
||||
sticky, `top: auto` sur les numéros de ligne pour éviter leur
|
||||
empilement en haut à gauche). *#153 A8/R5.*
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#153 A9 — chargement paresseux d'une feuille par fenêtres.**
|
||||
`GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` renvoie un
|
||||
bloc de lignes (`XlsxSheetWindowResponse`, plafond 1 000 lignes par
|
||||
requête, `has_more` de pagination) avec les **vraies** coordonnées A1
|
||||
et numéros de ligne de la feuille — une fenêtre se comporte exactement
|
||||
comme le rendu complet. Erreurs typées : 404 feuille inconnue, 415
|
||||
fichier non-`.xlsx`. La lecture des valeurs calculées en cache (#153
|
||||
A12) s'applique aussi aux fenêtres.
|
||||
- **#153 A9bis — « Charger la suite » sous une feuille tronquée.** Un
|
||||
pied de page annonce la progression et fetch la fenêtre suivante au
|
||||
clic ou à l'approche du bas du tableau (sentinelle de défilement).
|
||||
Les lignes ajoutées passent par le même pipeline d'édition que le
|
||||
rendu initial : éditables et sauvegardables immédiatement. Un fetch
|
||||
échoué restore le bouton (retry possible) ; feuille complète → pied
|
||||
de page masqué.
|
||||
|
||||
---
|
||||
|
||||
## [2.30.0] — 2026-09-27
|
||||
|
||||
### Correction
|
||||
|
||||
- **BUG-089 — un reindex manuel ne reconstruisait pas l'index inversé.**
|
||||
`reload_index()` / `reload_single_vault()` remplacent l'entrée de vault
|
||||
en bloc, ce qui n'émet pas les notifications incrémentales : la
|
||||
recherche TF-IDF continuait de servir un index périmé après un
|
||||
reindex. Les deux fonctions appellent désormais `init_inverted_index()`.
|
||||
Au passage, `backend/search.py` lisait l'index via
|
||||
`from backend.indexer import index` — une liaison **par valeur** du
|
||||
dict : un rechargement du module `backend.indexer` recréait le dict
|
||||
côté indexer alors que la recherche écrivait dans l'ancien, et
|
||||
l'index inversé n'indexait plus rien. Tous les accès passent par
|
||||
`_indexer.index`. *Trouvé en écrivant le test de recherche d'A5 : il
|
||||
passait isolément et échouait en suite complète selon l'ordre.*
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#153 A5 — les tableurs sont indexés par leur contenu.**
|
||||
`extract_indexable_text()` extrait les noms de feuilles et les 20
|
||||
premières lignes (plafond 5 000 caractères, 20 feuilles) pour le
|
||||
TF-IDF et la recherche sémantique. Un mot tapé dans une cellule rend
|
||||
désormais le classeur trouvable ; la lecture binaire pour l'affichage
|
||||
est inchangée et un classeur chiffré/corrompu s'indexe par son seul
|
||||
nom.
|
||||
- **#153 A10 — la saisie est typée comme dans Excel.** Une valeur
|
||||
`TRUE`/`FAUX`/`OUI`/`NON` devient un booléen, une date `JJ/MM/AAAA`
|
||||
(avec `HH:MM` optionnel) devient une vraie date — et dans l'ordre
|
||||
français : `01/02/2026` est le 1ᵉʳ février. Une saisie ressemblant à
|
||||
une formule n'est jamais convertie.
|
||||
- **#153 A12 — la valeur calculée s'affiche sous la formule.** Quand une
|
||||
cellule porte encore le résultat de son dernier calcul Excel, celui-ci
|
||||
s'affiche dans une ligne discrète sous la formule. La seconde lecture
|
||||
`data_only=True` n'a lieu que si l'archive contient réellement une
|
||||
valeur en cache, et toute erreur retombe sur l'affichage formules seul.
|
||||
Info-bulle traduite FR/EN (`xlsx.cached_value_title`).
|
||||
|
||||
---
|
||||
|
||||
## [2.29.0] — 2026-09-27
|
||||
|
||||
### Correction
|
||||
|
||||
+4
-4
@@ -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/)
|
||||
@@ -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` 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. 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)`
|
||||
- **📊 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
|
||||
- **🎨 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.29.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.43.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.29.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.43.0 | 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/)
|
||||
@@ -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` 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. 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
|
||||
- **📊 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
|
||||
- **🎨 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.29.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.29.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.43.0 | Last updated: September 2026*
|
||||
|
||||
+39
-7
@@ -351,6 +351,23 @@ def _decompress_excalidraw(compressed: str) -> dict[str, Any] | None:
|
||||
return data
|
||||
|
||||
|
||||
def extract_xlsx_indexable(file_path: Path) -> str:
|
||||
"""Return searchable text for a workbook (#153 A5).
|
||||
|
||||
Lazy wrapper: ``openpyxl`` is only imported when a spreadsheet is actually
|
||||
indexed, so a vault without workbooks never pays the import. Errors are
|
||||
swallowed — a corrupt or encrypted file still gets indexed by name.
|
||||
"""
|
||||
try:
|
||||
from backend.xlsx_reader import extract_indexable_text
|
||||
except Exception: # pragma: no cover - openpyxl missing
|
||||
return ""
|
||||
try:
|
||||
return extract_indexable_text(file_path)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return ""
|
||||
|
||||
|
||||
def extract_excalidraw_indexable(raw: str) -> str:
|
||||
"""Return indexable text content for a raw .excalidraw / .excalidraw.md file.
|
||||
|
||||
@@ -561,11 +578,12 @@ def _scan_vault(
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #152 — binary workbook: metadata only, the viewer renders
|
||||
# it (parity with _index_single_file_sync).
|
||||
raw = ""
|
||||
# #153 A5 — a workbook stays rendered by the viewer, but its
|
||||
# cell values are now indexed as text so a spreadsheet is
|
||||
# findable by its content (parity with _index_single_file_sync).
|
||||
raw = extract_xlsx_indexable(fpath)
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
@@ -807,6 +825,13 @@ async def reload_index() -> dict[str, Any]:
|
||||
await build_index()
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts()
|
||||
# The inverted index is NOT updated by the hooks here: the rebuild above
|
||||
# replaces whole vault entries, so the incremental notifications are not
|
||||
# emitted for the files that only changed content. Without this, a manual
|
||||
# reindex left TF-IDF search serving a stale index (BUG-089).
|
||||
from backend.search import init_inverted_index
|
||||
|
||||
init_inverted_index()
|
||||
stats = {}
|
||||
for name, data in index.items():
|
||||
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
|
||||
@@ -882,6 +907,13 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
# BUG-040/#86: complete the deferred PDF + excalidraw extraction.
|
||||
await enrich_pdf_texts(vault_name)
|
||||
|
||||
# Same as reload_index: the vault entry was replaced wholesale, so rebuild
|
||||
# the inverted index or TF-IDF search keeps serving stale postings
|
||||
# (BUG-089).
|
||||
from backend.search import init_inverted_index
|
||||
|
||||
init_inverted_index()
|
||||
|
||||
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
|
||||
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
|
||||
return stats
|
||||
@@ -955,9 +987,9 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
elif ext == ".xlsx":
|
||||
# #152 — binary workbook: metadata only (parity with _scan_vault).
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
# #153 A5 — index sheet names + header rows as text (see _scan_vault).
|
||||
raw = extract_xlsx_indexable(fpath)
|
||||
content_preview = raw[:200].strip()
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
content_preview = raw[:200].strip()
|
||||
|
||||
@@ -185,6 +185,26 @@ _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},
|
||||
},
|
||||
# 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"): {
|
||||
"response": {
|
||||
"vault": "TestVault",
|
||||
"path": "data/budget.xlsx",
|
||||
"sheet": "Budget",
|
||||
"offset": 0,
|
||||
"limit": 200,
|
||||
"rows": 2,
|
||||
"cols": 2,
|
||||
"total_rows": 640,
|
||||
"total_cols": 12,
|
||||
"max_rows": 500,
|
||||
"max_cols": 40,
|
||||
"truncated": True,
|
||||
"has_more": True,
|
||||
"html": "<table>…</table>",
|
||||
},
|
||||
},
|
||||
("post", "/api/search/replace"): {
|
||||
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
|
||||
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
|
||||
|
||||
@@ -13,15 +13,26 @@ sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=70.0
|
||||
httpx>=0.27.0
|
||||
pypdf>=4.0
|
||||
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
|
||||
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
|
||||
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
|
||||
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
|
||||
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
|
||||
# et n'est jamais mis à niveau.
|
||||
pypdf>=6.16.1
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.28.1
|
||||
# Plancher de sécurité (BUG-091) : pyjwt est une dépendance transitive (mcp) ;
|
||||
# 2.12.x est vulnérable (PYSEC-2026-178, fix 2.13.0) et pip-audit bloque sinon.
|
||||
pyjwt[crypto]>=2.13.0
|
||||
sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
xlrd==2.0.2
|
||||
odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
|
||||
+172
-21
@@ -38,10 +38,12 @@ from backend.schemas import (
|
||||
BrowseResponse,
|
||||
FileContentResponse,
|
||||
FileRawResponse,
|
||||
XlsxDashboardResponse,
|
||||
XlsxSheetWindowResponse,
|
||||
)
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
@@ -178,6 +180,112 @@ async def api_file_backlinks(
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/xlsx/dashboard", response_model=XlsxDashboardResponse
|
||||
)
|
||||
def api_file_xlsx_dashboard(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx workbook"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return the dashboard metadata of an .xlsx workbook (#153 A17).
|
||||
|
||||
Named ranges (workbook- or sheet-scoped), chart/pivot object counts and
|
||||
per-sheet KPI stats (non-empty cells, rows/cols coverage, formulas,
|
||||
numeric cells, first numeric values as KPI cards). Read-only, bounded by
|
||||
the 500x40 render caps; never raises for an unreadable workbook — an
|
||||
empty payload comes back and the viewer hides the panel.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
_vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(_vault_root, path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise HTTPException(
|
||||
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||
)
|
||||
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
try:
|
||||
dashboard = read_workbook_dashboard(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX dashboard read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
**dashboard,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
|
||||
)
|
||||
def api_file_xlsx_sheet(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx 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(
|
||||
200, ge=1, le=1000, description="Rows to return (server-capped)"
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return a window of rows of one sheet of an .xlsx 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
|
||||
numbers and the ``data-cell`` references are the real A1 coordinates of the
|
||||
sheet, so a window behaves like the full render (editing a cell in it
|
||||
targets the right cell).
|
||||
|
||||
The response also carries ``total_rows``/``total_cols`` and the ``truncated``
|
||||
flag, so the client can say what is hidden behind the 500x40 render caps
|
||||
instead of silently hiding it.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path of the .xlsx file within the vault.
|
||||
sheet: Sheet name; **404** if the workbook has no such sheet.
|
||||
offset: 0-based index of the first row to return.
|
||||
limit: Rows to return, capped server-side at 1000.
|
||||
|
||||
Returns:
|
||||
``XlsxSheetWindowResponse`` with the rendered ``html`` of the window.
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
|
||||
415 (not an .xlsx 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}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
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")
|
||||
|
||||
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
|
||||
try:
|
||||
window = read_sheet_window(file_path, sheet, offset=offset, limit=limit)
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX sheet read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
if window is None:
|
||||
raise HTTPException(status_code=404, detail=f"Feuille introuvable: {sheet}")
|
||||
|
||||
return {"vault": vault_name, "path": path, **window}
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
|
||||
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Return rendered HTML and metadata for a file.
|
||||
@@ -243,6 +351,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
try:
|
||||
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||
|
||||
# #153 A15 — every sheet dict already carries its styles, aligns,
|
||||
# merges and freeze anchor (read_workbook_meta, one normal-mode
|
||||
# load inside render_sheets).
|
||||
sheets = render_sheets(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
@@ -377,27 +488,67 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
logger.error(f"Unexpected error reading file {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
|
||||
|
||||
# === CSV: render as HTML table ===
|
||||
# === Excel .xlsm: same editable viewer as .xlsx, macros preserved on save ===
|
||||
if ext == ".xlsm":
|
||||
try:
|
||||
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||
|
||||
sheets = render_sheets(file_path)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": sheets[0]["html"] if sheets else "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
# 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": [],
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||
|
||||
# === Legacy/ODF spreadsheets (.xls, .ods): read-only table view ===
|
||||
if ext in (".xls", ".ods"):
|
||||
try:
|
||||
from backend.xlsx_reader import render_legacy_workbook
|
||||
|
||||
sheets = render_legacy_workbook(file_path, ext)
|
||||
size = file_path.stat().st_size
|
||||
return {
|
||||
"vault": vault_name,
|
||||
"path": path,
|
||||
"title": file_path.name,
|
||||
"tags": [],
|
||||
"frontmatter": {},
|
||||
"html": sheets[0]["html"] if sheets else "",
|
||||
"raw_length": size,
|
||||
"extension": ext,
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_readonly": True,
|
||||
"xlsx_sheets": sheets,
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Spreadsheet read error for {path}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error reading spreadsheet: {e!s}")
|
||||
|
||||
# === CSV: spreadsheet-style table (same shape as the xlsx viewer) ===
|
||||
if ext == ".csv":
|
||||
import csv
|
||||
import io as csv_io
|
||||
reader = csv.reader(csv_io.StringIO(raw))
|
||||
rows = list(reader)
|
||||
if not rows:
|
||||
html = "<p><em>Fichier CSV vide</em></p>"
|
||||
else:
|
||||
headers = rows[0]
|
||||
data_rows = rows[1:]
|
||||
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
|
||||
for h in headers:
|
||||
html += f"<th>{h}</th>"
|
||||
html += "</tr></thead><tbody>"
|
||||
for row in data_rows:
|
||||
html += "<tr>"
|
||||
for cell in row:
|
||||
html += f"<td>{cell}</td>"
|
||||
html += "</tr>"
|
||||
html += "</tbody></table></div>"
|
||||
from backend.xlsx_reader import render_csv_table
|
||||
|
||||
html = render_csv_table(raw)
|
||||
return {
|
||||
"vault": vault_name, "path": path,
|
||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||
|
||||
@@ -64,12 +64,18 @@ from backend.services.mutations import (
|
||||
from backend.services.mutations import (
|
||||
move_path as service_move_path,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_directory as service_rename_directory,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
rename_file as service_rename_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
save_csv_cells as service_save_csv_cells,
|
||||
)
|
||||
from backend.share import update_shares_after_rename
|
||||
from backend.sse import sse_manager
|
||||
from backend.webhooks import dispatch_webhooks
|
||||
@@ -174,6 +180,96 @@ def api_file_xlsx_save(
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
|
||||
def api_file_csv_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .csv file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description='{"cells": {"A1": value}} — A1-addressed text edits (#153 A16)',
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||
|
||||
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 not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
cells = body.get("cells")
|
||||
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
||||
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
||||
for ref, value in cells.items():
|
||||
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)
|
||||
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"]}
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||
def api_file_xlsx_structure(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||
'"force": false}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply structural changes to an .xlsx workbook (#153 A14).
|
||||
|
||||
``actions`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``sheet_add`` (``name``, optional ``at`` 0-based), ``sheet_rename``
|
||||
(``from``/``to``), ``sheet_delete`` (refused on the last sheet),
|
||||
``sheet_duplicate`` (``name``/``as``) and ``row_insert``/``row_delete``/
|
||||
``col_insert``/``col_delete`` (``sheet``, 1-based ``at``, ``count``).
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Relative path to the ``.xlsx`` file.
|
||||
body: JSON body with ``actions`` (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}'")
|
||||
|
||||
actions = body.get("actions")
|
||||
if not isinstance(actions, list) or not actions or len(actions) > 50:
|
||||
raise HTTPException(status_code=400, detail="Actions 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")
|
||||
|
||||
result = service_mutate_xlsx_structure(
|
||||
vault_name, path, actions, force=raw_force
|
||||
)
|
||||
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"])}
|
||||
|
||||
|
||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Delete a file from the vault.
|
||||
|
||||
+77
-1
@@ -285,8 +285,21 @@ class FileContentResponse(BaseModel):
|
||||
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
|
||||
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
||||
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
|
||||
xlsx_readonly: bool | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"True when the table is served read-only (.xls/.ods, #153 A16): "
|
||||
"the viewer hides the editable-cell wiring and the save/structure "
|
||||
"endpoints refuse the format"
|
||||
),
|
||||
)
|
||||
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
||||
default=None, description="Rendered xlsx sheets [{name, html}]"
|
||||
default=None,
|
||||
description=(
|
||||
"Rendered xlsx sheets [{name, html, rows, cols, total_rows, "
|
||||
"total_cols, max_rows, max_cols, truncated}] — `truncated` is true "
|
||||
"when the sheet exceeds the 500x40 render caps (#153 A8)"
|
||||
),
|
||||
)
|
||||
xlsx_lossy_features: list[str] | None = Field(
|
||||
default=None,
|
||||
@@ -305,6 +318,69 @@ class FileContentResponse(BaseModel):
|
||||
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
||||
|
||||
|
||||
class XlsxDashboardNamedRange(BaseModel):
|
||||
"""One named range of a workbook (#153 A17)."""
|
||||
|
||||
name: str = Field(description="Range name as declared in the workbook")
|
||||
scope: str = Field(description="Sheet name when sheet-scoped, empty when workbook-wide")
|
||||
ref: str = Field(description="Formula-style reference, e.g. Data!$A$1:$B$5")
|
||||
|
||||
|
||||
class XlsxDashboardSheetKpi(BaseModel):
|
||||
"""One KPI card of a sheet dashboard (#153 A17)."""
|
||||
|
||||
label: str = Field(description="A1 reference of the numeric cell")
|
||||
value: float = Field(description="Numeric value of the cell")
|
||||
|
||||
|
||||
class XlsxDashboardSheet(BaseModel):
|
||||
"""Per-sheet KPI stats of a workbook dashboard (#153 A17)."""
|
||||
|
||||
name: str = Field(description="Sheet name")
|
||||
cells: int = Field(description="Non-empty cells inside the 500x40 caps")
|
||||
rows: int = Field(description="Rows carrying at least one non-empty cell")
|
||||
cols: int = Field(description="Columns carrying at least one non-empty cell")
|
||||
formulas: int = Field(description="Cells whose value is a formula")
|
||||
numeric: int = Field(description="Cells carrying a numeric value")
|
||||
kpi: list[XlsxDashboardSheetKpi] = Field(description="First numeric cells as KPI cards")
|
||||
|
||||
|
||||
class XlsxDashboardResponse(BaseModel):
|
||||
"""Dashboard metadata of an .xlsx workbook (#153 A17)."""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
named_ranges: list[XlsxDashboardNamedRange] = Field(description="Named ranges, sorted by name")
|
||||
objects: dict[str, int] = Field(description="Object counts: {charts, pivots}")
|
||||
sheets: list[XlsxDashboardSheet] = Field(description="Per-sheet KPI stats")
|
||||
|
||||
|
||||
class XlsxSheetWindowResponse(BaseModel):
|
||||
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
|
||||
|
||||
Served by ``GET /api/file/{vault_name}/xlsx/sheet``; the row numbers and
|
||||
the ``data-cell`` references in ``html`` are the real A1 coordinates of the
|
||||
sheet, whatever the window.
|
||||
"""
|
||||
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path within the vault")
|
||||
sheet: str = Field(description="Sheet name (as shown in the tab)")
|
||||
offset: int = Field(description="0-based index of the first returned row")
|
||||
limit: int = Field(description="Maximum number of rows returned (capped server-side)")
|
||||
rows: int = Field(description="Rows actually returned in this window")
|
||||
cols: int = Field(description="Columns of the rendered window")
|
||||
total_rows: int = Field(description="Rows the sheet declares")
|
||||
total_cols: int = Field(description="Columns the sheet declares")
|
||||
max_rows: int = Field(description="Row cap of the renderer (500) — the coverage of this window")
|
||||
max_cols: int = Field(description="Column cap of the renderer (40)")
|
||||
truncated: bool = Field(
|
||||
description="True when the sheet exceeds the 500x40 render caps"
|
||||
)
|
||||
has_more: bool = Field(description="True when rows remain after this window")
|
||||
html: str = Field(description="Rendered HTML table for the window")
|
||||
|
||||
|
||||
class FileRawResponse(BaseModel):
|
||||
"""Raw text content of a file."""
|
||||
|
||||
|
||||
+10
-5
@@ -12,7 +12,12 @@ from sortedcontainers import SortedList
|
||||
|
||||
from backend import indexer as _indexer
|
||||
from backend import semantic_search as _semantic
|
||||
from backend.indexer import index
|
||||
|
||||
# NOTE: the shared index is read through ``_indexer.index`` everywhere, never
|
||||
# via ``from backend.indexer import index``. That import binds the dict object
|
||||
# once, so a module reload of ``backend.indexer`` (tests, dev reload) rebinds
|
||||
# the module-level name to a FRESH dict while this module keeps writing to the
|
||||
# stale one — the inverted index then silently indexes nothing (BUG-089).
|
||||
from backend.services.regex_safety import (
|
||||
MAX_REGEX_MATCHES,
|
||||
truncate_for_regex,
|
||||
@@ -399,7 +404,7 @@ class InvertedIndex:
|
||||
self.vault_docs = defaultdict(set)
|
||||
self.tag_docs = defaultdict(set)
|
||||
|
||||
for vault_name, vault_data in index.items():
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
for file_info in vault_data.get("files", []):
|
||||
doc_key = f"{vault_name}::{file_info['path']}"
|
||||
self.doc_count += 1
|
||||
@@ -688,7 +693,7 @@ _indexer.set_index_change_hook(_on_index_change_hook)
|
||||
|
||||
def init_inverted_index():
|
||||
"""Force initial inverted index build. Called after build_index completes on startup."""
|
||||
if any(vdata.get("files") for vdata in index.values()):
|
||||
if any(vdata.get("files") for vdata in _indexer.index.values()):
|
||||
_inverted_index.rebuild()
|
||||
logger.info("Inverted index initialized.")
|
||||
|
||||
@@ -784,7 +789,7 @@ def search(
|
||||
else:
|
||||
candidates = [
|
||||
(vault_name, file_info)
|
||||
for vault_name, vault_data in index.items()
|
||||
for vault_name, vault_data in _indexer.index.items()
|
||||
if vault_filter == "all" or vault_name == vault_filter
|
||||
for file_info in vault_data["files"]
|
||||
]
|
||||
@@ -1613,7 +1618,7 @@ def get_all_tags(vault_filter: str | None = None) -> dict[str, int]:
|
||||
Dict mapping tag names to their total occurrence count.
|
||||
"""
|
||||
merged: dict[str, int] = {}
|
||||
for vault_name, vault_data in index.items():
|
||||
for vault_name, vault_data in _indexer.index.items():
|
||||
if vault_filter and vault_filter != "all" and vault_name != vault_filter:
|
||||
continue
|
||||
for tag, count in vault_data.get("tags", {}).items():
|
||||
|
||||
@@ -19,6 +19,7 @@ import shutil
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -237,6 +238,14 @@ _XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$")
|
||||
# the legacy Lotus-style trigger. "+"/"-" are left alone: they are numbers here.
|
||||
_XLSX_FORMULA_RE = re.compile(r"^[=@]")
|
||||
|
||||
# #153 A10 — types recognised when a user types into a cell. Excel infers them
|
||||
# too; storing everything as text would make a spreadsheet unusable (a boolean
|
||||
# column stays a string, a date column sorts lexicographically).
|
||||
_XLSX_TRUE_LITERALS = {"true", "vrai", "oui", "yes"}
|
||||
_XLSX_FALSE_LITERALS = {"false", "faux", "non", "no"}
|
||||
# Shape check before strptime: keeps the hot path free of format attempts.
|
||||
_XLSX_DATE_RE = re.compile(r"^\d{1,2}[-/]\d{1,2}[-/]\d{4}(?:[ T]\d{1,2}:\d{2})?$")
|
||||
|
||||
# #153 A3 — per-file write lock. Two concurrent saves (two tabs, the AI agent
|
||||
# and the viewer, a watcher restore) would otherwise read-modify-write on the
|
||||
# same archive and the last writer silently wins. Kept deliberately small: the
|
||||
@@ -270,7 +279,21 @@ def _xlsx_write_lock(key: str) -> Iterator[None]:
|
||||
|
||||
|
||||
def _coerce_xlsx_value(value: Any) -> Any:
|
||||
"""Turn the string sent by the cell editor back into a scalar."""
|
||||
"""Turn the string sent by the cell editor back into a scalar (#153 A10).
|
||||
|
||||
The coercion is symmetric with :func:`backend.xlsx_reader._fmt`: a value
|
||||
typed by the user comes back as a string, and Excel would have inferred a
|
||||
type when typing the same thing. Recognised here:
|
||||
|
||||
* an empty cell -> ``None`` (clears it)
|
||||
* ``1234`` / ``-1`` -> ``int``
|
||||
* ``1.5`` / ``.5`` -> ``float``
|
||||
* ``TRUE``/``FAUX`` (case-insensitive) -> ``bool``
|
||||
* ``31/12/2026`` / ``31/12/2026 14:30`` -> ``date``/``datetime`` (FR)
|
||||
|
||||
Anything else stays text. A date-looking string typed with a leading
|
||||
``=`` is a formula and never reaches here as a date.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
text = value.strip()
|
||||
@@ -280,9 +303,35 @@ def _coerce_xlsx_value(value: Any) -> Any:
|
||||
return int(text)
|
||||
if _XLSX_FLOAT_RE.match(text):
|
||||
return float(text)
|
||||
lowered = text.lower()
|
||||
if lowered in _XLSX_TRUE_LITERALS:
|
||||
return True
|
||||
if lowered in _XLSX_FALSE_LITERALS:
|
||||
return False
|
||||
if not _XLSX_FORMULA_RE.match(text):
|
||||
parsed = _parse_fr_datetime(text)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return value
|
||||
|
||||
|
||||
def _parse_fr_datetime(text: str) -> date | datetime | None:
|
||||
"""Parse a FR-localised date/datetime, or return ``None``.
|
||||
|
||||
Accepts ``JJ/MM/AAAA`` and ``JJ/MM/AAAA HH:MM`` (also ``JJ-MM-AAAA``).
|
||||
``dayfirst`` is what makes ``01/02/2026`` the 1st of February rather than
|
||||
the 2nd of January — the French convention.
|
||||
"""
|
||||
if not _XLSX_DATE_RE.match(text):
|
||||
return None
|
||||
for fmt in ("%d/%m/%Y %H:%M", "%d/%m/%Y", "%d-%m-%Y %H:%M", "%d-%m-%Y"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _write_cell(ws: Any, ref: str, value: Any, *, allow_formula: bool) -> None:
|
||||
"""Assign one cell, forcing text when it looks like a formula.
|
||||
|
||||
@@ -338,9 +387,9 @@ def edit_xlsx_cells(
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
if file_path.suffix.lower() != ".xlsx":
|
||||
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||
raise ServiceError(
|
||||
f"Not an .xlsx file: {path}", code="invalid", status=400
|
||||
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
|
||||
)
|
||||
if not cells:
|
||||
raise ServiceError("No cells to update", code="invalid", status=400)
|
||||
@@ -350,7 +399,10 @@ def edit_xlsx_cells(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force:
|
||||
# #153 A16 — the lossy gate is skipped for .xlsm: the save re-serializes
|
||||
# with keep_vba=True, so the macro project (the only extra part a .xlsm
|
||||
# carries) survives and nothing is dropped.
|
||||
if not force and file_path.suffix.lower() != ".xlsm":
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
@@ -366,8 +418,11 @@ def edit_xlsx_cells(
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
|
||||
# project survives the save (the endpoint's lossy probe is empty
|
||||
# for .xlsm on purpose).
|
||||
try:
|
||||
wb = load_workbook(file_path)
|
||||
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
|
||||
@@ -406,6 +461,284 @@ def edit_xlsx_cells(
|
||||
}
|
||||
|
||||
|
||||
def mutate_xlsx_structure(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
actions: list[dict[str, Any]],
|
||||
*,
|
||||
backup: bool = True,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
|
||||
|
||||
``actions`` is an ordered list — the workbook is loaded once and every
|
||||
action is applied in sequence inside the same per-file lock and the same
|
||||
atomic replace, so a half-applied batch can never reach the disk:
|
||||
|
||||
* ``{"op": "sheet_add", "name": "X", "at": 1}`` — new sheet (at =
|
||||
optional 0-based position);
|
||||
* ``{"op": "sheet_rename", "from": "X", "to": "Y"}``;
|
||||
* ``{"op": "sheet_delete", "name": "X"}`` — refused when it is the
|
||||
last sheet (an openpyxl workbook must keep one);
|
||||
* ``{"op": "sheet_duplicate", "name": "X", "as": "Y"}`` — values,
|
||||
styles and merged ranges are copied (not the data-dependent objects);
|
||||
* ``{"op": "row_insert"|"row_delete"|"col_insert"|"col_delete",
|
||||
"sheet": "X", "at": N, "count": k}`` — 1-based position, default 1.
|
||||
|
||||
All of it rides the same guards as the cell edits (P0): per-file lock,
|
||||
``.tmp`` + ``os.replace`` atomic write and the ``force`` gate on lossy
|
||||
round-trips. The UI proposes these actions with an explicit confirmation
|
||||
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
|
||||
"""
|
||||
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 not actions or len(actions) > 50:
|
||||
raise ServiceError(
|
||||
"Invalid actions (1 to 50 per request)", code="invalid", status=400
|
||||
)
|
||||
|
||||
if not force:
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Restructuring 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},
|
||||
)
|
||||
|
||||
with _xlsx_write_lock(str(file_path)):
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.worksheet.copier import WorksheetCopy
|
||||
|
||||
try:
|
||||
wb = load_workbook(file_path)
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
applied: list[str] = []
|
||||
try:
|
||||
for i, action in enumerate(actions):
|
||||
op = action.get("op")
|
||||
try:
|
||||
if op == "sheet_add":
|
||||
name = str(action.get("name", "")).strip()
|
||||
if not name or name in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Nom de feuille invalide ou déjà pris: {name!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
ws = wb.create_sheet(name[:31])
|
||||
at = action.get("at")
|
||||
# create_sheet appends at the end: shift left by the
|
||||
# distance between the last index and the target.
|
||||
if isinstance(at, int) and 0 <= at < len(wb.sheetnames):
|
||||
wb.move_sheet(ws, offset=at - (len(wb.sheetnames) - 1))
|
||||
applied.append(f"sheet_add:{ws.title}")
|
||||
elif op == "sheet_rename":
|
||||
src, dst = str(action.get("from", "")), str(action.get("to", "")).strip()
|
||||
if src not in wb.sheetnames or not dst or dst in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Renommage invalide: {src!r} -> {dst!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
wb[src].title = dst[:31]
|
||||
applied.append(f"sheet_rename:{src}->{dst}")
|
||||
elif op == "sheet_delete":
|
||||
name = str(action.get("name", ""))
|
||||
if name not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {name}", code="invalid", status=400
|
||||
)
|
||||
if len(wb.sheetnames) <= 1:
|
||||
raise ServiceError(
|
||||
"Impossible de supprimer la dernière feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
del wb[name]
|
||||
applied.append(f"sheet_delete:{name}")
|
||||
elif op == "sheet_duplicate":
|
||||
name = str(action.get("name", ""))
|
||||
new_name = str(action.get("as", "")).strip()
|
||||
if name not in wb.sheetnames or not new_name or new_name in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Duplication invalide: {name!r} -> {new_name!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
# WorksheetCopy is the documented dup path (openpyxl
|
||||
# 3.1); it copies values, styles and merges — not
|
||||
# charts/images, which openpyxl itself cannot clone.
|
||||
copy = wb.create_sheet(new_name[:31])
|
||||
WorksheetCopy(wb[name], copy).copy_worksheet()
|
||||
applied.append(f"sheet_duplicate:{name}->{copy.title}")
|
||||
elif op in ("row_insert", "row_delete", "col_insert", "col_delete"):
|
||||
sheet = str(action.get("sheet", ""))
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Feuille introuvable: {sheet}", code="invalid", status=400
|
||||
)
|
||||
ws = wb[sheet]
|
||||
at = action.get("at", 1)
|
||||
count = action.get("count", 1)
|
||||
if not isinstance(at, int) or at < 1 or not isinstance(count, int) or count < 1:
|
||||
raise ServiceError(
|
||||
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||
)
|
||||
if op == "row_insert":
|
||||
ws.insert_rows(at, count)
|
||||
elif op == "row_delete":
|
||||
ws.delete_rows(at, count)
|
||||
elif op == "col_insert":
|
||||
ws.insert_cols(at, count)
|
||||
else:
|
||||
ws.delete_cols(at, count)
|
||||
applied.append(f"{op}:{sheet}@{at}x{count}")
|
||||
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()
|
||||
|
||||
logger.info(
|
||||
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"applied": applied,
|
||||
}
|
||||
|
||||
|
||||
def save_csv_cells(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
) -> 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).
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||
(409, concurrent write) or ``invalid`` (400) for a bad reference.
|
||||
"""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
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() != ".csv":
|
||||
raise ServiceError(f"Not a .csv file: {path}", code="invalid", status=400)
|
||||
if not cells:
|
||||
raise ServiceError("No cells to update", code="invalid", status=400)
|
||||
for ref in cells:
|
||||
if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref):
|
||||
raise ServiceError(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
rows = list(csv_mod.reader(io_mod.StringIO(raw)))
|
||||
except csv_mod.Error:
|
||||
rows = [[line] for line in raw.splitlines()]
|
||||
|
||||
def _col_num(ref: str) -> int:
|
||||
letters = ref.rstrip("0123456789").upper()
|
||||
n = 0
|
||||
for ch in letters:
|
||||
n = n * 26 + (ord(ch) - ord("A") + 1)
|
||||
return n
|
||||
|
||||
def _row_num(ref: str) -> int:
|
||||
return int(ref[len(ref.rstrip("0123456789")):])
|
||||
|
||||
for ref, value in cells.items():
|
||||
r, c = _row_num(ref), _col_num(ref)
|
||||
while len(rows) < r:
|
||||
rows.append([])
|
||||
row = rows[r - 1]
|
||||
while len(row) < c:
|
||||
row.append("")
|
||||
row[c - 1] = "" if value is None else str(value)
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
buf = io_mod.StringIO()
|
||||
csv_mod.writer(buf, 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")
|
||||
os.replace(tmp_path, file_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
logger.info(f"CSV cells saved: {vault_name}/{rel_path} +{len(cells)}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(cells),
|
||||
}
|
||||
|
||||
|
||||
def append_to_file(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
|
||||
@@ -13,6 +13,7 @@ from backend.tools import connected as _connected # noqa: F401 (registers conn
|
||||
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
|
||||
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
|
||||
from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
|
||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||
from backend.tools.context import (
|
||||
ToolConfirmationRequired,
|
||||
|
||||
@@ -52,6 +52,10 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
||||
"git_search_issues": ("git_issues", "query"),
|
||||
"git_get_file": ("git_file", "path"),
|
||||
"create_xlsx": ("xlsx_create", "path"),
|
||||
"list_xlsx_sheets": ("xlsx_sheets", "path"),
|
||||
"xlsx_to_markdown": ("xlsx_read", "path"),
|
||||
"update_xlsx_cells": ("xlsx_update", "path"),
|
||||
"append_xlsx_rows": ("xlsx_append", "path"),
|
||||
"create_docx": ("docx_create", "path"),
|
||||
"create_csv": ("csv_create", "path"),
|
||||
"create_pdf": ("pdf_create", "path"),
|
||||
|
||||
@@ -315,6 +315,61 @@ class DocxInput(BaseModel):
|
||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||
|
||||
|
||||
class ListXlsxSheetsInput(BaseModel):
|
||||
"""List the sheets of an existing .xlsx workbook (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
|
||||
|
||||
class XlsxToMarkdownInput(BaseModel):
|
||||
"""Read one sheet of an existing .xlsx workbook as markdown (#153 A6)."""
|
||||
|
||||
vault: str = Field(..., description="Vault name")
|
||||
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||
sheet: str = Field(
|
||||
"", description="Sheet name (empty = the first/active 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")
|
||||
cells: dict[str, str | int | float | bool | None] = Field(
|
||||
..., description="A1 reference -> new value (max 500 per call)"
|
||||
)
|
||||
allow_formula: bool = Field(
|
||||
False,
|
||||
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||
)
|
||||
|
||||
|
||||
class AppendXlsxRowsInput(BaseModel):
|
||||
"""Append rows at the end of a sheet of an existing .xlsx (#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 extend")
|
||||
rows: list[list[str | int | float | bool | None]] = Field(
|
||||
..., description="Rows of cell values, appended below the last used row (max 500)"
|
||||
)
|
||||
allow_formula: bool = Field(
|
||||
False,
|
||||
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
|
||||
)
|
||||
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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Spreadsheet tools (#153 A6) — read and mutate existing ``.xlsx`` workbooks.
|
||||
|
||||
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||
file; here the assistant can read and edit one that already exists):
|
||||
|
||||
* ``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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
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 (
|
||||
AppendXlsxRowsInput,
|
||||
ListXlsxSheetsInput,
|
||||
UpdateXlsxCellsInput,
|
||||
XlsxToMarkdownInput,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.spreadsheets")
|
||||
|
||||
# xlsx_to_markdown ceiling: a workbook is a data dump, not prose. The table is
|
||||
# for the LLM context, so both axes are bounded (same spirit as A5's index cap).
|
||||
MAX_MD_ROWS = 100
|
||||
MAX_MD_COLS = 20
|
||||
MAX_MD_CHARS = 20_000
|
||||
|
||||
|
||||
def _workbook_path(vault: str, path: str) -> Path:
|
||||
"""Resolve and validate a vault-relative ``.xlsx`` path."""
|
||||
path = (path or "").strip()
|
||||
if not path.lower().endswith(".xlsx"):
|
||||
raise ToolError("Extension attendue : .xlsx", code="invalid_arguments")
|
||||
try:
|
||||
root = get_vault_root(vault)
|
||||
except ServiceError as e:
|
||||
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||
return resolve_safe_path(root, path)
|
||||
|
||||
|
||||
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
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
@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."
|
||||
),
|
||||
input_model=XlsxToMarkdownInput,
|
||||
risk=ToolRisk.READ,
|
||||
requires_vault=True,
|
||||
)
|
||||
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()
|
||||
|
||||
lines: list[str] = []
|
||||
if rows:
|
||||
header = rows[0]
|
||||
lines.append("| " + " | ".join(header) + " |")
|
||||
lines.append("|" + "|".join("---" for _ in header) + "|")
|
||||
for row in rows[1:]:
|
||||
lines.append("| " + " | ".join(row) + " |")
|
||||
table = "\n".join(lines)[:MAX_MD_CHARS]
|
||||
|
||||
return {
|
||||
"vault": params.vault,
|
||||
"path": params.path,
|
||||
"sheet": title,
|
||||
"rows": len(rows),
|
||||
"cols": max((len(r) for r in rows), default=0),
|
||||
"truncated": truncated,
|
||||
"markdown": table,
|
||||
}
|
||||
|
||||
|
||||
@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…)."
|
||||
),
|
||||
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
|
||||
|
||||
if not params.cells:
|
||||
raise ToolError("Aucune cellule fournie", 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,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"sheet": params.sheet,
|
||||
"cells": len(params.cells),
|
||||
}
|
||||
|
||||
|
||||
@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."
|
||||
),
|
||||
input_model=AppendXlsxRowsInput,
|
||||
risk=ToolRisk.WRITE,
|
||||
requires_vault=True,
|
||||
)
|
||||
def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str, Any]:
|
||||
"""Append whole rows below the last used row of the sheet."""
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from backend.services.mutations import _coerce_xlsx_value, edit_xlsx_cells
|
||||
|
||||
if not params.rows:
|
||||
raise ToolError("Aucune ligne fournie", code="invalid_arguments")
|
||||
if len(params.rows) > 500:
|
||||
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
|
||||
|
||||
file_path = _workbook_path(params.vault, params.path)
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
try:
|
||||
if params.sheet not in wb.sheetnames:
|
||||
raise ToolError(
|
||||
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||
)
|
||||
ws = wb[params.sheet]
|
||||
first_free = (ws.max_row or 0) + 1
|
||||
finally:
|
||||
wb.close()
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||
|
||||
cells: dict[str, Any] = {}
|
||||
for i, row in enumerate(params.rows):
|
||||
for j, value in enumerate(row):
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
continue
|
||||
ref = f"{get_column_letter(j + 1)}{first_free + i}"
|
||||
cells[ref] = _coerce_xlsx_value(value)
|
||||
if not cells:
|
||||
raise ToolError("Aucune valeur fournie", code="invalid_arguments")
|
||||
|
||||
try:
|
||||
result = edit_xlsx_cells(
|
||||
params.vault,
|
||||
params.path,
|
||||
params.sheet,
|
||||
cells,
|
||||
allow_formula=params.allow_formula,
|
||||
force=params.force,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise _map_service_error(e) from e
|
||||
return {
|
||||
"status": "ok",
|
||||
"vault": result["vault"],
|
||||
"path": result["path"],
|
||||
"sheet": params.sheet,
|
||||
"rows": len(params.rows),
|
||||
"first_row": first_free,
|
||||
}
|
||||
+754
-15
@@ -6,11 +6,16 @@ Write-side lives in ``backend.services.mutations.edit_xlsx_cells``.
|
||||
|
||||
:func:`inspect_workbook` lists the workbook features that an openpyxl
|
||||
round-trip would drop (#153 A1) so the UI can warn before saving.
|
||||
|
||||
#153 A16 — :func:`render_sheets` also accepts ``.xlsm`` (macros preserved on
|
||||
save via ``keep_vba``), ``.xls`` (xlrd) and ``.ods`` (odfpy), both served
|
||||
read-only; :func:`render_csv_table` turns a CSV into the same table shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import date, datetime
|
||||
@@ -20,11 +25,19 @@ from typing import Any
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
logger = logging.getLogger("obsigate.xlsx_reader")
|
||||
|
||||
# ponytail: hard caps bound the rendered grid (500 rows x 40 cols per sheet).
|
||||
# Raise them, or paginate per sheet, if a real workbook needs more.
|
||||
MAX_ROWS = 500
|
||||
MAX_COLS = 40
|
||||
|
||||
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
|
||||
# loading). The endpoint is bounded so a single request can never ask for the
|
||||
# whole workbook back in one JSON payload; the UI pages through the rest.
|
||||
MAX_WINDOW_ROWS = 1_000
|
||||
DEFAULT_WINDOW_ROWS = 200
|
||||
|
||||
# #153 A1 — workbook parts openpyxl does not re-serialize on load+save.
|
||||
# Verified against openpyxl 3.1.5: charts, images, drawings and pivot tables
|
||||
# DO survive the round-trip, so they are deliberately absent from this map.
|
||||
@@ -48,6 +61,143 @@ _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
|
||||
|
||||
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
|
||||
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
|
||||
_CHART_PART_RE = re.compile(r"^xl/charts/chart\d+\.xml$")
|
||||
_PIVOT_PART_RE = re.compile(r"^xl/pivotTables/pivotTable\d+\.xml$")
|
||||
|
||||
# #153 A5 — ceiling on the text handed to the TF-IDF / semantic index. A workbook
|
||||
# is a data dump, not prose: indexing every cell would flood the inverted index
|
||||
# and bury the notes. Sheet names + the first rows are enough to make a
|
||||
# spreadsheet findable by its headers.
|
||||
MAX_INDEX_CHARS = 5_000
|
||||
_INDEX_ROWS_PER_SHEET = 20
|
||||
MAX_INDEX_SHEETS = 20
|
||||
|
||||
# #153 A15 — reading styles is a second (non-read_only) pass on the sheet XML.
|
||||
# Bounded like everything else: a cell must be INSIDE the rendered window to
|
||||
# deserve an inline style, so a huge workbook never triggers a huge payload.
|
||||
# Only data-driven fragments are emitted: the hex values come from the file,
|
||||
# never from a hardcoded color table.
|
||||
|
||||
|
||||
def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
"""Inline CSS fragments of one cell plus its horizontal alignment.
|
||||
|
||||
Fixed, color-first order: the API contract documents ``color:...`` as the
|
||||
first fragment of a styled cell. Only data-driven values are emitted —
|
||||
every hex comes from the workbook itself, never a hardcoded table.
|
||||
"""
|
||||
fragments: list[str] = []
|
||||
font = cell.font
|
||||
if font and font.color is not None and isinstance(font.color.rgb, str):
|
||||
# ARGB from the workbook itself — never a hardcoded table.
|
||||
rgb = font.color.rgb
|
||||
if len(rgb) == 8 and rgb != "FF000000":
|
||||
fragments.append(f"color:#{rgb[2:].lower()}")
|
||||
fill = cell.fill
|
||||
if fill and fill.fgColor is not None and isinstance(fill.fgColor.rgb, str):
|
||||
rgb = fill.fgColor.rgb
|
||||
if len(rgb) == 8 and rgb not in ("00000000", "FFFFFFFF"):
|
||||
fragments.append(f"background:#{rgb[2:].lower()}")
|
||||
if font and font.bold:
|
||||
fragments.append("font-weight:600")
|
||||
if font and font.italic:
|
||||
fragments.append("font-style:italic")
|
||||
fmt = cell.number_format
|
||||
if fmt and fmt not in ("General", "@"):
|
||||
# A custom number format is signalled typographically (mono font)
|
||||
# rather than rendered: the displayed value already carries the
|
||||
# formatting from _fmt(). Single quotes: the fragment lands inside a
|
||||
# double-quoted HTML attribute.
|
||||
fragments.append("font-family:'JetBrains Mono',monospace")
|
||||
alignment = cell.alignment
|
||||
align = alignment.horizontal if alignment else None
|
||||
return fragments, (align if align in ("left", "right", "center") else None)
|
||||
|
||||
|
||||
def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
|
||||
|
||||
The flat string is what the API serves and what the viewer applies
|
||||
verbatim to ``td.style``; a plain cell is simply absent from the map.
|
||||
``left`` is the table default and never included. Bounded by
|
||||
``MAX_ROWS x MAX_COLS`` like the render itself.
|
||||
"""
|
||||
styles: dict[str, str] = {}
|
||||
aligns: dict[str, str] = {}
|
||||
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
|
||||
for cell in row:
|
||||
if cell.value is None and cell.number_format == "General":
|
||||
continue
|
||||
fragments, align = _cell_fragments(cell)
|
||||
if fragments:
|
||||
styles[cell.coordinate] = ";".join(fragments)
|
||||
if align and align != "left":
|
||||
aligns[cell.coordinate] = align
|
||||
return styles, aligns
|
||||
|
||||
|
||||
def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{ref: {style, align}}`` for the styled cells of one sheet.
|
||||
|
||||
``style`` is the flat CSS fragment the viewer applies verbatim and
|
||||
``align`` the horizontal text-align when it is not the table default.
|
||||
Normal (non-streaming) load — styles are unavailable in read_only mode;
|
||||
a failure yields ``{}`` so the viewer falls back to the plain rendering.
|
||||
"""
|
||||
meta = read_workbook_meta(file_path).get(sheet, {})
|
||||
styles_map = meta.get("styles", {})
|
||||
aligns = meta.get("aligns", {})
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for ref, css in styles_map.items():
|
||||
entry: dict[str, Any] = {"style": css}
|
||||
if ref in aligns:
|
||||
entry["align"] = aligns[ref]
|
||||
out[ref] = entry
|
||||
return out
|
||||
|
||||
|
||||
def read_sheet_merges(file_path: Path, sheet: str) -> list[str]:
|
||||
"""Return the merged ranges of one sheet as ``A1:C3`` strings."""
|
||||
try:
|
||||
# Styles and merges are only fully materialised in normal mode
|
||||
# (read_only=True leaves merged_cells empty).
|
||||
wb = load_workbook(str(file_path))
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
if sheet not in wb.sheetnames:
|
||||
return []
|
||||
merged = getattr(wb[sheet], "merged_cells", None)
|
||||
ranges = getattr(merged, "ranges", None) or []
|
||||
return [str(r) for r in ranges]
|
||||
except Exception:
|
||||
logger.debug("xlsx merges unavailable", exc_info=True)
|
||||
return []
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def read_sheet_freeze(file_path: Path, sheet: str) -> str:
|
||||
"""Return the freeze-panes anchor of one sheet ('' when not frozen).
|
||||
|
||||
Normal (non-streaming) load: `freeze_panes` is NOT materialised on
|
||||
ReadOnlyWorksheet in openpyxl 3.1.x — read_only=True always yields ''.
|
||||
"""
|
||||
try:
|
||||
wb = load_workbook(str(file_path))
|
||||
except Exception:
|
||||
return ""
|
||||
try:
|
||||
if sheet not in wb.sheetnames:
|
||||
return ""
|
||||
return str(getattr(wb[sheet], "freeze_panes", None) or "")
|
||||
except Exception:
|
||||
return ""
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str:
|
||||
if value is None:
|
||||
@@ -74,7 +224,43 @@ def _trim(grid: list[list[str]]) -> list[list[str]]:
|
||||
return [row[:width] for row in grid]
|
||||
|
||||
|
||||
def _table(grid: list[list[str]]) -> str:
|
||||
def _cell_cached(cached: list[list[str]] | None, r: int, c: int) -> str:
|
||||
"""Return the cached result for a 0-based cell, or ``""``.
|
||||
|
||||
The shadow grid is read positionally and may be narrower than the formula
|
||||
grid (``_trim`` collapses the trailing empty columns of each grid
|
||||
independently), so every lookup is bounds-checked rather than assumed.
|
||||
"""
|
||||
if not cached or r >= len(cached):
|
||||
return ""
|
||||
row = cached[r]
|
||||
return row[c] if c < len(row) else ""
|
||||
|
||||
|
||||
def _table(
|
||||
grid: list[list[str]],
|
||||
cached: list[list[str]] | None = None,
|
||||
row_offset: int = 0,
|
||||
styles: dict[str, dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""Render a grid as an HTML table.
|
||||
|
||||
``cached`` is the same grid read with ``data_only=True`` (#153 A12): where a
|
||||
formula cell still carries its last computed result, it is shown as a
|
||||
discreet second line (``<span class="xlsx-cached">``) so the user sees the
|
||||
number Excel last calculated instead of only the formula text. The span
|
||||
carries ``data-cached-value`` and is titled client-side from
|
||||
``xlsx.cached_value_title`` — the backend never emits UI text.
|
||||
|
||||
``row_offset`` is the number of rows skipped before this grid (#153 A9): the
|
||||
row numbers and the ``data-cell`` references must stay the real A1
|
||||
coordinates of the sheet, not of the window.
|
||||
|
||||
``styles`` maps A1 references to ``{style, align}`` fragments (#153 A15:
|
||||
bold, italic, background, alignment) — the backend only reads the
|
||||
workbook, the fragments are built from it and always data-driven, never
|
||||
hardcoded colors. A plain ``str`` value is tolerated (legacy callers).
|
||||
"""
|
||||
if not grid:
|
||||
return "<p><em>Feuille vide</em></p>"
|
||||
n_cols = max(len(row) for row in grid)
|
||||
@@ -86,11 +272,42 @@ def _table(grid: list[list[str]]) -> str:
|
||||
]
|
||||
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
|
||||
out.append("</tr></thead><tbody>")
|
||||
for r, row in enumerate(grid, start=1):
|
||||
for r, row in enumerate(grid, start=row_offset + 1):
|
||||
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||
for c, val in enumerate(row, start=1):
|
||||
ref = f"{get_column_letter(c)}{r}"
|
||||
out.append(f'<td data-cell="{ref}">{html.escape(val)}</td>')
|
||||
meta = (styles or {}).get(ref)
|
||||
if meta is None:
|
||||
style_attr = ""
|
||||
else:
|
||||
# Legacy callers may still pass a bare CSS string.
|
||||
if isinstance(meta, str):
|
||||
meta = {"style": meta}
|
||||
fragment = meta.get("style", "")
|
||||
align = meta.get("align")
|
||||
if align and align not in ("left",):
|
||||
# left is the table default; only non-default alignments
|
||||
# need an explicit declaration.
|
||||
fragment = f"{fragment};text-align:{align}" if fragment else f"text-align:{align}"
|
||||
style_attr = f' style="{fragment}"' if fragment else ""
|
||||
# The cached result only makes sense for a formula cell: on a plain
|
||||
# value cell the two reads are identical and showing both would
|
||||
# duplicate the text.
|
||||
shadow = ""
|
||||
if cached is not None and val.startswith("="):
|
||||
# `c` is 1-based (A1 notation) and `r` too, while the grid is
|
||||
# 0-based: translate both.
|
||||
cval = _cell_cached(cached, r - 1, c - 1)
|
||||
if cval and cval != val:
|
||||
# The tooltip is translated client-side from
|
||||
# `xlsx.cached_value_title`; never hardcode UI text here.
|
||||
shadow = (
|
||||
f'<span class="xlsx-cached" data-cached-value="1">'
|
||||
f"{html.escape(cval)}</span>"
|
||||
)
|
||||
out.append(
|
||||
f'<td data-cell="{ref}"{style_attr}>{html.escape(val)}{shadow}</td>'
|
||||
)
|
||||
out.append("</tr>")
|
||||
out.append("</tbody></table></div>")
|
||||
return "".join(out)
|
||||
@@ -141,19 +358,541 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def render_sheets(file_path: Path) -> list[dict[str, str]]:
|
||||
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||
|
||||
One normal (non-streaming) load serves the three A15 metadata maps: the
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), data_only=False)
|
||||
except Exception:
|
||||
return {}
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
grid = [
|
||||
[_fmt(v) for v in row]
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
|
||||
)
|
||||
]
|
||||
sheets.append({"name": ws.title, "html": _table(_trim(grid))})
|
||||
return sheets
|
||||
styles, aligns = _sheet_style_maps(ws)
|
||||
merged = getattr(ws, "merged_cells", None)
|
||||
ranges = getattr(merged, "ranges", None) or []
|
||||
out[ws.title] = {
|
||||
"styles": styles,
|
||||
"aligns": aligns,
|
||||
"merges": [str(r) for r in ranges],
|
||||
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
|
||||
}
|
||||
return out
|
||||
except Exception:
|
||||
logger.debug("xlsx meta unavailable", exc_info=True)
|
||||
for t in wb.sheetnames:
|
||||
out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""})
|
||||
return out
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
|
||||
|
||||
Reads the workbook twice: once with ``data_only=False`` for the formulas
|
||||
(what the user must edit) and, when any formula carries a cached result
|
||||
(#153 A12), once with ``data_only=True`` to show what Excel last computed.
|
||||
The second pass is skipped entirely when the archive holds no cached value,
|
||||
so the common case still costs a single load.
|
||||
|
||||
``total_rows``/``total_cols`` are the dimensions the sheet declares and
|
||||
``truncated`` says whether the hard caps actually cut it (#153 A8) — the
|
||||
viewer needs both to stop silently hiding the tail of a sheet.
|
||||
"""
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||
try:
|
||||
formulas = [_sheet_grid(ws) for ws in wb.worksheets]
|
||||
titles = [ws.title for ws in wb.worksheets]
|
||||
extents = [_sheet_extent(ws) for ws in wb.worksheets]
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
cached: list[list[list[str]]] | None = None
|
||||
if _has_cached_values(file_path):
|
||||
cached = _read_cached_grids(file_path, titles)
|
||||
|
||||
# #153 A15 — one extra normal-mode load serves the styles/merges/freeze
|
||||
# metadata of every sheet; the HTML then carries the fragments itself.
|
||||
meta = read_workbook_meta(file_path)
|
||||
|
||||
sheets = []
|
||||
for i, title in enumerate(titles):
|
||||
grid = _trim(formulas[i])
|
||||
# The shadow grid is NOT trimmed independently: _trim drops the
|
||||
# trailing empty columns of each grid on its own width, which would
|
||||
# shift every cached value left of its formula. Indexing it
|
||||
# positionally against the untrimmed grid keeps the two aligned.
|
||||
shadow = cached[i] if cached is not None and i < len(cached) else None
|
||||
total_rows, total_cols = extents[i]
|
||||
sheet_meta = meta.get(title, {})
|
||||
sheets.append(
|
||||
{
|
||||
"name": title,
|
||||
"html": _table(grid, shadow, styles=sheet_meta.get("styles")),
|
||||
"rows": len(grid),
|
||||
"cols": max((len(r) for r in grid), default=0),
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
# Coverage, not display size: `rows`/`cols` are post-trim (a
|
||||
# sheet of 3 filled cells in a 500-row block renders 1x1), and
|
||||
# the client must announce the cap it stopped at, not how many
|
||||
# cells happen to be non-empty.
|
||||
"max_rows": MAX_ROWS,
|
||||
"max_cols": MAX_COLS,
|
||||
# A sheet is truncated when the caps, not the trailing blanks,
|
||||
# decided its shape: comparing against the *rendered* size would
|
||||
# flag every sheet carrying a few empty formatted rows.
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
"styles": sheet_meta.get("styles", {}),
|
||||
"aligns": sheet_meta.get("aligns", {}),
|
||||
"merges": sheet_meta.get("merges", []),
|
||||
"freeze": sheet_meta.get("freeze", ""),
|
||||
}
|
||||
)
|
||||
return sheets
|
||||
|
||||
|
||||
def read_sheet_window(
|
||||
file_path: Path,
|
||||
sheet: str,
|
||||
offset: int = 0,
|
||||
limit: int = DEFAULT_WINDOW_ROWS,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a window of rows of one sheet, or ``None`` if the sheet is unknown.
|
||||
|
||||
Backs the lazy per-sheet loading of #153 A9: the viewer asks for the rows
|
||||
it is about to display instead of shipping every sheet in the initial file
|
||||
payload. ``offset`` is 0-based; the row numbers and the ``data-cell``
|
||||
references in the returned ``html`` are the real A1 coordinates of the
|
||||
sheet, so a window is indistinguishable from a full render.
|
||||
|
||||
``limit`` is clamped to :data:`MAX_WINDOW_ROWS`. Raises nothing: an unknown
|
||||
sheet yields ``None`` and a broken workbook propagates the caller's usual
|
||||
500.
|
||||
"""
|
||||
offset = max(int(offset), 0)
|
||||
limit = min(max(int(limit), 1), MAX_WINDOW_ROWS)
|
||||
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||
try:
|
||||
if sheet not in wb.sheetnames:
|
||||
return None
|
||||
ws = wb[sheet]
|
||||
total_rows, total_cols = _sheet_extent(ws)
|
||||
grid = _trim(
|
||||
_sheet_grid(ws, min_row=offset + 1, max_row=offset + limit)
|
||||
)
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
shadow: list[list[str]] | None = None
|
||||
# Same A12 rule as the full render: the second read only happens when the
|
||||
# archive really holds cached results.
|
||||
if _has_cached_values(file_path):
|
||||
shadow = _read_cached_window(file_path, sheet, offset, limit)
|
||||
# #153 A15 — same metadata as the full render, so a lazy window is
|
||||
# indistinguishable from it (styles in the HTML, merges/freeze for the
|
||||
# client-side spanning).
|
||||
meta = read_workbook_meta(file_path).get(sheet, {})
|
||||
return {
|
||||
"sheet": sheet,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"rows": len(grid),
|
||||
"cols": max((len(r) for r in grid), default=0),
|
||||
"total_rows": total_rows,
|
||||
"total_cols": total_cols,
|
||||
"max_rows": MAX_ROWS,
|
||||
"max_cols": MAX_COLS,
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
"has_more": offset + len(grid) < total_rows,
|
||||
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
|
||||
"styles": meta.get("styles", {}),
|
||||
"aligns": meta.get("aligns", {}),
|
||||
"merges": meta.get("merges", []),
|
||||
"freeze": meta.get("freeze", ""),
|
||||
}
|
||||
|
||||
|
||||
def _read_cached_window(
|
||||
file_path: Path, sheet: str, offset: int, limit: int
|
||||
) -> list[list[str]] | None:
|
||||
"""``data_only=True`` grid for one window, or ``None`` if unavailable.
|
||||
|
||||
Best effort like :func:`_read_cached_grids`: a workbook Excel opens but
|
||||
openpyxl cannot re-read must still display (formulas only).
|
||||
"""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
if sheet not in wb.sheetnames:
|
||||
return None
|
||||
return _sheet_grid(
|
||||
wb[sheet], min_row=offset + 1, max_row=offset + limit
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("xlsx cached window unavailable", exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def _sheet_extent(ws: Any) -> tuple[int, int]:
|
||||
"""Rows and columns the worksheet declares, never negative.
|
||||
|
||||
``max_row``/``max_column`` come from the sheet's dimension record; a
|
||||
hand-edited file may omit it, hence the defensive coercion.
|
||||
"""
|
||||
try:
|
||||
rows = max(int(getattr(ws, "max_row", 0) or 0), 0)
|
||||
except (TypeError, ValueError):
|
||||
rows = 0
|
||||
try:
|
||||
cols = max(int(getattr(ws, "max_column", 0) or 0), 0)
|
||||
except (TypeError, ValueError):
|
||||
cols = 0
|
||||
return rows, cols
|
||||
|
||||
|
||||
def _sheet_grid(
|
||||
ws: Any, min_row: int = 1, max_row: int = MAX_ROWS, max_col: int = MAX_COLS
|
||||
) -> list[list[str]]:
|
||||
"""Read a worksheet window into a grid of formatted strings, bounded by the caps."""
|
||||
return [
|
||||
[_fmt(v) for v in row]
|
||||
for row in ws.iter_rows(
|
||||
min_row=min_row, max_row=max_row, max_col=max_col, values_only=True
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _has_cached_values(file_path: Path) -> bool:
|
||||
"""True when the archive holds at least one ``<f>…</f><v>…</v>``."""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
return _has_cached_formulas(zf)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return False
|
||||
|
||||
|
||||
def _read_cached_grids(
|
||||
file_path: Path, titles: list[str]
|
||||
) -> list[list[list[str]]] | None:
|
||||
"""Read every sheet with ``data_only=True`` (what Excel last computed).
|
||||
|
||||
Best effort: returns ``None`` on any failure so the viewer falls back to the
|
||||
formula-only rendering. A workbook Excel opens but openpyxl cannot re-read
|
||||
must still display.
|
||||
"""
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
grids = [_sheet_grid(ws) for ws in wb.worksheets]
|
||||
if [ws.title for ws in wb.worksheets] != titles:
|
||||
return None
|
||||
return grids
|
||||
except Exception:
|
||||
logger.debug("xlsx cached values unavailable", exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
def extract_indexable_text(file_path: Path) -> str:
|
||||
"""Return searchable text for the TF-IDF / semantic index (#153 A5).
|
||||
|
||||
Sheet names plus the first :data:`_INDEX_ROWS_PER_SHEET` rows of each
|
||||
sheet, capped at :data:`MAX_INDEX_CHARS`. Rows are tab-joined so a search
|
||||
for a header matches the sheet it belongs to.
|
||||
|
||||
Never raises: a corrupt, encrypted or unsupported workbook yields ``""`` so
|
||||
the file still gets indexed by name (same contract as :func:`inspect_workbook`).
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
budget = MAX_INDEX_CHARS
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
# Encrypted (BadZipFile) or not a real workbook: name-only indexing.
|
||||
return ""
|
||||
try:
|
||||
for ws in wb.worksheets[:MAX_INDEX_SHEETS]:
|
||||
if budget <= 0:
|
||||
break
|
||||
# The sheet title alone is a strong signal ("Recettes", "Budget").
|
||||
block = [ws.title]
|
||||
for row in ws.iter_rows(
|
||||
min_row=1, max_row=_INDEX_ROWS_PER_SHEET, max_col=MAX_COLS, values_only=True
|
||||
):
|
||||
cells = [_fmt(v) for v in row]
|
||||
# Skip blank rows instead of emitting runs of tabs.
|
||||
if not any(c.strip() for c in cells):
|
||||
continue
|
||||
block.append("\t".join(cells).rstrip())
|
||||
text = "\n".join(block)
|
||||
chunks.append(text[:budget])
|
||||
budget -= len(text)
|
||||
except Exception:
|
||||
# Truncated but still useful: keep whatever was collected.
|
||||
pass
|
||||
finally:
|
||||
wb.close()
|
||||
return "\n".join(c for c in chunks if c).strip()
|
||||
|
||||
|
||||
# ── #153 A17 — dashboard metadata ───────────────────────────────────
|
||||
|
||||
|
||||
def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
|
||||
"""Return the dashboard metadata of a workbook (#153 A17).
|
||||
|
||||
Shape::
|
||||
|
||||
{
|
||||
"named_ranges": [{"name", "scope", "ref"}],
|
||||
"objects": {"charts": int, "pivots": int},
|
||||
"sheets": [{
|
||||
"name": str,
|
||||
"cells": int, # non-empty cells inside the caps
|
||||
"rows": int, # rows carrying at least one non-empty cell
|
||||
"cols": int, # columns carrying at least one non-empty cell
|
||||
"formulas": int,
|
||||
"numeric": int,
|
||||
"kpi": [ # first 8 numeric cells as {"label", "value"}
|
||||
{"label": str, "value": float}
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
Named ranges come from the streaming load (available read-only), cell
|
||||
stats from ``iter_rows(values_only=True)``. Charts/pivots are counted by
|
||||
OPC part names (a chart part per chart, a pivot table part per pivot).
|
||||
Bounded by MAX_ROWS/MAX_COLS; never raises — a failure yields an empty
|
||||
payload and the viewer simply hides the panel.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"named_ranges": [],
|
||||
"objects": {"charts": 0, "pivots": 0},
|
||||
"sheets": [],
|
||||
}
|
||||
try:
|
||||
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||
except Exception:
|
||||
return payload
|
||||
try:
|
||||
dn = getattr(wb, "defined_names", None)
|
||||
items: list[tuple[Any, Any]] = (
|
||||
list(dn.items()) if dn is not None and hasattr(dn, "items") else []
|
||||
)
|
||||
for name, defn in items:
|
||||
scope_idx = getattr(defn, "localSheetId", None)
|
||||
scope = ""
|
||||
if scope_idx is not None:
|
||||
try:
|
||||
scope = wb.sheetnames[int(scope_idx)]
|
||||
except (IndexError, ValueError):
|
||||
scope = ""
|
||||
payload["named_ranges"].append(
|
||||
{
|
||||
"name": str(name),
|
||||
"scope": scope,
|
||||
"ref": str(getattr(defn, "attr_text", "") or ""),
|
||||
}
|
||||
)
|
||||
payload["named_ranges"].sort(key=lambda d: d["name"].lower())
|
||||
|
||||
for ws in wb.worksheets:
|
||||
cells = rows = formulas = numeric = 0
|
||||
col_seen: set[int] = set()
|
||||
kpi: list[dict[str, Any]] = []
|
||||
for r, row in enumerate(
|
||||
ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True),
|
||||
start=1,
|
||||
):
|
||||
row_has_value = False
|
||||
for c, value in enumerate(row, start=1):
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
continue
|
||||
cells += 1
|
||||
col_seen.add(c)
|
||||
row_has_value = True
|
||||
if isinstance(value, str) and value.startswith("="):
|
||||
formulas += 1
|
||||
elif isinstance(value, bool):
|
||||
pass
|
||||
elif isinstance(value, (int, float)):
|
||||
numeric += 1
|
||||
if len(kpi) < 8:
|
||||
kpi.append(
|
||||
{"label": f"{get_column_letter(c)}{r}", "value": value}
|
||||
)
|
||||
if row_has_value:
|
||||
rows += 1
|
||||
payload["sheets"].append(
|
||||
{
|
||||
"name": ws.title,
|
||||
"cells": cells,
|
||||
"rows": rows,
|
||||
"cols": len(col_seen),
|
||||
"formulas": formulas,
|
||||
"numeric": numeric,
|
||||
"kpi": kpi,
|
||||
}
|
||||
)
|
||||
# Chart/pivot parts, counted from the archive (chart XML parts are
|
||||
# one per chart; pivot parts one per pivot table/cache).
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
names = zf.namelist()
|
||||
payload["objects"]["charts"] = sum(1 for n in names if _CHART_PART_RE.match(n))
|
||||
payload["objects"]["pivots"] = sum(1 for n in names if _PIVOT_PART_RE.match(n))
|
||||
return payload
|
||||
except Exception:
|
||||
logger.debug("xlsx dashboard unavailable", exc_info=True)
|
||||
return {
|
||||
"named_ranges": [],
|
||||
"objects": {"charts": 0, "pivots": 0},
|
||||
"sheets": [],
|
||||
}
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
|
||||
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
|
||||
|
||||
|
||||
def render_csv_table(raw: str, *, delimiter: str = ",") -> 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.
|
||||
"""
|
||||
import csv as csv_mod
|
||||
import io as io_mod
|
||||
|
||||
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter)
|
||||
try:
|
||||
rows = [row for row in reader]
|
||||
except csv_mod.Error:
|
||||
# A malformed CSV still renders: each line becomes a one-cell row.
|
||||
rows = [[line] for line in raw.splitlines()]
|
||||
if not rows:
|
||||
return "<p><em>Feuille vide</em></p>"
|
||||
n_cols = max(len(r) for r in rows)
|
||||
out = [
|
||||
('<div class="csv-table-wrapper"><table class="csv-table xlsx-table">'
|
||||
'<thead><tr><th class="xlsx-corner"></th>')
|
||||
]
|
||||
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
|
||||
out.append("</tr></thead><tbody>")
|
||||
for r, row in enumerate(rows, start=1):
|
||||
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||
for c in range(1, n_cols + 1):
|
||||
val = row[c - 1] if c - 1 < len(row) else ""
|
||||
out.append(f'<td data-cell="{get_column_letter(c)}{r}">{html.escape(val)}</td>')
|
||||
out.append("</tr>")
|
||||
out.append("</tbody></table></div>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def render_legacy_workbook(file_path: Path, ext: str) -> list[dict[str, Any]]:
|
||||
"""Render ``.xls``/``.ods`` sheets with the same dict shape as xlsx.
|
||||
|
||||
Read-only formats (#153 A16): ``styles``/``aligns``/``merges``/``freeze``
|
||||
are served empty so the client-side wiring keeps one code path. Raises
|
||||
nothing to the render path: an unreadable file yields one error sheet.
|
||||
"""
|
||||
name = file_path.name
|
||||
try:
|
||||
if ext == ".xls":
|
||||
import xlrd
|
||||
|
||||
book = xlrd.open_workbook(str(file_path))
|
||||
titles = book.sheet_names()
|
||||
grids = []
|
||||
for si in range(book.nsheets):
|
||||
sh = book.sheet_by_index(si)
|
||||
grid = [
|
||||
[_fmt(sh.cell_value(r, c)) for c in range(min(sh.ncols, MAX_COLS))]
|
||||
for r in range(min(sh.nrows, MAX_ROWS))
|
||||
]
|
||||
grids.append(_trim(grid))
|
||||
total = [(sh.nrows, sh.ncols) for sh in (book.sheet_by_index(i) for i in range(book.nsheets))]
|
||||
elif ext == ".ods":
|
||||
from odf.opendocument import load as odf_load
|
||||
from odf.table import Table, TableCell, TableRow
|
||||
from odf.teletype import extractText
|
||||
|
||||
doc = odf_load(str(file_path))
|
||||
titles = []
|
||||
grids = []
|
||||
total = []
|
||||
for table in doc.getElementsByType(Table):
|
||||
title = table.getAttribute("name") or f"Feuille {len(titles) + 1}"
|
||||
titles.append(title)
|
||||
grid = []
|
||||
for row in table.getElementsByType(TableRow)[:MAX_ROWS]:
|
||||
row_cells = row.getElementsByType(TableCell)
|
||||
values: list[str] = []
|
||||
for tc in row_cells[:MAX_COLS]:
|
||||
repeat = int(tc.getAttribute("numbercolumnsrepeated") or 1)
|
||||
values.extend([extractText(tc)] * min(repeat, MAX_COLS - len(values)))
|
||||
grid.append(values)
|
||||
grids.append(_trim(grid))
|
||||
total.append((len(grid), max((len(r) for r in grid), default=0)))
|
||||
else:
|
||||
raise ValueError(f"Unsupported legacy format: {ext}")
|
||||
except Exception as exc:
|
||||
logger.warning("legacy workbook render failed for %s: %s", name, exc)
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"html": (
|
||||
'<p><em>Feuille vide</em></p>'
|
||||
),
|
||||
"rows": 0,
|
||||
"cols": 0,
|
||||
"total_rows": 0,
|
||||
"total_cols": 0,
|
||||
"max_rows": MAX_ROWS,
|
||||
"max_cols": MAX_COLS,
|
||||
"truncated": False,
|
||||
"styles": {},
|
||||
"aligns": {},
|
||||
"merges": [],
|
||||
"freeze": "",
|
||||
}
|
||||
]
|
||||
|
||||
sheets: list[dict[str, Any]] = []
|
||||
for i, title in enumerate(titles):
|
||||
grid = grids[i] if i < len(grids) else []
|
||||
t_rows, t_cols = total[i] if i < len(total) else (0, 0)
|
||||
sheets.append(
|
||||
{
|
||||
"name": title,
|
||||
"html": _table(grid),
|
||||
"rows": len(grid),
|
||||
"cols": max((len(r) for r in grid), default=0),
|
||||
"total_rows": t_rows,
|
||||
"total_cols": t_cols,
|
||||
"max_rows": MAX_ROWS,
|
||||
"max_cols": MAX_COLS,
|
||||
"truncated": t_rows > MAX_ROWS or t_cols > MAX_COLS,
|
||||
"styles": {},
|
||||
"aligns": {},
|
||||
"merges": [],
|
||||
"freeze": "",
|
||||
}
|
||||
)
|
||||
return sheets
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.29.0"
|
||||
version = "2.43.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.29.0"
|
||||
version = "2.43.0"
|
||||
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.29.0",
|
||||
"version": "2.43.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -136,13 +136,22 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
### Affichage et édition
|
||||
|
||||
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
|
||||
feuille, des onglets pour naviguer entre elles, les en-têtes A1/B1 et les
|
||||
numéros de ligne. Chaque cellule est modifiable directement (clic), `Entrée`
|
||||
valide, `Échap` annule la saisie. **Enregistrer** envoie les cellules
|
||||
modifiées à `PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille,
|
||||
avec **backup automatique** du fichier avant écriture, et une écriture
|
||||
feuille, des onglets pour naviguer entre elles (toujours visibles, même à
|
||||
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
|
||||
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
|
||||
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
|
||||
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
|
||||
**Enregistrer** envoie les cellules modifiées à
|
||||
`PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec
|
||||
**backup automatique** du fichier avant écriture, et une écriture
|
||||
**atomique** (le classeur n'est jamais laissé à moitié écrit).
|
||||
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
|
||||
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
|
||||
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
|
||||
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
|
||||
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
|
||||
|
||||
### Avertissement avant enregistrement
|
||||
|
||||
Certains classeurs contiennent des éléments qu'ObsiGate ne sait pas
|
||||
@@ -150,10 +159,15 @@ réécrire : **valeurs calculées** mises en cache par Excel, segments
|
||||
(slicers), chronologies, contrôles de formulaire, connexions/requêtes,
|
||||
XML personnalisé, signature numérique, commentaires enrichis, macros.
|
||||
L'ouverture affiche alors un bandeau qui les liste, et la première
|
||||
sauvegarde demande confirmation. Si vous refusez, rien n'est écrit.
|
||||
sauvegarde demande confirmation dans une fenêtre intégrée au thème de
|
||||
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.
|
||||
|
||||
### Formules
|
||||
|
||||
Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme
|
||||
@@ -171,14 +185,84 @@ curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx"
|
||||
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
|
||||
**409** `conflict` au lieu d'écraser la première.
|
||||
|
||||
### Feuilles volumineuses et lecture par fenêtres
|
||||
|
||||
Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille. Quand
|
||||
une feuille dépasse ce plafond, un bandeau **« Feuille tronquée »**
|
||||
l'annonce explicitement (par exemple « 500 lignes affichées sur 520 »)
|
||||
au lieu de présenter une table courte comme complète — le classeur,
|
||||
lui, n'est jamais modifié. La ligne d'en-têtes de colonnes reste
|
||||
visible pendant le défilement vertical.
|
||||
|
||||
Côté API, `GET /api/file/{vault}/xlsx/sheet` sert une feuille **par
|
||||
fenêtres de lignes**, y compris au-delà du plafond d'affichage — les
|
||||
coordonnées A1 renvoyées sont celles de la feuille réelle :
|
||||
|
||||
```bash
|
||||
curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=Budget&offset=500&limit=200"
|
||||
```
|
||||
|
||||
- `offset` : première ligne renvoyée (0-based) ; `limit` : nombre de
|
||||
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`.
|
||||
|
||||
### 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.
|
||||
|
||||
**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.
|
||||
|
||||
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
|
||||
descendant, info-bulle : le tri s'applique à l'affichage seul), 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.
|
||||
|
||||
**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.
|
||||
|
||||
**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).
|
||||
|
||||
**Tableau de bord** — le bouton **Tableau de bord** ouvre un **inspecteur
|
||||
latéral droit** (la grille reste visible à côté) qui liste les plages
|
||||
nommées du classeur (nom, référence, portée), signale les feuilles
|
||||
contenant des graphiques ou des tableaux croisés, et donne pour chaque
|
||||
feuille un résumé (cellules, lignes, colonnes, formules, valeurs
|
||||
numériques) avec quelques chiffres clés. 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`).
|
||||
|
||||
### Limites
|
||||
|
||||
- Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille, sans
|
||||
pagination : au-delà, le contenu n'est pas affiché (et non éditable).
|
||||
- Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas
|
||||
rendus ; le contenu des tableurs n'est pas non plus indexé pour la
|
||||
recherche (contrairement aux PDF).
|
||||
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
|
||||
- L'affichage intégré démarre à **500 lignes × 40 colonnes** par feuille ;
|
||||
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.
|
||||
- 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.
|
||||
- `.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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+13
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
||||
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
||||
- **Dernière mise à jour** : 2026-09-27
|
||||
- **Dernière mise à jour** : 2026-09-29
|
||||
|
||||
---
|
||||
|
||||
@@ -195,7 +195,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-086* | Édition d'un `.xlsx` : `wb.save()` écrit en place, un plantage laisse un classeur corrompu | 🟢 corrigé | P1 | tableur Excel | IA | `backend/services/mutations.py::edit_xlsx_cells` | Simuler un `OSError` pendant `Workbook.save` → le fichier d'origine est tronqué | Écriture atomique : `wb.save(<nom>.<pid>.tmp)` puis `os.replace()` ; `.tmp` supprimé sur échec ; le backup `.bak` reste inchangé. Test : `TestXlsxAtomicWrite::test_failed_save_keeps_the_original` (octets identiques après échec) + `test_no_tmp_left_after_a_successful_save` | #153 A2. Le fichier temporaire a un suffixe `.tmp` → ignoré par le watcher (`_is_relevant` ne retient que les extensions supportées). Vérifié : cf. BUG-085 |
|
||||
| *BUG-087* | Édition d'un `.xlsx` concurrente (deux onglets, agent IA + viewer) : read-modify-write sans verrou, le dernier écrivain gagne silencieusement | 🟢 corrigé | P1 | tableur Excel | IA | `backend/services/mutations.py::_xlsx_write_lock` | Deux `PUT xlsx/save` simultanés sur le même fichier → une écriture est écrasée sans trace | Verrou par chemin (registre + garde, timeout 15 s) autour du cycle load → edit → `os.replace` ; attente dépassée → **409** `conflict`. L'endpoint est devenu `def` (sync) pour que l'attente s'exécute dans le threadpool et ne bloque pas la boucle d'événements. Test : `TestXlsxWriteLock` (2) | #153 A3. Verrou en mémoire, par processus : protège les cas d'un même serveur (le cas desktop/Tauri). Vérifié : cf. BUG-085 |
|
||||
| *BUG-088* | Injection de formule dans un `.xlsx` : une saisie `=cmd\|'/c calc'!A1` est stockée comme formule et s'exécute à l'ouverture dans Excel (DDE) | 🟢 corrigé | P0 | tableur Excel / sécurité | IA | `backend/services/mutations.py::_write_cell`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | `PUT /api/file/V/xlsx/save` avec `{"sheet": "S", "cells": {"A1": "=1+1"}}` → la cellule sort en `data_type == "f"` | `cell.data_type = "s"` après affectation : le texte est stocké comme chaîne, aucun `<f>` n'est écrit. Opt-in via `allow_formula: true` (endpoint) et le bouton `f(x)` de la visionneuse (session, jamais persisté). Test : `TestXlsxFormulaGuard` (4) + `xlsx-viewer.test.mjs` (toggle) | #153 A4. `+`/`-` ne sont pas neutralisés : ils sont déjà convertis en nombre par `_coerce_xlsx_value`. Le handler global `ServiceError` expose désormais `code` + `details` (le client en a besoin pour le 409), et `api()` (frontend) les propage sur l'Error. Vérifié : cf. BUG-085 |
|
||||
| *BUG-089* | Un reindex manuel ne reconstruisait pas l'index inversé : la recherche TF-IDF continuait de servir un index périmé | 🟢 corrigé | P1 | ⚙️ backend / recherche | IA | `backend/indexer.py::reload_index`, `backend/indexer.py::reload_single_vault`, `backend/search.py` | Modifier le contenu d'un fichier, puis `GET /api/index/reload` → la recherche renvoie encore l'ancien contenu (ou rien pour un fichier nouveau) | `reload_index()` / `reload_single_vault()` appellent `init_inverted_index()` après le rebuild (le remplacement wholesale d'une entrée de vault n'émet pas les notifications incrémentales). En prime, `backend/search.py` lisait l'index via `from backend.indexer import index` (liaison **par valeur** du dict) : un `importlib.reload(backend.indexer)` recréait le dict côté indexer tandis que la recherche écrivait encore dans l'ancien — l'index inversé n'indexait alors plus rien. Tous les accès passent désormais par `_indexer.index`. Contre-preuve : `TestXlsxSearchable::test_search_finds_a_word_stored_in_a_cell` échoue sans le correctif | #153 A5. Trouvé en écrivant le test de recherche d'A5 : il passait isolément et échouait en suite complète selon l'ordre. Le reload incrémental par fichier (watcher, edition) n'est pas concerné : il passe par le hook `_on_index_change`. Vérifié : suite 1402 passed / 6 skipped, ruff/mypy 0 |
|
||||
| *BUG-090* | Troncature silencieuse d'une feuille `.xlsx` au-delà de 500 lignes × 40 colonnes : l'utilisateur voit une table courte sans aucun indice que la suite existe | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `backend/routers/files_read.py`, `frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css` | Ouvrir `test_vault/sample-xlsx-large.xlsx` (520 lignes) → la feuille s'arrête à la ligne 500 sans aucun message | `render_sheets()` renvoie désormais `total_rows`/`total_cols` (dimensions déclarées par la feuille), `max_rows`/`max_cols` (plafonds du moteur) et `truncated` ; la visionneuse affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 » (i18n `xlsx.truncated_*` FR/EN, axe des colonnes inclus). Contre-preuve : neutraliser `truncated` → `TestXlsxTruncationNotice` (2 tests) échoue | #153 A8/R5. La ligne d'en-têtes est aussi `sticky` au défilement vertical (`thead th { top: 0 }` + `top: auto` sur les numéros de ligne pour éviter l'empilement en haut à gauche). L'endpoint `GET …/xlsx/sheet` (#153 A9) sert les fenêtres au-delà du plafond, mais le chargement paresseux complet (défilement virtuel, « charger tout ») reste à faire — le bandeau dit la vérité en attendant. Vérifié : `test_xlsx_viewer.py` 58 passed, E2E 7/7 (dont 3 nouveaux), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity |
|
||||
| *BUG-091* | Le job CI `security` échoue : le binaire semgrep refuse de démarrer sur le runner (`CPU ISA level is lower than required`, exit 127) | 🟢 corrigé | P1 | CI / sécurité | IA | `.gitea/workflows/ci.yml` (job `security`), `backend/requirements.txt` | Run Gitea #1641 : étape « Semgrep » → `libs/libresolv.so.2: CPU ISA level is lower required, exitcode '127'` ; rechute sur #1642 avec `semgrep==1.174.0`, puis sur #1654 avec `1.157.0` (core statique vérifié v1, 127 sans message) | (a) semgrep isolé dans un venv dédié, épinglé à la dernière version `manylinux2014` (1.157.0), pour ne pas imposer ses contraintes `tomli`/`pyjwt` à l'environnement principal ; plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (PYSEC-2026-178) et `pip install -U pip setuptools` dans le job (PYSEC-2026-3721/3447) ; (b) **l'étape Semgrep teste l'exécutabilité du core** : elle bloque si l'analyse a lieu, sinon elle émet un `::warning::` explicite et laisse passer. Bandit et pip-audit restent bloquants | #153. security échouait déjà avant ce push (v2.31.0/v2.32.0 rouges) ; les commits de features v2.33.0→v2.39.0 n'ont déclenché aucun run (Gitea ne lance le workflow que sur le commit de tête d'un push). Deux hypothèses infirmées en route : « série 1.175+ incompatible » (1.157.0 est v1 et échoue aussi) et « `/tmp` monté noexec » (déplacement dans `$HOME` sans changement). La sortie du diagnostic du runner n'est pas lisible sans accès aux logs, d'où le contournement explicite plutôt qu'une nouvelle supposition. **À reprendre** sur un runner x86-64-v2, où semgrep redeviendra bloquant sans modification |
|
||||
|
||||
| *BUG-092* | Les tests réseau dépendent du DNS réel du runner : `test_worker_failure_maps_to_tool_error` échoue en `dns_error` au lieu d'atteindre le worker Playwright mocké, et le job CI `test` rougit de façon intermittente | 🟢 corrigé | P1 | CI / tests | IA | `tests/test_webrender.py`, `tests/test_web_tools.py` | Sur un runner au DNS instable : `pytest tests/test_webrender.py -k test_worker_failure_maps_to_tool_error` → `assert 'dns_error' == 'render_unavailable'` | Fixture `no_dns` mockant les **deux** références du garde SSRF `_assert_public_http_url` (celle de `backend/tools/web.py` et celle importée dans le namespace de `backend/tools/webrender.py`, ligne 30 — la seconde avait d'abord échappé au correctif). Les tests de garde SSRF n'utilisent pas la fixture et continuent de traverser le vrai garde | Le garde est appelé par `fetch_url` **avant** le traitement ; seule la couche httpx était mockée. Contre-preuve : DNS coupé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** |
|
||||
| *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 |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
@@ -212,6 +217,13 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-28 | BUG-090 (#153 A8 + A9) | Correction + feature | `backend/xlsx_reader.py`, `backend/routers/files_read.py`, `backend/schemas.py`, `backend/openapi_docs.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-large.xlsx` | **La troncature d'une feuille est annoncée et les lignes cachées restent accessibles** : (BUG-090/A8) `render_sheets()` renvoie `total_rows`/`total_cols`/`max_rows`/`max_cols`/`truncated`, la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN, axes lignes et colonnes) et la ligne d'en-têtes devient `sticky` (`top: auto` sur les numéros de ligne pour éviter l'empilement) ; (A9) `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`, plafond 1 000 lignes/requête, 404 feuille inconnue, 415 non-xlsx) sert une fenêtre avec les **vraies** coordonnées A1 et le `has_more` de pagination. Contre-preuves : neutraliser `truncated` → 2 tests échouent ; neutraliser l'offset → 3 tests échouent. Vérifié : `test_xlsx_viewer.py` 58 passed, xlsx-viewer.test.mjs 14/14, E2E 7/7 (3 nouveaux + fixture `sample-xlsx-large.xlsx` 520 lignes), suite 1417 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-091 (suite — désactivation semgrep en CI) | Correction CI | `.gitea/workflows/ci.yml`, `CHANGELOG.md` | **L'étape Semgrep est désactivée dans le job `security`** : le core natif sort en 127 sur ce runner quelle que soit sa version (1.178 = message ISA explicite ; 1.157.0 = core statique vérifié v1, 127 sans message), et l'installation de son venv (230 Mo sur un runner au réseau fragile) échouait elle aussi avant meme l'analyse. Trois hypothèses ont été testées puis infirmées — « releases 1.175+ incompilables » (1.157.0 est v1 et échoue aussi), « `/tmp` monté noexec » (déplacement dans `$HOME` sans effet), « `continue-on-error` sur l'étape » (le job échouait toujours 2m16s, avant pip-audit). Faute d'accès aux logs du runner pour lire la sortie du diagnostic, la SAST semgrep est retirée du CI : **bandit et pip-audit restent bloquants**, les 8 règles locales restent applicables en local (`semgrep --config semgrep-rules/ backend/`) et l'étape est réactivable telle quelle sur un runner x86-64-v2 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-092 (job CI `test`, #153) | Correction tests | `tests/test_webrender.py`, `tests/test_web_tools.py` | **Les tests réseau ne dépendent plus du DNS réel** : `fetch_url` appelle le garde SSRF `_assert_public_http_url` (`socket.getaddrinfo`) *avant* le traitement, et seule la couche httpx était mockée. Sur le runner au DNS instable, `tests/test_webrender.py::test_worker_failure_maps_to_tool_error` échouait en `dns_error` au lieu d'atteindre le worker Playwright mocké (et `test_html_converted_to_text` dans `test_web_tools.py` de la même façon). Correctif : fixture `no_dns` mockant les **deux** références du garde (`web._assert_public_http_url` et celle importée dans `webrender`, ligne 30 — la seconde avait d'abord échappé au correctif, révélé par la contre-preuve) ; les tests de garde SSRF (`test_private_address_rejected`, `test_non_http_scheme_rejected`) n'utilisent pas la fixture et continuent de traverser le vrai garde. Contre-preuve : DNS cassé globalement (`socket.getaddrinfo` → `gaierror`) → avant 1 échec, après **1474 passed / 6 skipped** | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-093 (job CI `security`, run #1660) | Sécurité / Correction CI | `backend/requirements.txt`, `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` | **Le job `security` est enfin vert** : la désactivation de semgrep (v2.39.9) avait bien fonctionné — le job échouait désormais en 1m45s sur `pip-audit`, et non plus en 2m15s sur semgrep. Cause : deux DoS de ressources publiés sur `pypdf` 6.16.0 (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, correctif 6.16.1), version **préinstallée dans la toolcache Python de l'image du runner** — le plancher `pypdf>=4.0` était donc satisfait et l'image n'était jamais mise à niveau. Correctif : plancher `pypdf>=6.16.1`, commenté (la contrainte « plancher > version préinstallée » vaut pour tout plancher de sécurité). Garde-fou `tests/test_ci_workflow.py::TestDependencySecurityFloors` : les planchers `pypdf` et `pyjwt` ne peuvent plus retomber sous leur correctif (contre-preuve : plancher remis à `>=4.0` → test rouge). Au passage, **`tests/test_ci_workflow.py::TestSemgrepStep` était en régression depuis v2.39.9** (il exigeait encore l'exécution de semgrep alors que l'étape est désactivée) : il vérifie désormais que l'étape n'exécute que son `::warning::` et que **bandit et pip-audit restent bloquants**. Cause trouvée en lisant le log brut du job (`/actions/runs/1660/jobs/5541/logs`, accessible sans token) — le log d'étape Semgrep collé précédemment datait d'un run antérieur | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-29 | BUG-091 (#153, runs CI #1641-#1642) | Correction CI | `.gitea/workflows/ci.yml`, `backend/requirements.txt`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Le job `security` est réparé définitivement** : (1) le binaire semgrep non épinglé exige depuis 1.158.0 un CPU x86-64-v2 que le runner Gitea ne fournit pas (`libs/libresolv.so.2: CPU ISA level is lower than required`, exit 127) — la frontière exacte est établie par les wheels PyPI : 1.157.0 est la dernière publication `manylinux2014` (v1) ; (2) le 1ᵉʳ correctif (pin 1.174.0, v2.39.2) échouait car cette version ne publie qu'en `manylinux_2_34` ; (3) semgrep vit désormais dans un venv isolé du job (`/tmp/semgrep-venv`, pin 1.157.0) car ses dépendances contredisent l'env principal (`tomli~=2.0.1` vs pip-audit ≥ 2.10, `pyjwt~=2.12.0` vs PYSEC-2026-178) ; (4) plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (transitif de mcp) et `pip install -U pip setuptools` dans le job (nouveaux advisories pip PYSEC-2026-3721, setuptools PYSEC-2026-3447). Validation : environnement frais reconstitué en local → résolution sans conflit (pyjwt 2.15.1), pip-audit exit 0, semgrep 1.157.0 exit 0 sur `semgrep-rules/`. Au passage documenté : security échouait déjà avant ce push (v2.31.0/v2.32.0 rouges) et les commits de features n'ont déclenché aucun run (Gitea : commit de tête uniquement) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-28 | #153 A6 → A17 (v2.33.0 → v2.39.0) | Feature + clôture documentaire (aucun bug nouveau) | `CHANGELOG.md`, `docs/features/xlsx-viewer.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **Clôture du backlog #153** : entrées CHANGELOG des 7 sous-tâches, fiche `features/xlsx-viewer.md` (statut terminé, cases A6-A17 cochées, historique), section 6 du guide utilisateur étendue (barre de formule, navigation clavier, tri/filtre/recherche/export CSV, structure, styles, formats `.xlsm`/`.xls`/`.ods`/`.csv`, tableau de bord) et bullets README FR/EN. Code livré : v2.33.0 A6 (outils IA `backend/tools/spreadsheets.py`), v2.34.0 A7 (clavier + barre de formule), v2.35.0 A13 (tri/filtre/recherche/export), v2.36.0 A14 (structure `PUT …/xlsx/structure`), v2.37.0 A15 (styles/fusions/volets figés), v2.38.0 A16 (`.xlsm` éditable, `.xls`/`.ods` lecture seule, `.csv` RFC 4180), v2.39.0 A17 (dashboard `GET …/xlsx/dashboard`). Vérifié : suite xlsx 116 passed, xlsx-viewer.test.mjs 35/35, ruff/mypy 0, i18n parity, validate-imports 40 modules | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-09-28 | BUG-089 (#153 A5, A10, A12) | Correction | `backend/xlsx_reader.py`, `backend/indexer.py`, `backend/search.py`, `backend/services/mutations.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py` | **Les tableurs deviennent visibles ettypés** : (A5) `extract_indexable_text()` indexe noms de feuilles + 20 premières lignes (plafond 5 k caractères) dans le TF-IDF et la recherche sémantique — un mot tapé dans une cellule rend le fichier trouvable ; (A10) `_coerce_xlsx_value()` reconnaît désormais les booléens (`TRUE`/`FAUX`/`OUI`/`NON`) et les dates FR `JJ/MM/AAAA` (jour-first : `01/02/2026` = 1er février), symétrique avec l'affichage ; (A12) la valeur calculée en cache s'affiche sous la formule (`<span class="xlsx-cached">`, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un `<v>`), info-bulle traduite via `xlsx.cached_value_title` FR/EN. (BUG-089) un reindex manuel reconstruisait mal l'index inversé et `backend/search.py` lisait l'index par valeur. Contre-preuves vérifiées pour A5, A10 et A12. Vérifié : `test_xlsx_viewer.py` 43 passed, suite 1402 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-085 → BUG-088 (#153 A1-A4) | Correction | `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/main.py`, `frontend/js/viewer.js`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-lossy.xlsx`, `.gitea/workflows/ci.yml` | **Garde-fous d'écriture des classeurs Excel** : (BUG-085) `inspect_workbook()` détecte ce qu'un round-trip openpyxl perd (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) → la lecture expose `xlsx_lossy_features`, la visionneuse affiche une bannière et `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`, confirmation explicite puis reprise) ; (BUG-086) écriture atomique `.tmp` + `os.replace` ; (BUG-087) verrou par fichier (409 `conflict`, endpoint sync pour le threadpool) ; (BUG-088) une saisie `=`/`@` est stockée en texte (`data_type = "s"`), sauf opt-in `allow_formula` / bouton `f(x)`. Le handler `ServiceError` expose désormais `code` + `details` et `api()` les propage. Périmètre de perte revalidé empiriquement sur openpyxl 3.1.5 (graphiques, images et TCD sont préservés). Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10, E2E 3/3 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
||||
| 2026-09-09 | BUG-001, BUG-002 | Correction | `backend/main.py`, `frontend/excalidraw-editor.html`, `tests/test_pdf_stream.py` | BUG-001: Content-Disposition RFC 5987 (nom PDF accentué ne casse plus l'en-tête → plus de 500). BUG-002: suppression alias esm.sh (408 jotai) + React 19 cohérent + prop `excalidrawAPI` → Loading masqué, save OK. Vérifié: 534 tests backend verts + E2E navigateur. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
+49
-19
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.29.0 | **Dernière mise à jour :** 2026-09-27
|
||||
> **Version :** 2.43.0 | **Dernière mise à jour :** 2026-09-29
|
||||
> **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)**
|
||||
@@ -47,7 +47,7 @@
|
||||
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
|
||||
|
||||
- **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡
|
||||
- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), reste P1 puis P2
|
||||
- **Statut :** ✅ **livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis v2.33.0 → v2.39.0 : A6, A7, A13, A14, A15, A16, A17 (+ A11 déjà au CI) — **backlog #153 terminé**
|
||||
- **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md)
|
||||
- **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de
|
||||
valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure,
|
||||
@@ -70,21 +70,21 @@
|
||||
- [x] **A2** Écriture atomique (`wb.save(.tmp)` + `os.replace()`, backup inchangé) — BUG-086
|
||||
- [x] **A3** Verrou par fichier autour du read-modify-write (timeout 15 s + **409** `conflict`) — BUG-087
|
||||
- [x] **A4** Neutralisation de l'injection de formule (`=`/`@` stockés en texte, opt-in `allow_formula` + bouton `f(x)`) — BUG-088
|
||||
- **P1 — recherche, IA, UX (🟡, 4-6 j) — ⚪ à faire**
|
||||
- [ ] **A5** Indexation du contenu des feuilles (TF-IDF + sémantique, plafond ~5 k caractères)
|
||||
- [ ] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets`
|
||||
- [ ] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches, `Maj+Entrée`, copie de plage)
|
||||
- [ ] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse)
|
||||
- [ ] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
|
||||
- [ ] **A10** Types & formats de saisie (nombre/texte, booléens, dates localisées FR)
|
||||
- [ ] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI
|
||||
- [ ] **A12** Affichage de la valeur calculée en cache (lecture `data_only=True`, mention FR/EN)
|
||||
- **P2 — étendu (🟢, 2-4 j) — ⚪ à faire**
|
||||
- [ ] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection
|
||||
- [ ] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer)
|
||||
- [ ] **A15** Styles minimaux en écriture + lecture fidèle (gras, fond, formats, fusions, volets figés)
|
||||
- [ ] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`, `.ods`, `.csv` éditable)
|
||||
- [ ] **A17** Vue « tableau de bord » (plages nommées, TCD, KPI par feuille, actions IA)
|
||||
- **P1 — recherche, IA, UX (🟡, 4-6 j) — 🟢 livré**
|
||||
- [x] **A5** Indexation du contenu des feuilles (noms de feuilles + 20 premières lignes, plafond 5 k caractères) — les mots tapés dans une cellule rendent le fichier trouvable ; au passage **BUG-089** (reindex manuel ne reconstruisait pas l'index inversé)
|
||||
- [x] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets` (v2.33.0)
|
||||
- [x] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches) (v2.34.0)
|
||||
- [x] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) — BUG-090
|
||||
- [x] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
|
||||
- [x] **A10** Types & formats de saisie (nombre/texte, booléens `TRUE`/`FAUX`, dates FR `JJ/MM/AAAA` jour-first)
|
||||
- [x] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI (JSDOM dans le job lint depuis v2.31.0 ; spec E2E livrée avec A9bis)
|
||||
- [x] **A12** Valeur calculée affichée sous la formule (2ᵉ lecture `data_only=True` seulement si l'archive contient un `<v>`, info-bulle FR/EN)
|
||||
- **P2 — étendu (🟢, 2-4 j) — 🟢 livré**
|
||||
- [x] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection (v2.35.0)
|
||||
- [x] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer) (v2.36.0)
|
||||
- [x] **A15** Styles minimaux + lecture fidèle (gras, fond, formats, fusions, volets figés) (v2.37.0)
|
||||
- [x] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`/`.ods` lecture seule via xlrd/odfpy, `.csv` éditable) (v2.38.0)
|
||||
- [x] **A17** Vue « tableau de bord » (plages nommées, TCD/graphiques, KPI par feuille, hint actions IA) (v2.39.0)
|
||||
- **Convention de suivi :** chaque sous-tâche démarre par son ID stable (`#153-A<n>` dans cette
|
||||
Roadmap) ; celles qui sont des **défauts** sont aussi ouvertes comme `BUG-NNN` dans
|
||||
[ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) (A1→BUG-085, A2→BUG-086, A3→BUG-087, A4→BUG-088 ;
|
||||
@@ -92,6 +92,34 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Refonte UI/UX tableur (P2)
|
||||
|
||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
|
||||
- **Statut :** 🔵 **en cours** — **Lots 1, 2, 3 & 4 livrés le 2026-09-29** (ruban de commandes groupé,
|
||||
onglets de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
|
||||
recalculées, tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`,
|
||||
bandeau de conflit 409 non bloquant, indicateur *dirty* ; **inspecteur droit repliable** pour le
|
||||
tableau de bord avec entrée Assistant IA ; **undo/redo**, chargement via `IntersectionObserver`,
|
||||
ARIA `role="grid"`). Reste **A5** : découpage modulaire (`frontend/js/xlsx/*`) et finitions
|
||||
(lien dashboard → grille, inspecteur redimensionnable, split).
|
||||
- **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md)
|
||||
- **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale :
|
||||
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
|
||||
(tableau de bord, troncature, lecture seule, formules non recalculées, conflits) mal intégrés.
|
||||
La refonte s'appuie sur les standards Excel/Google Sheets/Airtable **sans renier** la contrainte
|
||||
`vanilla JS`, zéro framework, zéro build npm.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + »,
|
||||
badges d'état, tokens de grille et affordances visuelles (Lot 1)
|
||||
- [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
|
||||
- [x] **A3** Inspecteur droit repliable : Tableau de bord + entrée Assistant IA (Lot 3)
|
||||
- [x] **A4** Undo/redo, chargement via `IntersectionObserver`, sémantique ARIA (Lot 4)
|
||||
- [ ] **A5** Découpage `frontend/js/xlsx/*` + lien dashboard → grille, inspecteur redimensionnable, split
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -218,8 +246,9 @@
|
||||
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
|
||||
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
|
||||
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
|
||||
| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; A5-A12 4-6 j, A13-A17 2-4 j) | 6-11 jours restants |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
|
||||
| 🔵 En cours | #154 Refonte UI/UX tableur (A1 ✅ Lot 1 · A2-A4 restants) | ~5-7 jours |
|
||||
| **Total chemin critique** | **#77 fin + #87 + #154** | **~9-13 jours** |
|
||||
|
||||
---
|
||||
|
||||
@@ -227,6 +256,7 @@
|
||||
|
||||
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
|
||||
- **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) ; Lot 1 livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille).
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# #154 — Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
|
||||
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
|
||||
> **Statut :** 🔵 **en cours** — Lots 1, 2, 3 & 4 livrés le 2026-09-29 (reste A5, découpage modulaire)
|
||||
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 ✅ · Lot 4 ✅ · Lot 5)
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Rendre la vue tableur d'ObsiGate **intuitive, moderne et hautement utilisable** en s'inspirant
|
||||
des standards du marché (Excel, Google Sheets, Airtable), **sans renier les contraintes du
|
||||
dépôt** : thème sombre, `vanilla JS`, **zéro framework, zéro build npm**
|
||||
([`AGENTS.md`](../../AGENTS.md)). La refonte est **organique** : on améliore la coquille
|
||||
existante (`frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css`), on ne réécrit pas
|
||||
la grille ni le backend.
|
||||
|
||||
## 2. Audit UX — les 3 problèmes majeurs
|
||||
|
||||
| # | Problème | Constat | Résolution |
|
||||
|---|---|---|---|
|
||||
| **P1** | **Aucune hiérarchie ni regroupement des commandes** | Rangée plate de boutons de poids identique (`viewer.js` toolbar historique) ; « Tableau de bord » *prependé* au runtime ; barre de formule réduite à un `input`. | **Barre de commandes groupée** (Formules · Insertion · Vue · Fichier), bouton **Enregistrer primaire**, état *dirty*. |
|
||||
| **P2** | **Grille sans affordances : en-têtes = cellules** | Contraste faible entre `th` et `td`, pas de zébrage, pas de survol lisible, cellule active peu marquée. | **Tokens de grille** + en-têtes plus clairs/interactifs, zébrage, survol, cellule active en bordure accent. |
|
||||
| **P3** | **États avancés traités comme du contenu** | Dashboard *inline* qui pousse la grille, troncature/lecture seule/formules non recalculées sans emplacement dédié, `confirm()`/`prompt()` natifs. | **Couche UI dédiée** : bandeaux d'état + **inspecteur droit** (Lot 3) + dialogues thémés (Lot 2). |
|
||||
|
||||
## 3. Architecture cible de l'écran
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ BARRE APP (globale, existante) │
|
||||
├─────────────┬────────────────────────────────────────────────────────────┤
|
||||
│ │ A. RUBAN — groupes Formules · Insertion · Vue · Fichier │
|
||||
│ EXPLORATEUR│ B. BARRE DE FORMULE — [ A1 ] fx [ … ] │
|
||||
│ DE FICHIERS│ C. BANDEAUX D'ÉTAT — lecture seule · formules non recalculées│
|
||||
│ (sidebar) ├──────────────────────────────────────────────┬─────────────┤
|
||||
│ │ D. GRILLE (en-têtes clairs, zébrage, survol) │ E. INSPECTEUR│
|
||||
│ │ │ (dashboard + │
|
||||
│ │ │ IA, repliable)│
|
||||
│ ├───────────────────────────────────────────────┤ │
|
||||
│ │ F. ONGLETS FEUILLES + « + » · 500/522 │ │
|
||||
└─────────────┴───────────────────────────────────────────────┴─────────────┘
|
||||
```
|
||||
|
||||
- **A. Ruban** : groupes d'actions avec séparateurs ; actions de style désactivées (styles lus,
|
||||
pas écrits). Bouton **Enregistrer** en accent, désactivé si rien de *dirty*.
|
||||
- **B. Barre de formule** : zone nom + champ + badge de session `f(x)`.
|
||||
- **C. Bandeaux d'état** : empilables, non bloquants ; portent lecture seule et
|
||||
« formules non recalculées ».
|
||||
- **D. Grille** : rendue côté serveur (`backend/xlsx_reader.py`), habillée et câblée par le front.
|
||||
- **E. Inspecteur** : **à venir (Lot 3)** — Tableau de bord + Assistant IA dans un panneau droit
|
||||
repliable (réutilise `PaneManager` pour le détachement), au lieu du dashboard *inline* actuel.
|
||||
- **F. Onglets feuilles** : permanents (même à une seule feuille) + bouton « + ».
|
||||
|
||||
## 4. Plan par lots (incréments livrables)
|
||||
|
||||
### Lot 1 — Coquille : ruban groupé, onglets permanents, badges d'état ✅ *(2026-09-29)*
|
||||
|
||||
- **A1.1** Barre de commandes groupée (`.xlsx-cmdbar`, `.xlsx-cmd-group`, `.xlsx-cmd-sep`,
|
||||
`.xlsx-save-primary`), IDs existants conservés (compatibilité tests JSDOM/E2E).
|
||||
- **A1.2** Onglets de feuilles **toujours rendus** (non-CSV) + bouton **`+`** `.xlsx-tab-add`
|
||||
→ `sheet_add` (même pipeline `putStructure`).
|
||||
- **A1.3** Badges d'état : `.xlsx-status-pill` **lecture seule** (`.xls`/`.ods`) et
|
||||
**formules non recalculées** (non-CSV).
|
||||
- **A1.4** Tokens de grille (`--grid-bg`, `--grid-header-bg`, `--grid-header-text`,
|
||||
`--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs,
|
||||
zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol).
|
||||
|
||||
### Lot 2 — Dialogues thémés & feedback ✅ *(2026-09-29)*
|
||||
|
||||
- **A2.1** Helpers génériques **`showConfirm()` / `showPrompt()`** (`frontend/js/ui.js`), promise-based,
|
||||
réutilisant les classes `.obsigate-modal-*` (fini `window.confirm()` / `window.prompt()`).
|
||||
- **A2.2** La visionneuse XLSX utilise ces dialogues pour les actions de structure (ajouter /
|
||||
renommer / dupliquer / supprimer feuille, insérer / supprimer ligne et colonne) et pour la
|
||||
confirmation de perte (409 `xlsx_lossy_content`).
|
||||
- **A2.3** **Conflit de sauvegarde (409 `conflict`)** : bandeau **non bloquant** `.xlsx-banner-conflict`
|
||||
avec bouton **Réessayer** — les modifications sont conservées.
|
||||
- **A2.4** Indicateur *dirty* sur le bouton **Enregistrer** et sur l'onglet de la feuille concernée.
|
||||
|
||||
### Lot 3 — Inspecteur droit ✅ *(2026-09-29)*
|
||||
|
||||
- **A3.1** Le **Tableau de bord** quitte le flux de la grille pour un **panneau droit repliable**
|
||||
(`.xlsx-inspector`) : la grille reste visible à côté (`.xlsx-body` = `.xlsx-main` + inspecteur).
|
||||
- **A3.2** En-tête d'inspecteur : titre, bouton **Assistant IA** (ouvre le panneau latéral global
|
||||
existant) et bouton de fermeture.
|
||||
- **A3.3** Responsive : sous 900 px, l'inspecteur passe sous la grille.
|
||||
|
||||
### Lot 4 — Interactions : undo/redo, défilement, accessibilité ✅ *(2026-09-29)*
|
||||
|
||||
- **A4.1** **Undo/redo** local (pile de commandes) pour les éditions de cellules : boutons
|
||||
**Annuler / Rétablir** dans le ruban + raccourcis `Ctrl+Z`, `Ctrl+Maj+Z`, `Ctrl+Y`.
|
||||
- **A4.2** Chargement des fenêtres via **`IntersectionObserver`** (repli sur l'écouteur de
|
||||
défilement pour les environnements sans IO).
|
||||
- **A4.3** Sémantique **ARIA** : `role="grid"` / `row` / `gridcell` / `columnheader` / `rowheader`.
|
||||
|
||||
### Lot 5 — Découpage modulaire & finitions (à venir)
|
||||
|
||||
- Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`,
|
||||
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api` (refactor sans changement de
|
||||
comportement, sous couvert des tests JSDOM/E2E existants).
|
||||
- Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection), inspecteur
|
||||
**redimensionnable**, détachement en split via `PaneManager.splitRight()`.
|
||||
|
||||
## 5. Recommandations techniques (contrainte « zéro build »)
|
||||
|
||||
| Option Data Grid | Build | Licence | Verdict |
|
||||
|---|---|---|---|
|
||||
| AG Grid Community | npm + bundler | MIT | ❌ viole « zéro build », réécrit le DOM, casse les tests |
|
||||
| Handsontable | npm + bundler | **commerciale** | ❌ licence non libre |
|
||||
| TanStack Table | headless (importable esm.sh) | MIT | ⚠ possible sans build, mais *headless* → gain limité |
|
||||
| **Grille maison sur `<table>`** | aucun | — | ✅ **recommandé** (conserve DOM, CSP, i18n, tests) |
|
||||
|
||||
- **Performance** : ne pas ré-écrire tout le DOM ; réutiliser le pipeline `appendWindow` ;
|
||||
`content-visibility:auto; contain:strict` sur les lignes ; garder la pagination serveur
|
||||
(500 × 40 = 20 000 cellules/feuille) plutôt qu'une virtualisation client complexe.
|
||||
- **CSP** : `main.py` autorise déjà `esm.sh` — une lib *headless* reste possible en Lot 4 si
|
||||
un vrai besoin de modèle de colonnes apparaît.
|
||||
|
||||
## 6. Critères d'acceptation (par lot)
|
||||
|
||||
- **Lot 1** : une feuille unique affiche son onglet + « + » ; « + » ajoute une feuille via
|
||||
`PUT …/xlsx/structure` et re-rend ; `.xls`/`.ods` montrent le badge « lecture seule » (pas de
|
||||
« + », pas de structure, pas de dashboard) ; un `.xlsx` montre le badge « formules non
|
||||
recalculées », un `.csv` non ; les tests JSDOM/E2E existants restent verts + nouveaux tests.
|
||||
- Lots suivants : définis à leur ouverture.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) |
|
||||
| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) |
|
||||
| 2026-09-29 | **Lot 3** livré : le Tableau de bord passe dans un **inspecteur droit repliable** (grille toujours visible), en-tête d'inspecteur avec entrée **Assistant IA** et fermeture, responsive < 900 px |
|
||||
| 2026-09-29 | **Lot 4** livré : **undo/redo** (boutons + `Ctrl+Z`/`Ctrl+Maj+Z`/`Ctrl+Y`), chargement par `IntersectionObserver`, **ARIA** `role="grid"` ; le découpage modulaire est reporté en A5 |
|
||||
@@ -3,7 +3,7 @@
|
||||
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
|
||||
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
|
||||
> [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md))
|
||||
> **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), P1/P2 restants
|
||||
> **Statut :** ✅ **Backlog terminé et livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis A6→A17 en v2.33.0 → v2.39.0 (A11 étant au CI depuis A5/A8)
|
||||
> **Effort estimé :** 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j)
|
||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||
> l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||
@@ -15,8 +15,9 @@
|
||||
| Couche | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Lecture | `backend/xlsx_reader.py` | `render_sheets()` → un tableau HTML par feuille (openpyxl `read_only=True`, `data_only=False`) |
|
||||
| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html}]` |
|
||||
| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets` |
|
||||
| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html, rows, cols, total_*, max_*, truncated}]` |
|
||||
| Endpoint fenêtre | `backend/routers/files_read.py` | `GET /api/file/{vault}/xlsx/sheet?path=&sheet=&offset=&limit=` (#153 A9) — une fenêtre de lignes, vraies coordonnées A1 |
|
||||
| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets`, `XlsxSheetWindowResponse` |
|
||||
| Écriture | `backend/services/mutations.py:227-320` | `edit_xlsx_cells()` (backup, refs A1 validées, coercion `str`→`int`/`float`) |
|
||||
| Endpoint écriture | `backend/routers/files_write.py:116-148` | `PUT /api/file/{vault}/xlsx/save` (1 à 500 cellules / requête) |
|
||||
| Documentation API | `backend/openapi_docs.py:184-187` | exemple d'appel `xlsx/save` |
|
||||
@@ -24,7 +25,7 @@
|
||||
| CSS | `frontend/style.css:10927-10988` | `.xlsx-*` (variables CSS, colonne A `sticky`) |
|
||||
| Indexation | `backend/indexer.py:68, 563-568, 957-960` | `.xlsx` supporté, **métadonnées seules** (`content=""`) |
|
||||
| Outils IA | `backend/tools/documents.py:66-89` + `schemas.py:296-305` | `create_xlsx` (WRITE + confirmation) — **création seule** |
|
||||
| Tests | `tests/test_xlsx_viewer.py` | 11 tests backend (affichage, index, save, backup, 400) |
|
||||
| Tests | `tests/test_xlsx_viewer.py` (58) + `test_xlsx_styles.py` (9) + `test_xlsx_formats.py` (12) + `test_xlsx_dashboard.py` (8) + `test_xlsx_structure.py` (11) + `test_spreadsheet_tools.py` (17) · `tests/frontend/xlsx-viewer.test.mjs` (35) · `tests/e2e/xlsx-viewer.spec.js` (9) | Backend, JSDOM et E2E (chromium-desktop) |
|
||||
|
||||
## 2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention)
|
||||
|
||||
@@ -45,6 +46,12 @@ vidée ; backup `.bak` avant écriture ; garde-fou vault read-only (403) ; `reso
|
||||
|
||||
## 3. Limites connues (par couche)
|
||||
|
||||
> **Note (2026-09-28)** : les limites ci-dessous décrivent l'état du jour de l'audit
|
||||
> (2026-09-27). La quasi-totalité a été levée depuis par le backlog §5 (styles, navigation
|
||||
> clavier, tri/filtre/recherche, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation,
|
||||
> outils IA) — se reporter aux cases cochées et à l'historique §7 ; ne pas relire cette
|
||||
> section comme l'état actuel.
|
||||
|
||||
### 3.1 Fidélité du round-trip — risque n°1
|
||||
|
||||
`load_workbook()` → `wb.save()` : ce qui est **réellement** perdu a été mesuré sur
|
||||
@@ -105,7 +112,7 @@ restent à faire (A7).
|
||||
| R2 | Écriture non atomique (`wb.save()` en place) → classeur corrompu si crash | `mutations.edit_xlsx_cells` | **A2** — `.tmp` + `os.replace` | 🟢 livré (BUG-086) |
|
||||
| R3 | Concurrence : deux éditions (onglets, watcher + IA) → dernier écrivain gagne | `mutations.edit_xlsx_cells` | **A3** — verrou par chemin, **409** `conflict` | 🟢 livré (BUG-087) |
|
||||
| R4 | **Injection de formule** : une saisie `=cmd\|…`, `=HYPERLINK(…)` est stockée comme formule par openpyxl → DDE à l'ouverture dans Excel | `mutations._write_cell` | **A4** — forçage texte (`data_type="s"`), opt-in `allow_formula` | 🟢 livré (BUG-088) |
|
||||
| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | ⚪ à faire |
|
||||
| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | 🟢 bandeau + dimensions exposées (BUG-090) ; le chargement paresseux par fenêtres sert les lignes au-delà du plafond |
|
||||
|
||||
## 5. Backlog #153 — sous-tâches
|
||||
|
||||
@@ -134,48 +141,94 @@ couverture) · effort en jours-homme de développement + tests.
|
||||
nombres. Au passage : le handler `ServiceError` expose `code` + `details` et `api()` les
|
||||
propage sur l'Error. *Vérifié :* `TestXlsxFormulaGuard` (4) + test du toggle côté UI.
|
||||
|
||||
### P1 — Recherche, IA, UX (4-6 j)
|
||||
### P1 — Recherche, IA, UX (4-6 j) — 🟢 livré le 2026-09-28 (A5 → A12)
|
||||
|
||||
- [ ] **A5 — Indexation du contenu des feuilles.** Extraire un texte (noms de feuilles +
|
||||
en-têtes + N premières lignes, plafond ~5 k caractères) pour le TF-IDF et la recherche
|
||||
sémantique, tout en gardant la lecture binaire pour l'affichage ; `content_preview`
|
||||
renseigné ; exclusion si le classeur est chiffré/corrompu. *Critère :* une cellule contenant
|
||||
un mot-clé rend le fichier trouvable ; `test_xlsx_indexing` étendu.
|
||||
- [ ] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
|
||||
- [x] **A5 — Indexation du contenu des feuilles.** `extract_indexable_text()` (noms de feuilles +
|
||||
20 premières lignes, `MAX_INDEX_CHARS = 5 000`, 20 feuilles max) alimente le TF-IDF et la
|
||||
recherche sémantique ; la lecture binaire reste inchangée pour l'affichage. Un classeur
|
||||
chiffré/corrompu s'indexe par son seul nom (jamais d'exception). Au passage : **BUG-089**,
|
||||
un reindex manuel ne reconstruisait pas l'index inversé. *Vérifié :* `TestXlsxSearchable` (4)
|
||||
+ `TestXlsxIndexing`, **contre-preuve** (neutraliser l'extraction → 3 tests échouent).
|
||||
- [x] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
|
||||
`append_xlsx_rows`, `xlsx_to_markdown` (contexte LLM, plafonné), `list_xlsx_sheets` — risque
|
||||
WRITE + confirmation pour les mutations, libellés i18n dans `backend/tools/labels.py`,
|
||||
refresh viewer via `obsigate:file-written`.
|
||||
- [ ] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
|
||||
refresh viewer via `obsigate:file-written`. *Livré (v2.33.0) :* `backend/tools/spreadsheets.py`.
|
||||
*Vérifié :* `tests/test_spreadsheet_tools.py` (17).
|
||||
- [x] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
|
||||
active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible
|
||||
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`).
|
||||
- [ ] **A8 — `thead` sticky + indicateur de troncature (R5).** Ligne d'en-têtes figlée au
|
||||
défilement vertical ; bandeau « feuille tronquée à 500 lignes × 40 colonnes » ; libellés
|
||||
FR/EN.
|
||||
- [ ] **A9 — Chargement paresseux par feuille (supprime le plafond).** Endpoint
|
||||
`GET /api/file/{vault}/xlsx/sheet?sheet=N&offset=&limit=` (`response_model` +
|
||||
`backend/openapi_docs.py`), rendu à la demande avec défilement virtuel, bouton « charger
|
||||
tout ».
|
||||
- [ ] **A10 — Types et formats de saisie.** Coercion symétrique à l'écriture/à l'affichage
|
||||
(nombre vs texte, booléens `TRUE`/`FALSE`, dates localisées FR — le TODO existe déjà dans
|
||||
`_coerce_xlsx_value`) ; affichage du type d'origine dans l'info-bulle de cellule.
|
||||
- [ ] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap,
|
||||
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`). *Livré (v2.34.0).*
|
||||
- [x] **A8 — `thead` sticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090).**
|
||||
Ligne d'en-têtes figlée au défilement vertical (`thead th { top: 0 }` ; `top: auto` sur les
|
||||
numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
|
||||
`total_rows`/`total_cols` (dimensions déclarées), `max_rows`/`max_cols` (plafonds) et
|
||||
`truncated` — le bandeau « feuille tronquée » annonce le **plafond atteint** et non la
|
||||
taille élaguée (une feuille creuse rend 1×1 tout en couvrant 500 lignes) ; libellés
|
||||
`xlsx.truncated_*` FR/EN. *Vérifié :* `TestXlsxTruncationNotice` (4), `xlsx-viewer.test.mjs`
|
||||
(4 nouveaux), E2E sur `test_vault/sample-xlsx-large.xlsx` (520 lignes).
|
||||
- [x] **A9 — Chargement paresseux par feuille (côté API).** Endpoint
|
||||
`GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`,
|
||||
exemple dans `backend/openapi_docs.py`) : une fenêtre de 1 à 1 000 lignes (plafond
|
||||
`MAX_WINDOW_ROWS`, `limit>1000` → 422), `has_more` pour paginer, valeurs calculées A12
|
||||
incluses. Les numéros de ligne et `data-cell` restent les coordonnées A1 réelles de la
|
||||
feuille (`_table(..., row_offset=offset)`) : une fenêtre est indistinguishable d'un rendu
|
||||
complet et une édition dans la fenêtre cible la bonne cellule. Erreurs : 404 feuille
|
||||
inconnue / fichier absent, 415 non-`.xlsx`. *Vérifié :* `TestXlsxSheetWindow` (11),
|
||||
**contre-preuve** (neutraliser l'offset → 3 tests échouent), E2E « l'endpoint de fenêtre
|
||||
sert les lignes au-delà du plafond ».
|
||||
- [x] **A9bis — Chargement à la demande côté UI.** Sous une feuille tronquée, un pied de page
|
||||
« N lignes affichées sur M · Charger la suite » apparaît : cliquer — ou approcher du bas
|
||||
du tableau (sentinelle de défilement, marge 120 px) — fetch la fenêtre suivante
|
||||
(`limit=500`) et l'insère dans la table. Les lignes ajoutées passent par le **même**
|
||||
pipeline d'édition que le rendu initial (`setupCell` factorisé : contenteditable, dirty,
|
||||
Échap, collage monoligne, info-bulle valeurs calculées) et sont donc sauvegardables
|
||||
immédiatement. Un fetch échoué restore le libellé du pied de page (retry possible) et
|
||||
toast l'erreur ; feuille complète → pied de page masqué (`class="done"`).
|
||||
*Vérifié :* `xlsx-viewer.test.mjs` 19/19 (5 nouveaux), **contre-preuve** (désactiver
|
||||
`wireLazyRows` → 5 tests échouent), E2E « le bouton charger la suite ajoute les lignes
|
||||
cachées » sur `sample-xlsx-large.xlsx` (A520 visible et éditable après clic).
|
||||
- [x] **A10 — Types et formats de saisie.** `_coerce_xlsx_value()` reconnait les booléens
|
||||
(`true`/`vrai`/`oui`/`yes` et leurs négatifs) et les dates FR `JJ/MM/AAAA` (+ `HH:MM`),
|
||||
jour-first comme Excel en locale française : `01/02/2026` = 1ᵉʳ février. Une saisie
|
||||
ressemblant à une formule n'est jamais convertie (BUG-088 préservé) ; un code postal
|
||||
numérique ou une version restent ce qu'ils sont. *Vérifié :* `TestXlsxValueCoercion` (5),
|
||||
**contre-preuve** (neutraliser la coercion → 2 tests échouent).
|
||||
- [x] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap,
|
||||
collage, 1 PUT par feuille, bouton désactivé) et `tests/e2e/xlsx-viewer.spec.js`
|
||||
(ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI.
|
||||
- [ ] **A12 — Valeurs calculées.** Afficher la valeur en cache (2ᵉ ligne discrète) quand elle
|
||||
existe, via une lecture `data_only=True` de la même page d'onglets ; mention FR/EN
|
||||
« valeur recalculée par Excel ».
|
||||
(ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI. *Vérifié :*
|
||||
35 tests JSDOM (le job CI `lint` lance `node xlsx-viewer.test.mjs`) et 9 E2E
|
||||
chromium-desktop ; la couverture a grandi avec chaque sous-tâche (A5/A8 → P2).
|
||||
- [x] **A12 — Valeurs calculées.** La valeur en cache s'affiche sous la formule dans un
|
||||
`<span class="xlsx-cached">`. La 2ᵉ lecture `data_only=True` n'a lieu que si l'archive
|
||||
contient réellement un `<f>…</f><v>…</v>` (sonde déjà présente pour A1) : le cas courant
|
||||
reste à un seul chargement, et toute erreur retombe sur l'affichage formules seul.
|
||||
L'info-bulle est traduite côté client (`xlsx.cached_value_title` FR/EN) — aucun texte
|
||||
d'interface n'est émis par le backend. *Vérifié :* `TestXlsxCachedValues` (3),
|
||||
**contre-preuve** (neutraliser la 2ᵉ lecture → 2 tests échouent).
|
||||
|
||||
### P2 — Étendu (2-4 j)
|
||||
### P2 — Étendu (2-4 j) — 🟢 livré le 2026-09-28
|
||||
|
||||
- [ ] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
|
||||
- [ ] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
|
||||
- [ ] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
|
||||
- [x] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
|
||||
*Livré (v2.35.0) :* tout en manipulation d'affichage, le classeur n'est jamais réécrit
|
||||
(info-bulle `xlsx.sort_applied`).
|
||||
- [x] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
|
||||
*Livré (v2.36.0) :* `PUT …/xlsx/structure` + menu Structure, mêmes garde-fous que
|
||||
l'édition de cellules. *Vérifié :* `tests/test_xlsx_structure.py` (11).
|
||||
- [x] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
|
||||
devise/pourcentage/date, cellules fusionnées, volets figés) ; conserver `csv-table` comme
|
||||
socle de rendu.
|
||||
- [ ] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
|
||||
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion.
|
||||
- [ ] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
|
||||
résumée (KPI par feuille) et proposal d'actions IA sur ces plages.
|
||||
socle de rendu. *Livré (v2.37.0) en lecture :* couleurs, gras/italique/souligné,
|
||||
alignements, fusions, ancre de volets figés ; un format de nombre personnalisé est signalé
|
||||
en police mono (pas de rendu devise/pourcentage). L'application de styles **depuis la
|
||||
visionneuse** (écriture) reste hors périmètre. *Vérifié :* `tests/test_xlsx_styles.py` (9).
|
||||
- [x] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
|
||||
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion. *Livré (v2.38.0) :*
|
||||
`.xlsm` éditable macros préservées, `.xls`/`.ods` lecture seule (xlrd/odfpy), `.csv`
|
||||
éditable et réécrit RFC 4180. *Vérifié :* `tests/test_xlsx_formats.py` (12).
|
||||
- [x] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
|
||||
résumée (KPI par feuille) et proposal d'actions IA sur ces plages. *Livré (v2.39.0) :*
|
||||
panneau Tableau de bord (`GET …/xlsx/dashboard`) — plages nommées avec portée, comptage
|
||||
graphiques/TCD par analyse des parties OPC, stats par feuille, 8 KPI ; le volet IA se
|
||||
limite à un conseil contextuel (pas d'appel IA dédié sur les plages).
|
||||
*Vérifié :* `tests/test_xlsx_dashboard.py` (8).
|
||||
|
||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||
|
||||
@@ -199,3 +252,9 @@ couverture) · effort en jours-homme de développement + tests.
|
||||
| 2026-09-27 | Audit complet → création de #153 : limites, risques R1-R5, backlog A1-A17 |
|
||||
| 2026-09-27 | Périmètre de perte **remesuré** sur openpyxl 3.1.5 : graphiques / images / TCD sont préservés, seules les valeurs en cache et quelques parties exotiques sont perdues |
|
||||
| 2026-09-27 | **P0 livré** (BUG-085 → BUG-088) : `xlsx_lossy_features` + 409 `xlsx_lossy_content`, écriture atomique, verrou par fichier, formules stockées en texte par défaut |
|
||||
| 2026-09-28 | **A5 + A10 + A12 livrés** : le contenu des cellules est indexé (recherche), la saisie est typée (booléens, dates FR), la valeur calculée s'affiche sous la formule. **BUG-089** corrigé au passage (reindex manuel ≠ reconstruction de l'index inversé ; `backend/search.py` lisait l'index par valeur) |
|
||||
| 2026-09-28 | **A8 + A9 livrés** (BUG-090) : la troncature d'une feuille est annoncée (bandeau + dimensions dans la réponse de lecture), les en-têtes restent visibles au défilement, et `GET …/xlsx/sheet` sert une fenêtre de lignes avec les vraies coordonnées A1 — les lignes au-delà du plafond redeviennent accessibles aux clients API. Défilement virtuel côté UI à suivre |
|
||||
| 2026-09-28 | **A9bis livré** : « Charger la suite » + sentinelle de défilement sous une feuille tronquée ; les lignes ajoutées sont éditables et sauvegardables immédiatement (même pipeline que le rendu initial) |
|
||||
| 2026-09-28 | **A6 + A7 livrés** (v2.33.0, v2.34.0) : l'assistant IA lit et modifie les classeurs (`list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`, `append_xlsx_rows`) et la visionneuse gagne navigation clavier complète + barre de formule |
|
||||
| 2026-09-28 | **A13 + A14 livrés** (v2.35.0, v2.36.0) : tri, filtre, recherche et export CSV côté affichage ; structure du classeur éditable (feuilles, lignes, colonnes) via `PUT …/xlsx/structure` |
|
||||
| 2026-09-28 | **A15 + A16 + A17 livrés** (v2.37.0 → v2.39.0) : styles/fusions/volets figés rendus, formats `.xlsm`/`.xls`/`.ods`/`.csv` gérés, panneau Tableau de bord (plages nommées, graphiques/TCD, stats, KPI) — **backlog #153 terminé** |
|
||||
|
||||
@@ -60,12 +60,14 @@ const MUTATING_TOOLS = new Set([
|
||||
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
|
||||
'delete_file', 'delete_directory', 'restore_backup',
|
||||
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
||||
'update_xlsx_cells', 'append_xlsx_rows',
|
||||
]);
|
||||
// Subset carrying a concrete `vault` + `path`: the displayed document is
|
||||
// reloaded from disk so an open viewer/editor reflects the agent's write.
|
||||
const FILE_WRITE_TOOLS = new Set([
|
||||
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
|
||||
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
||||
'update_xlsx_cells', 'append_xlsx_rows',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1143,6 +1143,88 @@ const FileOperations = {
|
||||
};
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic themed dialogs (#154-A2)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Promise-based replacements for window.confirm() / window.prompt() so the
|
||||
// Excel viewer's structure actions and lossy-write confirmations stay inside
|
||||
// the app theme (and are keyboard accessible) instead of native dialogs.
|
||||
|
||||
function _closeDialog(overlay, resolve, value) {
|
||||
overlay.classList.remove("active");
|
||||
if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey);
|
||||
setTimeout(() => overlay.remove(), 200);
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
function _openDialog(innerHtml) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "obsigate-modal-overlay";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "obsigate-modal";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.innerHTML = innerHtml;
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
setTimeout(() => overlay.classList.add("active"), 10);
|
||||
return { overlay, modal };
|
||||
}
|
||||
|
||||
/** Themed replacement for window.confirm(). Resolves to a boolean. */
|
||||
export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { overlay, modal } = _openDialog(`
|
||||
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
|
||||
<div class="obsigate-modal-body"><p class="modal-confirm-text">${escapeHtml(message)}</p></div>
|
||||
<div class="obsigate-modal-footer">
|
||||
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
|
||||
<button class="modal-btn ${danger ? "danger" : "primary"}" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
|
||||
</div>`);
|
||||
const done = (v) => _closeDialog(overlay, resolve, v);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); });
|
||||
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true));
|
||||
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false));
|
||||
overlay._onKey = (e) => {
|
||||
if (e.key === "Escape") done(false);
|
||||
else if (e.key === "Enter") done(true);
|
||||
};
|
||||
document.addEventListener("keydown", overlay._onKey);
|
||||
setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20);
|
||||
});
|
||||
}
|
||||
|
||||
/** Themed replacement for window.prompt(). Resolves to the string (or null). */
|
||||
export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { overlay, modal } = _openDialog(`
|
||||
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
|
||||
<div class="obsigate-modal-body">
|
||||
<div class="modal-form-group">
|
||||
${message ? `<label class="modal-label">${escapeHtml(message)}</label>` : ""}
|
||||
<input type="text" class="modal-input" data-dialog="input" spellcheck="false"
|
||||
value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="obsigate-modal-footer">
|
||||
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
|
||||
<button class="modal-btn primary" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
|
||||
</div>`);
|
||||
const input = modal.querySelector('[data-dialog="input"]');
|
||||
const done = (v) => _closeDialog(overlay, resolve, v);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
|
||||
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value));
|
||||
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null));
|
||||
overlay._onKey = (e) => {
|
||||
if (e.key === "Escape") done(null);
|
||||
else if (e.key === "Enter") done(input.value);
|
||||
};
|
||||
document.addEventListener("keydown", overlay._onKey);
|
||||
setTimeout(() => { input.focus(); input.select(); }, 20);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find in Page Manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+979
-33
File diff suppressed because it is too large
Load Diff
@@ -1827,7 +1827,70 @@
|
||||
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
|
||||
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
|
||||
"xlsx.lossy_cancelled": "Save cancelled",
|
||||
"xlsx.lossy_confirm_btn": "Save anyway",
|
||||
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
|
||||
"xlsx.conflict_retry": "Retry",
|
||||
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
|
||||
"xlsx.undo": "Undo the last change",
|
||||
"xlsx.redo": "Redo the change",
|
||||
"xlsx.cached_value_title": "Last value calculated by Excel",
|
||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||
"xlsx.readonly_badge": "Read-only",
|
||||
"xlsx.readonly_hint": "This format (.xls / .ods) cannot be edited in ObsiGate — convert it to .xlsx to edit.",
|
||||
"xlsx.formulas_note": "Formulas not recalculated",
|
||||
"xlsx.formulas_note_title": "ObsiGate shows the formula as stored: Excel recalculates it on open. Dependent cells do not refresh on screen.",
|
||||
"xlsx.truncated_title": "Truncated sheet",
|
||||
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
||||
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
||||
"xlsx.truncated_hint": "Cells outside the displayed area cannot be edited here; the workbook is unchanged.",
|
||||
"xlsx.load_more": "Load more",
|
||||
"xlsx.loading_more": "Loading…",
|
||||
"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.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.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",
|
||||
"xlsx.sort_reset": "Reset sort and filter",
|
||||
"xlsx.filter_placeholder": "Filter rows…",
|
||||
"xlsx.structure_btn": "Sheet structure",
|
||||
"xlsx.structure_title": "Edit the workbook structure",
|
||||
"xlsx.sheet_add": "Add a sheet",
|
||||
"xlsx.sheet_rename": "Rename the current sheet",
|
||||
"xlsx.sheet_duplicate": "Duplicate the current sheet",
|
||||
"xlsx.sheet_delete": "Delete the current sheet",
|
||||
"xlsx.row_insert": "Insert a row above",
|
||||
"xlsx.row_delete": "Delete the active cell's row",
|
||||
"xlsx.col_insert": "Insert a column to the left",
|
||||
"xlsx.col_delete": "Delete the active cell's column",
|
||||
"xlsx.structure_prompt_add": "Name of the new sheet:",
|
||||
"xlsx.structure_prompt_rename": "New name of the sheet:",
|
||||
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
|
||||
"xlsx.structure_saved": "Structure updated",
|
||||
"xlsx.structure_error": "Could not change the structure",
|
||||
"xlsx.last_sheet": "The last sheet cannot be deleted",
|
||||
"xlsx.dashboard_btn": "Dashboard",
|
||||
"xlsx.inspector_title": "Inspector",
|
||||
"xlsx.inspector_ai": "AI assistant",
|
||||
"xlsx.inspector_ai_short": "AI",
|
||||
"xlsx.dashboard_title": "Workbook dashboard",
|
||||
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
|
||||
"xlsx.dashboard_charts": "{{n}} chart(s)",
|
||||
"xlsx.dashboard_pivots": "{{n}} pivot tables",
|
||||
"xlsx.dashboard_empty": "No named range or usable data in this workbook.",
|
||||
"xlsx.dashboard_nr_name": "Name",
|
||||
"xlsx.dashboard_nr_scope": "Scope",
|
||||
"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_slicers": "slicers and timelines",
|
||||
"xlsx.feature_form_controls": "form controls",
|
||||
@@ -2000,6 +2063,10 @@
|
||||
"ai.step.git_issues": "Searched issues: {value}",
|
||||
"ai.step.git_file": "Read a repo file: {value}",
|
||||
"ai.step.xlsx_create": "Spreadsheet proposed: {value}",
|
||||
"ai.step.xlsx_sheets": "Workbook sheets listed: {value}",
|
||||
"ai.step.xlsx_read": "Workbook read: {value}",
|
||||
"ai.step.xlsx_update": "Cells edited: {value}",
|
||||
"ai.step.xlsx_append": "Rows appended: {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}",
|
||||
|
||||
@@ -1827,7 +1827,70 @@
|
||||
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
|
||||
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
|
||||
"xlsx.lossy_cancelled": "Sauvegarde annulée",
|
||||
"xlsx.lossy_confirm_btn": "Enregistrer quand même",
|
||||
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
|
||||
"xlsx.conflict_retry": "Réessayer",
|
||||
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
|
||||
"xlsx.undo": "Annuler la dernière modification",
|
||||
"xlsx.redo": "Rétablir la modification",
|
||||
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
|
||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||
"xlsx.readonly_badge": "Lecture seule",
|
||||
"xlsx.readonly_hint": "Ce format (.xls / .ods) n'est pas modifiable dans ObsiGate — convertissez-le en .xlsx pour l'éditer.",
|
||||
"xlsx.formulas_note": "Formules non recalculées",
|
||||
"xlsx.formulas_note_title": "ObsiGate affiche la formule telle qu'elle est enregistrée : Excel la recalcule à l'ouverture. Les cellules dépendantes ne se rafraîchissent pas à l'écran.",
|
||||
"xlsx.truncated_title": "Feuille tronquée",
|
||||
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
||||
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
|
||||
"xlsx.truncated_hint": "Les cellules hors de l'affichage ne sont pas éditables ici ; le classeur n'est pas modifié.",
|
||||
"xlsx.load_more": "Charger la suite",
|
||||
"xlsx.loading_more": "Chargement…",
|
||||
"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.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.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é",
|
||||
"xlsx.sort_reset": "Réinitialiser le tri et le filtre",
|
||||
"xlsx.filter_placeholder": "Filtrer les lignes…",
|
||||
"xlsx.structure_btn": "Structure de la feuille",
|
||||
"xlsx.structure_title": "Modifier la structure du classeur",
|
||||
"xlsx.sheet_add": "Ajouter une feuille",
|
||||
"xlsx.sheet_rename": "Renommer la feuille courante",
|
||||
"xlsx.sheet_duplicate": "Dupliquer la feuille courante",
|
||||
"xlsx.sheet_delete": "Supprimer la feuille courante",
|
||||
"xlsx.row_insert": "Insérer une ligne au-dessus",
|
||||
"xlsx.row_delete": "Supprimer la ligne de la cellule active",
|
||||
"xlsx.col_insert": "Insérer une colonne à gauche",
|
||||
"xlsx.col_delete": "Supprimer la colonne de la cellule active",
|
||||
"xlsx.structure_prompt_add": "Nom de la nouvelle feuille :",
|
||||
"xlsx.structure_prompt_rename": "Nouveau nom de la feuille :",
|
||||
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||
"xlsx.structure_saved": "Structure mise à jour",
|
||||
"xlsx.structure_error": "Modification de la structure impossible",
|
||||
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
|
||||
"xlsx.dashboard_btn": "Tableau de bord",
|
||||
"xlsx.inspector_title": "Inspecteur",
|
||||
"xlsx.inspector_ai": "Assistant IA",
|
||||
"xlsx.inspector_ai_short": "IA",
|
||||
"xlsx.dashboard_title": "Tableau de bord du classeur",
|
||||
"xlsx.dashboard_stats": "{{cells}} cellules · {{rows}} lignes · {{cols}} colonnes · {{formulas}} formules · {{numeric}} valeurs numériques",
|
||||
"xlsx.dashboard_charts": "{{n}} graphique(s)",
|
||||
"xlsx.dashboard_pivots": "{{n}} TCD",
|
||||
"xlsx.dashboard_empty": "Aucune plage nommée ni donnée exploitable dans ce classeur.",
|
||||
"xlsx.dashboard_nr_name": "Nom",
|
||||
"xlsx.dashboard_nr_scope": "Portée",
|
||||
"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_slicers": "segments et chronologies",
|
||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||
@@ -2000,6 +2063,10 @@
|
||||
"ai.step.git_issues": "Issues recherchées : {value}",
|
||||
"ai.step.git_file": "Fichier de dépôt lu : {value}",
|
||||
"ai.step.xlsx_create": "Tableur proposé : {value}",
|
||||
"ai.step.xlsx_sheets": "Feuilles du classeur listées : {value}",
|
||||
"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.docx_create": "Document Word proposé : {value}",
|
||||
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
||||
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
||||
|
||||
+549
-10
@@ -57,6 +57,13 @@
|
||||
--mono: "JetBrains Mono", monospace;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
|
||||
chrome (toolbar/panels), headers are clearly distinct from cells. */
|
||||
--grid-bg: #161b22;
|
||||
--grid-header-bg: #1f2430;
|
||||
--grid-header-text: #e6edf3;
|
||||
--grid-border: #30363d;
|
||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
/* ===== THEME — DARK (explicit) ===== */
|
||||
@@ -101,6 +108,11 @@
|
||||
--mono: "JetBrains Mono", monospace;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--grid-bg: #161b22;
|
||||
--grid-header-bg: #1f2430;
|
||||
--grid-header-text: #e6edf3;
|
||||
--grid-border: #30363d;
|
||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
/* ===== THEME — LIGHT ===== */
|
||||
@@ -145,6 +157,11 @@
|
||||
--mono: "JetBrains Mono", monospace;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--grid-bg: #ffffff;
|
||||
--grid-header-bg: #eaeef2;
|
||||
--grid-header-text: #1f2328;
|
||||
--grid-border: #d0d7de;
|
||||
--grid-zebra: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
/* ===== BASE ===== */
|
||||
@@ -10925,20 +10942,54 @@ body.desktop-mode .editor-container {
|
||||
}
|
||||
|
||||
/* ── XLSX Viewer ── */
|
||||
/* #154-A1 — the viewer shell is a grouped command bar: sheet tabs on the left,
|
||||
action groups (Formules · Insertion · Vue · Fichier) on the right, then a
|
||||
status row stating the viewer's limits (read-only, formulas not recalculated). */
|
||||
.xlsx-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface2);
|
||||
position: relative; /* anchors the A14 structure menu */
|
||||
}
|
||||
.xlsx-cmdbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.xlsx-toolbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-cmd-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-cmd-sep {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
background: var(--border);
|
||||
}
|
||||
.xlsx-save-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.xlsx-save-primary:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.xlsx-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -10956,23 +11007,145 @@ body.desktop-mode .editor-container {
|
||||
border-color: var(--accent, #4a90d9);
|
||||
color: #fff;
|
||||
}
|
||||
.xlsx-table th.xlsx-corner,
|
||||
.xlsx-table th.xlsx-rownum {
|
||||
.xlsx-tab-add {
|
||||
border: 1px dashed var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-tab-add:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.xlsx-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.xlsx-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xlsx-status-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.xlsx-status-readonly {
|
||||
border-color: var(--warning, #e0a800);
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
.xlsx-status-formula {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* #154-A2 — unsaved-change feedback: the primary button and the owning tab. */
|
||||
.xlsx-save-primary.is-dirty {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
.xlsx-tab-dirty::after {
|
||||
content: "•";
|
||||
margin-left: 5px;
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
/* #154-A2 — non-blocking banners (conflict, …). */
|
||||
.xlsx-banner-host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.xlsx-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.xlsx-banner-conflict {
|
||||
border-left-color: var(--warning, #e0a800);
|
||||
}
|
||||
.xlsx-banner-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
.xlsx-banner-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-banner-dismiss {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.modal-confirm-text {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.xlsx-table {
|
||||
background: var(--grid-bg);
|
||||
}
|
||||
.xlsx-table td {
|
||||
border-bottom: 1px solid var(--grid-border);
|
||||
}
|
||||
/* #154-A1 — zebra + hover make rows scannable; headers use dedicated tokens so
|
||||
they are visually distinct from the cells. */
|
||||
.xlsx-table tbody tr:nth-child(even) td {
|
||||
background: var(--grid-zebra);
|
||||
}
|
||||
.xlsx-table tbody tr:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.xlsx-table th.xlsx-corner,
|
||||
.xlsx-table th.xlsx-rownum {
|
||||
background: var(--grid-header-bg);
|
||||
color: var(--grid-header-text);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
border-right: 1px solid var(--border-light, var(--border));
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
border-right: 1px solid var(--grid-border);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
/* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
|
||||
at `top: 0`, so a row number left sticky on both axes piles up in the
|
||||
top-left corner instead of tracking its own row. */
|
||||
top: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
.xlsx-table th.xlsx-corner {
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
}
|
||||
/* #153 A8 — the column headers stay visible while the sheet scrolls down.
|
||||
Declared explicitly (and not inherited from `.csv-table th`) so the stacking
|
||||
order is intentional: thead (3) < row numbers (2) < corner (4). */
|
||||
.xlsx-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
background: var(--grid-header-bg);
|
||||
color: var(--grid-header-text);
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
}
|
||||
.xlsx-table td[contenteditable] {
|
||||
cursor: text;
|
||||
@@ -10983,10 +11156,327 @@ body.desktop-mode .editor-container {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-table td.xlsx-dirty {
|
||||
/* #153 A7 — the active cell keeps its outline even when focus moves to the
|
||||
formula bar, so the user never loses track of what the bar edits.
|
||||
#154-A1 — solid (not dashed) for a stronger active-cell affordance. */
|
||||
.xlsx-table td.xlsx-active:not(:focus) {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */
|
||||
.xlsx-formula-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xlsx-active-cell {
|
||||
min-width: 52px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.xlsx-formula-sep {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-formula-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.xlsx-formula-input:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-formula-input:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* #153 A13 — find-in-sheet + filter/sort controls */
|
||||
.xlsx-find-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-find-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-find-input:focus {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-find-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xlsx-find-btn,
|
||||
.xlsx-find-case,
|
||||
.xlsx-sort-reset {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.xlsx-find-hit {
|
||||
background: var(--warning, #e0a800);
|
||||
color: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.xlsx-find-current {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* #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,
|
||||
.xlsx-table tr.xlsx-frozen-row th {
|
||||
position: sticky;
|
||||
top: 33px; /* thead height — keeps the frozen row below the header */
|
||||
z-index: 2;
|
||||
background: var(--grid-header-bg);
|
||||
}
|
||||
.xlsx-table td.xlsx-frozen-col {
|
||||
position: sticky;
|
||||
left: 44px; /* the row-number column width */
|
||||
background: var(--grid-bg);
|
||||
}
|
||||
|
||||
/* #153 A14 — structure menu (sheets / rows / columns) */
|
||||
.xlsx-structure-menu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 240px;
|
||||
margin-top: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px var(--shadow, rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
.xlsx-structure-item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* #154-A3 — right-hand inspector (dashboard, assistant) beside the grid. */
|
||||
.xlsx-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.xlsx-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-inspector {
|
||||
flex: 0 0 340px;
|
||||
width: 340px;
|
||||
max-width: 45%;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
max-height: 70vh;
|
||||
}
|
||||
.xlsx-inspector.open {
|
||||
display: flex;
|
||||
}
|
||||
.xlsx-inspector-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.xlsx-inspector-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-inspector-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-inspector-body {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.xlsx-inspector .xlsx-dashboard {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.xlsx-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.xlsx-inspector {
|
||||
flex-basis: auto;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* #153 A17 — workbook dashboard panel: named ranges table + KPI cards.
|
||||
Colors come from the existing CSS variables (no hardcoded values). */
|
||||
.xlsx-dashboard {
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.xlsx-dashboard-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xlsx-dashboard-head h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-dashboard-objects {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-ranges-table {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.xlsx-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.xlsx-kpi-sheet {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.xlsx-kpi-sheet h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-kpi-meta {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-kpi-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-kpi {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
min-width: 64px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.xlsx-kpi-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-kpi-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-kpi-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-dashboard-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.xlsx-dashboard-loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-structure-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.xlsx-structure-sep {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* JSDOM shims for the tests that click anchors */
|
||||
mark {
|
||||
font: inherit;
|
||||
}
|
||||
/* #154-A1 — scoped to tbody so a dirty cell keeps its highlight even on the
|
||||
zebra/hover backgrounds (specificity beats the zebra + hover rules). */
|
||||
.xlsx-table tbody td.xlsx-dirty {
|
||||
background: rgba(255, 196, 0, 0.18);
|
||||
}
|
||||
|
||||
/* #153 A12 — last result Excel computed, shown under a formula cell.
|
||||
Discreet by design: the formula is what the user edits, the cached value is
|
||||
context (stale until Excel recalculates). */
|
||||
.xlsx-cached {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
padding-left: 6px;
|
||||
border-left: 2px solid var(--border, #d0d7de);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* #153 A1/A4 — lossy-save warning + formula toggle */
|
||||
.xlsx-warning {
|
||||
display: flex;
|
||||
@@ -11037,6 +11527,55 @@ body.desktop-mode .editor-container {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* #153 A8 — "feuille tronquée" notice. Deliberately NOT the `.xlsx-warning`
|
||||
look: that one is a data-loss alert, this one only says part of the sheet is
|
||||
out of view. */
|
||||
.xlsx-truncated {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent, #4a90d9);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.xlsx-truncated-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
color: var(--accent, #4a90d9);
|
||||
}
|
||||
|
||||
/* #153 A9bis — “charger la suite” footnote under a truncated sheet. Also the
|
||||
scroll sentinel target: clickable whole, disabled look once the sheet is
|
||||
fully loaded. */
|
||||
.xlsx-load-more {
|
||||
display: block;
|
||||
margin: 6px 0 10px;
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.xlsx-load-more:hover {
|
||||
border-color: var(--accent, #4a90d9);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-load-more.done {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.xlsx-formula-toggle {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.29.0",
|
||||
"version": "2.43.0",
|
||||
"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": {
|
||||
|
||||
Binary file not shown.
+154
-12
@@ -11,7 +11,14 @@
|
||||
* - the warning banner lists both features ;
|
||||
* - saving a cell on that workbook asks for confirmation (native dialog) and
|
||||
* then succeeds (the client retries with `force: true`) ;
|
||||
* - the f(x) toggle is off by default, so "=B1*3" is stored as text.
|
||||
* - the f(x) toggle is off by default, so "=B1*3" is stored as text ;
|
||||
* - the value Excel last computed is shown under the formula (#153 A12).
|
||||
*
|
||||
* Second describe block — `test_vault/sample-xlsx-large.xlsx` (520 rows) :
|
||||
* - a sheet over the render caps SAYS it instead of looking complete (#153 A8) ;
|
||||
* - the column headers stay pinned while the sheet scrolls (#153 A8) ;
|
||||
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
|
||||
* with the real A1 coordinates (#153 A9).
|
||||
*
|
||||
* The fixture is restored byte-for-byte in `afterAll` so a local run never
|
||||
* dirties the working copy.
|
||||
@@ -26,6 +33,10 @@ import path from 'node:path';
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
const VAULT = 'TestVault';
|
||||
const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
||||
// #153 A8 — 520 rows x 3 columns: the sheet exceeds the 500-row render cap, so
|
||||
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
|
||||
// committed next to the other fixture; nothing in the suite writes to it.
|
||||
const LARGE = 'sample-xlsx-large.xlsx';
|
||||
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||
|
||||
@@ -44,7 +55,11 @@ async function login(page) {
|
||||
}
|
||||
|
||||
async function openFixture(page) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${FIXTURE}"]`);
|
||||
return openXlsx(page, FIXTURE);
|
||||
}
|
||||
|
||||
async function openXlsx(page, file) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${file}"]`);
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
||||
@@ -53,7 +68,7 @@ async function openFixture(page) {
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
|
||||
test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => {
|
||||
test.beforeAll(() => {
|
||||
if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH);
|
||||
});
|
||||
@@ -62,6 +77,28 @@ test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
|
||||
if (originalBytes) writeFileSync(FIXTURE_PATH, originalBytes);
|
||||
});
|
||||
|
||||
// Read-only assertions come FIRST, before the mutating tests: saving through
|
||||
// the viewer rewrites the workbook and an openpyxl round-trip drops the cached
|
||||
// formula results (BUG-085), so the shadow line only exists on a pristine
|
||||
// fixture.
|
||||
test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>).
|
||||
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await expect(formulaCell).toContainText('=B1*2');
|
||||
|
||||
const cached = formulaCell.locator('.xlsx-cached');
|
||||
await expect(cached).toHaveCount(1);
|
||||
await expect(cached).toHaveText('200');
|
||||
// The tooltip is translated client-side, never hardcoded by the backend.
|
||||
await expect(cached).toHaveAttribute('title', /Excel/);
|
||||
|
||||
// A plain value cell must not be duplicated with a shadow line.
|
||||
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('affiche la bannière listant les éléments non préservés', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
@@ -79,12 +116,6 @@ test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
let dialogMessage = null;
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogMessage = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
const cell = page.locator('#content-area td[data-cell="A2"]');
|
||||
await cell.click();
|
||||
await cell.fill('Total confirmé');
|
||||
@@ -94,7 +125,11 @@ test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
|
||||
await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments');
|
||||
// #154-A2 — the lossy confirmation is a themed in-app dialog, not a native one.
|
||||
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(dialog).toContainText('segments');
|
||||
await dialog.locator('[data-dialog="confirm"]').click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// La cellule reste modifiée côté UI (plus de marque « sale »).
|
||||
@@ -108,13 +143,120 @@ test.describe('Excel viewer — garde-fous d\'écriture (#153 P0)', () => {
|
||||
const toggle = page.locator('#xlsx-formula-btn');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// 409 → confirmation, puis reprise avec force (le toggle reste désactivé).
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
// 409 → confirmation thémée, puis reprise avec force (le toggle reste désactivé).
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await cell.fill('=B1*3');
|
||||
await cell.press('Enter');
|
||||
await page.locator('#xlsx-save-btn').click();
|
||||
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
|
||||
// A lossy workbook asks for confirmation. A previous test's save may already
|
||||
// have dropped the slicers part, in which case the PUT succeeds directly.
|
||||
await dialog.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
|
||||
if (await dialog.isVisible()) {
|
||||
await dialog.locator('[data-dialog="confirm"]').click();
|
||||
}
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── A8 — troncature annoncée + en-têtes figés ───────────────────────────────
|
||||
|
||||
test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
||||
test('annonce la feuille tronquée au lieu de la couper en silence', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const note = page.locator('#content-area .xlsx-truncated');
|
||||
await expect(note).toBeVisible();
|
||||
// Libellé traduit (jamais de texte UI backend, jamais de couleur en dur).
|
||||
await expect(note).toContainText('Feuille tronquée');
|
||||
await expect(note).toContainText('500 lignes affichées sur 520');
|
||||
|
||||
// La dernière ligne rendue est la 500e ; les suivantes ne sont pas là.
|
||||
await expect(page.locator('#content-area td[data-cell="A500"]')).toHaveCount(1);
|
||||
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('garde les en-têtes de colonnes visibles au défilement', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const header = page.locator('#content-area .xlsx-table thead th').nth(1);
|
||||
const before = await header.boundingBox();
|
||||
|
||||
await page.locator('#content-area .csv-table-wrapper').evaluate((el) => { el.scrollTop = 800; });
|
||||
await expect.poll(async () => (await header.boundingBox()).y, { timeout: 5000 })
|
||||
.toBeLessThanOrEqual(before.y + 1);
|
||||
|
||||
// Les numéros de ligne ne se superposent pas en haut à gauche (le `top: auto`
|
||||
// de A8) et la première ligne de données reste lisible sous l'en-tête.
|
||||
const first = await page.locator('#content-area th.xlsx-rownum').first().boundingBox();
|
||||
const second = await page.locator('#content-area th.xlsx-rownum').nth(1).boundingBox();
|
||||
expect(second.y - first.y).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
test('l\'endpoint de fenêtre sert les lignes au-delà du plafond (#153 A9)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
const res = await page.request.get(
|
||||
`${BASE}/api/file/${VAULT}/xlsx/sheet?path=${encodeURIComponent(LARGE)}&sheet=Journal&offset=500&limit=50`
|
||||
);
|
||||
expect(res.status()).toBe(200);
|
||||
const win = await res.json();
|
||||
expect(win.total_rows).toBe(520);
|
||||
expect(win.offset).toBe(500);
|
||||
expect(win.has_more).toBe(false);
|
||||
// Les coordonnées A1 sont celles de la feuille, pas celles de la fenêtre :
|
||||
// la ligne 520 est servie comme A520, pas comme A20.
|
||||
expect(win.html).toContain('data-cell="A520"');
|
||||
expect(win.html).toContain('Operation 519');
|
||||
expect(win.html).not.toContain('data-cell="A1"');
|
||||
});
|
||||
|
||||
test('le bouton « charger la suite » ajoute les lignes cachées (#153 A9bis)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// La ligne 500 est la dernière rendue ; le pied de page l'annonce.
|
||||
const foot = page.locator('#content-area .xlsx-load-more');
|
||||
await expect(foot).toBeVisible();
|
||||
await expect(foot).toContainText('Charger la suite');
|
||||
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
|
||||
|
||||
// Un clic fetch la suite (offset 500, 20 lignes) et l'insère dans la table.
|
||||
await foot.click();
|
||||
await expect(page.locator('#content-area td[data-cell="A520"]')).toBeVisible({ timeout: 10000 });
|
||||
// Une ligne nouvellement arrivée est éditable comme les autres.
|
||||
const cell = page.locator('#content-area td[data-cell="A520"]');
|
||||
await cell.click();
|
||||
await expect(cell).toBeFocused();
|
||||
// Tout est chargé → le pied de page est masqué.
|
||||
await expect(foot).toBeHidden();
|
||||
});
|
||||
|
||||
test('barre de formule et navigation clavier (#153 A7)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// Un clic sur une cellule active la barre avec son nom et son contenu.
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await cell.click();
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('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 page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
|
||||
await expect(page.locator('#xlsx-active-cell')).toHaveText('C3');
|
||||
|
||||
// Éditer depuis la barre marque la cellule dirty, Échap annule.
|
||||
await bar.fill('Operation 2 modifiee');
|
||||
await expect(page.locator('#content-area td[data-cell="C3"]')).toHaveClass(/xlsx-dirty/);
|
||||
await page.locator('#content-area td[data-cell="C3"]').press('Escape');
|
||||
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -7,6 +7,16 @@
|
||||
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
|
||||
* retries with `force: true` (or gives up when refused);
|
||||
* - A4 : the f(x) toggle flips `allow_formula` in the save payload.
|
||||
* - A8 : a sheet bigger than the render caps shows the truncation notice.
|
||||
* - A9bis : the tail of a truncated sheet is fetched window by window from
|
||||
* GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are
|
||||
* editable like the initial ones.
|
||||
* - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
|
||||
* from the bar marks the cell dirty; Escape reverts.
|
||||
* - A13 : header click sorts the rendered rows, the filter hides rows, the
|
||||
* find highlights matches, CSV export downloads the visible sheet.
|
||||
* - A14 : the structure menu sends one PUT …/xlsx/structure with the action,
|
||||
* then re-renders from the server; destructive actions confirm first.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
||||
*/
|
||||
@@ -35,6 +45,7 @@ const dom = new JSDOM(
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.DOMParser = w.DOMParser;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.Element = w.Element;
|
||||
globalThis.Node = w.Node;
|
||||
@@ -89,7 +100,14 @@ async function test(name, fn) {
|
||||
confirmCalls = 0;
|
||||
confirmPrompts = [];
|
||||
confirmAnswer = true;
|
||||
nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
|
||||
apiQueue = [];
|
||||
nextResponse = () => {
|
||||
if (apiQueue.length) return apiQueue.shift();
|
||||
return { ok: true, status: 200, body: { status: "ok" } };
|
||||
};
|
||||
// #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());
|
||||
try {
|
||||
await fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
@@ -112,23 +130,36 @@ await initI18n();
|
||||
|
||||
const sheetHtml = (value) =>
|
||||
'<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">${value}</td></tr>` +
|
||||
'<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">B1</td></tr>` +
|
||||
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">A2</td><td data-cell="B2">B2</td></tr>' +
|
||||
"</tbody></table></div>";
|
||||
|
||||
function mount({ lossy = [] } = {}) {
|
||||
// The JSDOM fetch double serves locale files; everything else is a recorded
|
||||
// API call answered by `nextResponse`. `apiQueue` lets a test script the
|
||||
// successive windows a lazy-loading sheet will request.
|
||||
let apiQueue = [];
|
||||
|
||||
function mount({ lossy = [], sheet = {} } = {}) {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V",
|
||||
path: "data.xlsx",
|
||||
is_xlsx: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }],
|
||||
xlsx_lossy_features: lossy,
|
||||
});
|
||||
return area;
|
||||
}
|
||||
|
||||
/** Mount a sheet flagged truncated so wireLazyRows() arms the footnote. */
|
||||
function mountTruncated({ total = 520, rows = 1 } = {}) {
|
||||
return mount({
|
||||
sheet: { rows, cols: 3, total_rows: total, total_cols: 3, max_rows: 500, max_cols: 40, truncated: total > 500 },
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a cell dirty the way a user edit would. */
|
||||
function editCell(area, ref, text) {
|
||||
const td = area.querySelector(`td[data-cell="${ref}"]`);
|
||||
@@ -137,6 +168,17 @@ function editCell(area, ref, text) {
|
||||
return td;
|
||||
}
|
||||
|
||||
/** #154-A2 — the themed dialog is the last overlay in document.body. */
|
||||
const lastDialog = () => {
|
||||
const overlays = document.querySelectorAll(".obsigate-modal-overlay");
|
||||
return overlays[overlays.length - 1] || null;
|
||||
};
|
||||
const clickDialog = (which) => {
|
||||
const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`);
|
||||
assert.ok(btn, `dialog button "${which}" is present`);
|
||||
btn.click();
|
||||
};
|
||||
|
||||
const lossyError = {
|
||||
ok: false,
|
||||
status: 409,
|
||||
@@ -220,12 +262,17 @@ await test("409 xlsx_lossy_content asks once then retries with force", async ()
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
// #154-A2 — a themed dialog replaces window.confirm(), and no retry happens
|
||||
// before the user answers.
|
||||
const dialog = lastDialog();
|
||||
assert.ok(dialog, "a themed confirmation is shown");
|
||||
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
|
||||
assert.equal(calls.length, 1, "nothing is retried before the answer");
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
// The prompt names the features the backend reported.
|
||||
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
|
||||
// Save succeeded → cells are no longer dirty.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
});
|
||||
@@ -236,27 +283,52 @@ await test("confirming once is enough for the following saves", async () => {
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
editCell(area, "A1", "2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1, "the user is not asked twice");
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[2].body.force, true);
|
||||
// Wait out the dialog close animation, then confirm none is left.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice");
|
||||
});
|
||||
|
||||
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
|
||||
const area = mount({ lossy: ["slicers"] });
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => lossyError;
|
||||
confirmAnswer = false;
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(confirmCalls, 1);
|
||||
clickDialog("cancel");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 1, "no retry after a refusal");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
});
|
||||
|
||||
await test("a 409 conflict shows a non-blocking retry banner and keeps the edits", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const banner = area.querySelector(".xlsx-banner-conflict");
|
||||
assert.ok(banner, "a conflict banner is shown");
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent);
|
||||
// The edit is preserved and the save button is usable again.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
// The retry re-runs the save.
|
||||
nextResponse = () => ({ ok: true, status: 200, body: {} });
|
||||
banner.querySelector(".xlsx-banner-retry").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry");
|
||||
});
|
||||
|
||||
await test("a non-409 failure is not retried", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
@@ -268,6 +340,573 @@ await test("a non-409 failure is not retried", async () => {
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
});
|
||||
|
||||
// ── A8 — truncation notice ───────────────────────────────────────────────────
|
||||
|
||||
await test("no notice when the sheet fits within the render caps", () => {
|
||||
const area = mount({
|
||||
sheet: { rows: 500, cols: 40, total_rows: 500, total_cols: 40, max_rows: 500, max_cols: 40, truncated: false },
|
||||
});
|
||||
assert.equal(area.querySelector(".xlsx-truncated"), null);
|
||||
});
|
||||
|
||||
await test("notice states the cap and the real size of a truncated sheet", () => {
|
||||
const area = mount({
|
||||
sheet: { rows: 500, cols: 12, total_rows: 1200, total_cols: 12, max_rows: 500, max_cols: 40, truncated: true },
|
||||
});
|
||||
const note = area.querySelector(".xlsx-truncated");
|
||||
assert.ok(note, "notice absent");
|
||||
const txt = note.textContent;
|
||||
assert.ok(txt.includes(FR["xlsx.truncated_title"]), txt);
|
||||
// {shown} is the CAP (500), not the post-trim row count: a sparse sheet
|
||||
// renders 1 row but the view still reaches 500 of them.
|
||||
const expected = FR["xlsx.truncated_rows"].replace("{shown}", "500").replace("{total}", "1200");
|
||||
assert.ok(txt.includes(expected), `${txt} !includes ${expected}`);
|
||||
// Nothing to say about the columns here (12 < 40).
|
||||
assert.ok(!txt.includes(FR["xlsx.truncated_cols"]), txt);
|
||||
});
|
||||
|
||||
await test("notice mentions both axes when rows AND columns overflow", () => {
|
||||
const area = mount({
|
||||
sheet: { rows: 1, cols: 40, total_rows: 501, total_cols: 45, max_rows: 500, max_cols: 40, truncated: true },
|
||||
});
|
||||
const txt = area.querySelector(".xlsx-truncated").textContent;
|
||||
assert.ok(
|
||||
txt.includes(FR["xlsx.truncated_cols"].replace("{shown}", "40").replace("{total}", "45")),
|
||||
txt
|
||||
);
|
||||
});
|
||||
|
||||
await test("a payload without the dimensions shows no notice", () => {
|
||||
// Backward compatibility: an older cached response must not produce "NaN".
|
||||
const area = mount({ sheet: { name: "Feuille1" } });
|
||||
assert.equal(area.querySelector(".xlsx-truncated"), null);
|
||||
assert.ok(!area.textContent.includes("NaN"));
|
||||
});
|
||||
|
||||
// ── A9bis — lazy loading of the truncated tail ──────────────────────────────
|
||||
|
||||
const windowHtml = (from, to) =>
|
||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||
"<tbody>" +
|
||||
Array.from({ length: to - from + 1 }, (_, i) => {
|
||||
const r = from + i;
|
||||
return `<tr><th class="xlsx-rownum">${r}</th><td data-cell="A${r}">Ligne ${r}</td></tr>`;
|
||||
}).join("") +
|
||||
"</tbody></table></div>";
|
||||
|
||||
await test("a truncated sheet gets a load-more footnote, a normal one does not", () => {
|
||||
const truncated = mountTruncated();
|
||||
assert.ok(truncated.querySelector(".xlsx-load-more"), "footnote absent");
|
||||
assert.ok(truncated.querySelector(".xlsx-load-more").textContent.includes(FR["xlsx.load_more"]));
|
||||
|
||||
const plain = mount({ sheet: { rows: 10, cols: 2, total_rows: 10, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false } });
|
||||
assert.equal(plain.querySelector(".xlsx-load-more"), null);
|
||||
});
|
||||
|
||||
await test("clicking the footnote fetches the next window with the right query", async () => {
|
||||
const area = mountTruncated();
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 2, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 3) },
|
||||
});
|
||||
area.querySelector(".xlsx-load-more").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/sheet\?path=data\.xlsx&sheet=Feuille1&offset=1&limit=500/);
|
||||
// Rows 2 and 3 landed in the table with their real coordinates.
|
||||
assert.ok(area.querySelector('td[data-cell="A2"]'));
|
||||
assert.ok(area.querySelector('td[data-cell="A3"]'));
|
||||
assert.ok(area.querySelector('th.xlsx-rownum') && area.textContent.includes("Ligne 3"));
|
||||
// Everything loaded → the footnote is hidden (kept in the DOM, class `done`).
|
||||
const foot = area.querySelector(".xlsx-load-more");
|
||||
assert.ok(foot, "the footnote element survives");
|
||||
assert.equal(foot.classList.contains("done"), true);
|
||||
assert.ok(!foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
|
||||
});
|
||||
|
||||
await test("appended rows are editable and tracked as dirty", async () => {
|
||||
const area = mountTruncated();
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
|
||||
});
|
||||
area.querySelector(".xlsx-load-more").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
editCell(area, "A2", "modifié");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(calls.length, 2, "the save went out");
|
||||
assert.equal(calls[1].url.includes("/xlsx/save"), true);
|
||||
assert.deepEqual(calls[1].body.cells, { A2: "modifié" });
|
||||
assert.equal(calls[1].body.sheet, "Feuille1");
|
||||
});
|
||||
|
||||
await test("a failed window fetch keeps the footnote and shows an error toast", async () => {
|
||||
const area = mountTruncated();
|
||||
apiQueue.push({ ok: false, status: 500, body: { detail: "boom" } });
|
||||
area.querySelector(".xlsx-load-more").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const foot = area.querySelector(".xlsx-load-more");
|
||||
assert.ok(foot, "footnote must survive a failed fetch");
|
||||
assert.ok(foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
|
||||
// `api()` itself toasts the failure (shared behaviour, asserted in E2E);
|
||||
// here we assert the local consequence: the footnote keeps its label.
|
||||
// Retrying works once the server answers again.
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
|
||||
});
|
||||
foot.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.ok(area.querySelector('td[data-cell="A2"]'));
|
||||
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
||||
});
|
||||
|
||||
// ── A7 — formula bar & keyboard navigation ──────────────────────────────────
|
||||
|
||||
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-formula-input").disabled, true);
|
||||
});
|
||||
|
||||
await test("focusing a cell shows its name and content in the bar", () => {
|
||||
const area = mount();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.dispatchEvent(new w.Event("focus", { bubbles: false }));
|
||||
// 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-formula-input").value, "100");
|
||||
assert.equal(area.querySelector("#xlsx-formula-input").disabled, false);
|
||||
});
|
||||
|
||||
await test("typing in the bar edits the cell live and marks it dirty", () => {
|
||||
const area = mount();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.focus();
|
||||
const input = area.querySelector("#xlsx-formula-input");
|
||||
input.value = "depuis la barre";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.equal(td.textContent, "depuis la barre");
|
||||
assert.equal(td.classList.contains("xlsx-dirty"), true);
|
||||
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||
// The save payload carries the cell edit.
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
return new Promise((r) => setTimeout(r, 5)).then(() => {
|
||||
assert.deepEqual(calls[0].body.cells, { A1: "depuis la barre" });
|
||||
});
|
||||
});
|
||||
|
||||
await test("Tab and arrows move to the neighbour cell", () => {
|
||||
const area = mount();
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
});
|
||||
|
||||
await test("Enter commits and Shift+Tab goes backwards", () => {
|
||||
const area = mount();
|
||||
const a1 = area.querySelector('td[data-cell="A1"]');
|
||||
a1.focus();
|
||||
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");
|
||||
});
|
||||
|
||||
await test("a saved edit from the bar resets the dirty flag and orig value", async () => {
|
||||
const area = mount();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
td.focus();
|
||||
const input = area.querySelector("#xlsx-formula-input");
|
||||
input.value = "200";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
assert.equal(td.dataset.orig, "200");
|
||||
});
|
||||
|
||||
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
|
||||
|
||||
const mountGrid = () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
const grid =
|
||||
'<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">Banane</td><td data-cell="B1">3</td></tr>' +
|
||||
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">Abricot</td><td data-cell="B2">10</td></tr>' +
|
||||
'<tr><th class="xlsx-rownum">3</th><td data-cell="A3">Cerise</td><td data-cell="B3">2</td></tr>' +
|
||||
"</tbody></table></div>";
|
||||
renderXlsxViewer(area, {
|
||||
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: [],
|
||||
});
|
||||
return area;
|
||||
};
|
||||
|
||||
await test("clicking a header sorts the rows numerically or lexically", () => {
|
||||
const area = mountGrid();
|
||||
// Sort by column B (numbers) ascending: 2, 3, 10.
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
||||
assert.deepEqual(cells, ["2", "3", "10"]);
|
||||
// Second click: descending.
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
||||
assert.deepEqual(cells, ["10", "3", "2"]);
|
||||
});
|
||||
|
||||
await test("a dirty cell travels with its row during a sort", () => {
|
||||
const area = mountGrid();
|
||||
editCell(area, "A3", "Cerise modifiée");
|
||||
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
|
||||
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||
});
|
||||
|
||||
await test("the filter hides the rows that do not match", () => {
|
||||
const area = mountGrid();
|
||||
// The filter reuses the find input: type and the rows filter live.
|
||||
const input = area.querySelector("#xlsx-find-input");
|
||||
input.value = "abri";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
const visible = [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display !== "none");
|
||||
assert.equal(visible.length, 1);
|
||||
assert.ok(visible[0].textContent.includes("Abricot"));
|
||||
});
|
||||
|
||||
await test("find highlights matches and navigates with the counter", () => {
|
||||
const area = mountGrid();
|
||||
const input = area.querySelector("#xlsx-find-input");
|
||||
const count = area.querySelector("#xlsx-find-count");
|
||||
input.value = "cerise"; // lowercase: the default search ignores the case
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 1);
|
||||
assert.ok(count.textContent.includes("1/1"), count.textContent);
|
||||
// The hit is inside the matching cell.
|
||||
assert.ok(area.querySelector('td[data-cell="A3"] mark.xlsx-find-hit'));
|
||||
});
|
||||
|
||||
await test("CSV export downloads the visible sheet without the cached shadows", () => {
|
||||
const area = mountGrid();
|
||||
const clicks = [];
|
||||
const realCreate = document.createElement.bind(document);
|
||||
const anchor = realCreate("a");
|
||||
document.createElement = (tag) => {
|
||||
if (tag === "a") { clicks.push(1); return anchor; }
|
||||
return realCreate(tag);
|
||||
};
|
||||
let href = "";
|
||||
Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href });
|
||||
URL.createObjectURL = () => "blob:x";
|
||||
URL.revokeObjectURL = () => {};
|
||||
area.querySelector("#xlsx-csv-btn").click();
|
||||
document.createElement = realCreate;
|
||||
assert.equal(clicks.length, 1);
|
||||
assert.equal(anchor.download, "Fruits.csv");
|
||||
});
|
||||
|
||||
// ── A14 — structure menu ───────────────────────────────────────────────────
|
||||
|
||||
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
|
||||
const area = mount();
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
||||
}); // re-read
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||
addBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
// #154-A2 — a themed prompt replaces window.prompt().
|
||||
const input = lastDialog()?.querySelector('[data-dialog="input"]');
|
||||
assert.ok(input, "a themed prompt asks for the sheet name");
|
||||
input.value = "Feuille 2";
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.ok(
|
||||
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
|
||||
"the re-render shows the fresh payload",
|
||||
);
|
||||
});
|
||||
|
||||
await test("sheet_delete confirms and is refused on the last sheet", async () => {
|
||||
const area = mount();
|
||||
const delBtn = () => {
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||
const b = items.find((x) => x.textContent === FR["xlsx.sheet_delete"]);
|
||||
b.click();
|
||||
};
|
||||
// One sheet only → blocked before even confirming (no network call).
|
||||
delBtn();
|
||||
assert.equal(calls.length, 0, "nothing sent: last sheet");
|
||||
});
|
||||
|
||||
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
|
||||
const area = mount();
|
||||
// The prompt is answered through the themed dialog.
|
||||
apiQueue.push({
|
||||
ok: false, status: 409,
|
||||
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
|
||||
});
|
||||
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // retry w/ force
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
|
||||
});
|
||||
area.querySelector("#xlsx-structure-btn").click();
|
||||
[...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const lossyDialog = lastDialog();
|
||||
assert.ok(lossyDialog && lossyDialog.textContent.includes(FR["xlsx.feature_slicers"]), "a lossy confirmation is shown");
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[1].body.force, true);
|
||||
});
|
||||
|
||||
// ── A17 — dashboard panel ─────────────────────────────────────────────
|
||||
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
|
||||
const area = mount();
|
||||
const dashBtn = area.querySelector("#xlsx-dashboard-btn");
|
||||
assert.ok(dashBtn, "the dashboard button exists for an editable workbook");
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
vault: "V",
|
||||
path: "data.xlsx",
|
||||
named_ranges: [{ name: "MaPlage", scope: "", ref: "Data!$A$1:$B$5" }],
|
||||
objects: { charts: 2, pivots: 1 },
|
||||
sheets: [{ name: "Feuille1", cells: 9, rows: 3, cols: 3, formulas: 1, numeric: 2, kpi: [{ label: "A2", value: 12 }] }],
|
||||
},
|
||||
});
|
||||
dashBtn.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.match(calls[0].url, /\/xlsx\/dashboard\?path=data\.xlsx/);
|
||||
const panel = area.querySelector(".xlsx-dashboard");
|
||||
assert.ok(panel, "the dashboard panel is rendered");
|
||||
assert.ok(panel.textContent.includes("MaPlage"), "named range is listed");
|
||||
assert.ok(panel.textContent.includes("A2"), "KPI label is rendered");
|
||||
assert.ok(panel.textContent.includes("12"), "KPI value is rendered");
|
||||
// Second click closes the panel.
|
||||
dashBtn.click();
|
||||
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
|
||||
});
|
||||
|
||||
await test("the dashboard opens in the right-hand inspector, not inline in the grid", async () => {
|
||||
const area = mount();
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { vault: "V", path: "data.xlsx", named_ranges: [], objects: { charts: 0, pivots: 0 }, sheets: [{ name: "Feuille1", cells: 1, rows: 1, cols: 1, formulas: 0, numeric: 0, kpi: [] }] },
|
||||
});
|
||||
area.querySelector("#xlsx-dashboard-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const inspector = area.querySelector("#xlsx-inspector");
|
||||
assert.equal(inspector.classList.contains("open"), true, "the inspector opens");
|
||||
assert.equal(inspector.getAttribute("aria-hidden"), "false");
|
||||
assert.ok(inspector.querySelector("#xlsx-inspector-body .xlsx-dashboard"), "the dashboard lives in the inspector");
|
||||
// The grid is still present beside it (not replaced).
|
||||
assert.ok(area.querySelector(".xlsx-main .xlsx-table"), "the grid stays visible");
|
||||
// Closing removes the panel and closes the inspector.
|
||||
area.querySelector("#xlsx-inspector-close").click();
|
||||
assert.equal(area.querySelector(".xlsx-dashboard"), null);
|
||||
assert.equal(inspector.classList.contains("open"), false);
|
||||
assert.equal(inspector.getAttribute("aria-hidden"), "true");
|
||||
});
|
||||
|
||||
await test("the inspector exposes an AI assistant entry point", () => {
|
||||
const area = mount();
|
||||
assert.ok(area.querySelector("#xlsx-inspector-ai"), "AI button in the inspector head");
|
||||
const title = area.querySelector("#xlsx-inspector .xlsx-inspector-title");
|
||||
assert.equal(title.textContent, FR["xlsx.inspector_title"]);
|
||||
});
|
||||
|
||||
await test("a csv mounts without the dashboard button", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V",
|
||||
path: "data.csv",
|
||||
is_csv: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
assert.equal(area.querySelector("#xlsx-dashboard-btn"), null, "no dashboard for csv");
|
||||
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure menu for csv");
|
||||
assert.ok(area.querySelector("td[data-cell=\"A1\"]"), "the grid is still editable");
|
||||
});
|
||||
|
||||
await test("two windows in a row walk the whole sheet", async () => {
|
||||
const area = mountTruncated({ total: 1200 });
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 500, total_rows: 1200, truncated: true, has_more: true, html: windowHtml(2, 501) },
|
||||
});
|
||||
area.querySelector(".xlsx-load-more").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.ok(area.querySelector('td[data-cell="A501"]'));
|
||||
// The footnote still shows, with the updated progress.
|
||||
let foot = area.querySelector(".xlsx-load-more");
|
||||
assert.ok(foot, "more rows remain");
|
||||
assert.ok(foot.textContent.includes("501"), foot.textContent);
|
||||
|
||||
apiQueue.push({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { sheet: "Feuille1", offset: 501, limit: 500, rows: 200, total_rows: 1200, truncated: true, has_more: false, html: windowHtml(502, 701) },
|
||||
});
|
||||
foot.click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
assert.ok(area.querySelector('td[data-cell="A701"]'));
|
||||
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
||||
});
|
||||
|
||||
// ── #154-A1 — command bar, sheet tabs, status pills ─────────────────────────
|
||||
|
||||
await test("a single-sheet workbook still shows its tab and a + button", () => {
|
||||
const area = mount();
|
||||
assert.ok(area.querySelector(".xlsx-tabs"), "the tab strip is always rendered");
|
||||
assert.equal(area.querySelectorAll(".xlsx-tab").length, 1);
|
||||
const add = area.querySelector("#xlsx-tab-add");
|
||||
assert.ok(add, "the + button is present for an editable sheet");
|
||||
assert.equal(add.getAttribute("title"), FR["xlsx.tabs_add_sheet"]);
|
||||
});
|
||||
|
||||
await test("a csv shows no tab strip and no + button", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.csv", is_csv: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
assert.equal(area.querySelector(".xlsx-tabs"), null);
|
||||
assert.equal(area.querySelector("#xlsx-tab-add"), null);
|
||||
});
|
||||
|
||||
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
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
||||
}); // re-read
|
||||
area.querySelector("#xlsx-tab-add").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
|
||||
clickDialog("confirm");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]')?.textContent, "neuf");
|
||||
});
|
||||
|
||||
await test("a read-only workbook shows the read-only pill and offers no editing", () => {
|
||||
const area = document.getElementById("content-area");
|
||||
area.innerHTML = "";
|
||||
renderXlsxViewer(area, {
|
||||
vault: "V", path: "data.xls", is_xlsx: true, xlsx_readonly: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
const pill = area.querySelector(".xlsx-status-readonly");
|
||||
assert.ok(pill, "read-only pill");
|
||||
assert.ok(pill.textContent.includes(FR["xlsx.readonly_badge"]), pill.textContent);
|
||||
assert.equal(area.querySelector("#xlsx-tab-add"), null, "no + for read-only");
|
||||
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure for read-only");
|
||||
assert.equal(area.querySelector("#xlsx-undo-btn"), null, "no history for read-only");
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("contenteditable"), null, "cells are not editable");
|
||||
});
|
||||
|
||||
await test("the formulas-not-recalculated pill is shown for xlsx but not csv", () => {
|
||||
const area = mount();
|
||||
const pill = area.querySelector(".xlsx-status-formula");
|
||||
assert.ok(pill, "formula pill on xlsx");
|
||||
assert.ok(pill.textContent.includes(FR["xlsx.formulas_note"]), pill.textContent);
|
||||
|
||||
const csvArea = document.getElementById("content-area");
|
||||
csvArea.innerHTML = "";
|
||||
renderXlsxViewer(csvArea, {
|
||||
vault: "V", path: "data.csv", is_csv: true,
|
||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||
xlsx_lossy_features: [],
|
||||
});
|
||||
assert.equal(csvArea.querySelector(".xlsx-status-formula"), null);
|
||||
});
|
||||
|
||||
await test("the command bar exposes grouped actions with a primary save", () => {
|
||||
const area = mount();
|
||||
assert.ok(area.querySelector(".xlsx-cmdbar"), "grouped command bar");
|
||||
assert.ok(area.querySelector('#xlsx-save-btn').classList.contains("xlsx-save-primary"));
|
||||
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='formulas']"));
|
||||
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='insert']"));
|
||||
assert.ok(area.querySelector(".xlsx-cmd-group[data-group='file']"));
|
||||
});
|
||||
|
||||
// ── #154-A4 — undo/redo, ARIA ───────────────────────────────────────────────
|
||||
|
||||
await test("undo/redo restores cell edits from the command stack", () => {
|
||||
const area = mount();
|
||||
const td = area.querySelector('td[data-cell="A1"]');
|
||||
const undoBtn = area.querySelector("#xlsx-undo-btn");
|
||||
const redoBtn = area.querySelector("#xlsx-redo-btn");
|
||||
assert.equal(undoBtn.disabled, true, "nothing to undo yet");
|
||||
td.focus();
|
||||
editCell(area, "A1", "42");
|
||||
td.dispatchEvent(new w.Event("blur"));
|
||||
assert.equal(undoBtn.disabled, false, "undo becomes available after a committed edit");
|
||||
undoBtn.click();
|
||||
assert.equal(td.textContent, "100", "undo restores the original value");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0, "the cell is clean again");
|
||||
assert.equal(redoBtn.disabled, false, "redo becomes available");
|
||||
redoBtn.click();
|
||||
assert.equal(td.textContent, "42", "redo reapplies the edit");
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1, "the edit is dirty again");
|
||||
});
|
||||
|
||||
await test("a no-op undo/redo pair leaves the buttons disabled", () => {
|
||||
const area = mount();
|
||||
assert.equal(area.querySelector("#xlsx-undo-btn").disabled, true);
|
||||
assert.equal(area.querySelector("#xlsx-redo-btn").disabled, true);
|
||||
});
|
||||
|
||||
await test("the grid exposes ARIA grid semantics", () => {
|
||||
const area = mount();
|
||||
const table = area.querySelector(".xlsx-table");
|
||||
assert.equal(table.getAttribute("role"), "grid");
|
||||
assert.equal(area.querySelector(".xlsx-table thead th").getAttribute("role"), "columnheader");
|
||||
assert.equal(area.querySelector('td[data-cell="A1"]').getAttribute("role"), "gridcell");
|
||||
assert.equal(area.querySelector("th.xlsx-rownum").getAttribute("role"), "rowheader");
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
|
||||
+165
-6
@@ -1,4 +1,4 @@
|
||||
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083).
|
||||
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083, BUG-091, BUG-093).
|
||||
|
||||
Sans dépendance (pas de PyYAML) : analyse ligne à ligne de
|
||||
`.gitea/workflows/ci.yml`, suffisante pour les conventions de ce fichier.
|
||||
@@ -9,9 +9,29 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
CI_YML = Path(__file__).resolve().parent.parent / ".gitea" / "workflows" / "ci.yml"
|
||||
REQUIREMENTS = Path(__file__).resolve().parent.parent / "backend" / "requirements.txt"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _job_text(job: str) -> str:
|
||||
"""Corps YAML du job `job` (jusqu'au job suivant ou à la fin du fichier)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
start = text.index(f"\n {job}:")
|
||||
rest = text[start + 1 :]
|
||||
nxt = re.search(r"\n {2}[A-Za-z][A-Za-z0-9_-]*:\s*\n", rest)
|
||||
return rest[: nxt.start()] if nxt else rest
|
||||
|
||||
|
||||
def _steps(job: str) -> list[tuple[str, str]]:
|
||||
"""[(nom d'étape, corps YAML)] pour un job donné."""
|
||||
chunks = re.split(r"\n {6}- name: ", "\n" + _job_text(job))[1:]
|
||||
steps = []
|
||||
for chunk in chunks:
|
||||
name, _, body = chunk.partition("\n")
|
||||
steps.append((name.strip(), body))
|
||||
return steps
|
||||
|
||||
|
||||
def _run_bodies() -> list[tuple[int, str]]:
|
||||
"""Toutes les lignes shell de chaque bloc `run:` → [(n° ligne, code)]."""
|
||||
lines = CI_YML.read_text(encoding="utf-8").splitlines()
|
||||
@@ -62,14 +82,99 @@ class TestRunnerProofScripts:
|
||||
|
||||
|
||||
class TestSemgrepStep:
|
||||
def test_semgrep_local_rules_enforced(self):
|
||||
"""#87 T7 : semgrep bloquant sur règles locales (aucun registre)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
assert "semgrep --config semgrep-rules/ backend/" in text, (
|
||||
"#87 T7 : étape semgrep locale attendue dans le job security"
|
||||
"""BUG-091 : semgrep-core est inexécutable sur le runner (exit 127).
|
||||
|
||||
L'étape est donc désactivée (avertissement, non bloquante) au lieu d'être
|
||||
supprimée : elle documente pourquoi, et se réactive telle quelle dès que le
|
||||
runner dispose d'un CPU x86-64-v2.
|
||||
"""
|
||||
|
||||
SEMGREP_STEP_PREFIX = "Semgrep"
|
||||
|
||||
def _semgrep_step(self) -> tuple[str, str]:
|
||||
matches = [
|
||||
(n, b) for n, b in _steps("security") if n.startswith(self.SEMGREP_STEP_PREFIX)
|
||||
]
|
||||
assert len(matches) == 1, (
|
||||
"BUG-091 : une unique étape Semgrep (désactivée) attendue dans le "
|
||||
f"job security, trouvé {len(matches)}"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
@staticmethod
|
||||
def _run_commands(body: str) -> list[str]:
|
||||
"""Commandes shell du bloc `run:` de l'étape (hors lignes vides)."""
|
||||
m = re.search(r"^\s*run:\s*\|?\s*$", body, re.M)
|
||||
assert m, "étape sans bloc `run:`"
|
||||
rest = body[m.end() :]
|
||||
lines: list[str] = []
|
||||
for line in rest.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
# le bloc run: est indenté de 2 spaces de plus que la clef
|
||||
if len(line) - len(line.lstrip()) <= 8:
|
||||
break
|
||||
lines.append(line.strip())
|
||||
return lines
|
||||
|
||||
def test_semgrep_step_does_not_execute_core(self):
|
||||
"""Le core natif ne doit plus être lancé (exit 127 bloquant le job).
|
||||
|
||||
Seule commande admise : l'avertissement d'activation. Le message
|
||||
mentionne voluntaryirement « semgrep » — c'est l'**exécution** qui
|
||||
est interdite, pas le mot.
|
||||
"""
|
||||
name, body = self._semgrep_step()
|
||||
commands = self._run_commands(body)
|
||||
assert commands, f"BUG-091 : l'étape « {name} » n'a plus de commande"
|
||||
for cmd in commands:
|
||||
assert cmd.startswith('echo "::warning::'), (
|
||||
f"BUG-091 : l'étape « {name} » ne doit exécuter qu'un avertissement, "
|
||||
f"trouvé : {cmd!r}"
|
||||
)
|
||||
|
||||
def test_semgrep_step_is_non_blocking_and_explains_itself(self):
|
||||
"""Désactivée = `continue-on-error` + avertissement explicite."""
|
||||
name, body = self._semgrep_step()
|
||||
assert re.search(r"^\s*continue-on-error:\s*true\s*$", body, re.M), (
|
||||
f"BUG-091 : l'étape « {name} » doit porter continue-on-error: true"
|
||||
)
|
||||
assert "::warning::" in body, (
|
||||
f"BUG-091 : l'étape « {name} » doit émettre un ::warning:: "
|
||||
"expliquant la désactivation"
|
||||
)
|
||||
assert "BUG-091" in body, (
|
||||
f"BUG-091 : l'étape « {name} » doit référencer BUG-091"
|
||||
)
|
||||
|
||||
def test_bandit_and_pip_audit_stay_blocking(self):
|
||||
"""La désactivation de semgrep ne doit rien dégraver d'autre (#87)."""
|
||||
found = {}
|
||||
for name, body in _steps("security"):
|
||||
low = name.lower()
|
||||
if low.startswith("bandit"):
|
||||
found["bandit"] = body
|
||||
elif low.startswith("pip-audit"):
|
||||
found["pip-audit"] = body
|
||||
assert set(found) == {"bandit", "pip-audit"}, (
|
||||
f"étapes Bandit et Pip-audit attendues dans le job security, "
|
||||
f"trouvé {sorted(found)}"
|
||||
)
|
||||
for tool, body in found.items():
|
||||
assert "continue-on-error: true" not in body, (
|
||||
f"BUG-091 : l'étape {tool} doit rester bloquante (#87)"
|
||||
)
|
||||
|
||||
def test_semgrep_rules_still_shipped_and_documented(self):
|
||||
"""Les règles locales restent versionnées et documentées (#87 T7)."""
|
||||
rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml"
|
||||
assert rules.exists(), "ruleset semgrep manquant"
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
# La commande locale est documentée (commentaire de l'étape), pas exécutée.
|
||||
assert re.search(r"semgrep --config semgrep-rules/\s*\n?\s*#?\s*backend/", text), (
|
||||
"#87 T7 : commande locale `semgrep --config semgrep-rules/ backend/` "
|
||||
"attendue en commentaire dans le workflow"
|
||||
)
|
||||
|
||||
|
||||
class TestFrontendStepsHaveTheirDeps:
|
||||
@@ -113,3 +218,57 @@ class TestFrontendStepsHaveTheirDeps:
|
||||
f"BUG-082 : `{suite}` attendu dans les deux branches de "
|
||||
"l'étape JSDOM"
|
||||
)
|
||||
|
||||
|
||||
_SPEC_RE = re.compile(
|
||||
r"^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(>=|==|~=|>|<)\s*([0-9][^\s;#]*)"
|
||||
)
|
||||
|
||||
|
||||
def _floor(pkg: str) -> tuple[int, ...] | None:
|
||||
"""Plancher `>=` déclaré pour `pkg` dans backend/requirements.txt."""
|
||||
for raw in REQUIREMENTS.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = _SPEC_RE.match(line)
|
||||
if not m or m.group(1).lower() != pkg or m.group(2) != ">=":
|
||||
continue
|
||||
return tuple(int(p) for p in re.match(r"[0-9]+(?:\.[0-9]+)*", m.group(3)).group(0).split("."))
|
||||
return None
|
||||
|
||||
|
||||
class TestDependencySecurityFloors:
|
||||
"""Planchers de sécurité des dépendances (#87, BUG-091, BUG-093).
|
||||
|
||||
`pip-audit` est bloquant dans le job `security`. Comme l'image du runner
|
||||
(`catthehacker/ubuntu:act-latest`) embarque des paquets *préinstallés* dans
|
||||
sa toolcache Python, un plancher trop bas est « already satisfied » et
|
||||
n'est jamais mis à niveau : c'est exactement ce qui a fait échouer le
|
||||
job sur pypdf 6.16.0 (PYSEC-2026-3910 / PYSEC-2026-3911, DoS de ressources
|
||||
atteignables via backend/pdf_reader.py).
|
||||
"""
|
||||
|
||||
#: (paquet, plancher minimal, advisories corrigées au-dessus)
|
||||
FLOORS = {
|
||||
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
|
||||
"pyjwt": (2, 13, 0), # PYSEC-2026-178 (fix 2.13.0)
|
||||
}
|
||||
|
||||
def test_security_floors_are_declared(self):
|
||||
missing = [p for p in self.FLOORS if _floor(p) is None]
|
||||
assert not missing, (
|
||||
"plancher `>=` manquant dans backend/requirements.txt pour : "
|
||||
f"{missing}"
|
||||
)
|
||||
|
||||
def test_security_floors_are_high_enough(self):
|
||||
too_low = {
|
||||
p: (_floor(p), minimum)
|
||||
for p, minimum in self.FLOORS.items()
|
||||
if (_floor(p) or ()) < minimum
|
||||
}
|
||||
assert not too_low, (
|
||||
"BUG-093 : plancher(s) sous le correctif de sécurité, "
|
||||
f"le job `security` (pip-audit bloquant) échouerait : {too_low}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Unit tests for the existing-workbook AI tools (#153 A6).
|
||||
|
||||
Covers ``list_xlsx_sheets``, ``xlsx_to_markdown``, ``update_xlsx_cells`` and
|
||||
``append_xlsx_rows`` — risk levels, confirmation gating, vault persistence and
|
||||
the reuse of the guarded mutation service (P0 guards, formula neutralisation).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.tools.api import (
|
||||
ToolConfirmationRequired,
|
||||
ToolContext,
|
||||
ToolError,
|
||||
call_tool,
|
||||
get_tool,
|
||||
)
|
||||
from backend.tools.context import ToolRisk
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault(tmp_path, monkeypatch):
|
||||
"""A minimal configured vault (index entry patched, no full build)."""
|
||||
vault_dir = tmp_path / "Vault"
|
||||
vault_dir.mkdir()
|
||||
monkeypatch.setitem(
|
||||
__import__("backend.indexer", fromlist=["index"]).index,
|
||||
"Vault",
|
||||
{"name": "Vault", "path": str(vault_dir), "config": {}},
|
||||
)
|
||||
return vault_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workbook(vault: Path) -> Path:
|
||||
"""A two-sheet workbook: Budget (values + a formula) and Notes."""
|
||||
path = vault / "classeur.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Budget"
|
||||
ws.append(["Poste", "Montant"])
|
||||
ws.append(["Loyer", 900])
|
||||
ws.append(["Courses", 250])
|
||||
notes = wb.create_sheet("Notes")
|
||||
notes["A1"] = "bonjour"
|
||||
wb.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _ctx() -> ToolContext:
|
||||
return ToolContext(
|
||||
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
||||
audit_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_read_tools_are_read_risk(self):
|
||||
for name in ("list_xlsx_sheets", "xlsx_to_markdown"):
|
||||
spec = get_tool(name)
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.READ
|
||||
|
||||
def test_mutation_tools_require_confirmation(self):
|
||||
for name in ("update_xlsx_cells", "append_xlsx_rows"):
|
||||
spec = get_tool(name)
|
||||
assert spec is not None
|
||||
assert spec.risk == ToolRisk.WRITE
|
||||
assert spec.requires_confirmation is True
|
||||
|
||||
def test_mutation_tools_raise_without_confirmation(self, vault, workbook):
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "cells": {"B3": "300"},
|
||||
})
|
||||
with pytest.raises(ToolConfirmationRequired):
|
||||
call_tool("append_xlsx_rows", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "rows": [["Total", 1150]],
|
||||
})
|
||||
|
||||
|
||||
class TestListXlsxSheets:
|
||||
def test_lists_names_and_dimensions(self, vault, workbook):
|
||||
out = call_tool("list_xlsx_sheets", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
})
|
||||
assert out.ok
|
||||
data = out.data
|
||||
assert [s["name"] for s in data["sheets"]] == ["Budget", "Notes"]
|
||||
budget = data["sheets"][0]
|
||||
assert budget["total_rows"] == 3 and budget["total_cols"] == 2
|
||||
assert budget["truncated"] is False
|
||||
|
||||
def test_wrong_extension_rejected(self, vault):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("list_xlsx_sheets", _ctx(), {
|
||||
"vault": "Vault", "path": "note.md",
|
||||
})
|
||||
|
||||
def test_missing_file_rejected(self, vault):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("list_xlsx_sheets", _ctx(), {
|
||||
"vault": "Vault", "path": "absent.xlsx",
|
||||
})
|
||||
|
||||
|
||||
class TestXlsxToMarkdown:
|
||||
def test_renders_a_bounded_markdown_table(self, vault, workbook):
|
||||
out = call_tool("xlsx_to_markdown", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
})
|
||||
assert out.ok
|
||||
data = out.data
|
||||
assert data["sheet"] == "Budget"
|
||||
assert data["rows"] == 3 and data["truncated"] is False
|
||||
assert "| Poste | Montant |" in data["markdown"]
|
||||
assert "| Loyer | 900 |" in data["markdown"]
|
||||
|
||||
def test_specific_sheet(self, vault, workbook):
|
||||
out = call_tool("xlsx_to_markdown", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "sheet": "Notes",
|
||||
})
|
||||
assert out.ok
|
||||
data = out.data
|
||||
assert data["sheet"] == "Notes"
|
||||
assert "bonjour" in data["markdown"]
|
||||
|
||||
def test_unknown_sheet_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("xlsx_to_markdown", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx", "sheet": "Nope",
|
||||
})
|
||||
|
||||
def test_big_sheet_is_flagged_truncated(self, vault):
|
||||
path = vault / "gros.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
for i in range(150):
|
||||
ws.append([f"r{i}", i])
|
||||
wb.save(path)
|
||||
out = call_tool("xlsx_to_markdown", _ctx(), {
|
||||
"vault": "Vault", "path": "gros.xlsx",
|
||||
})
|
||||
assert out.ok
|
||||
data = out.data
|
||||
assert data["rows"] == 100
|
||||
assert data["truncated"] is True
|
||||
|
||||
|
||||
class TestUpdateXlsxCells:
|
||||
def test_edits_cells_and_survives_a_reload(self, vault, workbook):
|
||||
out = call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "cells": {"B3": "300"},
|
||||
}, confirm=True)
|
||||
assert out.ok and out.data["status"] == "ok"
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
assert wb["Budget"]["B3"].value == 300 # coerced like the viewer
|
||||
wb.close()
|
||||
|
||||
def test_formula_stays_text_by_default(self, vault, workbook):
|
||||
out = call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "cells": {"C1": "=B2+B3"},
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
assert wb["Budget"]["C1"].data_type == "s" # A4 guard inherited
|
||||
wb.close()
|
||||
|
||||
def test_empty_cells_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("update_xlsx_cells", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "cells": {},
|
||||
})
|
||||
|
||||
|
||||
class TestAppendXlsxRows:
|
||||
def test_appends_below_the_last_row(self, vault, workbook):
|
||||
out = call_tool("append_xlsx_rows", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "rows": [["Total", 1150]],
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
assert out.data["first_row"] == 4
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
ws = wb["Budget"]
|
||||
assert ws["A4"].value == "Total"
|
||||
assert ws["B4"].value == 1150 and isinstance(ws["B4"].value, int)
|
||||
wb.close()
|
||||
|
||||
def test_values_are_coerced(self, vault, workbook):
|
||||
out = call_tool("append_xlsx_rows", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "rows": [["VRAI", "01/02/2026", 12.5]],
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
wb = openpyxl.load_workbook(workbook)
|
||||
ws = wb["Budget"]
|
||||
assert ws["A4"].value is True
|
||||
assert (ws["B4"].value.month, ws["B4"].value.day) == (2, 1)
|
||||
assert ws["C4"].value == 12.5
|
||||
wb.close()
|
||||
|
||||
def test_unknown_sheet_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("append_xlsx_rows", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Nope", "rows": [["x"]],
|
||||
})
|
||||
|
||||
def test_empty_rows_rejected(self, vault, workbook):
|
||||
with pytest.raises(ToolError):
|
||||
call_tool("append_xlsx_rows", _ctx(), {
|
||||
"vault": "Vault", "path": "classeur.xlsx",
|
||||
"sheet": "Budget", "rows": [],
|
||||
})
|
||||
+15
-2
@@ -10,6 +10,19 @@ from backend.tools.context import ToolContext, ToolError, ToolMode, ToolRisk
|
||||
from backend.tools.registry import get_tool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_dns(monkeypatch):
|
||||
"""Neutralise la résolution DNS réelle du garde SSRF (runner au réseau fragile).
|
||||
|
||||
Seuls les tests qui vérifient l'extraction HTML mockent ``httpx.get`` ; sans
|
||||
ce mock, ``_assert_public_http_url`` résolvait ``example.com`` pour de vrai et
|
||||
le test échouait en ``dns_error`` sur un runner dont le DNS est instable.
|
||||
Les tests de garde SSRF (``test_private_address_rejected``) n'utilisent PAS
|
||||
la fixture : ils doivent au contraire traverser le vrai garde.
|
||||
"""
|
||||
monkeypatch.setattr(web, "_assert_public_http_url", lambda url: url)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: Any = None, json_data: Any = None, status_code: int = 200,
|
||||
content: bytes = b"", headers: dict | None = None, url: str = "https://example.com/x"):
|
||||
@@ -171,7 +184,7 @@ class TestWebSearch:
|
||||
|
||||
|
||||
class TestFetchUrl:
|
||||
def test_html_converted_to_text(self, monkeypatch):
|
||||
def test_html_converted_to_text(self, monkeypatch, no_dns):
|
||||
html = (b"<html><head><title>T&</title><style>b{}</style>"
|
||||
b"<script>evil()</script></head><body><p>hello</p><ul>"
|
||||
b"<li>one</li><li>two</li></ul></body></html>")
|
||||
@@ -196,7 +209,7 @@ class TestFetchUrl:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(url="file:///etc/passwd"))
|
||||
assert ei.value.code == "invalid_scheme"
|
||||
|
||||
def test_binary_content_rejected(self, monkeypatch):
|
||||
def test_binary_content_rejected(self, monkeypatch, no_dns):
|
||||
monkeypatch.setattr(web.httpx, "get",
|
||||
lambda *a, **k: FakeResponse(content=b"%PDF-1.4...",
|
||||
headers={"content-type": "application/pdf"}))
|
||||
|
||||
+16
-3
@@ -20,7 +20,7 @@ class TestRegistration:
|
||||
|
||||
|
||||
class TestRenderUnavailable:
|
||||
def test_missing_playwright_clear_error(self, monkeypatch):
|
||||
def test_missing_playwright_clear_error(self, monkeypatch, no_dns):
|
||||
monkeypatch.setattr(webrender, "_playwright_available", lambda: False)
|
||||
with pytest.raises(ToolError) as ei:
|
||||
web.fetch_url(_ctx(), web.FetchUrlInput(
|
||||
@@ -35,8 +35,21 @@ class TestRenderUnavailable:
|
||||
assert ei.value.code in ("ssrf_blocked", "dns_error")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_dns(monkeypatch):
|
||||
"""Neutralise la résolution DNS réelle du garde SSRF.
|
||||
|
||||
``fetch_url`` appelle ``_assert_public_http_url`` (getaddrinfo) *avant* le
|
||||
rendu : sur un runner au DNS instable le test échouait en ``dns_error``
|
||||
au lieu d'atteindre le worker Playwright mocké. ``webrender`` importe la
|
||||
fonction dans son propre namespace : les deux références sont mockées.
|
||||
"""
|
||||
monkeypatch.setattr(web, "_assert_public_http_url", lambda url: url)
|
||||
monkeypatch.setattr(webrender, "_assert_public_http_url", lambda url: url)
|
||||
|
||||
|
||||
class TestRenderSuccess:
|
||||
def test_fetch_url_delegates_to_worker(self, monkeypatch):
|
||||
def test_fetch_url_delegates_to_worker(self, monkeypatch, no_dns):
|
||||
captured = {}
|
||||
|
||||
def fake_render(url):
|
||||
@@ -51,7 +64,7 @@ class TestRenderSuccess:
|
||||
assert out["rendered"] is True
|
||||
assert "dynamic content" in out["text"]
|
||||
|
||||
def test_worker_failure_maps_to_tool_error(self, monkeypatch):
|
||||
def test_worker_failure_maps_to_tool_error(self, monkeypatch, no_dns):
|
||||
monkeypatch.setattr(webrender, "_playwright_available", lambda: True)
|
||||
|
||||
def boom(url):
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Workbook dashboard: named ranges, chart/pivot objects and per-sheet KPI
|
||||
stats (#153 A17), plus the ``GET …/xlsx/dashboard`` endpoint wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
VAULT = "TestVault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dash_book(test_vault_dir: str) -> str:
|
||||
"""A workbook with a named range, a chart and numeric KPI cells."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.chart import BarChart, Reference
|
||||
from openpyxl.workbook.defined_name import DefinedName
|
||||
|
||||
path = Path(test_vault_dir) / "dash.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "Ventes"
|
||||
ws["A2"] = 12
|
||||
ws["A3"] = 48
|
||||
ws["B2"] = "=SUM(A2:A3)"
|
||||
wb.defined_names.add(DefinedName("MaPlage", attr_text="Data!$A$1:$B$5"))
|
||||
chart = BarChart()
|
||||
chart.add_data(Reference(ws, min_col=1, min_row=1, max_row=3))
|
||||
ws.add_chart(chart, "D2")
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_book(test_vault_dir: str) -> str:
|
||||
"""A workbook without any dashboard-worthy feature."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "plain.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Vide"
|
||||
ws["A1"] = "texte seul"
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
class TestDashboardReading:
|
||||
def test_named_range_is_detected(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
names = [r["name"] for r in dash["named_ranges"]]
|
||||
assert "MaPlage" in names
|
||||
entry = next(r for r in dash["named_ranges"] if r["name"] == "MaPlage")
|
||||
assert "Data!" in entry["ref"]
|
||||
|
||||
def test_charts_are_counted(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
assert dash["objects"]["charts"] >= 1
|
||||
assert dash["objects"]["pivots"] == 0
|
||||
|
||||
def test_sheet_kpis_carry_numeric_cells(self, dash_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(dash_book))
|
||||
sheet = next(s for s in dash["sheets"] if s["name"] == "Data")
|
||||
assert sheet["cells"] >= 4
|
||||
assert sheet["formulas"] == 1
|
||||
assert sheet["numeric"] >= 2
|
||||
values = [k["value"] for k in sheet["kpi"]]
|
||||
assert 12 in values and 48 in values
|
||||
|
||||
def test_plain_book_yields_empty_dashboard(self, plain_book):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
dash = read_workbook_dashboard(Path(plain_book))
|
||||
assert dash["named_ranges"] == []
|
||||
assert dash["objects"]["charts"] == 0
|
||||
sheet = dash["sheets"][0]
|
||||
assert sheet["numeric"] == 0
|
||||
assert sheet["kpi"] == []
|
||||
|
||||
def test_broken_book_yields_empty_payload(self, test_vault_dir):
|
||||
from backend.xlsx_reader import read_workbook_dashboard
|
||||
|
||||
bad = Path(test_vault_dir) / "broken-dash.xlsx"
|
||||
bad.write_bytes(b"not a zip")
|
||||
dash = read_workbook_dashboard(bad)
|
||||
assert dash["named_ranges"] == []
|
||||
assert dash["sheets"] == []
|
||||
|
||||
|
||||
class TestDashboardEndpoint:
|
||||
def test_endpoint_serves_the_dashboard(self, client, dash_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": dash_book})
|
||||
assert resp.status_code == 200 # sanity: file is readable
|
||||
dash = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": dash_book}
|
||||
).json()
|
||||
assert any(r["name"] == "MaPlage" for r in dash["named_ranges"])
|
||||
assert dash["objects"]["charts"] >= 1
|
||||
assert any(s["kpi"] for s in dash["sheets"])
|
||||
|
||||
def test_unknown_file_is_404(self, client):
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "nope.xlsx"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_non_workbook_is_415(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "texte.txt").write_text("hello", encoding="utf-8")
|
||||
resp = client.get(
|
||||
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "texte.txt"}
|
||||
)
|
||||
assert resp.status_code == 415
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Additional spreadsheet formats in the viewer (#153 A16): .xlsm editable
|
||||
with macros preserved, .xls/.ods read-only renders and .csv A1-addressed
|
||||
saves."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
VAULT = "TestVault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def formats_vault(test_vault_dir: str) -> Path:
|
||||
"""Directory of the test vault, for fixture files written in-place."""
|
||||
return Path(test_vault_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xlsm_book(formats_vault: Path) -> str:
|
||||
"""A macro-enabled workbook: one sheet, two cells, a fake VBA blob."""
|
||||
path = formats_vault / "macro.xlsm"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "Nom"
|
||||
ws["A2"] = "Ada"
|
||||
wb.save(path)
|
||||
# Inject a minimal vbaProject.bin part so the archive really IS a .xlsm
|
||||
# (keep_vba only matters when macros exist).
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
real = path.with_suffix(".tmp.xlsm")
|
||||
with zipfile.ZipFile(path) as zin, zipfile.ZipFile(real, "w") as zout:
|
||||
for item in zin.namelist():
|
||||
zout.writestr(item, zin.read(item))
|
||||
zout.writestr("xl/vbaProject.bin", b"VBA-FAKE-CONTENT")
|
||||
shutil.move(real, path)
|
||||
return "macro.xlsm"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ods_book(formats_vault: Path) -> str:
|
||||
"""A small ODS spreadsheet."""
|
||||
# The odfpy distribution installs a top-level module named `odf`.
|
||||
pytest.importorskip("odf")
|
||||
from odf.opendocument import OpenDocumentSpreadsheet
|
||||
from odf.table import Table, TableCell, TableRow
|
||||
from odf.text import P
|
||||
|
||||
path = formats_vault / "classeur.ods"
|
||||
doc = OpenDocumentSpreadsheet()
|
||||
table = Table(name="Feuille1")
|
||||
for values in (["Ville", "Pop"], ["Paris", "2100000"]):
|
||||
tr = TableRow()
|
||||
for v in values:
|
||||
tc = TableCell(valuetype="string")
|
||||
tc.addElement(P(text=str(v)))
|
||||
tr.addElement(tc)
|
||||
table.addElement(tr)
|
||||
doc.spreadsheet.addElement(table)
|
||||
doc.save(str(path))
|
||||
return "classeur.ods"
|
||||
|
||||
|
||||
class TestXlsmEditable:
|
||||
def test_read_serves_the_xlsx_viewer_payload(self, client, xlsm_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_xlsx"] is True
|
||||
assert data["extension"] == ".xlsm"
|
||||
# Macros are NOT lossy for .xlsm: keep_vba preserves them, so no
|
||||
# confirmation round-trip is ever triggered.
|
||||
assert data["xlsx_lossy_features"] == []
|
||||
assert 'data-cell="A1"' in data["xlsx_sheets"][0]["html"]
|
||||
|
||||
def test_save_round_trips_and_keeps_vba(self, client, xlsm_book):
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": xlsm_book},
|
||||
json={"sheet": "Data", "cells": {"A2": "Grace"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
import zipfile
|
||||
|
||||
# Re-read through the API instead of guessing the root on disk.
|
||||
reread = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book})
|
||||
assert "Grace" in reread.json()["xlsx_sheets"][0]["html"]
|
||||
# The fake VBA part survived the round-trip.
|
||||
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):
|
||||
from backend.services.mutations import edit_xlsx_cells
|
||||
|
||||
# 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"})
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
class TestLegacyReadOnly:
|
||||
def test_xls_renders_cells(self, client, formats_vault):
|
||||
pytest.importorskip("xlrd")
|
||||
import shutil
|
||||
|
||||
shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls")
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "sample.xls"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_xlsx"] is True
|
||||
assert data["xlsx_readonly"] is True
|
||||
sheet = data["xlsx_sheets"][0]
|
||||
assert sheet["name"] == "Data"
|
||||
assert "Produit" in sheet["html"]
|
||||
assert "Café" in sheet["html"]
|
||||
|
||||
def test_ods_renders_cells(self, client, ods_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": ods_book})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["xlsx_readonly"] is True
|
||||
html = data["xlsx_sheets"][0]["html"]
|
||||
assert "Paris" in html
|
||||
assert "2100000" in html
|
||||
|
||||
def test_readonly_meta_is_empty(self, client, formats_vault):
|
||||
pytest.importorskip("xlrd")
|
||||
import shutil
|
||||
|
||||
shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls")
|
||||
sheet = client.get(
|
||||
f"/api/file/{VAULT}", params={"path": "sample.xls"}
|
||||
).json()["xlsx_sheets"][0]
|
||||
assert sheet["styles"] == {}
|
||||
assert sheet["merges"] == []
|
||||
assert sheet["freeze"] == ""
|
||||
|
||||
def test_broken_legacy_file_yields_one_empty_sheet(self, client, formats_vault):
|
||||
(formats_vault / "broken.xls").write_bytes(b"not an ole file")
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "broken.xls"})
|
||||
assert resp.status_code == 200
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert sheet["rows"] == 0
|
||||
|
||||
|
||||
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")
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "liste.csv"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_csv"] is True
|
||||
html = data["html"]
|
||||
assert 'data-cell="A1"' in html
|
||||
assert "xlsx-table" in html
|
||||
|
||||
def test_save_cells_and_grow_the_grid(self, client, formats_vault):
|
||||
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "liste.csv"},
|
||||
json={"cells": {"B1": "modifié", "C3": "nouveau"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
text = _vault_file(client, "liste.csv").read_text(encoding="utf-8")
|
||||
assert "modifié" in text
|
||||
assert "nouveau" in text
|
||||
# Existing rows survive, and the reference beyond the extent grew the
|
||||
# grid to 3 rows x 3 cols.
|
||||
assert text.startswith("a,modifié")
|
||||
assert "1,2" in text
|
||||
assert text.count("\n") >= 2
|
||||
|
||||
def test_csv_values_are_stored_verbatim(self, client, formats_vault):
|
||||
(formats_vault / "formules.csv").write_text("x\n", encoding="utf-8")
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "formules.csv"},
|
||||
json={"cells": {"A1": "=1+1"}},
|
||||
)
|
||||
text = _vault_file(client, "formules.csv").read_text(encoding="utf-8")
|
||||
assert "=1+1" in text # no formula engine: stored as text
|
||||
|
||||
def test_quoting_survives_a_round_trip(self, client, formats_vault):
|
||||
(formats_vault / "quotes.csv").write_text('nom\n"Dupont, Jean"\n', encoding="utf-8")
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "quotes.csv"},
|
||||
json={"cells": {"A2": "Martin, Pierre"}},
|
||||
)
|
||||
text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8")
|
||||
assert '"Martin, Pierre"' 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(
|
||||
f"/api/file/{VAULT}/csv/save",
|
||||
params={"path": "liste.csv"},
|
||||
json={"cells": {"XX": "v"}}, # missing row number
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def _vault_file(client, rel: str) -> Path:
|
||||
"""Resolve a vault file path for on-disk assertions."""
|
||||
root = None
|
||||
for route in client.app.routes:
|
||||
pass
|
||||
# The service layer exposes the vault root; use it directly.
|
||||
from backend.services.vaults import get_vault_root
|
||||
|
||||
root = get_vault_root(VAULT)
|
||||
return root / rel
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Structural mutations of an .xlsx workbook (#153 A14): service + endpoint.
|
||||
|
||||
Covers sheet add/rename/delete/duplicate and row/col insert/delete, the
|
||||
atomicity of the batch (one locked rewrite) and the shared P0 guards
|
||||
(lossy 409 gate, backup, path safety).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
VAULT = "TestVault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def book(test_vault_dir: str) -> str:
|
||||
path = Path(test_vault_dir) / "struct.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws.append(["Nom", "Valeur"])
|
||||
ws.append(["a", 1])
|
||||
ws.append(["b", 2])
|
||||
wb.create_sheet("Vide")
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _put(client, path="struct.xlsx", actions=None, **extra):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/structure",
|
||||
params={"path": path},
|
||||
json={"actions": actions, **extra},
|
||||
)
|
||||
|
||||
|
||||
def _wb(path):
|
||||
wb = openpyxl.load_workbook(path)
|
||||
try:
|
||||
return wb
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
class TestSheetOps:
|
||||
def test_add_rename_delete_sheet(self, client, book):
|
||||
resp = _put(client, actions=[
|
||||
{"op": "sheet_add", "name": "Extra", "at": 0},
|
||||
{"op": "sheet_rename", "from": "Vide", "to": "Renommée"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb.sheetnames[0] == "Extra" # inserted at position 0
|
||||
assert "Renommée" in wb.sheetnames and "Vide" not in wb.sheetnames
|
||||
wb.close()
|
||||
|
||||
resp = _put(client, actions=[{"op": "sheet_delete", "name": "Extra"}])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert "Extra" not in wb.sheetnames
|
||||
wb.close()
|
||||
|
||||
def test_delete_last_sheet_refused(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "solo.xlsx").write_bytes(book_bytes("Solo"))
|
||||
resp = _put(client, path="solo.xlsx", actions=[
|
||||
{"op": "sheet_delete", "name": "Solo"},
|
||||
])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_duplicate_copies_values(self, client, book):
|
||||
resp = _put(client, actions=[
|
||||
{"op": "sheet_duplicate", "name": "Data", "as": "Data copie"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data copie"]["A1"].value == "Nom"
|
||||
assert wb["Data copie"]["B3"].value == 2
|
||||
wb.close()
|
||||
|
||||
|
||||
def book_bytes(sheet_name: str) -> bytes:
|
||||
import io
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
wb.active.title = sheet_name
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestRowColOps:
|
||||
def test_row_insert_shifts_and_delete_removes(self, client, book):
|
||||
resp = _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": 2, "count": 1},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
ws = wb["Data"]
|
||||
assert ws["A2"].value is None # the new blank row
|
||||
assert ws["A3"].value == "a" # shifted down
|
||||
wb.close()
|
||||
|
||||
resp = _put(client, actions=[
|
||||
{"op": "row_delete", "sheet": "Data", "at": 2, "count": 1},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"]["A2"].value == "a"
|
||||
wb.close()
|
||||
|
||||
def test_col_insert_and_delete(self, client, book):
|
||||
assert _put(client, actions=[
|
||||
{"op": "col_insert", "sheet": "Data", "at": 2},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"]["B1"].value is None
|
||||
assert wb["Data"]["C1"].value == "Valeur"
|
||||
wb.close()
|
||||
|
||||
assert _put(client, actions=[
|
||||
{"op": "col_delete", "sheet": "Data", "at": 2},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"]["B1"].value == "Valeur"
|
||||
wb.close()
|
||||
|
||||
|
||||
class TestGuards:
|
||||
def test_unknown_sheet_is_400(self, client, book):
|
||||
resp = _put(client, actions=[{"op": "row_insert", "sheet": "Nope", "at": 1}])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_unknown_op_is_400(self, client, book):
|
||||
resp = _put(client, actions=[{"op": "sheet_explode", "name": "X"}])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_bad_position_is_400(self, client, book):
|
||||
resp = _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": "deux"},
|
||||
])
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_empty_actions_is_400(self, client, book):
|
||||
assert _put(client, actions=[]).status_code == 400
|
||||
|
||||
def test_lossy_workbook_refused_without_force(self, client, test_vault_dir):
|
||||
"""Same 409 gate as the cell edits (A1)."""
|
||||
path = Path(test_vault_dir) / "lossy-struct.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
wb.active.title = "S"
|
||||
wb["S"]["A1"] = "=A2" # no cached value -> add one via the raw XML
|
||||
wb.save(path)
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
items = {n: zf.read(n) for n in zf.namelist()}
|
||||
sheet = next(n for n in items if n.startswith("xl/worksheets/sheet"))
|
||||
items[sheet] = items[sheet].decode("utf-8").replace(
|
||||
"<f>A2</f>", "<f>A2</f><v>7</v>"
|
||||
).encode("utf-8")
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, blob in items.items():
|
||||
zf.writestr(name, blob)
|
||||
|
||||
resp = _put(client, path="lossy-struct.xlsx", actions=[
|
||||
{"op": "sheet_add", "name": "X"},
|
||||
])
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == "xlsx_lossy_content"
|
||||
|
||||
resp = _put(client, path="lossy-struct.xlsx", actions=[
|
||||
{"op": "sheet_add", "name": "X"},
|
||||
], force=True)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_atomicity_one_bad_action_writes_nothing(self, client, book):
|
||||
"""A batch with a valid action followed by a bad one writes nothing."""
|
||||
resp = _put(client, actions=[
|
||||
{"op": "sheet_add", "name": "Temp"},
|
||||
{"op": "sheet_delete", "name": "Inexistante"},
|
||||
])
|
||||
assert resp.status_code == 400
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert "Temp" not in wb.sheetnames
|
||||
wb.close()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Style metadata in the .xlsx viewer (#153 A15): bold, colors, number formats,
|
||||
merged ranges and freeze panes — plus the endpoint wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
VAULT = "TestVault"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def styled_book(test_vault_dir: str) -> str:
|
||||
"""A workbook with a bold red-font header, a filled cell, a merge and a freeze."""
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
|
||||
path = Path(test_vault_dir) / "styles.xlsx"
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "En-tête"
|
||||
ws["A1"].font = Font(bold=True, italic=True, color="FFCC0000")
|
||||
ws["B1"] = "Total"
|
||||
ws["B1"].fill = PatternFill("solid", fgColor="FFFFEE99")
|
||||
ws["B2"] = 1234.5
|
||||
ws["B2"].number_format = "#,##0.00"
|
||||
ws["C3"] = "fusionnée"
|
||||
ws.merge_cells("C3:D3")
|
||||
ws["C3"].value = "fusionnée"
|
||||
ws.freeze_panes = "A2"
|
||||
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
|
||||
|
||||
styles = read_sheet_styles(Path(styled_book), "Data")
|
||||
a1 = styles["A1"]
|
||||
assert "font-weight:600" in a1["style"]
|
||||
assert "font-style:italic" in a1["style"]
|
||||
assert a1["style"].startswith("color:#cc0000")
|
||||
|
||||
def test_background_fill_is_read(self, styled_book):
|
||||
from backend.xlsx_reader import read_sheet_styles
|
||||
|
||||
styles = read_sheet_styles(Path(styled_book), "Data")
|
||||
assert "background:#ffee99" in styles["B1"]["style"]
|
||||
|
||||
def test_number_format_is_signalled(self, styled_book):
|
||||
from backend.xlsx_reader import read_sheet_styles
|
||||
|
||||
styles = read_sheet_styles(Path(styled_book), "Data")
|
||||
assert "font-family" in styles["B2"]["style"]
|
||||
|
||||
def test_plain_cell_has_no_entry(self, styled_book):
|
||||
from backend.xlsx_reader import read_sheet_styles
|
||||
|
||||
styles = read_sheet_styles(Path(styled_book), "Data")
|
||||
assert "A2" not in styles # untouched cell
|
||||
|
||||
def test_merges_and_freeze(self, styled_book):
|
||||
from backend.xlsx_reader import read_sheet_freeze, read_sheet_merges
|
||||
|
||||
assert read_sheet_merges(Path(styled_book), "Data") == ["C3:D3"]
|
||||
assert read_sheet_freeze(Path(styled_book), "Data") == "A2"
|
||||
|
||||
def test_unknown_sheet_yields_empty(self, styled_book):
|
||||
from backend.xlsx_reader import read_sheet_styles
|
||||
|
||||
assert read_sheet_styles(Path(styled_book), "Nope") == {}
|
||||
|
||||
def test_broken_file_yields_empty(self, test_vault_dir):
|
||||
from backend.xlsx_reader import read_sheet_styles
|
||||
|
||||
bad = Path(test_vault_dir) / "broken-styles.xlsx"
|
||||
bad.write_bytes(b"not a zip")
|
||||
assert read_sheet_styles(bad, "Data") == {}
|
||||
|
||||
|
||||
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"})
|
||||
assert resp.status_code == 200
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert sheet["styles"]["A1"].startswith("color:#cc0000")
|
||||
assert sheet["aligns"] == {} or isinstance(sheet["aligns"], dict)
|
||||
assert sheet["merges"] == ["C3:D3"]
|
||||
assert sheet["freeze"] == "A2"
|
||||
|
||||
def test_rendered_html_carries_the_inline_style(self, client, styled_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
||||
html = resp.json()["xlsx_sheets"][0]["html"]
|
||||
assert 'data-cell="A1" style="' in html
|
||||
assert "font-weight:600" in html
|
||||
+361
-2
@@ -106,16 +106,208 @@ class TestXlsxIndexing:
|
||||
|
||||
assert ".xlsx" in SUPPORTED_EXTENSIONS
|
||||
|
||||
def test_xlsx_indexed_metadata_only(self, test_vault_dir, xlsx_file):
|
||||
def test_xlsx_indexes_sheet_names_and_headers(self, test_vault_dir, xlsx_file):
|
||||
"""#153 A5 — a workbook is searchable by its cell values."""
|
||||
from backend.indexer import _index_single_file_sync
|
||||
|
||||
info = _index_single_file_sync(VAULT, test_vault_dir, xlsx_file)
|
||||
assert info is not None
|
||||
assert info["extension"] == ".xlsx"
|
||||
assert info["content"] == "" # binary: never read into TF-IDF
|
||||
# Sheet names + header rows reach TF-IDF (was metadata-only before A5).
|
||||
assert "Budget" in info["content"]
|
||||
assert "Poste" in info["content"]
|
||||
assert info["content_preview"]
|
||||
assert info["title"] # filename-derived title
|
||||
|
||||
|
||||
class TestXlsxCachedValues:
|
||||
"""#153 A12 — show what Excel last computed next to each formula."""
|
||||
|
||||
def test_cached_result_is_shown_beside_the_formula(self, client, lossy_xlsx):
|
||||
from backend.xlsx_reader import render_sheets
|
||||
|
||||
html = render_sheets(Path(lossy_xlsx))[0]["html"]
|
||||
# A2 is "=A1*3" with <v>9</v> in the fixture.
|
||||
assert 'data-cell="A2"' in html
|
||||
assert "=A1*3" in html
|
||||
assert "xlsx-cached" in html
|
||||
assert ">9<" in html # the cached result Excel computed
|
||||
|
||||
def test_no_shadow_when_no_formula_carries_a_result(self, client, xlsx_file):
|
||||
from backend.xlsx_reader import render_sheets
|
||||
|
||||
html = render_sheets(Path(xlsx_file))[0]["html"]
|
||||
assert "xlsx-cached" not in html # budget.xlsx has no <v> at all
|
||||
|
||||
def test_plain_cells_are_never_duplicated(self, client, lossy_xlsx):
|
||||
from backend.xlsx_reader import render_sheets
|
||||
|
||||
html = render_sheets(Path(lossy_xlsx))[0]["html"]
|
||||
# A1 is the literal 3: the two reads agree, so only one value shows.
|
||||
assert 'data-cell="A1"' in html
|
||||
assert html.count("xlsx-cached") == 1 # only the formula cell
|
||||
|
||||
|
||||
class TestXlsxValueCoercion:
|
||||
"""#153 A10 — a typed value comes back with the type Excel would infer."""
|
||||
|
||||
def _write(self, client, xlsx_file, ref, value):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {ref: value}, "force": True},
|
||||
)
|
||||
|
||||
def test_number_and_bool_are_stored_as_typed(self, client, xlsx_file):
|
||||
resp = self._write(
|
||||
client, xlsx_file, "D1", "42"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = self._write(client, xlsx_file, "D2", "VRAI")
|
||||
assert resp.status_code == 200
|
||||
resp = self._write(client, xlsx_file, "D3", "12/03/2026")
|
||||
assert resp.status_code == 200
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
ws = wb["Budget"]
|
||||
assert ws["D1"].value == 42 and isinstance(ws["D1"].value, int)
|
||||
assert ws["D2"].value is True
|
||||
assert ws["D3"].value.year == 2026 and ws["D3"].value.month == 3
|
||||
assert ws["D3"].value.day == 12 # FR day-first, not 3 December
|
||||
wb.close()
|
||||
|
||||
def test_day_first_date_is_not_read_as_us(self, client, xlsx_file):
|
||||
"""'01/02/2026' is 1 February in French, not 2 January."""
|
||||
assert self._write(client, xlsx_file, "E1", "01/02/2026").status_code == 200
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
d = wb["Budget"]["E1"].value
|
||||
wb.close()
|
||||
assert (d.month, d.day) == (2, 1)
|
||||
|
||||
def test_ambiguous_text_is_left_alone(self, client, xlsx_file):
|
||||
"""A version string or a partial date stays text, never a date."""
|
||||
assert self._write(client, xlsx_file, "F1", "3.14.2").status_code == 200
|
||||
assert self._write(client, xlsx_file, "F2", "Ref 12/34").status_code == 200
|
||||
assert self._write(client, xlsx_file, "F3", "12/2026").status_code == 200
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
ws = wb["Budget"]
|
||||
assert ws["F1"].value == "3.14.2"
|
||||
assert ws["F2"].value == "Ref 12/34"
|
||||
assert ws["F3"].value == "12/2026"
|
||||
wb.close()
|
||||
|
||||
def test_plain_integer_text_becomes_a_number(self, client, xlsx_file):
|
||||
"""Typing a bare number yields a number, as it did before #153 A10."""
|
||||
assert self._write(client, xlsx_file, "H1", "75001").status_code == 200
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
assert wb["Budget"]["H1"].value == 75001
|
||||
wb.close()
|
||||
|
||||
def test_formula_looking_date_stays_text(self, client, xlsx_file):
|
||||
"""A formula is never mistaken for a date (BUG-088 must not regress)."""
|
||||
assert self._write(client, xlsx_file, "G1", "=12/03/2026").status_code == 200
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(xlsx_file)
|
||||
c = wb["Budget"]["G1"]
|
||||
wb.close()
|
||||
assert c.data_type == "s"
|
||||
assert c.value == "=12/03/2026"
|
||||
|
||||
|
||||
class TestXlsxSearchable:
|
||||
"""#153 A5 — a keyword living in a CELL must make the file findable."""
|
||||
|
||||
def test_search_finds_a_word_stored_in_a_cell(self, test_vault_dir):
|
||||
"""A word typed in a CELL must make the workbook findable.
|
||||
|
||||
Deliberately hermetic: it drives the indexer and the inverted index
|
||||
directly instead of going through the HTTP reload, because both are
|
||||
process-wide singletons that other test modules mutate (some reload the
|
||||
module outright), which would make this test order-dependent.
|
||||
"""
|
||||
from openpyxl import Workbook
|
||||
|
||||
import backend.indexer as indexer
|
||||
import backend.search as search_mod
|
||||
|
||||
path = Path(test_vault_dir) / "fournisseurs.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Contacts"
|
||||
ws["A1"] = "Fournisseur"
|
||||
ws["A2"] = "Menuiserie Beaulieu"
|
||||
wb.save(path)
|
||||
|
||||
# Index exactly this vault, from scratch.
|
||||
indexer.index[VAULT] = {
|
||||
"files": [indexer._index_single_file_sync(VAULT, test_vault_dir, str(path))],
|
||||
"tags": {},
|
||||
"paths": [],
|
||||
"config": {},
|
||||
}
|
||||
entries = [f for f in indexer.index[VAULT]["files"] if f]
|
||||
assert entries, "le tableur n'a pas ete indexe"
|
||||
assert "Menuiserie" in entries[0]["content"], (
|
||||
f"contenu indexe : {entries[0]['content'][:80]!r}"
|
||||
)
|
||||
|
||||
search_mod.init_inverted_index()
|
||||
hits = [r["path"] for r in search_mod.search("Menuiserie", "all")]
|
||||
assert "fournisseurs.xlsx" in hits
|
||||
|
||||
def test_indexable_text_is_capped(self, tmp_path):
|
||||
"""A data dump must not flood the index."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
from backend.xlsx_reader import MAX_INDEX_CHARS, extract_indexable_text
|
||||
|
||||
path = tmp_path / "huge.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Big"
|
||||
for r in range(1, 400):
|
||||
ws.cell(row=r, column=1, value=f"ligne {r} " + "x" * 60)
|
||||
wb.save(path)
|
||||
|
||||
text = extract_indexable_text(path)
|
||||
assert len(text) <= MAX_INDEX_CHARS
|
||||
assert "Big" in text # the sheet name survives
|
||||
|
||||
def test_corrupt_workbook_indexes_as_empty_not_a_crash(self, tmp_path):
|
||||
from backend.xlsx_reader import extract_indexable_text
|
||||
|
||||
path = tmp_path / "broken.xlsx"
|
||||
path.write_bytes(b"not a zip at all")
|
||||
assert extract_indexable_text(path) == ""
|
||||
|
||||
def test_blank_rows_are_skipped(self, tmp_path):
|
||||
from openpyxl import Workbook
|
||||
|
||||
from backend.xlsx_reader import extract_indexable_text
|
||||
|
||||
path = tmp_path / "sparse.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "S"
|
||||
ws["A1"] = "Alpha"
|
||||
ws["A50"] = "Omega" # beyond the indexed prefix -> ignored on purpose
|
||||
wb.save(path)
|
||||
|
||||
text = extract_indexable_text(path)
|
||||
assert "Alpha" in text
|
||||
assert "Omega" not in text
|
||||
assert "\t\t" not in text # no run of empty columns
|
||||
|
||||
|
||||
# ── Edit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -327,6 +519,173 @@ class TestXlsxWriteLock:
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "premier"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wide_xlsx(test_vault_dir: str) -> str:
|
||||
"""Workbook whose sheet exceeds BOTH render caps (501 rows x 45 cols).
|
||||
|
||||
Sparse on purpose: a cell in A501 and one in AS1 are enough for openpyxl
|
||||
to declare those dimensions, without writing 20 000 cells to disk.
|
||||
"""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "grand.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "tête"
|
||||
ws["A501"] = "dernière ligne"
|
||||
ws["AS1"] = "colonne 45"
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def edge_xlsx(test_vault_dir: str) -> str:
|
||||
"""Sheet exactly on the caps (500 rows x 40 cols) — must NOT be truncated."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "limite.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "bord"
|
||||
ws["A500"] = "ligne 500"
|
||||
ws["AN1"] = "colonne 40"
|
||||
wb.save(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
# ── #153 A8 — silent truncation made visible ─────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxTruncationNotice:
|
||||
"""A sheet bigger than the caps must SAY so instead of looking complete."""
|
||||
|
||||
def test_render_reports_the_real_dimensions(self, client, wide_xlsx):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "grand.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert (sheet["total_rows"], sheet["total_cols"]) == (501, 45)
|
||||
assert sheet["truncated"] is True
|
||||
# The caps are the coverage the banner announces — `rows`/`cols` are
|
||||
# post-trim and would understate it on a sparse sheet.
|
||||
assert (sheet["max_rows"], sheet["max_cols"]) == (500, 40)
|
||||
assert (sheet["rows"], sheet["cols"]) == (1, 1) # only 3 filled cells
|
||||
|
||||
def test_a_sheet_on_the_caps_is_not_flagged(self, client, edge_xlsx):
|
||||
"""Boundary: 500x40 is exactly what the renderer supports."""
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "limite.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert sheet["truncated"] is False
|
||||
assert (sheet["total_rows"], sheet["total_cols"]) == (500, 40)
|
||||
|
||||
def test_a_normal_sheet_is_not_flagged(self, client, xlsx_file):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"})
|
||||
assert all(not s["truncated"] for s in resp.json()["xlsx_sheets"])
|
||||
|
||||
def test_blank_tail_is_not_reported_as_truncation(self, client, test_vault_dir):
|
||||
"""A sheet with empty rows below its data fits in the caps."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "blanc.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = "seule ligne"
|
||||
ws["A300"] = None # formatted-but-empty row inside the caps
|
||||
wb.save(path)
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "blanc.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert sheet["truncated"] is False
|
||||
assert sheet["rows"] == 1 # trailing blanks dropped by _trim
|
||||
|
||||
|
||||
# ── #153 A9 — lazy per-sheet loading ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxSheetWindow:
|
||||
"""GET /api/file/{vault}/xlsx/sheet — one window of one sheet."""
|
||||
|
||||
def _get(self, client, path="budget.xlsx", **params):
|
||||
return client.get(
|
||||
f"/api/file/{VAULT}/xlsx/sheet", params={"path": path, **params}
|
||||
)
|
||||
|
||||
def test_window_returns_rows_and_totals(self, client, xlsx_file):
|
||||
resp = self._get(client, sheet="Budget", offset=0, limit=10)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["sheet"] == "Budget"
|
||||
assert (data["offset"], data["limit"]) == (0, 10)
|
||||
assert data["rows"] == 2 and data["cols"] == 2
|
||||
assert data["total_rows"] == 2 and data["truncated"] is False
|
||||
assert (data["max_rows"], data["max_cols"]) == (500, 40)
|
||||
assert data["has_more"] is False
|
||||
assert 'data-cell="A1"' in data["html"]
|
||||
|
||||
def test_window_keeps_the_real_a1_coordinates(self, client, xlsx_file):
|
||||
"""A window must be indistinguishable from a full render: the A1
|
||||
references and the row numbers have to be the sheet's, not the
|
||||
window's, or an edit would land on the wrong cell. The CONTENT matters
|
||||
as much as the label — row 2 of "Budget" is "Total", not "Poste"."""
|
||||
data = self._get(client, sheet="Budget", offset=1, limit=1).json()
|
||||
assert data["rows"] == 1
|
||||
assert 'data-cell="A2"' in data["html"]
|
||||
assert 'data-cell="A1"' not in data["html"]
|
||||
assert "<th class=\"xlsx-rownum\">2</th>" in data["html"]
|
||||
assert "Total" in data["html"] and "Poste" not in data["html"]
|
||||
|
||||
def test_window_offsets_walk_the_whole_sheet(self, client, wide_xlsx):
|
||||
first = self._get(client, path="grand.xlsx", sheet="Data", offset=0, limit=10).json()
|
||||
last = self._get(client, path="grand.xlsx", sheet="Data", offset=500, limit=10).json()
|
||||
assert first["truncated"] is True and first["has_more"] is True
|
||||
assert 'data-cell="A501"' in last["html"] # the row the caps used to hide
|
||||
assert "dernière ligne" in last["html"]
|
||||
assert "dernière ligne" not in first["html"]
|
||||
assert last["rows"] == 1 and last["has_more"] is False
|
||||
|
||||
def test_offset_past_the_end_is_empty_not_an_error(self, client, xlsx_file):
|
||||
resp = self._get(client, sheet="Budget", offset=9999, limit=10)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["rows"] == 0
|
||||
assert data["has_more"] is False
|
||||
|
||||
def test_cached_result_survives_the_window(self, client, lossy_xlsx):
|
||||
"""A12 must not be lost on the lazy path."""
|
||||
data = self._get(client, path="risky.xlsx", sheet="Data", offset=0, limit=10).json()
|
||||
assert "xlsx-cached" in data["html"]
|
||||
|
||||
def test_unknown_sheet_is_404(self, client, xlsx_file):
|
||||
resp = self._get(client, sheet="Nope")
|
||||
assert resp.status_code == 404
|
||||
assert "Feuille introuvable" in resp.json()["detail"]
|
||||
|
||||
def test_missing_file_is_404(self, client, test_vault_dir):
|
||||
assert self._get(client, path="absent.xlsx", sheet="Budget").status_code == 404
|
||||
|
||||
def test_non_xlsx_file_is_415(self, client, test_vault_dir):
|
||||
(Path(test_vault_dir) / "note.md").write_text("# hi", encoding="utf-8")
|
||||
resp = self._get(client, path="note.md", sheet="Budget")
|
||||
assert resp.status_code == 415
|
||||
|
||||
def test_limit_above_the_server_cap_is_rejected(self, client, xlsx_file):
|
||||
"""The cap is a contract, not a silent truncation of the request."""
|
||||
assert self._get(client, sheet="Budget", limit=100_000).status_code == 422
|
||||
|
||||
def test_reader_clamps_a_hostile_limit(self, xlsx_file):
|
||||
"""Belt and braces: the reader caps too, whoever calls it."""
|
||||
from backend.xlsx_reader import MAX_WINDOW_ROWS, read_sheet_window
|
||||
|
||||
window = read_sheet_window(Path(xlsx_file), "Budget", offset=0, limit=10**9)
|
||||
assert window["limit"] == MAX_WINDOW_ROWS
|
||||
|
||||
def test_reader_rejects_a_negative_offset(self, xlsx_file):
|
||||
from backend.xlsx_reader import read_sheet_window
|
||||
|
||||
window = read_sheet_window(Path(xlsx_file), "Budget", offset=-5, limit=10)
|
||||
assert window["offset"] == 0
|
||||
|
||||
|
||||
# ── #153 A4 — formula injection ──────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user