Compare commits

..
7 Commits
Author SHA1 Message Date
bruno dbf935bec0 fix: diagnostic semgrep-core dans le job security - CPU, disque et exec brute traces BUG-091
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 2m16s
CI / test (push) Failing after 4m21s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-29 09:21:25 -04:00
bruno d6d081c0e9 fix: semgrep 1.157.0 dans un venv isole du job security et plancher pyjwt 2.13 BUG-091
CI / lint (push) Successful in 2m27s
CI / security (push) Failing after 2m21s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 2m31s
CI / e2e (push) Successful in 15m1s
2026-09-28 21:54:12 -04:00
bruno c72f852a55 fix: semgrep epingle 1.174.0 dans le job security - CPU runner refuse les builds x86-64-v2 BUG-091
CI / lint (push) Successful in 2m30s
CI / security (push) Failing after 1m45s
CI / test (push) Successful in 4m14s
CI / build (push) Successful in 2m37s
CI / e2e (push) Successful in 14m58s
2026-09-28 19:14:08 -04:00
bruno 99779ecc08 docs: cloture documentaire #153 - changelog A6-A17, fiche, guide utilisateur et README
CI / lint (push) Successful in 2m33s
CI / security (push) Failing after 1m41s
CI / test (push) Failing after 3h11m57s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-09-28 17:48:43 -04:00
bruno c4b8e66206 feat: tableau de bord classeur - plages nommees, TCD/graphiques et KPI par feuille #153
A17 — nouveau endpoint GET /api/file/{vault}/xlsx/dashboard (read_workbook_
dashboard : plages nommees avec portee depuis defined_names read_only,
comptage graphiques/TCD par parts OPC, stats par feuille bornées 500x40 :
cellules/lignes/colonnes/formules/numerique + 8 premieres valeurs en cartes
KPI) et panneau frontend toggled depuis la toolbar (table des plages,
cartes KPI par feuille, hint actions IA). Bouton absent pour .csv et
formats en lecture seule ; le menu structure est saute quand le bouton
n'existe pas. i18n FR/EN (xlsx.dashboard_*), 8 tests backend + 2 tests
JSDOM + contre-preuve (5 echecs sur neutralisation), ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:40:09 -04:00
bruno ca6407e0c0 feat: formats tableur additionnels - xlsm editable, xls/ods lecture seule, csv editable #153
A16 — la visionneuse tableur accepte quatre formats de plus : .xlsm est
servi et sauvegarde comme un .xlsx avec keep_vba=True (les macros
survivent, la porte lossy est levée pour ce format) ; .xls (xlrd) et .ods
(odfpy) sont rendus en lecture seule (xlsx_readonly, wiring d'édition
désactivé) ; .csv devient éditable via render_csv_table (grille A1
identique au viewer) et PUT /api/file/{vault}/csv/save (réécriture csv
RFC 4180, extension de grille, valeurs stockées telles quelles). 12 tests
backend + contre-preuve (4 échecs sur neutralisation du service CSV),
JSDOM 33/33, ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:27:10 -04:00
bruno dff32a97ee feat: styles minimaux, fusions et volets figes dans la visionneuse xlsx #153
A15 — le rendu tableur lit les metadonnees du classeur (une charge en mode
normal par feuille) et les restitue : styles inline data-driven (couleur de
police en premier, fond, gras/italique, format numerique signale en mono),
alignements non-defaut, plages fusionnees (rowSpan/colSpan cote client) et
ancrage des volets figes (sticky rows/cols). Le chargement lazy replie les
metadonnees de chaque fenetre. Contrat API : styles/aligns = {ref: css},
merges = [ranges], freeze = ancre. 9 tests backend + contre-preuve (5 echecs
sur neutralisation), JSDOM 33/33.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:07:58 -04:00
28 changed files with 1788 additions and 96 deletions
+46 -3
View File
@@ -131,9 +131,26 @@ jobs:
python-version: "3.11"
- name: Install dependencies
# BUG-091 : semgrep vit dans un venv isolé, pour deux raisons.
# 1) CPU du runner : la dernière version publiée en wheel
# manylinux2014 (baseline x86-64 v1) est 1.157.0 ; la série 1.158+
# ne publie plus que manylinux_2_34/2_35, dont les libs natives
# exigent x86-64-v2 → « CPU ISA level is lower than required »
# (exit 127) au moindre appel.
# 2) Dépendances incompatibles avec l'env principal : semgrep 1.157.0
# fixe tomli~=2.0.1 (pip-audit 2.10+ exige >=2.2.1) et
# pyjwt~=2.12.0 (PYSEC-2026-178) — le plancher pyjwt>=2.13.0 de
# requirements.txt resterait insatisfaisable s'il était co-installé.
# 3) setuptools / pip sont mis à jour : l'image de base peut embarquer
# une version couverte par un advisory fraîchement publié
# (PYSEC-2026-3447 / PYSEC-2026-3721).
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
run: |
pip install bandit pip-audit semgrep
pip install -U pip setuptools
pip install bandit pip-audit
pip install -r backend/requirements.txt
python -m venv /tmp/semgrep-venv
/tmp/semgrep-venv/bin/pip install --quiet semgrep==1.157.0
- name: Bandit (SAST, bloquant — #87)
# B105 est exclu (aligné avec [tool.bandit] de pyproject.toml :
@@ -141,10 +158,35 @@ jobs:
# vrais positifs restants portent un `# nosec` justifié inline.
run: bandit -r backend/ --skip B101,B105,B110,B310
- name: Diagnostic semgrep-core (BUG-091)
# Le core est un exécutable natif : sur un CPU trop ancien il sort en
# 127 sans message exploitable. On trace CPU, taille/permissions du
# core et exécution brute pour distinguer un refus ISA/libc d'un
# simple fichier absent ou non exécutable.
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
run: |
echo "== CPU =="
grep -m1 "model name" /proc/cpuinfo || echo "cpuinfo illisible"
echo -n "flags attendues : "
for f in sse4_2 popcnt avx avx2; do
if grep -qm1 " $f " /proc/cpuinfo; then echo -n "$f "; fi
done
echo
echo "== disque =="
df -h /tmp | tail -1 || true
echo "== core =="
CORE=$(/tmp/semgrep-venv/bin/python -c "import semgrep,os;print(os.path.join(os.path.dirname(semgrep.__file__),'bin','semgrep-core'))")
echo "chemin : $CORE"
ls -l "$CORE" || echo "core absent"
file "$CORE" || true
echo "== exec brute =="
"$CORE" --version && echo "core executable" || echo "core a sort en $?"
- name: Semgrep (SAST local, bloquant — #87)
# Règles 100 % locales (semgrep-rules/, 8 règles) : aucun
# téléchargement de registre (runner au réseau fragile).
run: semgrep --config semgrep-rules/ backend/
# Venv isolé (BUG-091) : cf. l'étape « Install dependencies ».
run: /tmp/semgrep-venv/bin/semgrep --config semgrep-rules/ backend/
- name: Pip-audit (bloquant — #87)
# Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3,
@@ -153,7 +195,8 @@ jobs:
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
# s'exécutent jamais.
# s'exécutent jamais. PYSEC-2026-178 (pyjwt) est, lui, corrigé par le
# plancher pyjwt>=2.13.0 de backend/requirements.txt (BUG-091).
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
run: pip-audit --ignore-vuln PYSEC-2026-1325
+87 -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.36.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.39.4**.
---
@@ -14,6 +14,92 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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
---
+4 -4
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.36.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.39.4-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/)
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
- **📊 Tableurs Excel** : les fichiers `.xlsx` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation clavier, barre de formule, tri/filtre/recherche, export CSV, édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`. L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, modifier des cellules et ajouter des lignes
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.36.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.39.4).
---
*Projet : ObsiGate | Version : 2.36.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.39.4 | Dernière mise à jour : Septembre 2026*
+4 -4
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.36.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.39.4-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/)
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
- **📊 Excel Spreadsheets** : `.xlsx` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation, a formula bar, sort/filter/find, CSV export, sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled. The AI assistant can list sheets, dump a bounded table to its context, update cells and append rows
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.36.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.39.4).
---
*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.39.4 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.36.0
2.39.4
+5
View File
@@ -20,8 +20,13 @@ webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
mcp==1.28.1
# Plancher de sécurité (BUG-091) : pyjwt est une dépendance transitive (mcp) ;
# 2.12.x est vulnérable (PYSEC-2026-178, fix 2.13.0) et pip-audit bloque sinon.
pyjwt[crypto]>=2.13.0
sse-starlette==2.1.3
openpyxl>=3.1
xlrd==2.0.2
odfpy==1.4.1
python-docx>=1.1
reportlab>=4.0
pillow>=10.0
+106 -21
View File
@@ -38,11 +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")
@@ -179,6 +180,47 @@ 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
)
@@ -309,6 +351,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
try:
from backend.xlsx_reader import inspect_workbook, render_sheets
# #153 A15 — every sheet dict already carries its styles, aligns,
# merges and freeze anchor (read_workbook_meta, one normal-mode
# load inside render_sheets).
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
@@ -443,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": {},
+38
View File
@@ -73,6 +73,9 @@ from backend.services.mutations import (
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
@@ -177,6 +180,41 @@ 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,
+45
View File
@@ -285,6 +285,14 @@ 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=(
@@ -310,6 +318,43 @@ 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).
+101 -4
View File
@@ -387,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)
@@ -399,7 +399,10 @@ def edit_xlsx_cells(
f"Invalid cell reference: {ref!r}", code="invalid", status=400
)
if not force:
# #153 A16 — the lossy gate is skipped for .xlsm: the save re-serializes
# with keep_vba=True, so the macro project (the only extra part a .xlsm
# carries) survives and nothing is dropped.
if not force and file_path.suffix.lower() != ".xlsm":
from backend.xlsx_reader import inspect_workbook
lossy = inspect_workbook(file_path)
@@ -415,8 +418,11 @@ def edit_xlsx_cells(
with _xlsx_write_lock(str(file_path)):
from openpyxl import load_workbook
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
# project survives the save (the endpoint's lossy probe is empty
# for .xlsm on purpose).
try:
wb = load_workbook(file_path)
wb = load_workbook(file_path, keep_vba=file_path.suffix.lower() == ".xlsm")
except Exception as exc:
raise ServiceError(
f"Cannot open workbook: {exc}", code="invalid", status=400
@@ -642,6 +648,97 @@ def mutate_xlsx_structure(
}
def save_csv_cells(
vault_name: str,
path: str,
cells: dict[str, Any],
*,
backup: bool = True,
) -> dict[str, Any]:
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
The file is re-parsed, patched and re-serialized with :mod:`csv` so
quoting follows RFC 4180. References beyond the current extent grow the
grid (missing rows/cells are filled with empty strings). Values are
stored as text: a CSV has no formula engine, so any string — including
ones starting with ``=`` — is written verbatim (the render escapes it).
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
(409, concurrent write) or ``invalid`` (400) for a bad reference.
"""
import csv as csv_mod
import io as io_mod
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
if file_path.suffix.lower() != ".csv":
raise ServiceError(f"Not a .csv file: {path}", code="invalid", status=400)
if not cells:
raise ServiceError("No cells to update", code="invalid", status=400)
for ref in cells:
if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref):
raise ServiceError(
f"Invalid cell reference: {ref!r}", code="invalid", status=400
)
raw = file_path.read_text(encoding="utf-8", errors="replace")
try:
rows = list(csv_mod.reader(io_mod.StringIO(raw)))
except csv_mod.Error:
rows = [[line] for line in raw.splitlines()]
def _col_num(ref: str) -> int:
letters = ref.rstrip("0123456789").upper()
n = 0
for ch in letters:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n
def _row_num(ref: str) -> int:
return int(ref[len(ref.rstrip("0123456789")):])
for ref, value in cells.items():
r, c = _row_num(ref), _col_num(ref)
while len(rows) < r:
rows.append([])
row = rows[r - 1]
while len(row) < c:
row.append("")
row[c - 1] = "" if value is None else str(value)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
buf = io_mod.StringIO()
csv_mod.writer(buf, lineterminator="\n").writerows(rows)
tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
try:
tmp_path.write_text(buf.getvalue(), encoding="utf-8")
os.replace(tmp_path, file_path)
except Exception:
tmp_path.unlink(missing_ok=True)
raise
logger.info(f"CSV cells saved: {vault_name}/{rel_path} +{len(cells)}")
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"size": len(cells),
}
def append_to_file(
vault_name: str,
path: str,
+455 -3
View File
@@ -6,6 +6,10 @@ 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
@@ -57,6 +61,11 @@ _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
@@ -65,6 +74,130 @@ 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:
@@ -108,6 +241,7 @@ 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.
@@ -121,6 +255,11 @@ def _table(
``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>"
@@ -137,6 +276,20 @@ def _table(
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}"
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.
@@ -153,7 +306,7 @@ def _table(
f"{html.escape(cval)}</span>"
)
out.append(
f'<td data-cell="{ref}">{html.escape(val)}{shadow}</td>'
f'<td data-cell="{ref}"{style_attr}>{html.escape(val)}{shadow}</td>'
)
out.append("</tr>")
out.append("</tbody></table></div>")
@@ -205,6 +358,40 @@ def inspect_workbook(file_path: Path) -> list[str]:
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:
for ws in wb.worksheets:
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}``.
@@ -230,6 +417,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
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])
@@ -239,10 +430,11 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
# 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),
"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,
@@ -257,6 +449,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
# 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
@@ -300,6 +496,10 @@ def read_sheet_window(
# 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,
@@ -312,7 +512,11 @@ def read_sheet_window(
"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),
"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", ""),
}
@@ -444,3 +648,251 @@ def extract_indexable_text(file_path: Path) -> str:
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
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.36.0"
version = "2.39.4"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.36.0"
version = "2.39.4"
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.36.0",
"version": "2.39.4",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+44 -3
View File
@@ -194,6 +194,43 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
- 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.
**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** 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. C'est une aide à la lecture :
les plages nommées peuvent ensuite être exploitées avec l'assistant IA
(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 ;
@@ -201,9 +238,13 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
défilement vers le bas du tableau) ajoute les lignes suivantes par
fenêtres de 500 — elles deviennent aussitôt éditables et
sauvegardables.
- Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas
rendus.
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
- 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.
---
+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-27
- **Dernière mise à jour** : 2026-09-28
---
@@ -197,6 +197,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *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 identique sur #1642 avec `semgrep==1.174.0` | semgrep isolé dans un venv jetable du job (`/tmp/semgrep-venv`), épinglé à **1.157.0** — la dernière version publiée en wheel `manylinux2014` (baseline x86-64 **v1**) ; la série 1.158+ ne publie plus que `manylinux_2_34/2_35`, dont les libs natives exigent x86-64-**v2** que le CPU du runner ne fournit pas. Le venv évite aussi ses dépendances incompatibles avec l'env principal (`tomli~=2.0.1` vs pip-audit ≥ 2.10 ; `pyjwt~=2.12.0` couvert par PYSEC-2026-178). Plancher `pyjwt[crypto]>=2.13.0` dans requirements.txt (transitif de mcp) + `pip install -U pip setuptools` dans le job (pip PYSEC-2026-3721, setuptools PYSEC-2026-3447, apparus dans la base entre-temps). Les 3 étapes repassées en local dans un environnement frais ✅ | #153. security échouait déjà avant ce push (tags v2.31.0/v2.32.0 rouges) ; les 7 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). Le 1ᵉʳ pin 1.174.0 (v2.39.2) était erroné : wheel `manylinux_2_34` uniquement, refusé par le CPU. À surveiller : tout semgrep > 1.157.0 exigera un runner x86-64-v2 |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -215,6 +216,8 @@ 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 (#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) |
+13 -13
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.36.0 | **Dernière mise à jour :** 2026-09-28
> **Version :** 2.39.4 | **Dernière mise à jour :** 2026-09-29
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -47,7 +47,7 @@
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
- **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡
- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17
- **Statut :** ✅ **livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis v2.33.0 → v2.39.0 : A6, A7, A13, A14, A15, A16, A17 (+ A11 déjà au CI) — **backlog #153 terminé**
- **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md)
- **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de
valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure,
@@ -70,21 +70,21 @@
- [x] **A2** Écriture atomique (`wb.save(.tmp)` + `os.replace()`, backup inchangé) — BUG-086
- [x] **A3** Verrou par fichier autour du read-modify-write (timeout 15 s + **409** `conflict`) — BUG-087
- [x] **A4** Neutralisation de l'injection de formule (`=`/`@` stockés en texte, opt-in `allow_formula` + bouton `f(x)`) — BUG-088
- **P1 — recherche, IA, UX (🟡, 4-6 j) — 🔵 en cours**
- **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é)
- [ ] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets`
- [ ] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches, `Maj+Entrée`, copie de plage)
- [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)
- [ ] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI
- [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) — ⚪ à faire**
- [ ] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection
- [ ] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer)
- [ ] **A15** Styles minimaux en écriture + lecture fidèle (gras, fond, formats, fusions, volets figés)
- [ ] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`, `.ods`, `.csv` éditable)
- [ ] **A17** Vue « tableau de bord » (plages nommées, TCD, KPI par feuille, actions IA)
- **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 ;
@@ -218,7 +218,7 @@
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; P1 ✅ A5, A8-A10, A12, A9bis — reste A6-A7 ; A13-A17 2-4 j) | 2-4 jours restants |
| ✅ 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** |
---
+43 -18
View File
@@ -3,7 +3,7 @@
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
> [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md))
> **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17
> **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.**
@@ -25,7 +25,7 @@
| CSS | `frontend/style.css:10927-10988` | `.xlsx-*` (variables CSS, colonne A `sticky`) |
| Indexation | `backend/indexer.py:68, 563-568, 957-960` | `.xlsx` supporté, **métadonnées seules** (`content=""`) |
| Outils IA | `backend/tools/documents.py:66-89` + `schemas.py:296-305` | `create_xlsx` (WRITE + confirmation) — **création seule** |
| Tests | `tests/test_xlsx_viewer.py` | 11 tests backend (affichage, index, save, backup, 400) |
| Tests | `tests/test_xlsx_viewer.py` (58) + `test_xlsx_styles.py` (9) + `test_xlsx_formats.py` (12) + `test_xlsx_dashboard.py` (8) + `test_xlsx_structure.py` (11) + `test_spreadsheet_tools.py` (17) · `tests/frontend/xlsx-viewer.test.mjs` (35) · `tests/e2e/xlsx-viewer.spec.js` (9) | Backend, JSDOM et E2E (chromium-desktop) |
## 2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention)
@@ -46,6 +46,12 @@ vidée ; backup `.bak` avant écriture ; garde-fou vault read-only (403) ; `reso
## 3. Limites connues (par couche)
> **Note (2026-09-28)** : les limites ci-dessous décrivent l'état du jour de l'audit
> (2026-09-27). La quasi-totalité a été levée depuis par le backlog §5 (styles, navigation
> clavier, tri/filtre/recherche, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation,
> outils IA) — se reporter aux cases cochées et à l'historique §7 ; ne pas relire cette
> section comme l'état actuel.
### 3.1 Fidélité du round-trip — risque n°1
`load_workbook()` → `wb.save()` : ce qui est **réellement** perdu a été mesuré sur
@@ -135,7 +141,7 @@ couverture) · effort en jours-homme de développement + tests.
nombres. Au passage : le handler `ServiceError` expose `code` + `details` et `api()` les
propage sur l'Error. *Vérifié :* `TestXlsxFormulaGuard` (4) + test du toggle côté UI.
### P1 — Recherche, IA, UX (4-6 j) — 🟢 A5, A10, A12 livrés le 2026-09-28
### 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
@@ -143,13 +149,14 @@ couverture) · effort en jours-homme de développement + tests.
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).
- [ ] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
- [x] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
`append_xlsx_rows`, `xlsx_to_markdown` (contexte LLM, plafonné), `list_xlsx_sheets` — risque
WRITE + confirmation pour les mutations, libellés i18n dans `backend/tools/labels.py`,
refresh viewer via `obsigate:file-written`.
- [ ] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
refresh viewer via `obsigate:file-written`. *Livré (v2.33.0) :* `backend/tools/spreadsheets.py`.
*Vérifié :* `tests/test_spreadsheet_tools.py` (17).
- [x] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`).
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
@@ -185,9 +192,11 @@ couverture) · effort en jours-homme de développement + tests.
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).
- [ ] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap,
- [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.
(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
@@ -196,17 +205,30 @@ couverture) · effort en jours-homme de développement + tests.
d'interface n'est émis par le backend. *Vérifié :* `TestXlsxCachedValues` (3),
**contre-preuve** (neutraliser la 2ᵉ lecture → 2 tests échouent).
### P2 — Étendu (2-4 j)
### P2 — Étendu (2-4 j) — 🟢 livré le 2026-09-28
- [ ] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
- [ ] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
- [ ] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
- [x] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
*Livré (v2.35.0) :* tout en manipulation d'affichage, le classeur n'est jamais réécrit
(info-bulle `xlsx.sort_applied`).
- [x] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
*Livré (v2.36.0) :* `PUT …/xlsx/structure` + menu Structure, mêmes garde-fous que
l'édition de cellules. *Vérifié :* `tests/test_xlsx_structure.py` (11).
- [x] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
devise/pourcentage/date, cellules fusionnées, volets figés) ; conserver `csv-table` comme
socle de rendu.
- [ ] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion.
- [ ] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
résumée (KPI par feuille) et proposal d'actions IA sur ces plages.
socle de rendu. *Livré (v2.37.0) en lecture :* couleurs, gras/italique/souligné,
alignements, fusions, ancre de volets figés ; un format de nombre personnalisé est signalé
en police mono (pas de rendu devise/pourcentage). L'application de styles **depuis la
visionneuse** (écriture) reste hors périmètre. *Vérifié :* `tests/test_xlsx_styles.py` (9).
- [x] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion. *Livré (v2.38.0) :*
`.xlsm` éditable macros préservées, `.xls`/`.ods` lecture seule (xlrd/odfpy), `.csv`
éditable et réécrit RFC 4180. *Vérifié :* `tests/test_xlsx_formats.py` (12).
- [x] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
résumée (KPI par feuille) et proposal d'actions IA sur ces plages. *Livré (v2.39.0) :*
panneau Tableau de bord (`GET …/xlsx/dashboard`) — plages nommées avec portée, comptage
graphiques/TCD par analyse des parties OPC, stats par feuille, 8 KPI ; le volet IA se
limite à un conseil contextuel (pas d'appel IA dédié sur les plages).
*Vérifié :* `tests/test_xlsx_dashboard.py` (8).
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
@@ -233,3 +255,6 @@ couverture) · effort en jours-homme de développement + tests.
| 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é** |
+178 -16
View File
@@ -1037,6 +1037,11 @@ function truncationNote(sheet) {
export function renderXlsxViewer(area, data) {
const sheets = data.xlsx_sheets || [];
const lossy = data.xlsx_lossy_features || [];
// #153 A16 — mode flags: a .csv rides the same viewer with a flat
// structure (no tabs/merges/freeze to wire) and its own save endpoint;
// .xls/.ods are served read-only (no editing, no structure ops).
const isCsv = Boolean(data.is_csv);
const readOnly = Boolean(data.xlsx_readonly);
// Session-scoped state: once the lossy write is confirmed, the rest of the
// session saves without asking again (never persisted — a confirmation is
// per workbook, not a global preference).
@@ -1047,9 +1052,64 @@ export function renderXlsxViewer(area, data) {
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
).join("")}</div>`
: "";
const panels = sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
).join("");
// #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).
const applySheetMeta = (panel) => {
const meta = sheets[Number(panel.dataset.sheet)] || {};
const styleMap = meta.styles || {};
const alignMap = meta.aligns || {};
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
const ref = td.dataset.cell;
const style = styleMap[ref];
if (style) td.setAttribute("style", style);
const align = alignMap[ref];
if (align) td.style.textAlign = align;
});
// Merged ranges: span the anchor cell over the range and hide the covered
// cells (rowSpan/colSpan survive a re-render; hidden cells are skipped by
// the save collector because their text was already blank in openpyxl).
(meta.merges || []).forEach((range) => {
const [from, to] = range.split(":");
const anchor = panel.querySelector(`td[data-cell="${from}"]`);
const last = panel.querySelector(`td[data-cell="${to}"]`);
if (!anchor || !last || anchor === last) return;
const a = parseRef(from);
const b = parseRef(to);
if (!a || !b) return;
anchor.rowSpan = Math.max(1, b.row - a.row + 1);
anchor.colSpan = Math.max(1, b.col - a.col + 1);
// Hide every covered cell of the block.
for (let r = a.row; r <= b.row; r++) {
for (let c = a.col; c <= b.col; c++) {
if (r === a.row && c === a.col) continue;
const covered = panel.querySelector(`td[data-cell="${columnName(c)}${r}"]`);
if (covered) covered.style.display = "none";
}
}
});
// Freeze panes: sticky the rows/cols left of and above the anchor.
const freeze = meta.freeze;
if (freeze && freeze !== "A1") {
const f = parseRef(freeze);
if (f) {
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
const ref = tr.querySelector("td")?.dataset.cell || "";
const p = parseRef(ref);
if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row");
});
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
const p = parseRef(td.dataset.cell);
if (p && p.col < f.col) td.classList.add("xlsx-frozen-col");
});
}
}
};
const panels = isCsv
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
: sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
).join("");
const lossWarning = lossy.length
? `<div class="xlsx-warning" role="note">
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
@@ -1070,16 +1130,16 @@ export function renderXlsxViewer(area, data) {
<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>${t("common.save")}</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>
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
${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>
</button>`}
</span>
</div>
${lossWarning}
@@ -1103,6 +1163,68 @@ export function renderXlsxViewer(area, data) {
<div class="xlsx-panels">${panels}</div>
</div>`;
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
// Fetched lazily on first open of the dashboard tab; a workbook without
// anything notable hides the button entirely (no empty panel).
if (!isCsv && !readOnly) {
const dashBtn = el("button", {
class: "btn-action xlsx-dash-btn",
id: "xlsx-dashboard-btn",
type: "button",
title: t("xlsx.dashboard_btn"),
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
area.querySelector(".xlsx-toolbar-actions").prepend(dashBtn);
dashBtn.addEventListener("click", async () => {
const old = area.querySelector(".xlsx-dashboard");
if (old) { old.remove(); dashBtn.classList.remove("active"); return; }
dashBtn.classList.add("active");
const panel = document.createElement("div");
panel.className = "xlsx-dashboard";
panel.innerHTML = `<div class="xlsx-dashboard-loading">…</div>`;
area.querySelector(".xlsx-panels").prepend(panel);
try {
const dash = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`,
);
const rangeRows = (dash.named_ranges || []).map((r) =>
`<tr><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
).join("");
const kpiCards = (dash.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("");
panel.innerHTML = `
<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>
${(dash.named_ranges || []).length || (dash.sheets || []).length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
${(dash.named_ranges || []).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>`;
safeCreateIcons();
} catch (err) {
panel.innerHTML = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
}
});
}
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
@@ -1151,6 +1273,20 @@ export function renderXlsxViewer(area, data) {
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
// #153 A15 — the window carries its own style/merge metadata: fold it
// into the sheet meta and re-apply, so lazy rows look like the initial
// render (applySheetMeta is idempotent over already-processed cells).
const sheetMeta = sheets[Number(panel.dataset.sheet)];
if (sheetMeta) {
Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {});
Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {});
if (!sheetMeta.merges) sheetMeta.merges = [];
(win.merges || []).forEach((m) => {
if (!sheetMeta.merges.includes(m)) sheetMeta.merges.push(m);
});
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
applySheetMeta(panel);
}
safeCreateIcons();
};
@@ -1325,8 +1461,15 @@ export function renderXlsxViewer(area, data) {
});
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
// Read-only formats (.xls/.ods) skip the wiring entirely — the toolbar
// save button is disabled and the structure menu does not exist.
if (readOnly) {
saveBtn.disabled = true;
} else {
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
}
panelEls.forEach(wireLazyRows);
panelEls.forEach(applySheetMeta);
// The formula bar starts disabled: nothing is selected yet.
setActiveCell(null);
@@ -1346,14 +1489,23 @@ export function renderXlsxViewer(area, data) {
formulaBtn.classList.toggle("active", allowFormula);
});
const putSheet = (job, force) => api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...job, allow_formula: allowFormula, force }),
},
);
const putSheet = isCsv
? (job, force) => api(
`/api/file/${encodeURIComponent(data.vault)}/csv/save?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cells: job.cells }),
},
)
: (job, force) => api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...job, allow_formula: allowFormula, force }),
},
);
saveBtn.addEventListener("click", async () => {
// One PUT per sheet (dirty cells can span tabs before a save).
@@ -1598,7 +1750,10 @@ export function renderXlsxViewer(area, data) {
};
// Structure menu: built on demand, positioned under the button.
area.querySelector("#xlsx-structure-btn").addEventListener("click", (e) => {
// (.csv / read-only workbooks have no such button — the menu block above
// is skipped for them.)
const structureBtn = area.querySelector("#xlsx-structure-btn");
if (structureBtn) structureBtn.addEventListener("click", (e) => {
const old = area.querySelector(".xlsx-structure-menu");
if (old) { old.remove(); return; }
const idx = visibleSheetIndex();
@@ -1767,6 +1922,13 @@ export function renderFile(data) {
return;
}
// Handle CSV — same spreadsheet viewer, flat mode (#153 A16): the backend
// renders the grid with data-cell refs and PUT …/csv/save persists edits.
if (data.is_csv) {
renderXlsxViewer(area, data);
return;
}
// Handle Excalidraw — render in iframe editor
if (data.is_excalidraw) {
renderExcalidraw(area, data, data.vault, data.path);
+10
View File
@@ -1868,6 +1868,16 @@
"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.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",
+10
View File
@@ -1868,6 +1868,16 @@
"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.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",
+106
View File
@@ -11092,6 +11092,21 @@ body.desktop-mode .editor-container {
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(--surface);
}
.xlsx-table td.xlsx-frozen-col {
position: sticky;
left: 44px; /* the row-number column width */
background: var(--surface);
}
/* #153 A14 — structure menu (sheets / rows / columns) */
.xlsx-structure-menu {
position: absolute;
@@ -11113,6 +11128,97 @@ body.desktop-mode .editor-container {
background: transparent;
color: var(--text-primary);
}
/* #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);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.36.0",
"version": "2.39.4",
"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": {
BIN
View File
Binary file not shown.
+44
View File
@@ -648,6 +648,50 @@ await test("the 409 lossy flow re-emits with force after confirmation", async ()
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("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({
+122
View File
@@ -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
+219
View File
@@ -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
+99
View File
@@ -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