Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f52b56251 | ||
|
|
72da123a51 | ||
|
|
e94af0369b | ||
|
|
8da65611cb | ||
|
|
605060c51d | ||
|
|
856e654306 | ||
|
|
4de9ee038c | ||
|
|
290d62da4e | ||
|
|
140e9a679d | ||
|
|
267a33d43b | ||
|
|
435a0687d7 | ||
|
|
dbf935bec0 | ||
|
|
d6d081c0e9 | ||
|
|
c72f852a55 | ||
|
|
99779ecc08 | ||
|
|
c4b8e66206 | ||
|
|
ca6407e0c0 | ||
|
|
dff32a97ee | ||
|
|
d5c528fead | ||
|
|
38f39a10ae | ||
|
|
48e023ba25 | ||
|
|
011ec84f23 | ||
|
|
472ea9d309 | ||
|
|
6ba04c4381 | ||
|
|
06f8e63d06 | ||
|
|
31d4616baf | ||
|
|
4c4b1222d5 | ||
|
|
a3973b981c | ||
|
|
b6e2029770 | ||
|
|
6b878caff3 | ||
|
|
e9b7a317c1 | ||
|
|
14b8032635 | ||
|
|
7dfe26c83d |
+6
-5
@@ -12,11 +12,12 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local.
|
||||
# OBSIGATE_ALLOW_INSECURE=false
|
||||
|
||||
# Sécurité des cookies (activer si derrière HTTPS)
|
||||
# false par défaut : les navigateurs ignorent les cookies `Secure` en HTTP,
|
||||
# ce qui casserait les logins en local. En production (TLS + bind réseau),
|
||||
# posez true — un avertissement est loggé au démarrage sinon (#87).
|
||||
# OBSIGATE_SECURE_COOKIES=false
|
||||
# Sécurité des cookies : true|false|auto (défaut : auto — Secure si la
|
||||
# requête arrive en https, sinon pas de flag ; les navigateurs ignorent les
|
||||
# cookies `Secure` en HTTP, ce qui casserait les logins en local).
|
||||
# Derrière un reverse proxy qui termine TLS, auto suffit avec
|
||||
# OBSIGATE_TRUST_PROXY=true (X-Forwarded-Proto honoré).
|
||||
# OBSIGATE_SECURE_COOKIES=auto
|
||||
|
||||
# Tokens TTL en secondes
|
||||
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
|
||||
|
||||
+45
-7
@@ -47,9 +47,8 @@ jobs:
|
||||
node tests/frontend/pretty.test.mjs
|
||||
node tests/frontend/media-viewer.test.mjs
|
||||
node tests/frontend/mfa-settings.test.mjs
|
||||
node tests/frontend/config-ai-keys.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload)
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload + XLSX)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
@@ -68,6 +67,8 @@ jobs:
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -86,6 +87,8 @@ jobs:
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
@@ -128,7 +131,12 @@ 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 -U pip setuptools
|
||||
pip install bandit pip-audit
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
@@ -138,11 +146,41 @@ jobs:
|
||||
# vrais positifs restants portent un `# nosec` justifié inline.
|
||||
run: bandit -r backend/ --skip B101,B105,B110,B310
|
||||
|
||||
- name: Pip-audit (consultatif — #87)
|
||||
# Reste non bloquant tant que les montées de version requises
|
||||
# (starlette via fastapi, weasyprint) ne sont pas qualifiées :
|
||||
# upgrade FastAPI = chantier de régression dédié, hors périmètre.
|
||||
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking, see #87)"
|
||||
- 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,
|
||||
# python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1
|
||||
# + starlette 1.7.0, setuptools 84 — suite complète verte + 0 vuln).
|
||||
# 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. 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
|
||||
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
|
||||
+14
@@ -31,6 +31,20 @@ desktop/backend/
|
||||
desktop/frontend/
|
||||
backend/VERSION
|
||||
|
||||
# Artefacts générés par les runs E2E (excalidraw crée ces diagrammes)
|
||||
test_vault/IT/e2e-diagram-*.excalidraw
|
||||
|
||||
# Fixtures de test locales non versionnées (~200 Mo, pas de fixture CI).
|
||||
# Aucun test/CI ne les référence : les tests unitaires génèrent leurs fixtures
|
||||
# dans tmp_path (tests/conftest.py), et l'E2E n'utilise que les fixtures
|
||||
# committées (test_vault/sample-*.{mp3,png,svg,webm,pdf}, test_dir/*.md).
|
||||
# → à committer volontairement : `git add -f <chemin>`.
|
||||
test_dir/music/
|
||||
test_dir/video/
|
||||
test_vault/images/
|
||||
test_vault/markdown/
|
||||
test_vault/budget.xlsx
|
||||
|
||||
# Tauri updater signing keys (private key — never commit)
|
||||
desktop/*.key
|
||||
desktop/*.key.pub
|
||||
|
||||
+581
-14
@@ -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.28.9**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.43.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,17 +14,580 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.43.1] — 2026-09-29
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 5, clôture) : extraction modulaire et
|
||||
finitions.**
|
||||
Les unités **sans état** de la visionneuse sont extraites dans `frontend/js/xlsx/` :
|
||||
`refs.js` (références A1), `command-bar.js` (onglets + ruban + pastilles d'état) et
|
||||
`dashboard.js` (rendu du tableau de bord) — le noyau avec état reste dans `viewer.js` à
|
||||
comportement constant. Le tableau de bord devient interactif : **cliquer une plage nommée
|
||||
sélectionne et révèle sa première cellule** dans la grille (changement d'onglet si nécessaire).
|
||||
L'inspecteur est **redimensionnable** (largeur mémorisée par session). `SW_VERSION` passe à
|
||||
`v28` et les nouveaux modules entrent dans le pré-cache. Tests JSDOM
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (51) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) verts.
|
||||
Aucun changement backend.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
- **BUG-084 — l'index inversé conservait des documents fantômes après la
|
||||
suppression d'une vault.** `remove_vault_from_index()`
|
||||
(`backend/indexer.py`) ne notifiait pas le hook incrémental : après
|
||||
suppression d'une vault, ses documents restaient dans l'index inversé
|
||||
(`postings`, `doc_info`, `doc_vault`, `vault_docs`) et continuaient de
|
||||
correspondre aux recherches pour une vault inexistante — seul un reindex
|
||||
manuel les effaçait. Le correctif déclenche
|
||||
`_on_index_change('remove', …)` pour chaque fichier de la vault, et
|
||||
`_remove_doc_internals()` supprime désormais la clé `vault_docs` dont le set
|
||||
devient vide (c'est un `defaultdict` : une lecture la recréait).
|
||||
Test : `TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le
|
||||
correctif).
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **Index inversé — `is_stale()` renommé `is_ready()`.** La relecture de
|
||||
`plan.md` a établi que les étapes 6 et 7 (suppression du cooldown et du hack
|
||||
de coalescence) étaient **déjà livrées** : ni `_last_rebuild`, ni
|
||||
`_rebuild_cooldown`, ni `_source_generation`, ni `_on_vault_change` ne
|
||||
subsistent. `is_stale()` ne mesurait donc plus aucune staleness — il
|
||||
indiquait seulement si l'index initial était construit, sous un nom
|
||||
trompeur. Renommé `is_ready()`, cohérent avec le `is_ready()` de
|
||||
`SemanticIndex` ; l'alias `is_stale()` de `SemanticIndex`, sans appelant, est
|
||||
supprimé. `/api/diagnostics` expose désormais `is_ready` (libellé « Index
|
||||
prêt » côté `frontend/js/config.js`). Tests :
|
||||
`test_is_ready_tracks_initial_build`, `test_is_ready_survives_incremental_updates`.
|
||||
|
||||
- **`plan.md` recalibré.** Le fichier est désormais marqué « livré » et
|
||||
suivi d'une section « État réel » : le code a divergé du plan sur quatre
|
||||
points (pas de repli `_needs_rebuild`, `_ready` au lieu de `doc_count == 0`,
|
||||
`rebuild()` conservé au démarrage, `is_stale()` repurposé). Les extraits de
|
||||
code du plan sont explicitement signalés comme ne décrivant pas le code
|
||||
actuel.
|
||||
|
||||
- **Fixtures de test locales exclues du suivi Git.** `test_dir/music/`,
|
||||
`test_dir/video/`, `test_vault/images/`, `test_vault/markdown/` et
|
||||
`test_vault/budget.xlsx` (~200 Mo) sont ajoutés au `.gitignore` : aucun test
|
||||
ni job CI ne les référence — les tests unitaires génèrent leurs fixtures dans
|
||||
`tmp_path` et l'E2E n'utilise que les fixtures committées
|
||||
(`test_vault/sample-*.{mp3,png,svg,webm,pdf}`, `test_dir/*.md`). Ils
|
||||
restaient non suivis et polluaient `git status`.
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **BUG-088 — plus d'injection de formule via la visionneuse Excel.** Une
|
||||
saisie `=cmd|'/c calc'!A1` (ou `@…`) était stockée comme **formule** par
|
||||
openpyxl, donc exécutée par Excel à la réouverture du fichier (DDE).
|
||||
`edit_xlsx_cells` force maintenant le type texte (`cell.data_type = "s"`)
|
||||
pour toute valeur commençant par `=` ou `@` ; l'API accepte
|
||||
`allow_formula: true` et la visionneuse expose un bouton `f(x)`
|
||||
(opt-in, état de session, jamais persisté). `+`/`-` restent des nombres.
|
||||
- **BUG-087 — écriture concurrente d'un classeur.** `load_workbook()` →
|
||||
`save()` n'était pas sérialisé : deux sauvegardes simultanées (deux
|
||||
onglets, l'agent IA et la visionneuse) faisaient gagner la dernière, en
|
||||
silence. Verrou par chemin (`backend/services/mutations.py::_xlsx_write_lock`,
|
||||
timeout 15 s) autour du cycle lecture → édition → remplacement ; attente
|
||||
dépassée → **409** `conflict`. L'endpoint `PUT …/xlsx/save` est devenu
|
||||
synchrone pour que l'attente s'exécute dans le threadpool.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-085 — la perte de données à l'enregistrement d'un `.xlsx` est
|
||||
annoncée, plus silencieuse.** `GET /api/file/{vault}` renvoie
|
||||
`xlsx_lossy_features` (éléments qu'un round-trip openpyxl perd) ; la
|
||||
visionneuse affiche un bandeau listant ces éléments et la première
|
||||
sauvegarde demande confirmation avant de renvoyer `force: true`. Sans
|
||||
`force`, l'API répond **409** `xlsx_lossy_content` avec
|
||||
`details.features`. Périmètre **remesuré** sur openpyxl 3.1.5 : graphiques,
|
||||
images, dessins et tableaux croisés sont bien préservés ; sont perdus les
|
||||
valeurs calculées en cache, slicers/chronologies, contrôles de formulaire,
|
||||
connexions/requêtes, custom XML, signature numérique, commentaires
|
||||
enrichis et macros.
|
||||
- **BUG-086 — écriture atomique des classeurs.** `wb.save()` écrivait en
|
||||
place sur le fichier du vault : un plantage laissait un `.xlsx` tronqué.
|
||||
L'écriture passe désormais par un `.tmp` puis `os.replace()` (le backup
|
||||
`.bak` est inchangé, le `.tmp` est ignoré par le watcher).
|
||||
- Le handler global `ServiceError` expose maintenant `code` et `details` dans
|
||||
la réponse JSON, et `api()` (frontend) les propage sur l'Error — nécessaire
|
||||
pour que le client distingue un 409 de confirmation d'une autre erreur.
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#153 (P0) — tests de la visionneuse Excel.**
|
||||
`tests/frontend/xlsx-viewer.test.mjs` (10 tests JSDOM : bannière,
|
||||
confirmation + reprise `force`, refus, toggle `f(x)`, payload de
|
||||
sauvegarde) et `tests/e2e/xlsx-viewer.spec.js` (3 tests Playwright sur la
|
||||
fixture `test_vault/sample-xlsx-lossy.xlsx`) ; la suite JSDOM est branchée
|
||||
dans le CI.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **#153 — Visionneuse & édition XLSX : audit complet et backlog de
|
||||
complétude.** La visionneuse `.xlsx` livrée par #152 a été auditée couche
|
||||
par couche (lecture `backend/xlsx_reader.py`, écriture
|
||||
`backend/services.mutations.edit_xlsx_cells`, UI `renderXlsxViewer`,
|
||||
indexation, outils IA, tests). Bilan : la grille de valeurs est éditée
|
||||
correctement (sécurité, backup, audit, échappement HTML), mais l'ensemble
|
||||
supporté est étroit, une partie du classeur est perdue à l'enregistrement,
|
||||
les tableurs sont **invisibles pour la recherche** et l'IA ne sait que les
|
||||
**créer**. Ouverture de l'item **#153** dans `docs/ROADMAP.md` (17
|
||||
sous-tâches suivies **A1 → A17** ; **P0 livré**, reste P1 recherche/IA/UX
|
||||
puis P2 étendu) et création de la fiche
|
||||
[docs/features/xlsx-viewer.md](docs/features/xlsx-viewer.md) : cartographie
|
||||
du code, limites par couche, tableau des risques R1-R5 et critères
|
||||
d'acceptation par sous-tâche.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.16] — 2026-09-27
|
||||
|
||||
---
|
||||
|
||||
## [2.28.15] — 2026-09-27
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **#87 T6 — dépendances qualifiées, `pip-audit` bloquant (0 vulnérabilité).**
|
||||
mistune 3.0.2 → 3.3.3 (XSS/ReDoS/DoS dans le moteur de rendu),
|
||||
python-multipart 0.0.9 → 0.0.31, weasyprint 69 → 70, mcp 1.9.4 → 1.28.1,
|
||||
fastapi 0.110.3 → 0.141.1 + starlette 0.37.2 → 1.7.0, setuptools 84 ;
|
||||
`cast(str, …)` aux 3 sites d'appel mistune (typage 3.3 resserré). Suite
|
||||
complète 1359 passed, ruff/mypy 0. Seule exception : PYSEC-2026-1325
|
||||
(ecdsa, Minerva) — aucun correctif upstream ET JWT exclusivement HS256
|
||||
(`backend/auth/jwt_handler.py`), les chemins ECDSA P-256 ne s'exécutent
|
||||
jamais → `--ignore-vuln` documenté.
|
||||
|
||||
- **#87 T7 — semgrep SAST local bloquant (8 règles, 0 finding).**
|
||||
Ruleset `semgrep-rules/` (eval/exec, shell=True, os.system, pickle,
|
||||
yaml.load sans Loader, verify=False, Markup, mktemp) — 100 % local,
|
||||
aucun registre réseau (runner au réseau fragile). Trivy écarté :
|
||||
binaire + base de vulnérabilités à télécharger à chaque run, couche
|
||||
Python déjà couverte par `pip-audit` bloquant (image = slim + 4 libs).
|
||||
|
||||
- **#87 T8 — fin BUG-034 : cookies Secure auto, CORS same-origin explicite.**
|
||||
`OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut auto : Secure en https,
|
||||
sinon rien — logins http locaux préservés ; `X-Forwarded-Proto` honoré
|
||||
sous `TRUST_PROXY`, avertissement démarrage affiné, `TRUST_PROXY=true`
|
||||
dans le compose prod) ; `CORSMiddleware` same-origin explicite (sûr :
|
||||
web et desktop Tauri same-origin, API directe hors navigateur) ;
|
||||
`style-src 'unsafe-inline'` conservé et assumé (189 attributs `style=` +
|
||||
343 `el.style` — suppression = réécriture complète, risque nul côté
|
||||
exécution une fois `script-src` verrouillé en T5c).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.14] — 2026-09-27
|
||||
|
||||
---
|
||||
|
||||
## [2.28.13] — 2026-09-27
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#87 (T5c) — `script-src` sans `'unsafe-inline'`.**
|
||||
Seuls les scripts avec nonce frais (`backend/csp.py`, T5b) ou servis par
|
||||
`'self'`/CDN listés s'exécutent ; `style-src` garde `'unsafe-inline'`
|
||||
(chantier séparé). Vérifié : `test_csp_nonce.py` 5/5, 0 handler inline
|
||||
restant dans les pages HTML (propriétés `onXxx = fn` en JS non concernées
|
||||
par la CSP).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.12] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-081 — `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée.**
|
||||
Le pseudo-user `anonymous` (auth désactivée, mode E2E/CI) n'a aucune entrée
|
||||
en store : `get_user(...)` → `None` puis `AttributeError` sur `user.get`.
|
||||
Garde `None` → payload « MFA désactivé » (`mfa_enabled: false`,
|
||||
`totp_enabled: false`, `webauthn_credentials: 0`). Test : `tests/test_mfa.py`
|
||||
(`TestMfaStatusAuthDisabled`, échoue en 500 sans le correctif).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.11] — 2026-09-27
|
||||
|
||||
---
|
||||
|
||||
## [2.28.10] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-083 — job CI `security` rouge : le runner tronquait le `#` du `run:` pip-audit.**
|
||||
Le runner Gitea Act coupe naïvement au premier `#` (même entre
|
||||
guillemets) : `echo "... see #87)"` devenait une citation non fermée
|
||||
(`unexpected EOF while looking for matching '"'"`). Seul `run:` du
|
||||
workflow avec un `#` ; l'echo n'a plus de `#` (réf `#87` en commentaire
|
||||
YAML, jamais vu par le shell). Garde-fou : `tests/test_ci_workflow.py`
|
||||
(aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé dans
|
||||
l'étape JSDOM — BUG-082).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.9] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-082 — CI `lint` rouge : `upload.test.mjs` exige `jsdom`.**
|
||||
`tests/frontend/upload.test.mjs` (import statique `jsdom`, introduit par
|
||||
`#89`) était exécuté dans l'étape frontend racine où `jsdom` n'est jamais
|
||||
installé (`ERR_MODULE_NOT_FOUND`, rouge depuis `7bee4a2`). Déplacé dans
|
||||
l'étape JSDOM (les deux branches, après install si besoin) ; seul fichier
|
||||
de l'étape racine avec import statique `jsdom`, les autres suites racine
|
||||
n'en ont pas besoin.
|
||||
- **BUG-082 — CI `lint` rouge : suites frontend exigeant `jsdom`.**
|
||||
`tests/frontend/upload.test.mjs` puis `config-ai-keys.test.mjs` (imports
|
||||
statiques `jsdom`, introduits par `#89`) étaient exécutés dans l'étape
|
||||
frontend racine où `jsdom` n'est jamais installé (`ERR_MODULE_NOT_FOUND`,
|
||||
rouge depuis `7bee4a2`). Déplacés dans l'étape JSDOM (les deux branches,
|
||||
après install si besoin) ; garde-fou `tests/test_ci_workflow.py` :
|
||||
aucun fichier de l'étape racine ne doit importer `jsdom` statiquement.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,14 +596,18 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
### Corrigé
|
||||
|
||||
- **BUG-080 — harnais E2E local anti-blocage (plus de run pendu toute la nuit).**
|
||||
`run-e2e-local.ps1/.sh` : `npx --yes` (jamais de prompt interactif),
|
||||
`run-e2e-local.ps1` : Playwright lancé via `node` direct sur la CLI locale
|
||||
(jamais de prompt interactif, `Start-Process` ne sachant pas exécuter `npx` ;
|
||||
paramètre `$Arguments`, `$Args` étant une variable automatique qui l'écraserait),
|
||||
installation Chromium sautée si déjà présent (`E2E_INSTALL_BROWSERS=1`
|
||||
pour forcer), étapes `install`/`test` bornées (`E2E_TIMEOUT_SEC`,
|
||||
défaut 900 s / 600 s, exit 124 au dépassement) ; `playwright.config.ts` :
|
||||
`globalTimeout` (15 min en local, 30 min en CI, `E2E_GLOBAL_TIMEOUT_MS`
|
||||
pour surcharger) ; `e2e-server.ps1` : pidfile resynchronisé sur le vrai
|
||||
propriétaire du port et `stop` qui tue l'arbre complet (fini les serveurs
|
||||
orphelins qui squattent le port 2029). Garde-fou : `tests/test_e2e_harness.py`.
|
||||
défaut 1800 s / 600 s, exit 124 au dépassement — au-delà du globalTimeout
|
||||
pour un abandon propre avec rapport) ; `run-e2e-local.sh` : `npx --yes` +
|
||||
mêmes bornes ; `playwright.config.ts` : `globalTimeout` (25 min en local,
|
||||
30 min en CI, `E2E_GLOBAL_TIMEOUT_MS` pour surcharger) ; `e2e-server.ps1` :
|
||||
pidfile resynchronisé sur le vrai propriétaire du port et `stop` qui tue
|
||||
l'arbre complet (fini les serveurs orphelins qui squattent le port 2029).
|
||||
Garde-fous : `tests/test_e2e_harness.py` (8 tests).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -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/)
|
||||
@@ -23,7 +23,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
| Guide | Contenu |
|
||||
|---|---|
|
||||
| 🚀 [Prise en main](docs/GUIDES/PRISE_EN_MAIN.md) | Premier lancement, interface, navigation, vaults, raccourcis |
|
||||
| 🔍 [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
|
||||
| 🔍 [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes |
|
||||
| 🤖 [Assistant IA & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
|
||||
| 📝 [Édition & collaboration](docs/GUIDES/COLLABORATION.md) | Édition simultanée, curseurs distants, persistance |
|
||||
| 📱 [PWA & hors-ligne](docs/GUIDES/PWA_HORS_LIGNE.md) | Installation, cache hors-ligne, file de synchro, notifications |
|
||||
@@ -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), plus le téléchargement du fichier d'origine
|
||||
- **📊 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
|
||||
@@ -673,7 +673,7 @@ curl "http://localhost:2020/api/file/Recettes?path=pizza.md"
|
||||
|
||||
## 🔍 Recherche avancée
|
||||
|
||||
> 📖 Guide complet : [Recherche, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
> 📖 Guide complet : [Recherche, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Syntaxe de requête
|
||||
|
||||
@@ -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.28.9).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.43.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.28.9 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.43.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -21,7 +21,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
| Guide | What it covers |
|
||||
|---|---|
|
||||
| 🚀 [Getting Started](docs/GUIDES/PRISE_EN_MAIN.md) | First run, interface, navigation, vaults, shortcuts |
|
||||
| 🔍 [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF viewer, diagrams |
|
||||
| 🔍 [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) | Query syntax, semantic search, PDF/Excel viewers, diagrams |
|
||||
| 🤖 [AI Assistant & Forge](docs/GUIDES/ASSISTANT_IA_FORGE.md) | Providers, AI editor, BooksLM, Forge, `@` / `/` commands |
|
||||
| 📝 [Editing & Collaboration](docs/GUIDES/COLLABORATION.md) | Simultaneous editing, remote cursors, persistence |
|
||||
| 📱 [PWA & Offline](docs/GUIDES/PWA_HORS_LIGNE.md) | Install as an app, offline cache, sync queue, push |
|
||||
@@ -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), plus download of the original file
|
||||
- **📊 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
|
||||
@@ -804,7 +804,7 @@ curl "http://localhost:2020/api/file/Recipes?path=pizza.md"
|
||||
|
||||
## 🔍 Advanced Search
|
||||
|
||||
> 📖 Full guide: [Search, PDF & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
> 📖 Full guide: [Search, PDF, Excel & Excalidraw](docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md)
|
||||
|
||||
### Query Syntax
|
||||
|
||||
@@ -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.28.9).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.43.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.28.9 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.43.1 | Last updated: September 2026*
|
||||
|
||||
+45
-16
@@ -16,7 +16,7 @@ from backend.ratelimit import record_account_failure as rl_record_account_failur
|
||||
from backend.ratelimit import record_account_success as rl_record_account_success
|
||||
from backend.ratelimit import record_failure as rl_record_failure
|
||||
from backend.ratelimit import record_success as rl_record_success
|
||||
from backend.services.net import get_client_ip
|
||||
from backend.services.net import get_client_ip, is_trusted_proxy
|
||||
|
||||
from .jwt_handler import (
|
||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||
@@ -57,15 +57,32 @@ logger = logging.getLogger("obsigate.auth.router")
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def is_secure_cookies() -> bool:
|
||||
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
|
||||
def is_secure_cookies(request: Request | None = None) -> bool:
|
||||
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
|
||||
|
||||
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
|
||||
Default stays ``false`` so logins keep working over plain HTTP on
|
||||
trusted loopback deployments — browsers drop ``Secure`` cookies sent
|
||||
over HTTP, which would silently break localhost logins.
|
||||
``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) :
|
||||
``true``/``false`` forcent le comportement ; ``auto`` met ``Secure``
|
||||
si la requête arrive en https (production derrière TLS) et l'omet
|
||||
sinon (dev local en http — les navigateurs jettent les cookies
|
||||
``Secure`` sur http, ce qui casserait silencieusement les logins
|
||||
localhost). Derrière un reverse proxy qui termine TLS, le schéma perçu
|
||||
est http : avec ``OBSIGATE_TRUST_PROXY=true``, ``X-Forwarded-Proto``
|
||||
est honoré (même garde que ``get_client_ip``, BUG-030).
|
||||
"""
|
||||
return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
forced = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
|
||||
if forced in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
if forced in ("0", "false", "no", "off"):
|
||||
return False
|
||||
if request is None:
|
||||
return False
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
if is_trusted_proxy():
|
||||
proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
|
||||
if proto == "https":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Pydantic request models ──────────────────────────────────────────
|
||||
@@ -230,10 +247,11 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
"remember_me": body.remember_me,
|
||||
}
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
|
||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response,
|
||||
request: Request | None = None) -> dict:
|
||||
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
||||
record_login_success(username)
|
||||
rl_record_account_success(username)
|
||||
@@ -242,7 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
||||
|
||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||
secure = is_secure_cookies()
|
||||
secure = is_secure_cookies(request)
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
@@ -311,7 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
if stale:
|
||||
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
||||
|
||||
secure = is_secure_cookies()
|
||||
secure = is_secure_cookies(request)
|
||||
remember_me = bool(payload.get("remember", False))
|
||||
|
||||
# BUG-027: rotate the refresh token — the old one is now single-use.
|
||||
@@ -437,6 +455,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Change own password.
|
||||
@@ -452,7 +471,7 @@ async def change_password(
|
||||
updated = get_user(current_user["username"])
|
||||
result: dict = {"message": "Mot de passe mis à jour"}
|
||||
if updated is not None:
|
||||
result.update(_issue_tokens(updated, updated["username"], False, response))
|
||||
result.update(_issue_tokens(updated, updated["username"], False, response, request))
|
||||
return result
|
||||
|
||||
|
||||
@@ -815,7 +834,7 @@ async def mfa_webauthn_verify(
|
||||
|
||||
rl_record_success(client_ip)
|
||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
@router.get("/mfa/status")
|
||||
@@ -823,6 +842,16 @@ async def mfa_status(current_user=Depends(require_auth)):
|
||||
"""Return current user's MFA status."""
|
||||
from .user_store import get_user
|
||||
user = get_user(current_user["username"])
|
||||
if user is None:
|
||||
# BUG-081 : auth désactivée (OBSIGATE_AUTH_ENABLED=false) → le
|
||||
# pseudo-user "anonymous" n'a aucune entrée en store : pas de MFA,
|
||||
# et surtout pas de 500 (`AttributeError` sur `user.get`).
|
||||
return {
|
||||
"mfa_enabled": False,
|
||||
"mfa_method": None,
|
||||
"totp_enabled": False,
|
||||
"webauthn_credentials": 0,
|
||||
}
|
||||
return {
|
||||
"mfa_enabled": user.get("mfa_enabled", False),
|
||||
"mfa_method": user.get("mfa_method"),
|
||||
@@ -858,7 +887,7 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
|
||||
# Clear IP rate limit on success
|
||||
rl_record_success(client_ip)
|
||||
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
return _issue_tokens(user, body.username, body.remember_me, response, request)
|
||||
|
||||
|
||||
@router.post("/mfa/recovery")
|
||||
@@ -896,7 +925,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
||||
rl_record_success(client_ip)
|
||||
|
||||
logger.info(f"User '{body.username}' logged in via recovery code")
|
||||
return _issue_tokens(user, body.username, False, response)
|
||||
return _issue_tokens(user, body.username, False, response, request)
|
||||
|
||||
|
||||
# ── Admin endpoints ───────────────────────────────────────────────────
|
||||
|
||||
+4
-1
@@ -23,6 +23,7 @@ import re
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
@@ -246,7 +247,9 @@ def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> st
|
||||
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
|
||||
md = _inline_images(md, file_dir, vault_path)
|
||||
md = _convert_wikilinks(md, vault_path, current)
|
||||
return _markdown(md)
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le renderer
|
||||
# HTML renvoie toujours `str` à l'exécution).
|
||||
return cast(str, _markdown(md))
|
||||
|
||||
|
||||
def _build_nav(vault_path: Path, current: Path) -> str:
|
||||
|
||||
+45
-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()
|
||||
@@ -1226,6 +1258,12 @@ async def remove_vault_from_index(vault_name: str):
|
||||
if not _file_lookup[key]:
|
||||
_file_lookup.pop(key, None)
|
||||
|
||||
# Notify the inverted index, otherwise every document of the vault
|
||||
# stays in it as a ghost (postings, doc_info, doc_vault, vault_docs)
|
||||
# and keeps matching searches for a vault that no longer exists.
|
||||
if _on_index_change:
|
||||
_on_index_change('remove', vault_name, rel_path, f) # type: ignore[misc]
|
||||
|
||||
# Clean path_index
|
||||
path_index.pop(vault_name, None)
|
||||
|
||||
|
||||
+46
-6
@@ -182,9 +182,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
# A route may set a stricter per-response policy (e.g. ``sandbox`` for
|
||||
# standalone SVG, #108-B3); keep it instead of overwriting it.
|
||||
if "Content-Security-Policy" not in response.headers:
|
||||
# #87 T5c : `script-src` sans 'unsafe-inline' — seuls les scripts
|
||||
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
|
||||
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
|
||||
# `style=` et `el.style` omniprésents — chantier séparé).
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
f"script-src 'self' 'unsafe-inline' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
@@ -256,12 +260,19 @@ async def lifespan(app: FastAPI):
|
||||
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
|
||||
_guard_insecure_auth()
|
||||
|
||||
# #87 T3 : avertir quand les cookies d'auth circulent sans flag Secure
|
||||
# sur un bind non-loopback (transactions observables en clair).
|
||||
# #87 T3/T8 : avertir quand les cookies d'auth circulent sans flag Secure
|
||||
# sur un bind non-loopback (transactions observables en clair). Avec
|
||||
# `OBSIGATE_SECURE_COOKIES=auto` (défaut) + `OBSIGATE_TRUST_PROXY=true`,
|
||||
# le flag suit `X-Forwarded-Proto` : pas d'avertissement, le https du
|
||||
# reverse proxy est honoré.
|
||||
from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
|
||||
from backend.auth.router import is_secure_cookies
|
||||
from backend.services.net import is_trusted_proxy
|
||||
|
||||
if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()):
|
||||
secure_mode = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
|
||||
proxy_secure = secure_mode == "auto" and is_trusted_proxy()
|
||||
if (is_auth_enabled() and not is_secure_cookies()
|
||||
and not is_loopback_host(bind_host_from_argv()) and not proxy_secure):
|
||||
logger.warning(
|
||||
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
|
||||
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
|
||||
@@ -378,12 +389,25 @@ app.openapi = _custom_openapi # type: ignore[method-assign]
|
||||
|
||||
@app.exception_handler(ServiceError)
|
||||
async def _service_error_handler(request: Request, exc: ServiceError):
|
||||
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``)."""
|
||||
return JSONResponse(status_code=exc.status, content={"detail": exc.message})
|
||||
"""Map shared-layer domain errors to HTTP responses (``{"detail": ...}``).
|
||||
|
||||
``code`` and ``details`` travel with the message so the client can react to
|
||||
a specific case instead of parsing prose (#153 A1 : ``xlsx_lossy_content``
|
||||
asks the viewer to confirm before forcing a lossy write).
|
||||
"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status,
|
||||
content={
|
||||
"detail": exc.message,
|
||||
"code": exc.code,
|
||||
"details": exc.details,
|
||||
},
|
||||
)
|
||||
|
||||
# GZip compression — reduces bandwidth by ~70% for text responses
|
||||
# Custom wrapper: skip compression for SSE streams (/api/events)
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
|
||||
@@ -414,6 +438,22 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
|
||||
# Security headers on all responses
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
|
||||
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
|
||||
# donc toute lecture cross-origin est refusée (défense explicite, plus
|
||||
# seulement l'absence de middleware). Sûr pour tous les clients : web
|
||||
# (same-origin), desktop Tauri (la webview est redirigée same-origin sur
|
||||
# http://127.0.0.1:<port>, voir frontend/js/desktop.js) et API directe
|
||||
# (curl/scripts, CORS non appliqué hors navigateur). Ajouté en dernier :
|
||||
# le plus externe, les preflights court-circuitent avant tout le reste.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[],
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Auth router
|
||||
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
|
||||
from backend.ai_routes import router as ai_router
|
||||
|
||||
+21
-1
@@ -182,9 +182,29 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
|
||||
},
|
||||
("put", "/api/file/{vault_name}/xlsx/save"): {
|
||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}},
|
||||
"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},
|
||||
|
||||
+4
-1
@@ -15,6 +15,7 @@ import html as html_mod
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import mistune
|
||||
|
||||
@@ -196,7 +197,9 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
||||
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
|
||||
converted = _normalize_line_breaks(converted)
|
||||
|
||||
rendered = _markdown_renderer(converted)
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (les
|
||||
# renderers HTML renvoient toujours `str` à l'exécution).
|
||||
rendered = cast(str, _markdown_renderer(converted))
|
||||
|
||||
# Add heading IDs for TOC navigation
|
||||
rendered = _add_heading_ids(rendered)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
fastapi==0.110.3
|
||||
uvicorn==0.30.0
|
||||
fastapi==0.141.1
|
||||
uvicorn==0.54.0
|
||||
websockets>=12.0
|
||||
python-frontmatter==1.1.0
|
||||
mistune==3.0.2
|
||||
python-multipart==0.0.9
|
||||
mistune==3.3.3
|
||||
python-multipart==0.0.31
|
||||
aiofiles==23.2.1
|
||||
aiohttp>=3.9.0
|
||||
watchdog>=4.0.0
|
||||
@@ -11,17 +11,28 @@ argon2-cffi>=23.1.0
|
||||
python-jose>=3.3.0
|
||||
sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=60.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.9.4
|
||||
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
|
||||
|
||||
@@ -481,7 +481,7 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
"total_postings": word_index_entries,
|
||||
"documents": inv.doc_count,
|
||||
"sorted_tokens": len(inv._sorted_tokens),
|
||||
"is_stale": inv.is_stale(),
|
||||
"is_ready": inv.is_ready(),
|
||||
"memory_estimate_mb": mem_estimate_mb,
|
||||
},
|
||||
"config": _load_config(),
|
||||
|
||||
+176
-22
@@ -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.
|
||||
@@ -241,8 +349,11 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
|
||||
if ext == ".xlsx":
|
||||
try:
|
||||
from backend.xlsx_reader import render_sheets
|
||||
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 {
|
||||
@@ -257,6 +368,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
"is_markdown": False,
|
||||
"is_xlsx": True,
|
||||
"xlsx_sheets": sheets,
|
||||
# #153 A1 — parts a save would drop; the viewer warns and asks
|
||||
# for an explicit confirmation before forcing the write.
|
||||
"xlsx_lossy_features": inspect_workbook(file_path),
|
||||
"unsupported": False,
|
||||
"size_bytes": size,
|
||||
}
|
||||
@@ -374,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
|
||||
@@ -114,17 +120,35 @@ async def api_file_save(
|
||||
|
||||
|
||||
@router.put("/api/file/{vault_name}/xlsx/save", response_model=FileSaveResponse)
|
||||
async def api_file_xlsx_save(
|
||||
def api_file_xlsx_save(
|
||||
vault_name: str,
|
||||
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||
body: dict = Body(..., description='{"sheet": str, "cells": {"A1": value}}'),
|
||||
body: dict = Body(
|
||||
...,
|
||||
description=(
|
||||
'{"sheet": str, "cells": {"A1": value}, '
|
||||
'"allow_formula": false, "force": false}'
|
||||
),
|
||||
),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Apply cell edits to an .xlsx workbook.
|
||||
|
||||
Expects a JSON body with ``sheet`` and ``cells`` (A1 references to new
|
||||
scalar values, max 500 per request). A backup is created before the
|
||||
workbook is rewritten.
|
||||
scalar values, max 500 per request) plus two optional boolean flags:
|
||||
|
||||
* ``allow_formula`` — keep values starting with ``=``/``@`` as real
|
||||
formulas. Off by default (#153 A4): such a value is stored as text so a
|
||||
later Excel session cannot execute it (DDE).
|
||||
* ``force`` — write a workbook carrying features openpyxl cannot re-serialize
|
||||
(slicers, form controls, connections, custom XML, signature, cached formula
|
||||
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
|
||||
client asks the user to confirm (#153 A1).
|
||||
|
||||
A backup is created before the workbook is rewritten, and the new archive
|
||||
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
|
||||
round-trip and the per-file lock wait (#153 A3) then run in the threadpool
|
||||
instead of blocking the event loop.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
@@ -138,8 +162,16 @@ async def api_file_xlsx_save(
|
||||
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}")
|
||||
flags: dict[str, bool] = {}
|
||||
for name in ("allow_formula", "force"):
|
||||
raw = body.get(name, False)
|
||||
if not isinstance(raw, bool):
|
||||
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
|
||||
flags[name] = raw
|
||||
|
||||
result = service_edit_xlsx_cells(vault_name, path, sheet, cells)
|
||||
result = service_edit_xlsx_cells(
|
||||
vault_name, path, sheet, cells, **flags
|
||||
)
|
||||
log_file_save(
|
||||
current_user["username"], vault_name, path,
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
@@ -148,6 +180,96 @@ async 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.
|
||||
|
||||
+85
-1
@@ -285,8 +285,29 @@ 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,
|
||||
description=(
|
||||
"Workbook parts an openpyxl save would drop (#153 A1) — e.g. "
|
||||
"cached_values, slicers, form_controls, connections, custom_xml, "
|
||||
"signature, rich_comments, macros. Empty/absent = nothing at risk."
|
||||
),
|
||||
)
|
||||
is_json: bool | None = Field(default=None, description="True for JSON files")
|
||||
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
|
||||
@@ -297,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."""
|
||||
|
||||
|
||||
+24
-9
@@ -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,
|
||||
@@ -371,9 +376,15 @@ class InvertedIndex:
|
||||
self._sorted_tokens: SortedList = SortedList()
|
||||
self._ready: bool = False # True after initial build
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""Return True if the index has not been built yet."""
|
||||
return not self._ready
|
||||
def is_ready(self) -> bool:
|
||||
"""Return True once the initial build has completed.
|
||||
|
||||
The index is then kept current incrementally by ``add_document()`` /
|
||||
``remove_document()``, so it never goes stale: there is no generation
|
||||
counter, no cooldown and no lazy rebuild. Searches simply fall back to
|
||||
a full scan while this is False (see ``search()``).
|
||||
"""
|
||||
return self._ready
|
||||
|
||||
def rebuild(self) -> None:
|
||||
"""Rebuild inverted index from the global ``index`` dict.
|
||||
@@ -393,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
|
||||
@@ -537,6 +548,10 @@ class InvertedIndex:
|
||||
self.doc_vault.pop(doc_key, None)
|
||||
if vault_name in self.vault_docs:
|
||||
self.vault_docs[vault_name].discard(doc_key)
|
||||
# Drop the empty entry so a fully removed vault leaves no trace
|
||||
# (it is a defaultdict: a bare lookup would recreate the key).
|
||||
if not self.vault_docs[vault_name]:
|
||||
del self.vault_docs[vault_name]
|
||||
# Tags (per-document, NOT the global tag_norm_map)
|
||||
for tag in file_info.get("tags", []):
|
||||
td = self.tag_docs.get(tag.lower())
|
||||
@@ -678,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.")
|
||||
|
||||
@@ -739,7 +754,7 @@ def search(
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
inv = get_inverted_index()
|
||||
use_index = (not inv.is_stale()) and inv.doc_count > 0
|
||||
use_index = inv.is_ready() and inv.doc_count > 0
|
||||
|
||||
if use_index:
|
||||
# BUG-033: retrieve candidates from the inverted index instead of
|
||||
@@ -774,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"]
|
||||
]
|
||||
@@ -1603,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():
|
||||
|
||||
@@ -457,10 +457,6 @@ class SemanticIndex:
|
||||
"""Return True once a full rebuild has completed."""
|
||||
return self._ready
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""Alias used by callers that check index freshness."""
|
||||
return not self._ready
|
||||
|
||||
def _ensure_provider(self) -> EmbeddingProvider:
|
||||
if self.provider is None:
|
||||
self.provider = get_embedding_provider()
|
||||
|
||||
+445
-26
@@ -16,7 +16,10 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
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
|
||||
|
||||
@@ -230,10 +233,67 @@ _XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$")
|
||||
# number; dates/booleans stay text (upgrade path: parse locale dates too).
|
||||
_XLSX_INT_RE = re.compile(r"^[+-]?\d+$")
|
||||
_XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$")
|
||||
# #153 A4 — openpyxl turns any string starting with "=" into a formula, which
|
||||
# Excel then evaluates on open (DDE / =cmd|… / =HYPERLINK exfiltration). "@" is
|
||||
# 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
|
||||
# lock only covers the load → edit → atomic-replace window.
|
||||
_XLSX_LOCK_TIMEOUT = 15.0
|
||||
_xlsx_locks: dict[str, threading.Lock] = {}
|
||||
_xlsx_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _xlsx_write_lock(key: str) -> Iterator[None]:
|
||||
"""Serialize the read-modify-write of one workbook path.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``conflict`` (409) when the lock is still held after
|
||||
:data:`_XLSX_LOCK_TIMEOUT` seconds.
|
||||
"""
|
||||
with _xlsx_locks_guard:
|
||||
lock = _xlsx_locks.setdefault(key, threading.Lock())
|
||||
if not lock.acquire(timeout=_XLSX_LOCK_TIMEOUT):
|
||||
raise ServiceError(
|
||||
"Workbook is being modified by another operation, retry shortly",
|
||||
code="conflict",
|
||||
status=409,
|
||||
details={"path": key, "timeout_seconds": _XLSX_LOCK_TIMEOUT},
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
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()
|
||||
@@ -243,9 +303,48 @@ 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.
|
||||
|
||||
``cell.data_type = "s"`` is what stops openpyxl from emitting ``<f>``: the
|
||||
text is then stored as an inline/shared string and Excel shows it verbatim.
|
||||
"""
|
||||
cell = ws[ref]
|
||||
coerced = _coerce_xlsx_value(value)
|
||||
cell.value = coerced
|
||||
if not allow_formula and isinstance(coerced, str) and _XLSX_FORMULA_RE.match(coerced):
|
||||
cell.data_type = "s"
|
||||
|
||||
|
||||
def edit_xlsx_cells(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
@@ -253,15 +352,29 @@ def edit_xlsx_cells(
|
||||
cells: dict[str, Any],
|
||||
*,
|
||||
backup: bool = True,
|
||||
allow_formula: bool = False,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403) or
|
||||
``invalid`` (400) for a bad sheet, cell reference or value.
|
||||
Args:
|
||||
vault_name: Name of the vault the workbook belongs to.
|
||||
path: Vault-relative path of the ``.xlsx`` file.
|
||||
sheet: Worksheet title to edit.
|
||||
cells: Mapping of A1 references to new scalar values.
|
||||
backup: Create a timestamped ``.bak`` before rewriting the archive.
|
||||
allow_formula: Keep values starting with ``=``/``@`` as real formulas.
|
||||
Off by default (#153 A4): a typed ``=cmd|…`` is a DDE payload when
|
||||
the file is later opened in Excel.
|
||||
force: Write even when the workbook carries features openpyxl drops
|
||||
(slicers, form controls, connections, custom XML, signature, cached
|
||||
formula results — see :data:`backend.xlsx_reader.LOSSY_PARTS`).
|
||||
|
||||
ponytail: openpyxl round-trips values/formulas/styles but drops charts,
|
||||
images and pivot tables; use the SheetJS path if a workbook needs those.
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||
(409, concurrent write), ``xlsx_lossy_content`` (409, a lossy write was
|
||||
attempted without ``force``) or ``invalid`` (400) for a bad sheet, cell
|
||||
reference or value.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
@@ -274,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)
|
||||
@@ -286,32 +399,338 @@ def edit_xlsx_cells(
|
||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||
)
|
||||
|
||||
from openpyxl import load_workbook
|
||||
# #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
|
||||
|
||||
try:
|
||||
wb = load_workbook(file_path)
|
||||
except Exception as exc:
|
||||
lossy = inspect_workbook(file_path)
|
||||
if lossy:
|
||||
raise ServiceError(
|
||||
"Saving 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
|
||||
|
||||
# #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, keep_vba=file_path.suffix.lower() == ".xlsm")
|
||||
except Exception as exc:
|
||||
raise ServiceError(
|
||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Unknown sheet: {sheet}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
)
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
if backup:
|
||||
create_backup(file_path, vault_name, rel_path)
|
||||
|
||||
ws = wb[sheet]
|
||||
for ref, value in cells.items():
|
||||
_write_cell(ws, ref, value, allow_formula=allow_formula)
|
||||
# #153 A2 — write beside the target then swap: a crash mid-save leaves
|
||||
# the original workbook intact instead of a truncated archive.
|
||||
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)
|
||||
raise
|
||||
|
||||
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"path": rel_path,
|
||||
"size": len(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"Cannot open workbook: {exc}", code="invalid", status=400
|
||||
) from exc
|
||||
if sheet not in wb.sheetnames:
|
||||
raise ServiceError(
|
||||
f"Unknown sheet: {sheet}",
|
||||
code="invalid",
|
||||
status=400,
|
||||
details={"sheets": wb.sheetnames},
|
||||
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)
|
||||
|
||||
ws = wb[sheet]
|
||||
for ref, value in cells.items():
|
||||
ws[ref].value = _coerce_xlsx_value(value)
|
||||
wb.save(file_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"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
|
||||
logger.info(f"CSV cells saved: {vault_name}/{rel_path} +{len(cells)}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,7 @@ import csv as csv_lib
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
|
||||
from xml.sax import saxutils # nosec B406
|
||||
@@ -173,7 +173,9 @@ def _render_markdown_pdf(content: str, title: str) -> bytes | None:
|
||||
escape=False,
|
||||
plugins=["table", "strikethrough", "footnotes", "task_lists"],
|
||||
)
|
||||
html = renderer(content)
|
||||
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le
|
||||
# renderer HTML renvoie toujours `str` à l'exécution).
|
||||
html = cast(str, renderer(content))
|
||||
return generate_pdf(build_pdf_html(html, title), title)
|
||||
except Exception as e:
|
||||
# WeasyPrint loads GTK lazily: a missing native library can surface at
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+827
-15
@@ -3,11 +3,21 @@
|
||||
Read-only: formulas are shown as their text (``data_only=False``) so a
|
||||
round-trip through the viewer never depends on Excel's cached values.
|
||||
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
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,11 +25,179 @@ 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.
|
||||
LOSSY_PARTS: dict[str, tuple[str, ...]] = {
|
||||
"slicers": ("xl/slicers/", "xl/slicerCaches/", "xl/timelines/"),
|
||||
"form_controls": ("xl/ctrlProps/", "xl/activeX/"),
|
||||
"connections": ("xl/queryTables/", "xl/connections.xml"),
|
||||
"custom_xml": ("customXml/",),
|
||||
"signature": ("_xmlsignatures/",),
|
||||
"rich_comments": ("xl/threadedComments/", "xl/persons/"),
|
||||
"macros": ("xl/vbaProject.bin",),
|
||||
}
|
||||
|
||||
# A formula cell carrying its last computed result: ``<f>…</f><v>…</v>``.
|
||||
# openpyxl writes an EMPTY ``<v></v>`` itself, hence the ``[^<]`` guard: only a
|
||||
# non-empty value counts. openpyxl keeps the formula but drops the cached result,
|
||||
# so any reader using ``data_only=True`` (pandas, converters) sees ``None`` until
|
||||
# Excel recalculates.
|
||||
_CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
||||
|
||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||
_MAX_PROBE_BYTES = 8_000_000
|
||||
|
||||
# #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:
|
||||
@@ -46,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)
|
||||
@@ -58,29 +272,627 @@ 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)
|
||||
|
||||
|
||||
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 _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
|
||||
"""True when at least one formula cell still carries its computed value."""
|
||||
budget = _MAX_PROBE_BYTES
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
|
||||
continue
|
||||
try:
|
||||
with zf.open(name) as fh:
|
||||
while budget > 0:
|
||||
chunk = fh.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
budget -= len(chunk)
|
||||
if _CACHED_FORMULA_RE.search(chunk):
|
||||
return True
|
||||
except (KeyError, OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def inspect_workbook(file_path: Path) -> list[str]:
|
||||
"""Return the sorted keys of :data:`LOSSY_PARTS` present in *file_path*.
|
||||
|
||||
Read-only inspection of the OPC package (central directory + a bounded scan
|
||||
of the sheet XML). Never raises: an unreadable or encrypted workbook simply
|
||||
yields ``[]`` and the save path keeps its current behaviour.
|
||||
|
||||
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
|
||||
the cached result, so the workbook stays correct once Excel recalculates it.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path) as zf:
|
||||
names = set(zf.namelist())
|
||||
found = {
|
||||
key
|
||||
for key, prefixes in LOSSY_PARTS.items()
|
||||
if any(name.startswith(prefix) for name in names for prefix in prefixes)
|
||||
}
|
||||
if _has_cached_formulas(zf):
|
||||
found.add("cached_values")
|
||||
return sorted(found)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return []
|
||||
|
||||
|
||||
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.28.9"
|
||||
version = "2.43.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.28.9"
|
||||
version = "2.43.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.28.9",
|
||||
"version": "2.43.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+6
-1
@@ -53,7 +53,12 @@ services:
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
||||
# OBSIGATE_SECURE_COOKIES=true # si derrière reverse proxy HTTPS
|
||||
# OBSIGATE_SECURE_COOKIES : auto par défaut (Secure si https, sinon
|
||||
# pas de flag) — forcer à true uniquement si le proxy termine TLS
|
||||
# sans X-Forwarded-Proto (avec TRUST_PROXY, l'auto suffit).
|
||||
# Reverse proxy devant l'app : IPs d'audit réelles (BUG-030) et
|
||||
# X-Forwarded-Proto honoré pour les cookies Secure (auto).
|
||||
- OBSIGATE_TRUST_PROXY=true
|
||||
- OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
- OLLAMA_MODEL=qwen2.5-coder:1.5b
|
||||
env_file:
|
||||
|
||||
@@ -6,7 +6,7 @@ vaults Obsidian et raccourcis essentiels.
|
||||
|
||||
> **Public :** tous les utilisateurs · **Durée de lecture :** ~10 min
|
||||
> **Voir aussi :** [Déploiement Docker](./DEPLOIEMENT_DOCKER.md) ·
|
||||
> [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [API REST](./API_REST.md)
|
||||
|
||||
---
|
||||
@@ -185,7 +185,7 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
|
||||
La recherche est un point fort d'ObsiGate : index inversé TF-IDF, stemming
|
||||
français, normalisation des accents, facettes et pagination. La syntaxe complète
|
||||
(`tag:`, `#`, `vault:`, `title:`, `path:`, `ext:`, phrases exactes) est décrite
|
||||
dans le [Guide Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
|
||||
dans le [Guide Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md).
|
||||
|
||||
Démarrage rapide :
|
||||
|
||||
@@ -234,7 +234,7 @@ Voir [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md).
|
||||
|
||||
| Objectif | Guide |
|
||||
|---|---|
|
||||
| Mieux chercher, lire PDF et Excalidraw | [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) |
|
||||
| Mieux chercher, lire PDF/Excel et Excalidraw | [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) |
|
||||
| Utiliser l'IA intégrée | [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) |
|
||||
| Éditer à plusieurs | [Édition & collaboration](./COLLABORATION.md) |
|
||||
| Sécuriser l'accès | [Authentification & sécurité](./AUTHENTIFICATION_SECURITE.md) |
|
||||
|
||||
@@ -15,7 +15,7 @@ captures conceptuelles).
|
||||
| Guide | Public | Contenu |
|
||||
|---|---|---|
|
||||
| 🚀 [Prise en main](./PRISE_EN_MAIN.md) | Tous | Premier lancement, interface, navigation, vaults, raccourcis |
|
||||
| 🔍 [Recherche, PDF & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteur PDF, diagrammes |
|
||||
| 🔍 [Recherche, PDF, Excel & Excalidraw](./RECHERCHE_PDF_EXCALIDRAW.md) | Tous | Syntaxe de requête, recherche sémantique, lecteurs PDF/Excel, diagrammes |
|
||||
| 🤖 [Assistant IA & Forge](./ASSISTANT_IA_FORGE.md) | Tous | Fournisseurs, éditeur IA, BooksLM, Forge, commandes `@` / `/` |
|
||||
| 📝 [Édition & collaboration](./COLLABORATION.md) | Tous | Édition simultanée, curseurs distants, persistance |
|
||||
| 📱 [PWA & mode hors-ligne](./PWA_HORS_LIGNE.md) | Tous | Installation PWA, cache, file de synchronisation, notifications |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 🔍 Guide Recherche, PDF & Excalidraw
|
||||
# 🔍 Guide Recherche, PDF, Excel & Excalidraw
|
||||
|
||||
ObsiGate va au-delà de la simple lecture : recherche puissante, rendu des
|
||||
documents riches (PDF, diagrammes) et indexation de leur contenu pour que tout
|
||||
@@ -131,7 +131,144 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
|
||||
---
|
||||
|
||||
## 6. Diagrammes Excalidraw
|
||||
## 6. Tableurs Excel (XLSX)
|
||||
|
||||
### Affichage et édition
|
||||
|
||||
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
|
||||
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
|
||||
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 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
|
||||
texte** (une formule injectée s'exécuterait à l'ouverture du fichier dans
|
||||
Excel). Le bouton `f(x)` de la barre d'outils active les vraies formules pour
|
||||
la session en cours.
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx" -H "Content-Type: application/json" -d '{"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": false, "force": false}'
|
||||
```
|
||||
|
||||
- `allow_formula` : `true` pour écrire une vraie formule (`=B1*2`).
|
||||
- `force` : `true` pour enregistrer malgré les éléments non préservés
|
||||
(sinon l'API répond **409** `xlsx_lossy_content`).
|
||||
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
|
||||
**409** `conflict` au lieu d'écraser la première.
|
||||
|
||||
### 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. Cliquer une **plage nommée**
|
||||
sélectionne sa première cellule dans la grille, et le panneau est
|
||||
**redimensionnable**. L'en-tête de l'inspecteur offre
|
||||
aussi un accès direct à l'**assistant IA**, qui peut ensuite exploiter ces
|
||||
plages (outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||
`append_xlsx_rows`).
|
||||
|
||||
### Limites
|
||||
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 7. Diagrammes Excalidraw
|
||||
|
||||
Les fichiers `.excalidraw` et `.excalidraw.md` (dont le format compressé du
|
||||
**plugin Obsidian Excalidraw**) s'ouvrent dans un **éditeur visuel Excalidraw
|
||||
@@ -147,7 +284,7 @@ Fiche technique : [`features/excalidraw.md`](../features/excalidraw.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Autres contenus riches
|
||||
## 8. Autres contenus riches
|
||||
|
||||
### Mermaid
|
||||
|
||||
@@ -182,7 +319,7 @@ curl -X POST "http://localhost:2020/api/attachments/rescan/Recettes"
|
||||
|
||||
---
|
||||
|
||||
## 8. Dépannage
|
||||
## 9. Dépannage
|
||||
|
||||
| Symptôme | Piste |
|
||||
|---|---|
|
||||
|
||||
+27
-7
@@ -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-24
|
||||
- **Dernière mise à jour** : 2026-09-29
|
||||
|
||||
---
|
||||
|
||||
@@ -188,16 +188,23 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
|
||||
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
|
||||
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
|
||||
| *BUG-082* | CI `lint` rouge : `tests/frontend/upload.test.mjs` (import statique `jsdom`) exécuté dans l'étape racine où `jsdom` n'est jamais installé | 🟢 corrigé | P0 | 🧩 tests | IA | `.gitea/workflows/ci.yml`, `tests/frontend/upload.test.mjs` | CI job `lint` → `ERR_MODULE_NOT_FOUND: jsdom` (introduit par `7bee4a2`, jamais vert depuis) | `upload.test.mjs` déplacé dans l'étape JSDOM (les deux branches) ; vérifié : étape racine verte + `upload` vert depuis `tests/frontend/` | Seul fichier de l'étape racine avec import statique jsdom ; `jsdom` ne vit que dans `tests/frontend/node_modules` (installé par l'étape JSDOM). Correctif : déplacer `upload.test.mjs` dans l'étape JSDOM |
|
||||
| *BUG-081* | `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée (`user` None, `AttributeError` sur `user.get`) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status` (l.821-831) | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27, `e2e-server.err.log` l.116-183) | — | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 |
|
||||
| *BUG-080* | [🔴 BLOQUANT] E2E locaux bloqués toute la nuit : `npm run test:e2e:ps` ne termine jamais (serveurs orphelins sur le port 2029, `npx playwright install` sans `--yes` ni garde-fou, suite ~130 tests sans timeout global) | 🟢 corrigé | P0 | 🧩 tests | IA | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau) | `npm run test:e2e:ps` | `run-e2e-local` : `npx --yes`, skip install Chromium si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124) ; `playwright.config.ts` : `globalTimeout` 15 min local / 30 min CI (`E2E_GLOBAL_TIMEOUT_MS`) ; `e2e-server.ps1` : pidfile = vrai owner du port, `stop` tue l'arbre complet. Tests : `tests/test_e2e_harness.py` (8/8), cycle start/stop live (pidfile cohérent, port libéré) | Constat 2026-09-27 : `e2e-server.ps1 start` OK (READY 12 s) mais run suivant pendu toute la nuit ; 2 python orphelins (PID 81180 parent + 81936 sur le port, pidfile périmé). Double processus systématique (parent `.venv` parqué + enfant qui sert — aussi sur flowdeck/3.13 : environnemental, sans impact après correctif). Trouvé au passage : BUG-081 (`/api/auth/mfa/status` → 500 auth désactivée) |
|
||||
| *BUG-081* | `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée (`user` None, `AttributeError` sur `user.get`) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status`, `tests/test_mfa.py` | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27) | Garde `user is None` → payload MFA désactivé (`mfa_enabled: false`, `totp_enabled: false`, `webauthn_credentials: 0`) ; test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0 | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 |
|
||||
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
|
||||
| *BUG-084* | Index inversé : la suppression d'une vault y laisse des documents fantômes (résultats pour une vault inexistante) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/indexer.py::remove_vault_from_index`, `backend/search.py::_remove_doc_internals` | Supprimer une vault configurée, puis chercher un terme contenu dans ses fichiers → les résultats la concernent encore | `remove_vault_from_index()` déclenche `_on_index_change('remove', …)` pour chaque fichier de la vault ; `_remove_doc_internals()` supprime la clé `vault_docs` dont le set devient vide (`defaultdict` : une lecture la recréait). Test `tests/test_search_advanced.py::TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le correctif) | Trouvé pendant la relecture de `plan.md` (étape 6 déjà livrée). Mesuré : 8 documents fantômes sur 8 après suppression de la vault de test (`postings`, `doc_info`, `doc_vault`, `vault_docs`) ; seul un reindex manuel les effaçait. Vérifié : `test_search_advanced.py` 27 passed, ruff/mypy 0, suite complète 1374 passed / 6 skipped |
|
||||
| *BUG-085* | Édition d'un `.xlsx` : les valeurs calculées en cache disparaissent du classeur (et tout lecteur `data_only=True` voit `None`) | 🟢 corrigé | P1 | tableur Excel | IA | `backend/xlsx_reader.py::inspect_workbook`, `backend/services/mutations.py::edit_xlsx_cells`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | Ouvrir un classeur contenant `=B1*2` (avec sa valeur calculée) → éditer une cellule → le `<v>` disparaît du XML de la feuille | `LOSSY_PARTS` + sonde `<f>…</f><v>[^<]` ; la lecture renvoie `xlsx_lossy_features` ; `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`) ; bandeau + confirmation UI puis reprise `force: true`. Tests : `TestXlsxLossyGuard` (5) + `xlsx-viewer.test.mjs` (10) + `tests/e2e/xlsx-viewer.spec.js` (3) | #153 A1. Périmètre réel vérifié sur openpyxl 3.1.5 : graphiques, images, dessins **et** TCD survivent au round-trip ; les pertes sont valeurs en cache, slicers/chronologies, contrôles de formulaire, connexions/requêtes, custom XML, signature, commentaires enrichis, macros. Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, E2E 3/3 |
|
||||
| *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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| *(exemple)* TODO-002 | Rendre l'index inversé incrémental (40k+ fichiers) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/indexer.py`, `backend/search.py` | Recherche sur très gros vault | — | Exemple à remplacer. Cf. plan.md |
|
||||
| *(À remplir)* | | | | | | | | | |
|
||||
|
||||
---
|
||||
@@ -210,6 +217,14 @@ 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) |
|
||||
| 2026-09-11 | BUG-003, BUG-004 | Correction | `backend/{main,indexer,export,pdf_reader,bookslm_routes}.py`, `backend/auth/router.py`, `.gitea/workflows/ci.yml`, `README.md`, `README.fr.md` | BUG-003: 33 erreurs mypy corrigées (annotations, gardes `None`, import `PROVIDERS` manquant → bug latent) + étape CI mypy rendue bloquante. BUG-004: lien `README.md` → `docs/CONTRIBUTING.md`. Vérifié: mypy 0 erreur, ruff OK, pytest 728 passed, frontend OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
@@ -277,7 +292,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-080, BUG-081 | Correction + enregistrement | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-080** : run E2E local pendu toute la nuit → harnais anti-blocage : `npx --yes` (plus de prompt interactif), install Chromium sautée si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124), `globalTimeout` Playwright (15 min local / 30 min CI, `E2E_GLOBAL_TIMEOUT_MS`), pidfile resynchronisé sur le vrai owner du port + `stop` qui tue l'arbre complet (orphelins 81180/81936 nettoyés, port 2029 libéré). Diagnostic : double processus systématique (parent `.venv` parqué + enfant qui sert — environnemental, aussi sur flowdeck/3.13). **BUG-081** (ouvert, non traité) : `GET /api/auth/mfa/status` → 500 auth désactivée (`user` None, `router.py:827`, reproduit live). Vérifié : `test_e2e_harness.py` 8/8, cycle start/stop live (pidfile cohérent, port libéré). | 🟢 corrigé (en attente vérif utilisateur) ; BUG-081 🔴 ouvert |
|
||||
| 2026-09-27 | BUG-082 | Correction CI | `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-082** : `lint` rouge (`ERR_MODULE_NOT_FOUND: jsdom` sur `upload.test.mjs`, rouge depuis `7bee4a2`) — seul fichier de l'étape frontend racine avec import statique `jsdom`, alors que `jsdom` n'est installé que dans `tests/frontend/node_modules` (étape JSDOM). `upload.test.mjs` déplacé dans l'étape JSDOM (les deux branches). Vérifié : étape racine verte (validate-imports, unit, pretty, media-viewer, mfa-settings, config-ai-keys) + `upload` vert depuis `tests/frontend/`. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-082 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-082** : `lint` rouge (`ERR_MODULE_NOT_FOUND: jsdom`, rouge depuis `7bee4a2`) — les fichiers de l'étape frontend racine à import statique `jsdom` (`upload.test.mjs`, puis `config-ai-keys.test.mjs` révélé par le CI après le 1er fix), alors que `jsdom` n'est installé que dans `tests/frontend/node_modules` (étape JSDOM). Les deux déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` généralisé (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM, contre-preuve OK). Vérifié : étape racine verte (11 suites) + `upload` et `config-ai-keys` verts depuis `tests/frontend/`. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
@@ -288,7 +306,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| # | Titre | Date résolution | Résolu par | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| *(aucun pour l'instant)* | | | | | |
|
||||
| *BUG-083* | Job CI `security` rouge : le runner Gitea Act tronque le script `pip-audit` au premier `#` (citation de l'echo non fermée → `unexpected EOF while looking for matching '"'`) | 2026-09-27 | Utilisateur | `run:` assaini (echo sans `#`, réf `#87` en commentaire YAML) ; `tests/test_ci_workflow.py` (2 tests : aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM) ; vérifié : 56 passed (ci_workflow + e2e_harness + version), contre-preuve OK sur l'ancien `ci.yml` | Seul `run:` du workflow contenant un `#` (`see #87` dans l'echo). Les `#` des noms d'étapes (Bandit, Npm audit) sont inoffensifs (ces étapes passent). Correctif : echo sans `#`, réf `#87` en commentaire YAML |
|
||||
| *BUG-082* | CI `lint` rouge : suites frontend à import statique `jsdom` exécutées dans l'étape racine où `jsdom` n'est jamais installé | 2026-09-27 | Utilisateur | `upload.test.mjs` + `config-ai-keys.test.mjs` déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM) ; vérifié : étape racine verte + `upload` et `config-ai-keys` verts depuis `tests/frontend/` | `jsdom` ne vit que dans `tests/frontend/node_modules` (installé par l'étape JSDOM). Correctif : déplacer les suites concernées dans l'étape JSDOM |
|
||||
| *BUG-080* | [🔴 BLOQUANT] E2E locaux bloqués toute la nuit : `npm run test:e2e:ps` ne termine jamais (serveurs orphelins sur le port 2029, `npx playwright install` sans `--yes` ni garde-fou, suite ~130 tests sans timeout global) | 2026-09-27 | Utilisateur | `run-e2e-local` : `npx --yes`, skip install Chromium si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124) ; `playwright.config.ts` : `globalTimeout` 15 min local / 30 min CI (`E2E_GLOBAL_TIMEOUT_MS`) ; `e2e-server.ps1` : pidfile = vrai owner du port, `stop` tue l'arbre complet. Tests : `tests/test_e2e_harness.py` (8/8), cycle start/stop live (pidfile cohérent, port libéré) | Constat 2026-09-27 : `e2e-server.ps1 start` OK (READY 12 s) mais run suivant pendu toute la nuit ; 2 python orphelins (PID 81180 parent + 81936 sur le port, pidfile périmé). Double processus systématique (parent `.venv` parqué + enfant qui sert — aussi sur flowdeck/3.13 : environnemental, sans impact après correctif). Trouvé au passage : BUG-081 (`/api/auth/mfa/status` → 500 auth désactivée) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+88
-2
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.28.9 | **Dernière mise à jour :** 2026-09-27
|
||||
> **Version :** 2.43.1 | **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)**
|
||||
@@ -42,6 +42,83 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2)
|
||||
|
||||
### 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 :** ✅ **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,
|
||||
ni styles en écriture, ni formule recalculée), **écriture destructive** d'une partie du classeur,
|
||||
tableurs **invisibles à la recherche** et **inutilisables par l'IA** au-delà de la création. Ce
|
||||
lot suit ces ajouts ; les cases ci-dessous sont le **suivi de référence**, la fiche feature porte
|
||||
le détail.
|
||||
- **Constat (points de départ) :** `MAX_ROWS = 500` / `MAX_COLS = 40` sans indicateur (troncature
|
||||
silencieuse) · `wb.save()` non atomique et sans verrou (concurrence) · saisie `=…` stockée comme
|
||||
formule par openpyxl (injection DDE) · `content=""` à l'indexation (recherche TF-IDF et sémantique
|
||||
aveugles) · aucun outil IA de lecture/édition d'un classeur existant · aucun test frontend ni
|
||||
E2E sur le viewer.
|
||||
- **Périmètre réel des pertes au round-trip (mesuré sur openpyxl 3.1.5, 2026-09-27) :** graphiques,
|
||||
images, dessins **et** tableaux croisés sont préservés ; sont perdus les **valeurs calculées en
|
||||
cache**, slicers/chronologies, contrôles de formulaire, connexions/requêtes, custom XML,
|
||||
signature numérique, commentaires enrichis et macros.
|
||||
- **Sous-tâches :**
|
||||
- **P0 — garde-fous d'écriture (🔴, 2-3 j) — 🟢 livré**
|
||||
- [x] **A1** Alerte de fidélité avant écriture : `inspect_workbook()` → `xlsx_lossy_features` + bandeau FR/EN + **409** `xlsx_lossy_content` sans `force` (confirmation explicite puis reprise) — BUG-085
|
||||
- [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) — 🟢 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 ;
|
||||
A8 le sera à son tour).
|
||||
|
||||
---
|
||||
|
||||
## 🔵 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 :** ✅ **livré le 2026-09-29 (Lots 1 → 5, A1-A5)** — 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** (tableau de bord + entrée
|
||||
Assistant IA, redimensionnable) ; **undo/redo**, chargement via `IntersectionObserver`,
|
||||
ARIA `role="grid"` ; extraction des unités sans état dans `frontend/js/xlsx/*`.
|
||||
- **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)
|
||||
- [x] **A5** Découpage `frontend/js/xlsx/*`, lien dashboard → grille, inspecteur redimensionnable (Lot 5)
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -68,11 +145,15 @@
|
||||
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
|
||||
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
|
||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — par tranches. **T1 livrée (v2.28.1) :** bandit bloquant (`nosec` justifiés B324/B404/B603/B607/B406, B105 exclu comme `pyproject`), `npm audit` bloquant (0 vulnérabilité), 5 suites frontend intégrées au CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`). pip-audit reste consultatif (montées starlette/weasyprint à qualifier).
|
||||
- **T6 livrée (v2.28.15) :** dépendances qualifiées — mistune 3.3.3, python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 (`cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant, 0 vulnérabilité** (seule exception documentée : PYSEC-2026-1325 ecdsa, sans correctif upstream, JWT HS256 uniquement).
|
||||
- **T7 livrée (v2.28.15) :** **semgrep bloquant** sur ruleset 100 % local `semgrep-rules/` (8 règles, 0 finding, contrôle négatif OK) ; trivy écarté (binaire + DB réseau, couche Python couverte).
|
||||
- **T8 livrée (v2.28.15, fin BUG-034) :** cookies `Secure` auto (`true|false|auto`, `X-Forwarded-Proto` sous `TRUST_PROXY`, warning affiné, `TRUST_PROXY=true` en prod) ; `CORSMiddleware` same-origin explicite ; `style-src 'unsafe-inline'` conservé assumé (189 `style=` + 343 `el.style`, T5c ayant verrouillé `script-src`).
|
||||
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown) — **T4 livrée :** `tests/e2e/xss.spec.js` (BUG-021/022, 2/2 vert) + `scripts/e2e-server.ps1` (cycle de vie serveur E2E avec progression `start|stop|status|logs`) + validation locale projet `chromium-desktop` : **108/108 verts** (obsigate 44, split 37, viewers 24, xss/header 3), mobiles ciblés 10/10
|
||||
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`) — **T2 livrée (v2.28.2) :** `tests/test_hardening_concurrency.py` (users.json concurrent + budget temps regex) ; 5 suites au CI (T1)
|
||||
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte)
|
||||
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte) ; **T5c livrée (v2.28.13)** (`script-src` sans `unsafe-inline`) ; **T8 livrée (v2.28.15)** (fin BUG-034 : Secure auto + CORS explicite ; `style-src` résiduel assumé ; rotation DeepSeek BUG-006 toujours côté utilisateur)
|
||||
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD — **T6/T9 livrées (v2.28.15)** (`pip-audit` 0, `npm audit` 0, locales FR/EN 2213 clés parité testée `test_i18n_parity.py`, gardes `test_version.py` + `test_ci_workflow.py`)
|
||||
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
|
||||
|
||||
---
|
||||
@@ -86,6 +167,7 @@
|
||||
| # | Domaine / fonctionnalité | Version | Détails |
|
||||
|---|---|---|---|
|
||||
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.44.0 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
|
||||
@@ -164,6 +246,8 @@
|
||||
| 🔵 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 |
|
||||
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
|
||||
| ✅ Terminé | #154 Refonte UI/UX tableur (A1-A5 **toutes livrées**, v2.40.0 → v2.44.0) | 0 jour restant |
|
||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||
|
||||
---
|
||||
@@ -171,6 +255,8 @@
|
||||
## Notes
|
||||
|
||||
- **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) ; **livré en 5 lots** (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille, dialogues thémés, inspecteur droit, undo/redo, extraction `frontend/js/xlsx/*`).
|
||||
- **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,149 @@
|
||||
# #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 :** ✅ **terminé** — Lots 1 → 5 livrés le 2026-09-29 (A1-A5)
|
||||
> **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 ✅ *(2026-09-29)*
|
||||
|
||||
- **A5.1** Extraction des parties pures/sans état du monolithe `renderXlsxViewer` dans
|
||||
`frontend/js/xlsx/` : **`refs.js`** (`parseRef`, `columnName`, `findTd`, `sheetOfRef`,
|
||||
`firstCellOfRange`), **`command-bar.js`** (`buildCommandBar` : onglets + ruban + pastilles),
|
||||
**`dashboard.js`** (`renderDashboardLoading`, `renderDashboardHtml`). Le noyau **avec état**
|
||||
(orchestration DOM, édition, écouteurs) reste dans `viewer.js` : l'extraction est volontairement
|
||||
limitée aux unités sans état, à comportement constant et sous couvert des tests.
|
||||
- **A5.2** Lien **dashboard → grille** : cliquer une plage nommée sélectionne et révèle sa
|
||||
première cellule (change d'onglet si la plage est sur une autre feuille).
|
||||
- **A5.3** Inspecteur **redimensionnable** (poignée gauche, largeur 260–640 px, restaurée par
|
||||
session via `localStorage`).
|
||||
- **A5.4** `SW_VERSION` incrémenté (`v28`) et nouveaux modules ajoutés au pré-cache du service
|
||||
worker.
|
||||
|
||||
> **Hors périmètre (documenté) :** le détachement de l'inspecteur en split view
|
||||
> (`PaneManager.splitRight()`) n'est pas retenu — l'inspecteur est intrinsèquement lié à la
|
||||
> visionneuse d'un document ; un split générique ouvrirait un second contexte sans le classeur.
|
||||
> À réévaluer si un usage concret apparaît.
|
||||
|
||||
## 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 |
|
||||
| 2026-09-29 | **Lot 5** livré (clôture #154) : extraction des unités sans état dans `frontend/js/xlsx/*` (`refs.js`, `command-bar.js`, `dashboard.js`), lien **dashboard → grille**, inspecteur **redimensionnable**, `SW_VERSION` v28 + pré-cache. Split view écarté (documenté) |
|
||||
@@ -0,0 +1,260 @@
|
||||
# #153 — Visionneuse & édition XLSX — état des lieux et backlog
|
||||
|
||||
> **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 :** ✅ **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.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Périmètre et architecture
|
||||
|
||||
| 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, 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` |
|
||||
| UI | `frontend/js/viewer.js:998-1100` | `renderXlsxViewer()` (onglets, cellules sales, Entrée/Échap, collage monoligne) |
|
||||
| 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` (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)
|
||||
|
||||
**Lecture** — multi-feuilles avec onglets ; en-têtes A1/A2/B1 et numéros de ligne ; valeurs
|
||||
`_fmt()` (dates `YYYY-MM-DD` / `YYYY-MM-DD HH:MM`) ; lignes et colonnes de fin élaguées
|
||||
(`_trim`) ; feuille vide affichée ; `html.escape()` sur chaque valeur.
|
||||
|
||||
**Édition** — `contentEditable` par `<td>`, classe `xlsx-dirty`, bouton Save actif seulement si
|
||||
modification ; `Entrée` → blur, `Échap` → restauration, collage forcé en monoligne ; un `PUT` par
|
||||
feuille sale ; coercion automatique des nombres (`"250"` → int `250`) ; chaîne vide → cellule
|
||||
vidée ; backup `.bak` avant écriture ; garde-fou vault read-only (403) ; `resolve_safe_path()`
|
||||
(anti path-traversal) ; `check_vault_access()` + `require_auth` ; journalisation d'audit
|
||||
(`log_file_save`).
|
||||
|
||||
**Divers** — téléchargement de l'original ; refresh de l'arborescence via le watcher après
|
||||
écriture ; rafraîchissement de la visionneuse après une action IA (`create_xlsx` →
|
||||
`obsigate:file-written`, BUG-076).
|
||||
|
||||
## 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
|
||||
openpyxl 3.1.5 (2026-09-27), et non repris de la documentation :
|
||||
|
||||
| Élément | Round-trip openpyxl 3.1.5 |
|
||||
|---|---|
|
||||
| Graphiques, images, dessins | ✅ **préservés** (mesuré : `xl/charts/`, `xl/drawings/`, `xl/media/` intacts) |
|
||||
| Tableaux croisés (pivot) + caches | ✅ **préservés** (`reader/excel.py` relit les `TableDefinition`, `workbook/_writer.py` les réécrit) |
|
||||
| Styles, formats, fusions, validation de données, mise en forme conditionnelle, commentaires | ✅ préservés |
|
||||
| **Valeurs calculées en cache** (`<f>…</f><v>…</v>`) | ❌ **perdues** → tout lecteur `data_only=True` (pandas, script tiers, convertisseur) renvoie `None` tant qu'Excel n'a pas recalculé |
|
||||
| Slicers / chronologies, contrôles de formulaire (`ctrlProps`/`activeX`), connexions & requêtes, custom XML, signature numérique, commentaires enrichis, macros | ❌ **perdus** (parties absentes de l'archive après écriture) |
|
||||
|
||||
La liste fait foi dans le code : [`LOSSY_PARTS`](../backend/xlsx_reader.py) + la sonde
|
||||
`<f>…</f><v>[^<]` pour les valeurs en cache (openpyxl écrivant lui-même un `<v></v>` vide).
|
||||
|
||||
**Ce qui reste ouvert** (non mesuré, prudence) : types de graphiques exotiques (treemap,
|
||||
sunburst, funnel…), `sparklines`, `xl/queryTables` en lecture Excel. Un classeur qui en contient
|
||||
peut sortir dégradé, voire échouer au chargement — d'où le refus par défaut (A1).
|
||||
|
||||
### 3.2 Lecture
|
||||
|
||||
- Aucun style, format de nombre, devise, pourcentage, largeur de colonne, ligne figée, cellule
|
||||
fusionnée, commentaire, lien hypertexte, validation de données, mise en forme conditionnelle.
|
||||
- Plafonds durs `MAX_ROWS = 500`, `MAX_COLS = 40` par feuille, **sans indicateur dans l'UI** : au-delà,
|
||||
contenu silencieusement tronqué et **non éditable**.
|
||||
- Pas de pagination ni de chargement à la demande : toutes les feuilles sont rendues d'un bloc
|
||||
dans le JSON (20 feuilles × 20 000 cellules = payload énorme, UI gelée).
|
||||
- Formules affichées **en texte** (`=B1*2`), jamais recalculées ; après édition, les cellules
|
||||
dépendantes ne se mettent pas à jour à l'écran.
|
||||
|
||||
### 3.3 UI (`viewer.js`)
|
||||
|
||||
Navigation clavier (Tab/flèches) absente ; pas de barre de formule, pas de nom de cellule actif,
|
||||
pas d'undo/redo global, pas de recherche dans la feuille, pas de tri/filtre, pas d'export CSV,
|
||||
pas d'ajout/renommage/suppression de feuille, pas d'insertion/suppression de ligne ou colonne,
|
||||
pas de sélection de plage, pas de copie d'une plage, pas de retour ligne dans une cellule
|
||||
(`Maj+Entrée`) ; seul le retour de l'API est signalé (plafond 500 cellules) ; seule la
|
||||
**colonne A** est `sticky` (le `thead` ne l'est pas → les en-têtes de colonnes disparaissent au
|
||||
défilement vertical). **Couverture de test** : `tests/frontend/xlsx-viewer.test.mjs` (10) et
|
||||
`tests/e2e/xlsx-viewer.spec.js` (3) depuis #153 P0 — la navigation clavier et la barre de formule
|
||||
restent à faire (A7).
|
||||
|
||||
### 3.4 Recherche, IA et knowledge base
|
||||
|
||||
- **Indexation** : `content=""` → un `.xlsx` est totalement **invisible** à la recherche TF-IDF, à
|
||||
la recherche sémantique, au remplacement global, aux tags et aux statistiques de contenu.
|
||||
- **Outils IA** : seul `create_xlsx` existe (crée un fichier neuf, une seule feuille,
|
||||
`overwrite=True` par défaut) ; `read_file` fait un `read_text()` sur l'archive ZIP → **bruit
|
||||
binaire** envoyé au LLM ; pas de `update_xlsx_cells` pourtant le service existe déjà, pas
|
||||
d'ajout de lignes, pas de `xlsx → markdown` pour le contexte.
|
||||
|
||||
## 4. Risques de sécurité / robustesse
|
||||
|
||||
| # | Risque | Où | Traitement | État |
|
||||
|---|---|---|---|---|
|
||||
| R1 | Perte silencieuse (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) | `mutations.edit_xlsx_cells` | **A1** — bandeau + **409** `xlsx_lossy_content` sans `force` | 🟢 livré (BUG-085) |
|
||||
| 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 | 🟢 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
|
||||
|
||||
Légende : 🔴 P0 (sécurité / perte de données) · 🟡 P1 (valeur immédiate) · 🟢 P2 (confort /
|
||||
couverture) · effort en jours-homme de développement + tests.
|
||||
|
||||
### P0 — Garde-fous d'écriture (2-3 j) — 🟢 livré le 2026-09-27
|
||||
|
||||
- [x] **A1 — Alerte de fidélité avant écriture (R1).** `inspect_workbook()` liste ce qu'un
|
||||
round-trip perd (`LOSSY_PARTS` + sonde valeurs en cache) ; la lecture renvoie
|
||||
`xlsx_lossy_features` ; la visionneuse affiche un bandeau listant les éléments ; `PUT
|
||||
…/xlsx/save` répond **409** `xlsx_lossy_content` (avec `details.features`) tant que `force` n'est
|
||||
pas passé, le client demande confirmation puis réémet avec `force: true` (une seule fois par
|
||||
session). *Vérifié :* `TestXlsxLossyGuard` (5), `xlsx-viewer.test.mjs` (10), E2E (3).
|
||||
- [x] **A2 — Écriture atomique (R2).** `wb.save(<nom>.<pid>.tmp)` puis `os.replace()` ; `.tmp`
|
||||
supprimé sur échec ; backup `.bak` inchangé. Le `.tmp` est ignoré par le watcher. *Vérifié :*
|
||||
`TestXlsxAtomicWrite` (2) — les octets d'origine sont intacts après un `save` en échec.
|
||||
- [x] **A3 — Verrou par fichier (R3).** Verrou `threading.Lock` par chemin (registre + garde,
|
||||
timeout 15 s) autour du cycle load → edit → replace ; **409** `conflict` si le délai est dépassé.
|
||||
L'endpoint est passé en `def` (sync) pour que l'attente s'exécute dans le threadpool. *Vérifié :*
|
||||
`TestXlsxWriteLock` (2). *Limite :* verrou en mémoire, par processus (suffisant pour un serveur
|
||||
ObsiGate, y compris desktop).
|
||||
- [x] **A4 — Neutralisation de l'injection de formule (R4).** `cell.data_type = "s"` après
|
||||
affectation : une saisie `=`/`@` est stockée en texte. Opt-in `allow_formula: true` côté API et
|
||||
bouton `f(x)` dans la visionneuse (état de session, jamais persisté). `+`/`-` restent des
|
||||
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) — 🟢 livré le 2026-09-28 (A5 → A12)
|
||||
|
||||
- [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`. *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`). *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. *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) — 🟢 livré le 2026-09-28
|
||||
|
||||
- [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. *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`)
|
||||
|
||||
- Chaque sous-tâche démarre par un **ID stable** : nouvelle feature = `#153-A<n>` dans la
|
||||
Roadmap ; si la sous-tâche est un **défaut** (A1, A2, A3, A4, A8), l'ouvrir aussi comme
|
||||
`BUG-NNN` dans `docs/ISSUES_TODOLIST.md` au moment du démarrage.
|
||||
- Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans
|
||||
`backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`.
|
||||
- Frontend : vanilla JS sans build, `safeCreateIcons()`, **variables CSS** (jamais de couleur
|
||||
hardcodée), **i18n FR + EN** pour chaque nouveau texte (`test_i18n_parity.py` vert).
|
||||
- Tests : `pytest tests/test_xlsx_viewer.py`, `ruff`, `mypy`, `validate-imports`, suite frontend
|
||||
ciblée, E2E si l'UI change — puis CI verte.
|
||||
- Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée), cette fiche (résultat),
|
||||
guide utilisateur i18n + README si impact utilisateur.
|
||||
|
||||
## 7. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2.27.0 | #152 livré : affichage multi-feuilles, édition des cellules, téléchargement (`docs/archive/COMPLETED_v1-v2.md`) |
|
||||
| 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é** |
|
||||
+12
-1
@@ -72,14 +72,25 @@ async function api(path, opts) {
|
||||
}
|
||||
if (!res.ok) {
|
||||
var detail = "";
|
||||
var code = "";
|
||||
var details = null;
|
||||
try {
|
||||
var body = await res.json();
|
||||
detail = body.detail || "";
|
||||
// #153 A1 : the service layer exposes a stable code + details so callers
|
||||
// can branch on the failure (e.g. confirm a lossy .xlsx write) instead of
|
||||
// matching on the message.
|
||||
code = body.code || "";
|
||||
details = body.details || null;
|
||||
} catch (_) {
|
||||
/* no json body */
|
||||
}
|
||||
showToast(detail || "Erreur API : " + res.status, "error");
|
||||
throw new Error(detail || "API error: " + res.status);
|
||||
var apiError = new Error(detail || "API error: " + res.status);
|
||||
apiError.status = res.status;
|
||||
apiError.code = code;
|
||||
apiError.details = details;
|
||||
throw apiError;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1222,7 +1222,7 @@ function renderDiagnostics(container, data) {
|
||||
["Postings total", data.inverted_index.total_postings.toLocaleString()],
|
||||
["Documents", data.inverted_index.documents],
|
||||
["Mémoire estimée", data.inverted_index.memory_estimate_mb + " MB"],
|
||||
["Stale", data.inverted_index.is_stale ? "Oui" : "Non"],
|
||||
["Index prêt", data.inverted_index.is_ready ? "Oui" : "Non"],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+993
-27
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
/* ObsiGate — XLSX command bar builder (#154-A5).
|
||||
*
|
||||
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
|
||||
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
|
||||
* is testable and the viewer only wires behaviour.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {Array<{name: string}>} ctx.sheets
|
||||
* @param {boolean} ctx.isCsv
|
||||
* @param {boolean} ctx.readOnly
|
||||
* @param {boolean} ctx.editable - not a CSV and not read-only
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
|
||||
*/
|
||||
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
||||
// as a real affordance; an editable workbook gets an explicit “+” button.
|
||||
const tabs = isCsv
|
||||
? ""
|
||||
: `<div class="xlsx-tabs" role="tablist">${sheets.map((s, i) =>
|
||||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}" role="tab" aria-selected="${i === 0}">${escapeHtml(s.name)}</button>`
|
||||
).join("")}${editable
|
||||
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}">+</button>`
|
||||
: ""}</div>`;
|
||||
|
||||
// Status pills make the viewer's limits visible up front.
|
||||
const statusPills = [
|
||||
readOnly
|
||||
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
|
||||
: "",
|
||||
!isCsv
|
||||
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
|
||||
: "",
|
||||
].filter(Boolean).join("");
|
||||
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
|
||||
|
||||
// Command groups, separated by thin rules.
|
||||
const actionGroups = [];
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
|
||||
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
|
||||
<i data-lucide="undo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-redo-btn" type="button" title="${escapeHtml(t("xlsx.redo"))}" disabled>
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
</span>`);
|
||||
}
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="insert">
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||
</span>`);
|
||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
||||
|
||||
return { tabs, actionsHtml, statusBar };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* ObsiGate — XLSX dashboard renderer (#154-A5).
|
||||
*
|
||||
* Pure string builders for the workbook dashboard inspector (named ranges, KPI
|
||||
* cards). Extracted from frontend/js/viewer.js; the viewer handles the fetch,
|
||||
* the inspector host and the range → grid link.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
export function renderDashboardLoading() {
|
||||
return `<div class="xlsx-dashboard-loading">…</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} dash - payload of GET …/xlsx/dashboard
|
||||
* @returns {string} the inspector body markup
|
||||
*/
|
||||
export function renderDashboardHtml(dash) {
|
||||
const namedRanges = dash.named_ranges || [];
|
||||
const sheets = dash.sheets || [];
|
||||
const rangeRows = namedRanges.map((r) =>
|
||||
`<tr class="xlsx-range-row" data-ref="${escapeHtml(r.ref || "")}"><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
|
||||
).join("");
|
||||
const kpiCards = sheets.map((s) => {
|
||||
const cards = (s.kpi || []).map((k) =>
|
||||
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
|
||||
).join("");
|
||||
return `<div class="xlsx-kpi-sheet">
|
||||
<h4>${escapeHtml(s.name)}</h4>
|
||||
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
|
||||
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
|
||||
}))}</p>
|
||||
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
return `
|
||||
<div class="xlsx-dashboard-head">
|
||||
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
|
||||
<span class="xlsx-dashboard-objects">
|
||||
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
|
||||
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
|
||||
</span>
|
||||
</div>
|
||||
${namedRanges.length || sheets.length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
|
||||
${namedRanges.length ? `
|
||||
<table class="csv-table xlsx-ranges-table"><thead><tr>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
|
||||
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
|
||||
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
|
||||
<div class="xlsx-kpi-grid">${kpiCards}</div>
|
||||
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* ObsiGate — XLSX A1 reference helpers (#154-A5).
|
||||
*
|
||||
* Pure functions (no DOM, no i18n): safe to unit-test in Node and shared by the
|
||||
* viewer. Extracted from frontend/js/viewer.js without behaviour change.
|
||||
*/
|
||||
|
||||
/** Parse an A1 reference ("B12") into its 1-based (row, col) parts. */
|
||||
export function parseRef(ref) {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
|
||||
if (!m) return null;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { row: Number(m[2]), col };
|
||||
}
|
||||
|
||||
/** Column number (1-based) → letters ("A", "Z", "AA"). */
|
||||
export function columnName(col) {
|
||||
let name = "";
|
||||
while (col > 0) {
|
||||
const rem = (col - 1) % 26;
|
||||
name = String.fromCharCode(65 + rem) + name;
|
||||
col = Math.floor((col - 1) / 26);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Find a rendered cell inside a sheet panel by (row, col). */
|
||||
export function findTd(panel, row, col) {
|
||||
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||
}
|
||||
|
||||
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
|
||||
export function sheetOfRef(ref) {
|
||||
const s = String(ref || "");
|
||||
if (!s.includes("!")) return null;
|
||||
return s.split("!")[0].replace(/^'|'$/g, "");
|
||||
}
|
||||
|
||||
/** First cell of a range ref ("Data!$A$1:$B$5" → "A1"), or null. */
|
||||
export function firstCellOfRange(ref) {
|
||||
const s = String(ref || "");
|
||||
const body = s.includes("!") ? s.split("!").pop() : s;
|
||||
const m = /([A-Za-z]+)\$?(\d+)/.exec(body);
|
||||
return m ? `${m[1].toUpperCase()}${m[2]}` : null;
|
||||
}
|
||||
@@ -1823,6 +1823,84 @@
|
||||
"viewer.copy": "Copy",
|
||||
"viewer.copy_error": "Copy error",
|
||||
"viewer.download": "Download",
|
||||
"xlsx.lossy_title": "Simplified save",
|
||||
"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.inspector_resize": "Resize the inspector",
|
||||
"xlsx.range_not_visible": "Cell out of view (truncated sheet)",
|
||||
"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",
|
||||
"xlsx.feature_connections": "connections and queries",
|
||||
"xlsx.feature_custom_xml": "custom XML",
|
||||
"xlsx.feature_signature": "digital signature",
|
||||
"xlsx.feature_rich_comments": "rich comments",
|
||||
"xlsx.feature_macros": "macros",
|
||||
"viewer.download_md": "Download as .md",
|
||||
"viewer.download_file": "Download file",
|
||||
"viewer.pretty": "Pretty",
|
||||
@@ -1987,6 +2065,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}",
|
||||
|
||||
@@ -1823,6 +1823,84 @@
|
||||
"viewer.copy": "Copier",
|
||||
"viewer.copy_error": "Erreur lors de la copie",
|
||||
"viewer.download": "Télécharger",
|
||||
"xlsx.lossy_title": "Enregistrement simplifié",
|
||||
"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.inspector_resize": "Redimensionner l'inspecteur",
|
||||
"xlsx.range_not_visible": "Cellule hors de l'affichage (feuille tronquée)",
|
||||
"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",
|
||||
"xlsx.feature_connections": "connexions et requêtes",
|
||||
"xlsx.feature_custom_xml": "XML personnalisé",
|
||||
"xlsx.feature_signature": "signature numérique",
|
||||
"xlsx.feature_rich_comments": "commentaires enrichis",
|
||||
"xlsx.feature_macros": "macros",
|
||||
"viewer.download_md": "Télécharger en .md",
|
||||
"viewer.download_file": "Télécharger le fichier",
|
||||
"viewer.pretty": "Pretty",
|
||||
@@ -1987,6 +2065,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}",
|
||||
|
||||
+629
-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,456 @@ 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;
|
||||
position: relative;
|
||||
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;
|
||||
}
|
||||
/* #154-A5 — resize handle on the inspector's left edge. */
|
||||
.xlsx-inspector-resize {
|
||||
position: absolute;
|
||||
left: -3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
.xlsx-inspector-resize:hover {
|
||||
background: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
.xlsx-range-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-range-row:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.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;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--warning, #e0a800);
|
||||
border-left-width: 3px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.xlsx-warning-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 1px;
|
||||
color: var(--warning, #e0a800);
|
||||
}
|
||||
.xlsx-warning-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-warning-body strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.xlsx-warning-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.xlsx-warning-tag {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xlsx-warning-hint {
|
||||
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;
|
||||
}
|
||||
.xlsx-formula-toggle.active {
|
||||
background: var(--accent, #4a90d9);
|
||||
border-color: var(--accent, #4a90d9);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── JSON Viewer ── */
|
||||
.json-viewer {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
|
||||
+4
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v26';
|
||||
const SW_VERSION = 'v28';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
@@ -30,6 +30,9 @@ const PRECACHE_URLS = [
|
||||
'/static/js/ui.js',
|
||||
'/static/js/sidebar.js',
|
||||
'/static/js/viewer.js',
|
||||
'/static/js/xlsx/refs.js',
|
||||
'/static/js/xlsx/command-bar.js',
|
||||
'/static/js/xlsx/dashboard.js',
|
||||
'/static/js/search.js',
|
||||
'/static/js/config.js',
|
||||
'/static/js/utils.js',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.28.9",
|
||||
"version": "2.43.1",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
# Plan: Incremental InvertedIndex for 40k+ files
|
||||
# Incremental InvertedIndex for 40k+ files — livré
|
||||
|
||||
> **Statut : LIVRÉ (BUG-033, v2.3.0).** Ce fichier a servi de plan
|
||||
> d'exécution ; il est conservé comme **trace de conception**. Le code réel
|
||||
> a divergé sur plusieurs points (voir [État réel](#état-réel-corrigé-au-2026-09-27))
|
||||
> — ne pas lire les extraits de code ci-dessous comme du code actuel.
|
||||
|
||||
## Problem Summary
|
||||
|
||||
@@ -17,6 +22,11 @@ Then hook these into `_add_file_to_structures` and `_remove_file_from_structures
|
||||
|
||||
Remove the `is_stale()` / `rebuild()` / cooldown mechanism entirely. The inverted index is always current.
|
||||
|
||||
> ⚠️ **Nuance retenue à l'implémentation** : un unique `rebuild()` reste nécessaire au
|
||||
> démarrage (le hook est inerte tant que l'index n'est pas prêt) et au reindex manuel
|
||||
> d'une vault. Ce qui disparaît, c'est la *staleness* : plus de compteur de génération,
|
||||
> plus de cooldown, plus de rebuild paresseux.
|
||||
|
||||
## Dependency Architecture
|
||||
|
||||
**Current import chain:**
|
||||
@@ -346,3 +356,61 @@ This hack was only needed to reduce the number of inverted index rebuilds. With
|
||||
4. **Sorted tokens performance:** `bisect.insort` and `list.pop(idx)` are O(V) worst case for large V. For 40k files, the vocabulary size V is typically 50k-200k tokens. O(V) for a single insertion is ~0.001ms, acceptable. The rebuild() call at startup handles the initial bulk.
|
||||
|
||||
5. **tag_norm_map / tag_prefix_index growth:** These grow monotonically (never shrink on incremental remove). With 40k files and thousands of tags, this is a few thousand entries — negligible. A manual "Réindexer" button triggers a full `rebuild()` to clean up.
|
||||
|
||||
---
|
||||
|
||||
## État réel (corrigé le 2026-09-27)
|
||||
|
||||
Le plan ci-dessus a servi de brouillon : **le code livré en est différent sur
|
||||
quatre points**. Relevé fait sur `backend/search.py`, `backend/indexer.py` et
|
||||
`backend/main.py`, pas de mémoire.
|
||||
|
||||
| Point prévu | État réel |
|
||||
|---|---|
|
||||
| Étapes 1-2 : hook + `add_document()` / `remove_document()` | ✅ livré tel que prévu |
|
||||
| Étapes 4-5 : `rebuild()` initial via `init_inverted_index()` appelé depuis la lifespan | ✅ livré (`backend/main.py:297`, dans l'exécuteur de recherche) |
|
||||
| Étape 6 : retirer `is_stale()` + `_last_rebuild` / `_rebuild_cooldown` / `_source_generation` | ✅ **déjà fait** avant cette relecture — aucun de ces symboles ne subsiste |
|
||||
| Étape 7 : retirer le hack de coalescence `_index_generation` dans `_on_vault_change` | ✅ **déjà fait** — `_on_vault_change` n'existe plus |
|
||||
| `get_inverted_index()` simplifié | ✅ mais **sans le fallback `_needs_rebuild`** prévu par le plan |
|
||||
|
||||
### Écarts assumés
|
||||
|
||||
1. **`is_stale()` a survécu sous un autre nom.** L'étape 6 est faite, mais la
|
||||
méthode a été conservée car elle répond à une autre question : *l'index
|
||||
initial est-il construit ?* Elle ne mesure plus aucune staleness (le compteur
|
||||
de génération et le cooldown ont disparu) et le nom était trompeur. Elle est
|
||||
donc renommée `is_ready()` — cohérent avec le `is_ready()` déjà exposé par
|
||||
`SemanticIndex` (`backend/semantic_search.py`). L'alias `is_stale()` de
|
||||
`SemanticIndex`, sans aucun appelant, est supprimé.
|
||||
|
||||
Impact : le champ de `/api/diagnostics` passe de `is_stale` à `is_ready`
|
||||
(libellé « Index prêt » côté `frontend/js/config.js`).
|
||||
|
||||
2. **Pas de repli `_needs_rebuild`.** Le plan prévoyait qu'un échec
|
||||
d'incrémentation marque l'index pour reconstruction. L'implémentation
|
||||
retenue se contente de logger un warning et de continuer à servir l'index.
|
||||
Choix assumé : un échec d'incrémentation est exceptionnel, et reconstruire
|
||||
silencieusement serait plus coûteux que l'état dégradé. **Si ce compromis
|
||||
devient critiquique, c'est le point à rouvrir.**
|
||||
|
||||
3. **`_ready` remplace `doc_count == 0`.** Le plan prévoyait de sauter le hook
|
||||
« index vide » ; le drapeau explicite `_ready` est plus sûr (un vault
|
||||
réellement vide serait sinon pris pour un index non construit).
|
||||
|
||||
4. **`rebuild()` reste nécessaire** au démarrage et au reindex manuel d'une
|
||||
vault. Le plan parlait de le supprimer de `get_inverted_index()`, ce qui est
|
||||
fait, mais la méthode elle-même est conservée.
|
||||
|
||||
### Bug trouvé pendant cette relecture (corrigé ici)
|
||||
|
||||
`remove_vault_from_index()` (`backend/indexer.py`) ne notifiait pas le hook.
|
||||
Conséquence mesurée : après suppression d'une vault, ses 8 documents test
|
||||
restaient dans l'index inversé — `postings`, `doc_info`, `doc_vault`,
|
||||
`vault_docs` — et continuaient de correspondre aux recherches pour une vault
|
||||
inexistante. Seul un reindex manuel les effaçait.
|
||||
|
||||
Le correctif déclenche `_on_index_change('remove', …)` pour chaque fichier de
|
||||
la vault, et `_remove_doc_internals()` supprime désormais la clé `vault_docs`
|
||||
quand son set devient vide (c'est un `defaultdict` : une simple lecture la
|
||||
ré créait). Test de non-régression :
|
||||
`TestVaultRemovalPurgesInvertedIndex` (contre-preuve : échoue sans le patch).
|
||||
|
||||
@@ -9,12 +9,13 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
timeout: 60000,
|
||||
expect: { timeout: 10000 },
|
||||
// BUG-080 : la suite (~130 tests, workers: 1) ne doit jamais pendre toute
|
||||
// la nuit. Au-delà du timeout global, Playwright abandonne avec un échec
|
||||
// explicite au lieu de bloquer. Surchargable : E2E_GLOBAL_TIMEOUT_MS.
|
||||
// BUG-080 : la suite (~120 tests, workers: 1, ~10-15 s/test sur un poste
|
||||
// chargé) ne doit jamais pendre toute la nuit. Au-delà du timeout global,
|
||||
// Playwright abandonne avec un échec explicite au lieu de bloquer.
|
||||
// Surchargable : E2E_GLOBAL_TIMEOUT_MS.
|
||||
globalTimeout: Number(
|
||||
process.env.E2E_GLOBAL_TIMEOUT_MS ??
|
||||
(process.env.CI ? 30 * 60 * 1000 : 15 * 60 * 1000),
|
||||
(process.env.CI ? 30 * 60 * 1000 : 25 * 60 * 1000),
|
||||
),
|
||||
reportSlowTests: process.env.CI ? null : { max: 5, threshold: 30000 },
|
||||
|
||||
|
||||
+28
-12
@@ -10,7 +10,7 @@
|
||||
conditions que le job CI `e2e`), lance la suite Playwright puis nettoie.
|
||||
|
||||
.PARAMETER PlaywrightArgs
|
||||
Arguments transmis à `npx playwright test`, ex. `-g "image viewer"`,
|
||||
Arguments transmis à `playwright test` (via `node`), ex. `-g "image viewer"`,
|
||||
`--headed`.
|
||||
|
||||
.EXAMPLE
|
||||
@@ -35,15 +35,20 @@ $ServerLog = "data/e2e-server.log"
|
||||
$ServerErrLog = "data/e2e-server.err.log"
|
||||
|
||||
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
|
||||
$TestTimeoutSec = if ($env:E2E_TIMEOUT_SEC) { [int]$env:E2E_TIMEOUT_SEC } else { 900 }
|
||||
# E2E_TIMEOUT_SEC dépasse volontairement le globalTimeout Playwright (25 min en
|
||||
# local) pour que ce soit Playwright qui abandonne proprement (avec rapport) en premier.
|
||||
$TestTimeoutSec = if ($env:E2E_TIMEOUT_SEC) { [int]$env:E2E_TIMEOUT_SEC } else { 1800 }
|
||||
$BrowserTimeoutSec = if ($env:E2E_BROWSER_INSTALL_TIMEOUT_SEC) { [int]$env:E2E_BROWSER_INSTALL_TIMEOUT_SEC } else { 600 }
|
||||
|
||||
function Invoke-NativeWithTimeout([string]$Label, [int]$TimeoutSec, [string]$Exe, [string[]]$Args) {
|
||||
function Invoke-NativeWithTimeout([string]$Label, [int]$TimeoutSec, [string]$Exe, [string[]]$Arguments) {
|
||||
# Lance un processus natif en gardant la sortie console en direct, et le
|
||||
# tue après $TimeoutSec s'il n'a pas terminé (exit 124, comme `timeout`).
|
||||
# NOTE : le paramètre NE DOIT PAS s'appeler `$Args` (variable automatique
|
||||
# PowerShell qui l'écraserait → `node` lancé sans arguments, exit 0
|
||||
# silencieux immédiat en lisant un stdin vide).
|
||||
$stamp = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$stamp] $Label (timeout ${TimeoutSec}s)..."
|
||||
$proc = Start-Process -FilePath $Exe -ArgumentList $Args -NoNewWindow -PassThru
|
||||
$proc = Start-Process -FilePath $Exe -ArgumentList $Arguments -NoNewWindow -PassThru
|
||||
$proc | Wait-Process -Timeout $TimeoutSec -ErrorAction SilentlyContinue
|
||||
if (-not $proc.HasExited) {
|
||||
Write-Host "[ERR] $Label : timeout après ${TimeoutSec}s, arrêt du processus (PID $($proc.Id))."
|
||||
@@ -70,7 +75,7 @@ function Assert-Command([string]$Name, [string]$Hint) {
|
||||
}
|
||||
|
||||
Assert-Command "uv" "Installez-le : https://docs.astral.sh/uv/"
|
||||
Assert-Command "npx" "Installez Node.js (>= 20)."
|
||||
Assert-Command "node" "Installez Node.js (>= 20)."
|
||||
|
||||
# ----- Venv Python 3.11 (créé une seule fois) -----
|
||||
$Python = ".venv-e2e/Scripts/python.exe"
|
||||
@@ -130,21 +135,32 @@ try {
|
||||
}
|
||||
Write-Host "[OK] Serveur prêt."
|
||||
|
||||
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
|
||||
# BUG-080 : `--yes` (jamais de prompt interactif npx qui pend), skip si un
|
||||
# chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1), timeout dédié.
|
||||
# ----- Playwright via node direct (pas npx) -----
|
||||
# BUG-080 : `Start-Process` ne peut pas lancer `npx` (ni le `.ps1` ni le
|
||||
# `.cmd` ne sont des applications Win32 directes) → on appelle la CLI
|
||||
# locale via `node.exe`, sans prompt interactif possible. Skip de
|
||||
# l'install si un chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1),
|
||||
# timeouts dédiés sur chaque étape.
|
||||
$PlaywrightCli = Join-Path $Root "node_modules/@playwright/test/cli.js"
|
||||
if (-not (Test-Path -LiteralPath $PlaywrightCli)) {
|
||||
throw "[ERR] $PlaywrightCli introuvable. Lancez d'abord : npm ci"
|
||||
}
|
||||
if (($env:E2E_INSTALL_BROWSERS -eq "1") -or (-not (Test-ChromiumInstalled))) {
|
||||
$code = Invoke-NativeWithTimeout "npx playwright install chromium" $BrowserTimeoutSec "npx" @("--yes", "playwright", "install", "chromium")
|
||||
$code = Invoke-NativeWithTimeout "playwright install chromium" $BrowserTimeoutSec "node" @($PlaywrightCli, "install", "chromium")
|
||||
if ($code -ne 0) { exit $code }
|
||||
} else {
|
||||
Write-Host "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
|
||||
}
|
||||
|
||||
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
|
||||
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
|
||||
Write-Host "[INFO] BASE_URL=$BaseUrl node $PlaywrightCli test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
|
||||
$env:BASE_URL = $BaseUrl
|
||||
$testArgs = @("--yes", "playwright", "test", "--project=chromium-desktop") + @($PlaywrightArgs)
|
||||
$exitCode = Invoke-NativeWithTimeout "playwright test" $TestTimeoutSec "npx" $testArgs
|
||||
$testArgs = @($PlaywrightCli, "test", "--project=chromium-desktop")
|
||||
if ($PlaywrightArgs) { $testArgs += @($PlaywrightArgs) }
|
||||
$exitCode = Invoke-NativeWithTimeout "playwright test" $TestTimeoutSec "node" $testArgs
|
||||
} catch {
|
||||
Write-Host "[ERR] $($_.Exception.Message)"
|
||||
$exitCode = 1
|
||||
} finally {
|
||||
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
|
||||
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# ObsiGate — règles Semgrep locales (#87 T7).
|
||||
#
|
||||
# Volontairement LOCALES (aucun `--config auto`/registre) : le runner CI a un
|
||||
# accès réseau fragile, et ces règles n'ont besoin d'aucun téléchargement.
|
||||
# Exécution : `semgrep --config semgrep-rules/ backend/` (job CI `lint`,
|
||||
# bloquant). Chaque règle est un garde-fou : aucun code existant ne doit
|
||||
# la déclencher (vérifié à l'ajout) ; toute violation future échoue le CI.
|
||||
rules:
|
||||
- id: obsigate-no-eval-exec
|
||||
message: "Interdit : eval()/exec() sur du contenu dynamique (injection de code). Restructurer sans exécution de code."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: eval(...)
|
||||
- pattern: exec(...)
|
||||
|
||||
- id: obsigate-no-shell-true
|
||||
message: "Interdit : subprocess avec shell=True (injection shell). Passer argv en liste, shell=False."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: subprocess.run(..., shell=True, ...)
|
||||
- pattern: subprocess.Popen(..., shell=True, ...)
|
||||
- pattern: subprocess.call(..., shell=True, ...)
|
||||
- pattern: subprocess.check_output(..., shell=True, ...)
|
||||
- pattern: subprocess.check_call(..., shell=True, ...)
|
||||
|
||||
- id: obsigate-no-os-system
|
||||
message: "Interdit : os.system() (shell implicite). Utiliser subprocess avec argv en liste."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern: os.system(...)
|
||||
|
||||
- id: obsigate-no-pickle-load
|
||||
message: "Interdit : pickle.load/loads sur des données non fiables (exécution arbitraire). Utiliser JSON."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: pickle.load(...)
|
||||
- pattern: pickle.loads(...)
|
||||
|
||||
- id: obsigate-no-yaml-unsafe-load
|
||||
message: "Interdit : yaml.load() sans Loader (exécution arbitraire). Utiliser yaml.safe_load()."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
patterns:
|
||||
- pattern: yaml.load(...)
|
||||
- pattern-not: yaml.load(..., Loader=...)
|
||||
|
||||
- id: obsigate-no-unverified-tls
|
||||
message: "Interdit : verify=False (MITM). Ne jamais désactiver la vérification TLS."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern-either:
|
||||
- pattern: requests.$METHOD(..., verify=False, ...)
|
||||
- pattern: httpx.$METHOD(..., verify=False, ...)
|
||||
- pattern: httpx.Client(..., verify=False, ...)
|
||||
- pattern: httpx.AsyncClient(..., verify=False, ...)
|
||||
|
||||
- id: obsigate-no-markupsafe-markup
|
||||
message: "Interdit : markupsafe.Markup() (contourne l'échappement XSS, BUG-021/022). Le sanitizer serveur est la seule voie."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern: Markup(...)
|
||||
|
||||
- id: obsigate-no-tempfile-mktemp
|
||||
message: "Interdit : tempfile.mktemp() (race symlink, CWE-377). Utiliser NamedTemporaryFile/mkdtemp."
|
||||
severity: ERROR
|
||||
languages: [python]
|
||||
pattern: tempfile.mktemp(...)
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* E2E tests — Excel viewer, write guards (ROADMAP #153 P0).
|
||||
*
|
||||
* Fixture : `test_vault/sample-xlsx-lossy.xlsx` — a plain 2x2 workbook whose
|
||||
* sheet XML carries a cached formula result (`<f>B1*2</f><v>200</v>`) and whose
|
||||
* package contains `xl/slicers/slicer1.xml`. Both are dropped by an openpyxl
|
||||
* round-trip, so the read response must report
|
||||
* `xlsx_lossy_features: ["cached_values", "slicers"]` (BUG-085 A1).
|
||||
*
|
||||
* Covered :
|
||||
* - 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 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.
|
||||
*
|
||||
* Run (local) : BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xlsx-viewer.spec.js
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
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);
|
||||
|
||||
let originalBytes = null;
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(BASE);
|
||||
const loginForm = page.locator('#login-screen');
|
||||
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
|
||||
if (await loginForm.isVisible()) {
|
||||
await page.fill('#login-username', process.env.OBSIGATE_USER || 'admin');
|
||||
await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123');
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFixture(page) {
|
||||
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 });
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => {
|
||||
test.beforeAll(() => {
|
||||
if (existsSync(FIXTURE_PATH)) originalBytes = readFileSync(FIXTURE_PATH);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
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);
|
||||
|
||||
const banner = page.locator('#content-area .xlsx-warning');
|
||||
await expect(banner).toBeVisible();
|
||||
// 2 features : valeurs calculées + segments (jamais de couleur codée en dur,
|
||||
// les libellés viennent bien des locales).
|
||||
await expect(banner.locator('.xlsx-warning-tag')).toHaveCount(2);
|
||||
await expect(banner).toContainText('segments');
|
||||
await expect(banner).toContainText('valeurs calculées');
|
||||
});
|
||||
|
||||
test('demande confirmation puis enregistre la cellule', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const cell = page.locator('#content-area td[data-cell="A2"]');
|
||||
await cell.click();
|
||||
await cell.fill('Total confirmé');
|
||||
await cell.press('Enter');
|
||||
|
||||
const save = page.locator('#xlsx-save-btn');
|
||||
await expect(save).toBeEnabled();
|
||||
await save.click();
|
||||
|
||||
// #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 »).
|
||||
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('le toggle f(x) est désactivé par défaut (formule stockée en texte)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const toggle = page.locator('#xlsx-formula-btn');
|
||||
await expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
// 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.
@@ -0,0 +1,958 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM integration tests for the Excel viewer (ROADMAP #153 P0).
|
||||
*
|
||||
* Loads the real viewer.js module and drives renderXlsxViewer():
|
||||
* - A1 : `xlsx_lossy_features` renders a warning banner; a save on such a
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// ── JSDOM bootstrap ─────────────────────────────────────────────────────────
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="content-area"></div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true }
|
||||
);
|
||||
|
||||
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;
|
||||
globalThis.Event = w.Event;
|
||||
globalThis.CustomEvent = w.CustomEvent;
|
||||
globalThis.MouseEvent = w.MouseEvent;
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// ── fetch / confirm doubles ─────────────────────────────────────────────────
|
||||
let calls = [];
|
||||
let nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
|
||||
let confirmAnswer = true;
|
||||
let confirmCalls = 0;
|
||||
// Every confirm() prompt is captured so the tests can assert on its text.
|
||||
let confirmPrompts = [];
|
||||
|
||||
const FR = JSON.parse(
|
||||
readFileSync(path.join(REPO_ROOT, "frontend", "locales", "fr.json"), "utf8")
|
||||
);
|
||||
|
||||
globalThis.fetch = async (url, opts = {}) => {
|
||||
// The i18n bootstrap fetches the locale files: serve the real FR one so the
|
||||
// assertions run on the shipped strings, not on raw keys.
|
||||
if (url.includes("/static/locales/")) {
|
||||
return { ok: true, status: 200, json: async () => FR };
|
||||
}
|
||||
calls.push({ url, body: opts.body ? JSON.parse(opts.body) : null });
|
||||
const res = nextResponse(url, opts);
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
json: async () => res.body,
|
||||
};
|
||||
};
|
||||
globalThis.confirm = (msg) => { confirmCalls++; confirmPrompts.push(msg); return confirmAnswer; };
|
||||
w.confirm = globalThis.confirm;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
calls = [];
|
||||
confirmCalls = 0;
|
||||
confirmPrompts = [];
|
||||
confirmAnswer = true;
|
||||
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}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
if (e.stack) console.log(` ${e.stack.split("\n").slice(1, 3).join("\n ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { renderXlsxViewer } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "viewer.js")).href
|
||||
);
|
||||
// Load the FR catalog so t() resolves the real strings.
|
||||
const { initI18n } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
|
||||
);
|
||||
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><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>";
|
||||
|
||||
// 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"), ...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}"]`);
|
||||
td.textContent = text;
|
||||
td.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
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,
|
||||
body: {
|
||||
detail: "Saving this workbook would drop features…",
|
||||
code: "xlsx_lossy_content",
|
||||
details: { features: ["slicers"] },
|
||||
},
|
||||
};
|
||||
|
||||
console.log("\n── xlsx viewer JSDOM integration tests (#153 P0) ──\n");
|
||||
|
||||
// ── A1 — warning banner ─────────────────────────────────────────────────────
|
||||
|
||||
await test("no banner when the workbook has nothing at risk", () => {
|
||||
const area = mount();
|
||||
assert.equal(area.querySelector(".xlsx-warning"), null);
|
||||
});
|
||||
|
||||
await test("banner lists every lossy feature reported by the backend", () => {
|
||||
const area = mount({ lossy: ["cached_values", "slicers"] });
|
||||
const banner = area.querySelector(".xlsx-warning");
|
||||
assert.ok(banner, "banner absent");
|
||||
const tags = [...banner.querySelectorAll(".xlsx-warning-tag")].map((n) => n.textContent);
|
||||
assert.equal(tags.length, 2);
|
||||
assert.ok(tags.includes(FR["xlsx.feature_cached_values"]), tags.join("|"));
|
||||
assert.ok(tags.includes(FR["xlsx.feature_slicers"]), tags.join("|"));
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.lossy_title"]));
|
||||
assert.ok(banner.textContent.includes(FR["xlsx.lossy_hint"]));
|
||||
});
|
||||
|
||||
await test("formula toggle is present and starts unpressed", () => {
|
||||
const area = mount();
|
||||
const btn = area.querySelector("#xlsx-formula-btn");
|
||||
assert.ok(btn);
|
||||
assert.equal(btn.getAttribute("aria-pressed"), "false");
|
||||
assert.equal(btn.getAttribute("title"), FR["xlsx.formula_toggle_title"]);
|
||||
});
|
||||
|
||||
// ── Save payload ────────────────────────────────────────────────────────────
|
||||
|
||||
await test("save sends one PUT per dirty sheet with the cell map", async () => {
|
||||
const area = mount();
|
||||
editCell(area, "A1", "250");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/save\?path=data\.xlsx/);
|
||||
assert.deepEqual(calls[0].body.cells, { A1: "250" });
|
||||
assert.equal(calls[0].body.sheet, "Feuille1");
|
||||
assert.equal(calls[0].body.force, false);
|
||||
assert.equal(calls[0].body.allow_formula, false);
|
||||
});
|
||||
|
||||
await test("save button stays disabled when nothing is dirty", async () => {
|
||||
const area = mount();
|
||||
const btn = area.querySelector("#xlsx-save-btn");
|
||||
assert.equal(btn.disabled, true);
|
||||
btn.click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
// ── A4 — formula toggle ─────────────────────────────────────────────────────
|
||||
|
||||
await test("f(x) toggle flips allow_formula on the next save", async () => {
|
||||
const area = mount();
|
||||
area.querySelector("#xlsx-formula-btn").click();
|
||||
assert.equal(area.querySelector("#xlsx-formula-btn").getAttribute("aria-pressed"), "true");
|
||||
editCell(area, "A1", "=B1*2");
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
assert.equal(calls[0].body.allow_formula, true);
|
||||
});
|
||||
|
||||
// ── A1 — 409 confirmation & force retry ─────────────────────────────────────
|
||||
|
||||
await test("409 xlsx_lossy_content asks once then retries with force", async () => {
|
||||
const area = mount({ lossy: ["slicers"] });
|
||||
editCell(area, "A1", "250");
|
||||
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// #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);
|
||||
// Save succeeded → cells are no longer dirty.
|
||||
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||
});
|
||||
|
||||
await test("confirming once is enough for the following saves", async () => {
|
||||
const area = mount({ lossy: ["cached_values"] });
|
||||
editCell(area, "A1", "1");
|
||||
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(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;
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
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");
|
||||
nextResponse = () => ({ ok: false, status: 500, body: { detail: "boom" } });
|
||||
area.querySelector("#xlsx-save-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(confirmCalls, 0);
|
||||
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");
|
||||
});
|
||||
|
||||
// ── #154-A5 — extracted refs module & dashboard → grid link ────────────────
|
||||
|
||||
const { parseRef, columnName, firstCellOfRange, sheetOfRef } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "refs.js")).href
|
||||
);
|
||||
|
||||
await test("parseRef / columnName round-trip A1 references", () => {
|
||||
assert.deepEqual(parseRef("A1"), { row: 1, col: 1 });
|
||||
assert.deepEqual(parseRef("B12"), { row: 12, col: 2 });
|
||||
assert.deepEqual(parseRef("AA3"), { row: 3, col: 27 });
|
||||
assert.equal(parseRef("A"), null);
|
||||
assert.equal(columnName(1), "A");
|
||||
assert.equal(columnName(26), "Z");
|
||||
assert.equal(columnName(27), "AA");
|
||||
assert.equal(columnName(parseRef("AB9").col), "AB");
|
||||
});
|
||||
|
||||
await test("range helpers extract the sheet and the first cell", () => {
|
||||
assert.equal(firstCellOfRange("Data!$A$1:$B$5"), "A1");
|
||||
assert.equal(firstCellOfRange("'Mon onglet'!$C$3"), "C3");
|
||||
assert.equal(firstCellOfRange("A1:B2"), "A1");
|
||||
assert.equal(sheetOfRef("Data!$A$1:$B$5"), "Data");
|
||||
assert.equal(sheetOfRef("'Mon onglet'!$C$3"), "Mon onglet");
|
||||
assert.equal(sheetOfRef("A1:B2"), null);
|
||||
});
|
||||
|
||||
await test("clicking a named range reveals its first cell in the grid", async () => {
|
||||
const area = mount();
|
||||
apiQueue.push({
|
||||
ok: true, status: 200,
|
||||
body: { vault: "V", path: "data.xlsx", named_ranges: [{ name: "MaPlage", scope: "", ref: "Feuille1!$A$1:$B$2" }], objects: { charts: 0, pivots: 0 }, sheets: [] },
|
||||
});
|
||||
area.querySelector("#xlsx-dashboard-btn").click();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
const row = area.querySelector(".xlsx-range-row[data-ref]");
|
||||
assert.ok(row, "the named range row is rendered");
|
||||
row.click();
|
||||
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1", "the anchor cell becomes active");
|
||||
assert.ok(area.querySelector('td[data-cell="A1"]').classList.contains("xlsx-active"));
|
||||
});
|
||||
|
||||
await test("the inspector exposes a resize handle", () => {
|
||||
const area = mount();
|
||||
assert.ok(area.querySelector("#xlsx-inspector-resize"), "resize handle present");
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
+80
-1
@@ -402,4 +402,83 @@ class TestAvatar:
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["user"]["avatar"] == TINY_PNG
|
||||
assert resp.json()["user"]["avatar"] == TINY_PNG
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Secure cookies (#87 T8)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSecureCookies:
|
||||
"""`Secure` auto par défaut : https → flag, http → pas de flag
|
||||
(les navigateurs jettent les cookies Secure sur http)."""
|
||||
|
||||
@staticmethod
|
||||
def _req(scheme="http", forwarded_proto=None):
|
||||
from types import SimpleNamespace
|
||||
headers = {}
|
||||
if forwarded_proto is not None:
|
||||
headers["x-forwarded-proto"] = forwarded_proto
|
||||
return SimpleNamespace(
|
||||
url=SimpleNamespace(scheme=scheme),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def test_forced_true(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "true")
|
||||
assert is_secure_cookies(self._req("http")) is True
|
||||
|
||||
def test_forced_false(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "false")
|
||||
assert is_secure_cookies(self._req("https")) is False
|
||||
|
||||
def test_auto_http(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
assert is_secure_cookies(self._req("http")) is False
|
||||
|
||||
def test_auto_https(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
assert is_secure_cookies(self._req("https")) is True
|
||||
|
||||
def test_auto_forwarded_proto_trusted(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true")
|
||||
assert is_secure_cookies(self._req("http", "https")) is True
|
||||
|
||||
def test_auto_forwarded_proto_untrusted(self, monkeypatch):
|
||||
from backend.auth.router import is_secure_cookies
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_TRUST_PROXY", raising=False)
|
||||
assert is_secure_cookies(self._req("http", "https")) is False
|
||||
|
||||
def test_login_http_sets_cookie_without_secure(self, auth_client, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
resp = auth_client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
set_cookie = resp.headers.get("set-cookie", "")
|
||||
assert "access_token" in set_cookie
|
||||
assert "secure" not in set_cookie.lower()
|
||||
|
||||
def test_login_https_sets_secure_cookie(self, auth_client, monkeypatch):
|
||||
"""Même app servie en https → flag Secure présent."""
|
||||
from backend.main import app
|
||||
from fastapi.testclient import TestClient
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
https_client = TestClient(app, base_url="https://testserver",
|
||||
raise_server_exceptions=False)
|
||||
try:
|
||||
resp = https_client.post("/api/auth/login", json={
|
||||
"username": "admin", "password": "chab30",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "secure" in resp.headers.get("set-cookie", "").lower()
|
||||
finally:
|
||||
if hasattr(https_client, "close"):
|
||||
https_client.close()
|
||||
@@ -0,0 +1,274 @@
|
||||
"""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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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()
|
||||
bodies: list[tuple[int, str]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
m = re.match(r"^(\s*)run:(?:\s*\|\s*)?$", lines[i])
|
||||
inline = re.match(r"^(\s*)run:\s+(\S.*)$", lines[i])
|
||||
if m:
|
||||
base = len(m.group(1))
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
cur = lines[i]
|
||||
if not cur.strip():
|
||||
i += 1
|
||||
continue
|
||||
if len(cur) - len(cur.lstrip()) <= base:
|
||||
break
|
||||
bodies.append((i + 1, cur.strip()))
|
||||
i += 1
|
||||
elif inline:
|
||||
bodies.append((i + 1, inline.group(2).strip()))
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
return bodies
|
||||
|
||||
|
||||
class TestRunnerProofScripts:
|
||||
def test_no_hash_inside_run_bodies(self):
|
||||
"""BUG-083 : aucun `#` dans le code shell des `run:`.
|
||||
|
||||
Le runner Gitea Act tronque naïvement au premier `#` (même entre
|
||||
guillemets) : `echo "... see #87)"` devenait une citation non
|
||||
fermée → `unexpected EOF while looking for matching '"'` (job
|
||||
`security` rouge). Les lignes-commentaires shell (`# ...`) restent
|
||||
autorisées : leur troncature est sémantiquement neutre.
|
||||
"""
|
||||
offenders = [
|
||||
f"L{n}: {code}"
|
||||
for n, code in _run_bodies()
|
||||
if not code.startswith("#") and "#" in code
|
||||
]
|
||||
assert not offenders, (
|
||||
"BUG-083 : `#` interdit dans le code des `run:` "
|
||||
f"(tronqué par le runner) :\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
class TestSemgrepStep:
|
||||
"""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:
|
||||
@staticmethod
|
||||
def _root_step_files() -> list[str]:
|
||||
"""Fichiers `node tests/frontend/<f>` de l'étape racine (sans jsdom)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
root_part = text.split("Frontend JSDOM tests", 1)[0]
|
||||
root_steps = root_part.split("Frontend unit tests", 1)[1]
|
||||
return re.findall(r"node tests/frontend/(\S+\.mjs)", root_steps)
|
||||
|
||||
@staticmethod
|
||||
def _has_static_jsdom_import(rel: str) -> bool:
|
||||
path = REPO_ROOT / "tests" / "frontend" / rel
|
||||
return any(
|
||||
re.match(r"^\s*import\b.*\bfrom\s+['\"]jsdom['\"]", line)
|
||||
or re.match(r"""\brequire\(\s*['"]jsdom['"]\s*\)""", line)
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
|
||||
def test_root_step_files_need_no_jsdom(self):
|
||||
"""BUG-082 : l'étape racine tourne sans `tests/frontend/node_modules`
|
||||
(installé seulement par l'étape JSDOM) : aucun de ses fichiers ne
|
||||
doit importer `jsdom` statiquement — sinon `ERR_MODULE_NOT_FOUND`
|
||||
et `lint` rouge (cas `upload.test.mjs`, puis `config-ai-keys.test.mjs`).
|
||||
"""
|
||||
offenders = [f for f in self._root_step_files() if self._has_static_jsdom_import(f)]
|
||||
assert not offenders, (
|
||||
"BUG-082 : ces fichiers importent `jsdom` mais tournent dans "
|
||||
"l'étape racine (sans node_modules) — les déplacer dans l'étape "
|
||||
f"JSDOM :\n" + "\n".join(offenders)
|
||||
)
|
||||
|
||||
def test_jsdom_dependent_tests_run_in_jsdom_step(self):
|
||||
"""BUG-082 : les suites à import statique `jsdom` tournent bien dans
|
||||
l'étape JSDOM (les deux branches)."""
|
||||
text = CI_YML.read_text(encoding="utf-8")
|
||||
jsdom_part = text.split("Frontend JSDOM tests", 1)[1]
|
||||
for suite in ("node upload.test.mjs", "node config-ai-keys.test.mjs"):
|
||||
assert jsdom_part.count(suite) >= 2, (
|
||||
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}"
|
||||
)
|
||||
@@ -23,18 +23,34 @@ def _read(rel: str) -> str:
|
||||
class TestE2ELocalPs:
|
||||
SCRIPT = "scripts/run-e2e-local.ps1"
|
||||
|
||||
def test_npx_never_prompts(self):
|
||||
"""`npx --yes` partout : aucun prompt « Ok to proceed? » qui pend."""
|
||||
def test_playwright_via_node_no_npx(self):
|
||||
"""Playwright est lancé via `node` direct, jamais via `npx`.
|
||||
|
||||
`Start-Process` ne peut pas exécuter `npx` (ni le `.ps1` ni le
|
||||
`.cmd` ne sont des applications Win32 directes : "%1 is not a valid
|
||||
Win32 application"), et `npx` sans `--yes` peut pendre sur un prompt
|
||||
interactif. Seules les mentions en commentaires/logs sont tolérées.
|
||||
"""
|
||||
content = _read(self.SCRIPT)
|
||||
# Aucune invocation nue `npx ...` / `& npx ...` (toujours via le helper
|
||||
# avec `--yes`) ; les mentions dans commentaires/Write-Host sont OK.
|
||||
bare = [
|
||||
line.strip()
|
||||
for line in content.splitlines()
|
||||
if re.match(r"^\s*(?:&\s*)?npx\s", line)
|
||||
]
|
||||
assert not bare, f"invocations npx nues (sans --yes) : {bare}"
|
||||
assert content.count("--yes") >= 2, "au moins install + test en --yes"
|
||||
assert not bare, f"invocations npx nues : {bare}"
|
||||
assert "node_modules/@playwright/test/cli.js" in content, (
|
||||
"CLI Playwright locale attendue (via node)"
|
||||
)
|
||||
# `$Args` est une variable automatique PowerShell : un paramètre de
|
||||
# ce nom serait écrasé (helper lancé sans arguments → exit 0 muet).
|
||||
# (commentaires `#` exclus : la mise en garde elle-même le cite).
|
||||
code_lines = [
|
||||
line for line in content.splitlines()
|
||||
if not line.strip().startswith("#")
|
||||
]
|
||||
assert not re.search(r"\$Args\b", "\n".join(code_lines)), (
|
||||
"BUG-080 : paramètre `$Args` interdit (shadowing par $args automatique)"
|
||||
)
|
||||
|
||||
def test_browser_install_skippable(self):
|
||||
"""Install navigateurs sautée si chromium déjà présent (sauf forçage)."""
|
||||
@@ -43,12 +59,16 @@ class TestE2ELocalPs:
|
||||
assert "E2E_INSTALL_BROWSERS" in content
|
||||
|
||||
def test_test_step_has_timeout(self):
|
||||
"""L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 900)."""
|
||||
"""L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 1800).
|
||||
|
||||
Le défaut dépasse le globalTimeout Playwright (25 min en local) pour
|
||||
que ce soit Playwright qui abandonne proprement (avec rapport) en premier.
|
||||
"""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "E2E_TIMEOUT_SEC" in content
|
||||
assert "Wait-Process -Timeout" in content
|
||||
assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*900\s*\}", content), (
|
||||
"défaut E2E_TIMEOUT_SEC=900 attendu"
|
||||
assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*1800\s*\}", content), (
|
||||
"défaut E2E_TIMEOUT_SEC=1800 attendu"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Parité i18n FR/EN des locales du frontend (#87 T9).
|
||||
|
||||
`frontend/locales/fr.json` et `en.json` doivent exposer exactement les mêmes
|
||||
clés (comparaison profonde) : toute clé manquante fait afficher la clé brute
|
||||
dans l'UI au lieu du libellé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
LOCALES = Path(__file__).resolve().parent.parent / "frontend" / "locales"
|
||||
|
||||
|
||||
def _flat(d: dict, prefix: str = "") -> set[str]:
|
||||
keys = set()
|
||||
for k, v in d.items():
|
||||
name = f"{prefix}.{k}" if prefix else str(k)
|
||||
if isinstance(v, dict):
|
||||
keys |= _flat(v, name)
|
||||
else:
|
||||
keys.add(name)
|
||||
return keys
|
||||
|
||||
|
||||
def _load(lang: str) -> set[str]:
|
||||
return _flat(json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
class TestI18nParity:
|
||||
def test_fr_en_same_keys(self):
|
||||
fr, en = _load("fr"), _load("en")
|
||||
assert not (fr - en), f"clés sans traduction EN : {sorted(fr - en)[:10]}"
|
||||
assert not (en - fr), f"clés sans traduction FR : {sorted(en - fr)[:10]}"
|
||||
@@ -367,3 +367,64 @@ class TestMfaApiEndpoints:
|
||||
data = login_resp.json()
|
||||
assert "access_token" in data
|
||||
assert data.get("mfa_required") is None
|
||||
|
||||
|
||||
# ── BUG-081 : /api/auth/mfa/status avec auth désactivée ──────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def mfa_client_noauth():
|
||||
"""TestClient avec auth DÉSACTIVÉE (OBSIGATE_AUTH_ENABLED=false)."""
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
data_dir = tmp / "data"
|
||||
data_dir.mkdir()
|
||||
|
||||
orig_cwd = os.getcwd()
|
||||
test_vault_path = os.path.abspath("test-vault")
|
||||
os.chdir(str(tmp))
|
||||
|
||||
os.environ["VAULT_1_NAME"] = "TestVault"
|
||||
os.environ["VAULT_1_PATH"] = test_vault_path
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
|
||||
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.main import app
|
||||
from backend.indexer import build_index, index
|
||||
for key in list(index.keys()):
|
||||
del index[key]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
from backend.search import init_inverted_index
|
||||
init_inverted_index()
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
yield client
|
||||
|
||||
if hasattr(client, 'close'):
|
||||
client.close()
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
|
||||
os.chdir(orig_cwd)
|
||||
shutil.rmtree(str(tmp), ignore_errors=True)
|
||||
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
|
||||
"OBSIGATE_WATCHER_ENABLED"]:
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
class TestMfaStatusAuthDisabled:
|
||||
"""BUG-081 : `GET /api/auth/mfa/status` ne doit pas répondre 500 quand
|
||||
l'auth est désactivée (pseudo-user `anonymous` sans entrée en store)."""
|
||||
|
||||
def test_mfa_status_anonymous_returns_disabled(self, mfa_client_noauth):
|
||||
resp = mfa_client_noauth.get("/api/auth/mfa/status")
|
||||
assert resp.status_code == 200, f"BUG-081: {resp.status_code} {resp.text[:200]}"
|
||||
body = resp.json()
|
||||
assert body["mfa_enabled"] is False
|
||||
assert body["totp_enabled"] is False
|
||||
assert body["webauthn_credentials"] == 0
|
||||
|
||||
@@ -115,6 +115,25 @@ class TestInvertedIndex:
|
||||
inv.remove_document("V", "p.md")
|
||||
assert inv.doc_count == 0 # Skipped
|
||||
|
||||
def test_is_ready_tracks_initial_build(self, client):
|
||||
"""is_ready() is the only freshness signal: no generation counter,
|
||||
no cooldown, no lazy rebuild (plan.md step 6)."""
|
||||
inv = InvertedIndex()
|
||||
assert inv.is_ready() is False
|
||||
inv.rebuild()
|
||||
assert inv.is_ready() is True
|
||||
# The old staleness API is gone for good.
|
||||
assert not hasattr(inv, "is_stale")
|
||||
|
||||
def test_is_ready_survives_incremental_updates(self, client):
|
||||
"""Incremental add/remove must not flip readiness back (which would
|
||||
silently push search() onto the O(N) full-scan fallback)."""
|
||||
self.inv.rebuild()
|
||||
assert self.inv.is_ready() is True
|
||||
self.inv.add_document("V", "p.md", {"path": "p.md", "title": "T", "tags": [], "content": "x"})
|
||||
self.inv.remove_document("V", "p.md")
|
||||
assert self.inv.is_ready() is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Search / Advanced Search integration tests
|
||||
@@ -192,3 +211,40 @@ class TestSearchFunctions:
|
||||
def test_suggest_tags_no_match(self, client):
|
||||
suggestions = suggest_tags("xyznonexistent", vault_filter="all")
|
||||
assert len(suggestions) == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Vault removal — inverted index must not keep ghost documents
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVaultRemovalPurgesInvertedIndex:
|
||||
"""`remove_vault_from_index()` must notify the inverted-index hook.
|
||||
|
||||
Regression: it only cleaned the indexer's own structures, so every document
|
||||
of the removed vault survived in the inverted index (postings, doc_info,
|
||||
doc_vault, vault_docs) and kept matching searches for a vault that no
|
||||
longer exists — a leak that only a manual reindex used to clear.
|
||||
"""
|
||||
|
||||
def test_removing_a_vault_purges_its_documents(self, client):
|
||||
import asyncio
|
||||
|
||||
import backend.indexer as ix
|
||||
import backend.search as bs
|
||||
|
||||
ix.set_index_change_hook(bs._on_index_change_hook)
|
||||
inv = bs._inverted_index
|
||||
inv.rebuild()
|
||||
|
||||
vault_keys = [k for k in inv.doc_info if k.startswith("TestVault::")]
|
||||
assert vault_keys, "vault non indexe, test sans valeur"
|
||||
before = inv.doc_count
|
||||
|
||||
asyncio.run(ix.remove_vault_from_index("TestVault"))
|
||||
|
||||
ghosts = [k for k in inv.doc_info if k.startswith("TestVault::")]
|
||||
assert not ghosts, f"documents fantomes dans l'index inverse : {ghosts[:5]}"
|
||||
assert inv.doc_count == before - len(vault_keys)
|
||||
assert "TestVault" not in inv.vault_docs
|
||||
# The index stays usable for the remaining vaults.
|
||||
assert inv.is_ready() is True
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
"""Tests — cookies Secure, CORS same-origin implicite, avertissement bind (ROADMAP #87 T3).
|
||||
"""Tests — cookies Secure, CORS same-origin explicite, avertissement bind (ROADMAP #87 T3/T8).
|
||||
|
||||
- `is_secure_cookies()` suit `OBSIGATE_SECURE_COOKIES` (défaut `false` :
|
||||
compatibilité logins en HTTP local — les navigateurs ignorent les cookies
|
||||
`Secure` en clair).
|
||||
- Aucun en-tête CORS permissif n'est émis : sans `CORSMiddleware`, les
|
||||
navigateurs appliquent le same-origin par défaut (politique explicite par
|
||||
l'absence — vérifiée ici pour qu'un ajout futur de CORS soit conscient).
|
||||
- `is_secure_cookies()` : `OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut
|
||||
`auto` : Secure si la requête arrive en https, sinon pas de flag — les
|
||||
navigateurs ignorent les cookies `Secure` en clair).
|
||||
- CORS same-origin EXPLICITE : `CORSMiddleware(allow_origins=[])` — aucun
|
||||
`Access-Control-Allow-*` n'est émis même avec un `Origin` cross-origin,
|
||||
et les preflights sont rejetés (400).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_secure_cookies_default_false(monkeypatch):
|
||||
"""Défaut `false` (logins HTTP locaux préservés)."""
|
||||
def test_secure_cookies_default_auto(monkeypatch):
|
||||
"""Défaut `auto` : sans requête → pas de flag (logins HTTP locaux préservés)."""
|
||||
from backend.auth.router import is_secure_cookies
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
assert is_secure_cookies() is False
|
||||
|
||||
|
||||
def test_secure_cookies_opt_in(monkeypatch):
|
||||
"""`OBSIGATE_SECURE_COOKIES=true` → flag actif (insensible à la casse)."""
|
||||
def test_secure_cookies_forced_values(monkeypatch):
|
||||
"""`true`/`1`/`yes` → flag ; `false`/`0`/`no` → pas de flag (insensible à la casse)."""
|
||||
from backend.auth.router import is_secure_cookies
|
||||
|
||||
for value in ("true", "True", "TRUE", "1", "yes"):
|
||||
for value in ("true", "True", "TRUE", "1", "yes", "on"):
|
||||
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
|
||||
assert is_secure_cookies() is (value.lower() == "true")
|
||||
assert is_secure_cookies() is True
|
||||
for value in ("false", "False", "FALSE", "0", "no", "off"):
|
||||
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
|
||||
assert is_secure_cookies() is False
|
||||
|
||||
|
||||
def test_no_cors_headers_on_api(client):
|
||||
@@ -42,6 +45,25 @@ def test_no_cors_headers_on_public_share(client):
|
||||
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
|
||||
|
||||
|
||||
def test_cross_origin_get_emits_no_acao(client):
|
||||
"""#87 T8 : même avec un `Origin` cross-origin, aucun ACAO (refus explicite)."""
|
||||
resp = client.get("/api/health", headers={"Origin": "http://evil.example"})
|
||||
assert resp.status_code == 200
|
||||
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
|
||||
|
||||
|
||||
def test_cross_origin_preflight_rejected(client):
|
||||
"""#87 T8 : preflight cross-origin → 400 (origine non autorisée)."""
|
||||
resp = client.options(
|
||||
"/api/health",
|
||||
headers={
|
||||
"Origin": "http://evil.example",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_security_headers_present(client):
|
||||
"""En-têtes de durcissement posés par le middleware (non-régression)."""
|
||||
resp = client.get("/api/health")
|
||||
|
||||
@@ -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
|
||||
+571
-3
@@ -1,7 +1,13 @@
|
||||
"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save)."""
|
||||
"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save).
|
||||
|
||||
Covers #152 (affichage / édition) and #153 P0 : A1 alerte de fidélité avant
|
||||
écriture, A2 écriture atomique, A3 verrou par fichier, A4 neutralisation de
|
||||
l'injection de formule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -29,6 +35,43 @@ def xlsx_file(test_vault_dir: str) -> str:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _add_lossy_parts(path: Path, parts: dict[str, bytes]) -> None:
|
||||
"""Re-pack *path* with extra OPC parts openpyxl cannot write back."""
|
||||
with zipfile.ZipFile(path) as zf:
|
||||
items = {n: zf.read(n) for n in zf.namelist()}
|
||||
items.update(parts)
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, blob in items.items():
|
||||
zf.writestr(name, blob)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lossy_xlsx(test_vault_dir: str) -> str:
|
||||
"""Workbook with a slicer + a formula carrying its cached result."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
path = Path(test_vault_dir) / "risky.xlsx"
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Data"
|
||||
ws["A1"] = 3
|
||||
ws["A2"] = "=A1*3"
|
||||
wb.save(path)
|
||||
# <f>…</f><v>…</v> : openpyxl keeps the formula, drops the cached result.
|
||||
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"))
|
||||
xml = items[sheet].decode("utf-8").replace(
|
||||
"<f>A1*3</f>", "<f>A1*3</f><v>9</v>"
|
||||
)
|
||||
items[sheet] = xml.encode("utf-8")
|
||||
items["xl/slicers/slicer1.xml"] = b"<slicer/>"
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, blob in items.items():
|
||||
zf.writestr(name, blob)
|
||||
return str(path)
|
||||
|
||||
|
||||
# ── Display ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -63,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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -150,3 +385,336 @@ class TestXlsxSave:
|
||||
def test_missing_sheet_field_400(self, client, xlsx_file):
|
||||
resp = self._save(client, {"cells": {"A1": "x"}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_non_boolean_flag_400(self, client, xlsx_file):
|
||||
for flag in ("force", "allow_formula"):
|
||||
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "x"}, flag: "yes"})
|
||||
assert resp.status_code == 400, flag
|
||||
|
||||
|
||||
# ── #153 A1 — lossy-write guard ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxLossyGuard:
|
||||
def _save(self, client, body, path):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save", params={"path": path}, json=body,
|
||||
)
|
||||
|
||||
def test_read_reports_lossy_features(self, client, lossy_xlsx):
|
||||
data = client.get(f"/api/file/{VAULT}", params={"path": "risky.xlsx"}).json()
|
||||
assert data["is_xlsx"] is True
|
||||
assert "slicers" in data["xlsx_lossy_features"]
|
||||
assert "cached_values" in data["xlsx_lossy_features"]
|
||||
|
||||
def test_read_reports_nothing_for_a_plain_workbook(self, client, xlsx_file):
|
||||
data = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"}).json()
|
||||
# B2 holds "=B1*2" but openpyxl wrote no cached <v> for it.
|
||||
assert data["xlsx_lossy_features"] == []
|
||||
|
||||
def test_save_refuses_without_force(self, client, lossy_xlsx):
|
||||
resp = self._save(client, {"sheet": "Data", "cells": {"B1": "hello"}}, "risky.xlsx")
|
||||
assert resp.status_code == 409
|
||||
body = resp.json()
|
||||
assert body["code"] == "xlsx_lossy_content"
|
||||
assert "slicers" in body["details"]["features"]
|
||||
|
||||
def test_refused_save_leaves_the_file_untouched(self, client, lossy_xlsx):
|
||||
before = Path(lossy_xlsx).read_bytes()
|
||||
self._save(client, {"sheet": "Data", "cells": {"B1": "hello"}}, "risky.xlsx")
|
||||
assert Path(lossy_xlsx).read_bytes() == before
|
||||
|
||||
def test_save_with_force_succeeds(self, client, lossy_xlsx):
|
||||
resp = self._save(
|
||||
client,
|
||||
{"sheet": "Data", "cells": {"B1": "hello"}, "force": True},
|
||||
"risky.xlsx",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert openpyxl.load_workbook(lossy_xlsx)["Data"]["B1"].value == "hello"
|
||||
|
||||
def test_inspect_flags_every_known_family(self, lossy_xlsx):
|
||||
from backend.xlsx_reader import LOSSY_PARTS, inspect_workbook
|
||||
|
||||
for key, prefixes in LOSSY_PARTS.items():
|
||||
_add_lossy_parts(
|
||||
Path(lossy_xlsx),
|
||||
{f"{prefixes[0]}probe.xml": b"<x/>" for _ in [0]},
|
||||
)
|
||||
assert key in inspect_workbook(Path(lossy_xlsx)), key
|
||||
|
||||
def test_inspect_is_quiet_on_a_corrupt_archive(self, test_vault_dir):
|
||||
from backend.xlsx_reader import inspect_workbook
|
||||
|
||||
bad = Path(test_vault_dir) / "broken.xlsx"
|
||||
bad.write_bytes(b"not a zip at all")
|
||||
assert inspect_workbook(bad) == []
|
||||
|
||||
|
||||
# ── #153 A2 — atomic write ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxAtomicWrite:
|
||||
def test_failed_save_keeps_the_original(self, client, xlsx_file, monkeypatch):
|
||||
from openpyxl.workbook.workbook import Workbook
|
||||
|
||||
before = Path(xlsx_file).read_bytes()
|
||||
|
||||
def boom(self, *args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(Workbook, "save", boom)
|
||||
# TestClient re-raises the server exception (in production: 500).
|
||||
with pytest.raises(OSError):
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {"A1": "perdu"}},
|
||||
)
|
||||
# The workbook on disk is byte-identical : the write never reached it.
|
||||
assert Path(xlsx_file).read_bytes() == before
|
||||
# No temporary file left behind in the vault.
|
||||
assert list(Path(xlsx_file).parent.glob("*.tmp")) == []
|
||||
|
||||
def test_no_tmp_left_after_a_successful_save(self, client, xlsx_file):
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {"A1": "ok"}},
|
||||
)
|
||||
assert list(Path(xlsx_file).parent.glob("*.tmp")) == []
|
||||
|
||||
|
||||
# ── #153 A3 — per-file write lock ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxWriteLock:
|
||||
def test_concurrent_write_returns_409(self, client, xlsx_file):
|
||||
from backend.services import mutations
|
||||
|
||||
key = str(Path(xlsx_file).resolve())
|
||||
with mutations._xlsx_write_lock(key):
|
||||
resp = client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {"A1": "concurrent"}},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == "conflict"
|
||||
# The blocked call wrote nothing.
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "Poste"
|
||||
|
||||
def test_lock_is_released_after_a_normal_save(self, client, xlsx_file):
|
||||
from backend.services import mutations
|
||||
|
||||
key = str(Path(xlsx_file).resolve())
|
||||
client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": {"A1": "premier"}},
|
||||
)
|
||||
# The lock must be free again once the request returned.
|
||||
with mutations._xlsx_write_lock(key):
|
||||
pass
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestXlsxFormulaGuard:
|
||||
def _save(self, client, cells, **extra):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/save",
|
||||
params={"path": "budget.xlsx"},
|
||||
json={"sheet": "Budget", "cells": cells, **extra},
|
||||
)
|
||||
|
||||
def test_formula_like_value_is_stored_as_text(self, client, xlsx_file):
|
||||
resp = self._save(client, {"A3": "=cmd|'/c calc'!A1"})
|
||||
assert resp.status_code == 200
|
||||
cell = openpyxl.load_workbook(xlsx_file)["Budget"]["A3"]
|
||||
assert cell.value == "=cmd|'/c calc'!A1"
|
||||
assert cell.data_type == "s" # pas de <f> dans l'archive
|
||||
|
||||
def test_at_prefix_is_stored_as_text(self, client, xlsx_file):
|
||||
self._save(client, {"A4": "@SUM(A1:A2)"})
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A4"].data_type == "s"
|
||||
|
||||
def test_allow_formula_keeps_a_real_formula(self, client, xlsx_file):
|
||||
resp = self._save(client, {"A3": "=B1+5"}, allow_formula=True)
|
||||
assert resp.status_code == 200
|
||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A3"].data_type == "f"
|
||||
|
||||
def test_numbers_are_unaffected(self, client, xlsx_file):
|
||||
self._save(client, {"B3": "42", "B4": "-3.5"})
|
||||
ws = openpyxl.load_workbook(xlsx_file)["Budget"]
|
||||
assert ws["B3"].value == 42 and isinstance(ws["B3"].value, int)
|
||||
assert ws["B4"].value == -3.5
|
||||
|
||||
Reference in New Issue
Block a user