Compare commits

..
2 Commits
Author SHA1 Message Date
bruno 605060c51d feat: visionneuse XLSX - ruban groupe, onglets permanents et badges d'etat #154
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Successful in 15m10s
2026-09-29 14:30:36 -04:00
bruno 856e654306 fix: plancher pypdf >= 6.16.1 - deux DoS de ressources bloques le job security BUG-093
CI / lint (push) Successful in 2m30s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 3m2s
CI / e2e (push) Successful in 15m9s
pip-audit bloquait sur PYSEC-2026-3910 (outlines) et PYSEC-2026-3911 (XForm),
toutes deux atteignables via backend/pdf_reader.py. Le plancher pypdf>=4.0 ne
protégeait rien : l'image Act du runner embarque 6.16.0 dans sa toolcache
Python, donc pip répondait « already satisfied » sans jamais aligner.

Au passage, le garde-fou TestSemgrepStep était en régression depuis la
désactivation de semgrep (v2.39.9) et aurait rougi le job `test` : il vérifie
désormais que l'étape n'exécute que son avertissement et que bandit et
pip-audit restent bloquants. Nouveau TestDependencySecurityFloors pour
verrouiller les planchers de sécurité (contre-preuve : pypdf remis à >=4.0).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-29 13:06:53 -04:00
20 changed files with 699 additions and 64 deletions
+9
View File
@@ -170,6 +170,15 @@ jobs:
# (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
+52 -1
View File
@@ -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.39.9**.
> [Unreleased](#unreleased). La dernière version livrée est **2.40.0**.
---
@@ -14,6 +14,57 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.39.9-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.40.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.39.9).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.40.0).
---
*Projet : ObsiGate | Version : 2.39.9 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.40.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.39.9-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.40.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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.39.9).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.40.0).
---
*Project: ObsiGate | Version: 2.39.9 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.40.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.39.9
2.40.0
+7 -1
View File
@@ -13,7 +13,13 @@ sortedcontainers>=2.4.0
snowballstemmer>=2.2.0
weasyprint>=70.0
httpx>=0.27.0
pypdf>=4.0
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
# et n'est jamais mis à niveau.
pypdf>=6.16.1
pyotp>=2.10.0
segno>=1.5.0
webauthn==2.6.0
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.39.9"
version = "2.40.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.39.9"
version = "2.40.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.39.9",
"version": "2.40.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+14 -5
View File
@@ -136,13 +136,22 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
### Affichage et édition
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
feuille, des onglets pour naviguer entre elles, les en-têtes A1/B1 et les
numéros de ligne. Chaque cellule est modifiable directement (clic), `Entrée`
valide, `Échap` annule la saisie. **Enregistrer** envoie les cellules
modifiées à `PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille,
avec **backup automatique** du fichier avant écriture, et une écriture
feuille, des onglets pour naviguer entre elles (toujours visibles, même à
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
**Enregistrer** envoie les cellules modifiées à
`PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec
**backup automatique** du fichier avant écriture, et une écriture
**atomique** (le classeur n'est jamais laissé à moitié écrit).
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
### Avertissement avant enregistrement
Certains classeurs contiennent des éléments qu'ObsiGate ne sait pas
+4 -1
View File
@@ -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-28
- **Dernière mise à jour** : 2026-09-29
---
@@ -199,6 +199,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *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 |
@@ -218,6 +220,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 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) |
+28 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.39.9 | **Dernière mise à jour :** 2026-09-29
> **Version :** 2.40.0 | **Dernière mise à jour :** 2026-09-29
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -92,6 +92,30 @@
---
## 🔵 En cours — Refonte UI/UX tableur (P2)
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
- **Statut :** 🔵 **en cours** — **Lot 1 livré le 2026-09-29** (ruban de commandes groupé, onglets
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
recalculées, tokens de grille et affordances). Lots suivants : dialogues thémés, inspecteur
droit (dashboard + IA), découpage modulaire + undo/redo.
- **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)
- [ ] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
- [ ] **A3** Inspecteur droit repliable : Tableau de bord + Assistant IA, lien dashboard ⇄ grille (Lot 3)
- [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4)
---
## ⚪ Backlog — Priorité 4 (P4)
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
@@ -219,7 +243,8 @@
| ⚪ 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 |
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
| 🔵 En cours | #154 Refonte UI/UX tableur (A1 ✅ Lot 1 · A2-A4 restants) | ~5-7 jours |
| **Total chemin critique** | **#77 fin + #87 + #154** | **~9-13 jours** |
---
@@ -227,6 +252,7 @@
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
- **Ajout 2026-09-27 :** #153 ouvert à la suite de l'audit de la visionneuse XLSX (limitations, risques de perte de données, périmètre IA/recherche) — détail et critères dans [features/xlsx-viewer.md](./features/xlsx-viewer.md).
- **Ajout 2026-09-29 :** #154 ouvert — refonte UI/UX de la visionneuse/éditeur XLSX (audit UX, architecture cible, plan par lots) dans [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) ; Lot 1 livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille).
- **Clôture #85 (v2.27.13) :** monolithe découpé (T1→T9), stores verrouillés + rate-limit SQLite (T10), fiche `docs/features/archi-refonte-85.md`.
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
+118
View File
@@ -0,0 +1,118 @@
# #154 — Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
> **Statut :** 🔵 **en cours** — Lot 1 livré le 2026-09-29
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4)
> **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 (à venir)
- Remplacer `confirm()`/`prompt()` par des **modales thémées** (`.obsigate-modal-*`) et des
**toasts** : structure (feuille/ligne/colonne), perte (409 `xlsx_lossy_content`), conflit
(409 `conflict`) en bandeau non bloquant.
- Indicateur *dirty* sur l'onglet de feuille et la barre.
### Lot 3 — Inspecteur droit (à venir)
- Panneau repliable hébergeant **Tableau de bord** et **Assistant IA** en onglets ; la grille
reste visible. Lien **dashboard → grille** (clic sur KPI/plage = scroll + sélection).
- Détachement en split via `PaneManager.splitRight()`.
### Lot 4 — Découpage & finitions (à venir)
- Extraction de `renderXlsxViewer` en modules (`frontend/js/xlsx/*.js`) : `toolbar`, `formula-bar`,
`grid`, `sheet-tabs`, `inspector`, `cell-editor`, `api`.
- **Undo/redo** local (pile de commandes), défilement paresseux via `IntersectionObserver`,
accessibilité ARIA (`role="grid"`, *roving tabindex*).
## 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) |
+72 -23
View File
@@ -1047,11 +1047,18 @@ export function renderXlsxViewer(area, data) {
// per workbook, not a global preference).
let lossyConfirmed = false;
let allowFormula = false;
const tabs = sheets.length > 1
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
).join("")}</div>`
: "";
// #154-A1 — the command bar is grouped (Formules · Insertion · Vue · Fichier)
// and the sheet tabs are ALWAYS rendered (a single sheet used to show no tab
// at all, hiding the fact that a workbook can hold several). An editable
// workbook also gets an explicit “+” button next to the tabs.
const editable = !isCsv && !readOnly;
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>`;
// #153 A15 — inline styles (bold/italic/colors from the file itself), merged
// ranges and the freeze anchor are applied AFTER the panels are in the DOM
// (the HTML string alone cannot carry them: col/row spans need the table).
@@ -1123,24 +1130,55 @@ export function renderXlsxViewer(area, data) {
</div>`
: "";
// #154-A1 — status pills make the viewer's limits visible up front: a
// read-only format (.xls/.ods) and the fact that formulas are never
// recalculated on screen (the workbook is only written back, Excel recomputes).
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. The dashboard button is appended
// later into the dedicated (empty) “view” group.
const actionGroups = [];
if (!readOnly) {
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>');
area.innerHTML = `
<div class="xlsx-viewer">
<div class="xlsx-toolbar">
${tabs}
<span class="xlsx-toolbar-actions">
<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>
<button class="btn-action" id="xlsx-save-btn" disabled${isCsv || readOnly ? "" : ""}>${t("common.save")}</button>
<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>
${isCsv || readOnly ? "" : `<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>
<div class="xlsx-toolbar" role="toolbar">
<div class="xlsx-cmdbar">
${tabs}
<span class="xlsx-toolbar-actions">${actionsHtml}</span>
</div>
${statusBar}
</div>
${lossWarning}
<div class="xlsx-formula-bar">
@@ -1173,7 +1211,7 @@ export function renderXlsxViewer(area, data) {
type: "button",
title: t("xlsx.dashboard_btn"),
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
area.querySelector(".xlsx-toolbar-actions").prepend(dashBtn);
(area.querySelector("#xlsx-view-group") || area.querySelector(".xlsx-toolbar-actions")).appendChild(dashBtn);
dashBtn.addEventListener("click", async () => {
const old = area.querySelector(".xlsx-dashboard");
if (old) { old.remove(); dashBtn.classList.remove("active"); return; }
@@ -1482,8 +1520,9 @@ export function renderXlsxViewer(area, data) {
});
// Formula toggle (#153 A4) — opt-in for this viewing session only.
// #154-A1 — absent on a read-only workbook (nothing can be saved).
const formulaBtn = area.querySelector("#xlsx-formula-btn");
formulaBtn.addEventListener("click", () => {
if (formulaBtn) formulaBtn.addEventListener("click", () => {
allowFormula = !allowFormula;
formulaBtn.setAttribute("aria-pressed", String(allowFormula));
formulaBtn.classList.toggle("active", allowFormula);
@@ -1749,6 +1788,16 @@ export function renderXlsxViewer(area, data) {
return null;
};
// #154-A1 — the “+” button mirrors the structure menu's « Ajouter une
// feuille » item, right where the tabs are (Excel-like).
const tabAddBtn = area.querySelector("#xlsx-tab-add");
if (tabAddBtn) tabAddBtn.addEventListener("click", async () => {
const name = prompt(t("xlsx.structure_prompt_add"));
if (!name) return;
const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
});
// Structure menu: built on demand, positioned under the button.
// (.csv / read-only workbooks have no such button — the menu block above
// is skipped for them.)
+5
View File
@@ -1829,6 +1829,11 @@
"xlsx.lossy_cancelled": "Save cancelled",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"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.",
+5
View File
@@ -1829,6 +1829,11 @@
"xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"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}.",
+127 -14
View File
@@ -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,21 +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;
position: relative; /* anchors the A14 structure menu */
}
.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;
}
@@ -10957,15 +11007,73 @@ body.desktop-mode .editor-container {
border-color: var(--accent, #4a90d9);
color: #fff;
}
.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);
}
.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(--surface);
color: var(--text-secondary);
font-weight: 400;
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;
/* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
@@ -10986,7 +11094,9 @@ body.desktop-mode .editor-container {
position: sticky;
top: 0;
z-index: 3;
background: var(--surface);
background: var(--grid-header-bg);
color: var(--grid-header-text);
border-bottom: 2px solid var(--grid-border);
}
.xlsx-table td[contenteditable] {
cursor: text;
@@ -10998,9 +11108,10 @@ body.desktop-mode .editor-container {
outline-offset: -2px;
}
/* #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. */
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 dashed var(--accent, #4a90d9);
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
@@ -11099,12 +11210,12 @@ body.desktop-mode .editor-container {
position: sticky;
top: 33px; /* thead height — keeps the frozen row below the header */
z-index: 2;
background: var(--surface);
background: var(--grid-header-bg);
}
.xlsx-table td.xlsx-frozen-col {
position: sticky;
left: 44px; /* the row-number column width */
background: var(--surface);
background: var(--grid-bg);
}
/* #153 A14 — structure menu (sheets / rows / columns) */
@@ -11232,7 +11343,9 @@ body.desktop-mode .editor-container {
mark {
font: inherit;
}
.xlsx-table td.xlsx-dirty {
/* #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);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.39.9",
"version": "2.40.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+82
View File
@@ -718,6 +718,88 @@ await test("two windows in a row walk the whole sheet", async () => {
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();
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
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));
globalThis.prompt = realPrompt;
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('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']"));
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+165 -6
View File
@@ -1,4 +1,4 @@
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083).
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083, BUG-091, BUG-093).
Sans dépendance (pas de PyYAML) : analyse ligne à ligne de
`.gitea/workflows/ci.yml`, suffisante pour les conventions de ce fichier.
@@ -9,9 +9,29 @@ import re
from pathlib import Path
CI_YML = Path(__file__).resolve().parent.parent / ".gitea" / "workflows" / "ci.yml"
REQUIREMENTS = Path(__file__).resolve().parent.parent / "backend" / "requirements.txt"
REPO_ROOT = Path(__file__).resolve().parent.parent
def _job_text(job: str) -> str:
"""Corps YAML du job `job` (jusqu'au job suivant ou à la fin du fichier)."""
text = CI_YML.read_text(encoding="utf-8")
start = text.index(f"\n {job}:")
rest = text[start + 1 :]
nxt = re.search(r"\n {2}[A-Za-z][A-Za-z0-9_-]*:\s*\n", rest)
return rest[: nxt.start()] if nxt else rest
def _steps(job: str) -> list[tuple[str, str]]:
"""[(nom d'étape, corps YAML)] pour un job donné."""
chunks = re.split(r"\n {6}- name: ", "\n" + _job_text(job))[1:]
steps = []
for chunk in chunks:
name, _, body = chunk.partition("\n")
steps.append((name.strip(), body))
return steps
def _run_bodies() -> list[tuple[int, str]]:
"""Toutes les lignes shell de chaque bloc `run:` → [(n° ligne, code)]."""
lines = CI_YML.read_text(encoding="utf-8").splitlines()
@@ -62,14 +82,99 @@ class TestRunnerProofScripts:
class TestSemgrepStep:
def test_semgrep_local_rules_enforced(self):
"""#87 T7 : semgrep bloquant sur règles locales (aucun registre)."""
text = CI_YML.read_text(encoding="utf-8")
assert "semgrep --config semgrep-rules/ backend/" in text, (
"#87 T7 : étape semgrep locale attendue dans le job security"
"""BUG-091 : semgrep-core est inexécutable sur le runner (exit 127).
L'étape est donc désactivée (avertissement, non bloquante) au lieu d'être
supprimée : elle documente pourquoi, et se réactive telle quelle dès que le
runner dispose d'un CPU x86-64-v2.
"""
SEMGREP_STEP_PREFIX = "Semgrep"
def _semgrep_step(self) -> tuple[str, str]:
matches = [
(n, b) for n, b in _steps("security") if n.startswith(self.SEMGREP_STEP_PREFIX)
]
assert len(matches) == 1, (
"BUG-091 : une unique étape Semgrep (désactivée) attendue dans le "
f"job security, trouvé {len(matches)}"
)
return matches[0]
@staticmethod
def _run_commands(body: str) -> list[str]:
"""Commandes shell du bloc `run:` de l'étape (hors lignes vides)."""
m = re.search(r"^\s*run:\s*\|?\s*$", body, re.M)
assert m, "étape sans bloc `run:`"
rest = body[m.end() :]
lines: list[str] = []
for line in rest.splitlines():
if not line.strip():
continue
# le bloc run: est indenté de 2 spaces de plus que la clef
if len(line) - len(line.lstrip()) <= 8:
break
lines.append(line.strip())
return lines
def test_semgrep_step_does_not_execute_core(self):
"""Le core natif ne doit plus être lancé (exit 127 bloquant le job).
Seule commande admise : l'avertissement d'activation. Le message
mentionne voluntaryirement « semgrep » — c'est l'**exécution** qui
est interdite, pas le mot.
"""
name, body = self._semgrep_step()
commands = self._run_commands(body)
assert commands, f"BUG-091 : l'étape « {name} » n'a plus de commande"
for cmd in commands:
assert cmd.startswith('echo "::warning::'), (
f"BUG-091 : l'étape « {name} » ne doit exécuter qu'un avertissement, "
f"trouvé : {cmd!r}"
)
def test_semgrep_step_is_non_blocking_and_explains_itself(self):
"""Désactivée = `continue-on-error` + avertissement explicite."""
name, body = self._semgrep_step()
assert re.search(r"^\s*continue-on-error:\s*true\s*$", body, re.M), (
f"BUG-091 : l'étape « {name} » doit porter continue-on-error: true"
)
assert "::warning::" in body, (
f"BUG-091 : l'étape « {name} » doit émettre un ::warning:: "
"expliquant la désactivation"
)
assert "BUG-091" in body, (
f"BUG-091 : l'étape « {name} » doit référencer BUG-091"
)
def test_bandit_and_pip_audit_stay_blocking(self):
"""La désactivation de semgrep ne doit rien dégraver d'autre (#87)."""
found = {}
for name, body in _steps("security"):
low = name.lower()
if low.startswith("bandit"):
found["bandit"] = body
elif low.startswith("pip-audit"):
found["pip-audit"] = body
assert set(found) == {"bandit", "pip-audit"}, (
f"étapes Bandit et Pip-audit attendues dans le job security, "
f"trouvé {sorted(found)}"
)
for tool, body in found.items():
assert "continue-on-error: true" not in body, (
f"BUG-091 : l'étape {tool} doit rester bloquante (#87)"
)
def test_semgrep_rules_still_shipped_and_documented(self):
"""Les règles locales restent versionnées et documentées (#87 T7)."""
rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml"
assert rules.exists(), "ruleset semgrep manquant"
text = CI_YML.read_text(encoding="utf-8")
# La commande locale est documentée (commentaire de l'étape), pas exécutée.
assert re.search(r"semgrep --config semgrep-rules/\s*\n?\s*#?\s*backend/", text), (
"#87 T7 : commande locale `semgrep --config semgrep-rules/ backend/` "
"attendue en commentaire dans le workflow"
)
class TestFrontendStepsHaveTheirDeps:
@@ -113,3 +218,57 @@ class TestFrontendStepsHaveTheirDeps:
f"BUG-082 : `{suite}` attendu dans les deux branches de "
"l'étape JSDOM"
)
_SPEC_RE = re.compile(
r"^([A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(>=|==|~=|>|<)\s*([0-9][^\s;#]*)"
)
def _floor(pkg: str) -> tuple[int, ...] | None:
"""Plancher `>=` déclaré pour `pkg` dans backend/requirements.txt."""
for raw in REQUIREMENTS.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = _SPEC_RE.match(line)
if not m or m.group(1).lower() != pkg or m.group(2) != ">=":
continue
return tuple(int(p) for p in re.match(r"[0-9]+(?:\.[0-9]+)*", m.group(3)).group(0).split("."))
return None
class TestDependencySecurityFloors:
"""Planchers de sécurité des dépendances (#87, BUG-091, BUG-093).
`pip-audit` est bloquant dans le job `security`. Comme l'image du runner
(`catthehacker/ubuntu:act-latest`) embarque des paquets *préinstallés* dans
sa toolcache Python, un plancher trop bas est « already satisfied » et
n'est jamais mis à niveau : c'est exactement ce qui a fait échouer le
job sur pypdf 6.16.0 (PYSEC-2026-3910 / PYSEC-2026-3911, DoS de ressources
atteignables via backend/pdf_reader.py).
"""
#: (paquet, plancher minimal, advisories corrigées au-dessus)
FLOORS = {
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
"pyjwt": (2, 13, 0), # PYSEC-2026-178 (fix 2.13.0)
}
def test_security_floors_are_declared(self):
missing = [p for p in self.FLOORS if _floor(p) is None]
assert not missing, (
"plancher `>=` manquant dans backend/requirements.txt pour : "
f"{missing}"
)
def test_security_floors_are_high_enough(self):
too_low = {
p: (_floor(p), minimum)
for p, minimum in self.FLOORS.items()
if (_floor(p) or ()) < minimum
}
assert not too_low, (
"BUG-093 : plancher(s) sous le correctif de sécurité, "
f"le job `security` (pip-audit bloquant) échouerait : {too_low}"
)