Files
ObsiGate/docs/features/create-file-xlsx-186.md
T
bruno 634ba8a272
CI / lint (push) Successful in 2m43s
CI / security (push) Successful in 1m33s
CI / test (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
Restaure et termine le lot tableur resté non committé. Il n'existait que dans un
`git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js,
color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le
stash, créé avant le commit BUG-108, n'a jamais été restauré.

#180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint),
grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu
Fichier au niveau des onglets, barre épinglée pleine largeur.

#181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans
quitter contenteditable, double-clic / F2 / première frappe qui remplace,
Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts.

#182 Désélection fiable (clic simple sans dépendre du focus), contours de plage
non empilés (sélecteur de classes sans point), couleurs texte/fond sur une
plage, clic droit qui préserve la sélection multiple.

#183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD,
PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était
rejetée par normHex).

#184 Peinture de format complète (toutes propriétés, source sans format =
réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille
étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au
défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules
câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de
cellules `ref → td`, court-circuits formule/styles, marqueur data-wired.

#185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36
annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème.

#186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC
(openpyxl) au lieu d'une charge utile texte illisible.

Corrections trouvées en restaurant et en exerçant le lot :
- fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque
  rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ;
- sorties Markdown / HTML / Imprimer de la barre de menus inertes
  (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ;
- collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu
  Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ;
- curseur col/row-resize absent quand le pointeur est sur la table elle-même ;
- l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown,
  HTML, impression) ;
- clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au
  seul appui long tactile) ;
- une entrée indisponible laissait la barre de menus ouverte.

Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ;
33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19,
xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer
19/19.
2026-10-08 12:00:15 -04:00

4.9 KiB

#186 — Création d'un fichier Excel depuis « Créer un fichier »

Statut : ✅ livré le 2026-10-05 | Effort : 0,5 jour | Impact : 🟢 Références : Roadmap · Changelog · Guide Recherche, PDF, Excel & Excalidraw · #153 — Visionneuse XLSX

1. Constat

ObsiGate sait lire et éditer les classeurs depuis #152/#153, et l'assistant IA sait en produire un (create_xlsx, #92). Mais la modale « Créer un fichier » — celle du menu contextuel d'un vault / d'un dossier et de la palette de commandes — ne proposait que des formats texte :

.md, .txt, .py, .js, .json, .yaml, .sh, .ps1 (+ Excalidraw).

Démarrer un classeur depuis l'UI imposait donc de passer par l'IA (avec confirmation) ou de déposer un fichier existant dans le vault. Le point de départ naturel manquait alors que l'éditeur complet était disponible.

2. Livré (A1-A3)

A1 — Option Excel dans la liste des types

frontend/js/ui.js, FileOperations.showCreateFileModal() : ajout de <option value=".xlsx"> dans le <select id="file-ext-select">, après .json (ordre : formats texte, puis formats enrichis, comme .excalidraw).

Au passage, les dix libellés du sélecteur passent en i18n (modal.ext_md, modal.ext_txt, …, modal.ext_xlsx) : ils étaient des chaînes françaises codées en dur, ce qui exposait un texte non traduit en mode EN et violait la règle « tout texte d'interface existe en FR et en EN ». Les deux locales sont alignées (tests/test_i18n_parity.py).

Aucun changement pour les autres formats : le nom saisi sans extension reçoit celle choisie (name.endsWith(ext) ? name : name + ext), donc un nom déjà suffiché n'est jamais doublé.

A2 — Un vrai classeur côté backend

Le point non évident : POST /api/file/{vault} écrit du texte (file_path.write_text(content)), et la modale poste content: "". Un .xlsx n'est pas un fichier texte mais une archive OPC (un ZIP) : écrire la charge utile vide produisait un fichier de 0 octet que ni openpyxl, ni la visionneuse, ni Excel n'ouvrent — l'utilisateur aurait vu une erreur à l'ouverture, ou pire, un fichier corrompu.

backend/services/mutations.py :

  • _empty_xlsx_bytes() construit l'archive avec openpyxl (une feuille Feuille1, le titre par défaut déjà utilisé par l'outil IA create_xlsx) — import paresseux, comme le reste du module qui garde openpyxl optionnel à l'import ;
  • create_file() écrit ces octets (write_bytes) pour cette extension et ignore content, qui n'a pas de sens pour un classeur ; size renvoie le nombre d'octets réellement écrits (et non len(content), qui aurait valu 0) ;
  • même garde-fou que .excalidraw : le squelette est injecté par le service, donc la palette, le glisser-déposer, l'IA et l'API en bénéficient, pas seulement la modale.

openpyxl reste importé à la demande : un vault sans tableur ne paie pas l'import.

A3 — Tests

  • tests/test_api_main.py::TestFileCRUD — test_create_xlsx_empty_workbook : le fichier créé est relu par openpyxl (sheetnames == ["Feuille1"]) et servi par GET /api/file/{vault} avec is_xlsx: true (donc lisible par la visionneuse) ; test_create_xlsx_in_new_subdir couvre la création dans un dossier inexistant (auto-créé). Non-régression : sans A2, l'archive serait vide et les deux tests échoueraient.
  • tests/e2e/xlsx-viewer.spec.js — bloc « création depuis l'UI » : l'option .xlsx existe et son libellé vient des locales, la création passe par le menu contextuel d'un dossier, et le classeur s'ouvre dans la grille (.xlsx-table + onglet Feuille1) ; l'API est interrogée pour confirmer is_xlsx. Le fichier est supprimé en fin de test (le run ne doit pas salir test_vault/).

3. Limites assumées

  • Le nom de feuille est Feuille1, en dur. Un vrai nouveau classeur de Google Sheets le demande ; ici le classeur s'ouvre sur sa seule feuille et l'utilisateur la renomme via le menu Structure. La modale reste celle d'un éditeur de texte, volontairement simple.
  • .csv reste absent de la liste, alors que le backend l'accepte (SUPPORTED_EXTENSIONS) et que la visionneuse sait l'éditer comme tableur (#156-A14). L'écart est réel mais hors périmètre de #186 ; le même correctif s'appliquerait (une ligne dans le <select>).
  • frontend/app.js (monolithe obsolète, non chargé par index.html) duplique l'ancienne liste sans .xlsx. Le code vivant est dans frontend/js/; ce fichier n'a pas été touché.
  • L'outil IA create_xlsx reste le chemin pour un classeur rempli : la modale crée une grille vierge, comme un .md vierge.

4. Historique

Date Événement
2026-10-05 #186 ouvert puis livré dans la foulée : option .xlsx + squelette openpyxl dans create_file()