diff --git a/CHANGELOG.md b/CHANGELOG.md index cffccb1..d59e3d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.53.3**. +> [Unreleased](#unreleased). La dernière version livrée est **2.54.0**. --- @@ -14,6 +14,302 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.54.0] — 2026-10-08 + +### Ajouté + +- **#186 - Création d'un fichier Excel depuis « Créer un fichier »** + - **Excel (`.xlsx`) fait partie des types proposés** dans la modale « Créer un + fichier » (menu contextuel d'un vault ou d'un dossier, palette de commandes) : + le classeur s'ouvre directement dans la visionneuse, prêt à être saisi. Les 10 + libellés du sélecteur passent en i18n FR/EN (`modal.ext_*`) — ils étaient + codés en dur en français. + - **Un vrai classeur, pas un fichier vide.** Un `.xlsx` est une archive OPC, pas + du texte : `create_file()` construit désormais l'archive avec openpyxl (une + feuille `Feuille1`) au lieu d'écrire la charge utile vide de la modale, qui + aurait produit un fichier que ni la visionneuse ni Excel n'ouvrent. Le champ + `content` est ignoré pour cette extension. + +- **#183 — Panneau de sélection de couleurs façon Google Sheets (texte et fond)** + - Le ruban et le menu **Mise en forme** n'utilisent plus le sélecteur natif : + ils ouvrent un **panneau** conforme à la description fournie — + *Réinitialiser* en en-tête, palette **8 × 10** (ligne 1 = 8 nuances de gris + du noir au blanc, lignes 2 à 10 = 8 teintes × 9 nuances du clair au foncé), + section **STANDARD** (8 couleurs prédéfinies) avec crayon, section + **PERSONNALISÉ** (pastille + bouton « + » + crayon), pied de page + **Mise en forme conditionnelle** / **Couleurs en alternance**. + - Un **clic sur une pastille referme le panneau** et applique la couleur à la + cellule ou à la plage sélectionnée ; l'indicateur de sélection est une + coche **blanche ou noire selon la luminance** de la pastille. + - **Correction d'un piège d'encodage** : la palette est calculée en HSL pour + la lisibilité mais sort en **`#rrggbb`**. Une valeur `hsl(...)` était + silencieusement rejetée par `normHex()` — la couleur n'était donc jamais + écrite sur la cellule. + - Le chrome du panneau suit le **thème actif** (variables CSS uniquement) ; + « + » et les crayons ouvrent le sélecteur natif pour une couleur + arbitraire. + - Les deux entrées de pied de page sont **annoncées mais pas encore + disponibles** : elles affichent un message explicite plutôt qu'un bouton + inerte. + + +### Modifié + +- **#185 - Tableur : barre de menus Google Sheets, boutons façon Sheets, grille unie** + - **Les dix menus de Google Sheets sont là** : Fichier, Modifier, Affichage, + Insertion, Format, Données, Outils, Gemini, Extensions, Aide — dans l'ordre + et avec les libellés exacts de Sheets (161 entrées). Le nouveau module + `frontend/js/xlsx/menus.js` décrit l'arborescence, `viewer.js` ne fait que + brancher chaque entrée sur un flux existant. Les sections imbriquées + (Nombre, Thème, Alignement, Zoom, Nettoyage…) sont des **accordéons**, une + seule ouverte à la fois, comme dans Sheets. + - **La majorité des fonctions sont opérationnelles** : Partager, Télécharger + (original / CSV / Markdown / HTML), Imprimer, **Renommer**, **Déplacer vers + la corbeille**, **Historique des versions**, Détails ; Annuler, Rétablir, + Couper, Copier, Coller, **Collage spécial (valeurs uniquement)**, + **Supprimer (lignes / colonnes / contenu)**, Tout sélectionner, + **Rechercher et remplacer** ; **Figer** (ici / ligne / colonne), **Afficher + → barre de formule · grille · en-têtes · tableau de bord**, **Zoom**, + **Plein écran** ; Lignes / Colonnes / Cellules, Lien, Commentaire, + **Fonction** (curseur placé entre les parenthèses) ; Nombre, **Thème**, + Gras / Italique / Souligné / Barré, Taille de police, Alignement, Retour à la + ligne, Rotation, **Couleurs alternées**, Effacer la mise en forme ; Trier, + **Créer un filtre**, **Plages nommées**, **Nettoyage des données + (doublons / espaces superflus)**, **Diviser le texte en colonnes** ; + **Saisie semi-automatique**, **Accessibilité** ; Gemini (6 entrées vers + l'assistant IA) ; Aide, **Mises à jour**, **Raccourcis clavier**, + **Signaler un problème** (diagnostic copié dans le presse-papiers). + - **Les options qu'ObsiGate n'a pas le disent** : les 36 entrées absentes + (macros, Apps Script, tableaux croisés, graphiques, formulaires, modules, + AppSheet, Looker Studio, formation…) restent **listées** pour tenir la + parité demandée, mais un clic y répond « *« X » n'est pas disponible dans + ObsiGate* » au lieu de ne rien faire. + - **Le menu Fichier a quitté la barre d'onglets** pour la barre de menus, en + **conservant ses identifiants** (`#xlsx-file-btn`, `#xlsx-file-menu`, + `#xlsx-download-btn`, `#xlsx-csv-btn`) : seuls les raccourcis internes et les + tests bougent. Seul **Enregistrer** reste épinglé à droite des onglets. + - **Ruban refait façon Google Sheets** : boutons plats de 30 × 30, sans bordure + ni fond au repos, survol discret, état actif teinté, groupes séparés par des + filets verticaux. **Enregistrer** reste le seul pilier plein. Nouveau bouton + **grille**, synchronisé avec Affichage ▸ Afficher ▸ Grille. + - **Grille unie et visible** : chaque cellule est encadrée sur **les quatre + côtés** par **la même** ligne de 1 px, en-têtes compris (ils se distinguent + par leur fond, plus par une bordure plus lourde). `--grid-border` n'est plus + `--border`, quasi identique au fond des cellules sur la plupart des thèmes : + c'est désormais un `color-mix` du texte vers la surface, calculé par + `themes.js`, donc une seule valeur pour toute la grille et lisible sur les 15 + thèmes comme sur les modes contraste élevé et sépia. + +- **#184 - Tableur : peinture de format complète, sélection de lignes/colonnes + multiples, grille A → Z × 1000 lignes** + - **Peindre le format ne ratait plus aucun cas.** L'outil copiait un + sous-ensemble de propriétés et **refusait** une cellule source sans mise en + forme explicite — donc une cellule simple vers une cellule colorée et en + grande taille ne produisait rien. La copie porte maintenant sur **toute** la + mise en forme (gras, italique, souligné, barré, couleur du texte, couleur de + fond, taille, alignement, valign, retour ligne, rotation, format de nombre, + bordures) ; une source **sans** format **réinitialise** la cible au lieu de + ne rien faire. + - **Fuite corrigée** : annuler la peinture (`Échap`, second clic) laissait le + gestionnaire « appliquer au prochain clic » en place — le clic suivant dans la + grille repeignait une vieille mise en forme. Il est maintenant désinstallé. + - **Sélection de plusieurs lignes / colonnes depuis les marges**, comme dans + Sheets et Excel : glisser sur les numéros de ligne (ou `Maj`+clic) sélectionne + une plage de lignes entières ; idem sur les lettres de colonne. Le + redimensionnement par les bords d'en-tête reste prioritaire sur ce geste. + - **Grille étendue à A → Z, et jusqu'à 1000 lignes**, les cellules vides + étant de **vraies cellules éditables**. Les colonnes sont posées d'emblée ; + les lignes s'ajoutent **par blocs de 100 au défilement**, et non d'un seul + coup : matérialiser 1000 lignes revient à ~26 000 cellules câblées par + feuille, ce qui épuisait le tas de la suite JSDOM. Une feuille tronquée + garde son chargement serveur « Charger la suite » (les lignes manquantes + sont des données, pas du remplissage). + - **Trois goulots d'étranglement levés**, sinon la grille étendue rendait + l'éditeur inutilisable : `findTd` faisait un `querySelector` par appel + (sélectionner une colonne = 26 000 requêtes) → **index de cellules** avec + auto-réparation ; le rafraîchissement des formules clonait 26 000 nœuds à + chaque frappe → **court-circuit** sur les cellules vides ; l'application des + styles touchait 26 000 cellules → **court-circuit** sur celles que le + classeur ne style pas. + - Marqueur `data-wired` explicite : `data-orig` valait `""` pour une cellule + vide, ce qui recâblait les cellules déjà câblées (double écouteur). + +- **#181 — Éditeur tableur : modèle de saisie Google Sheets (sélection ≠ édition)** + - **Deux états distincts.** Une cellule est *sélectionnée* (clic, flèches) ou + *en cours d'édition*, et plus les deux en même temps. Le curseur de texte + n'apparaît plus en sélection (`caret-color: transparent`, sans quitter + `contenteditable`) : on ne voit plus un curseur clignotant au clic ou aux + flèches. + - **Entrées en édition**, comme Google Sheets / Excel : **double-clic** (le + curseur est placé *sous le pointeur*, l'édition se fait en place), **première + frappe** — qui **remplace** le contenu au lieu de s'y ajouter —, ou `F2`. + `Alt`+flèche insère un saut de ligne, `Échap` quitte l'édition et restaure + la valeur. + - **`Entrée` contextuelle** : en édition, elle valide et déplace le focus sur + **la cellule du dessous** (au lieu de perdre le focus) ; en sélection, elle + bascule la cellule courante en mode édition. `Tab` suit la même logique. + - **Indicateur de sélection renforcé** : la plage est **contourée** comme dans + Excel / Sheets (quatre classes de bord sur les côtés réels du bloc, pas un + simple lavis), et les en-têtes couverts — lettres `A`, `B`… et numéros + `1`, `2`… — sont reflétés. + - Un clic simple après une édition abandonne l'état d'édition : plus de + curseur résiduel. + - **Inventaire des écarts** vs Google Sheets / Excel dressé et priorisé + (poignée de recopie, *remplacer*, validation de données, filtre + automatique, tri multi-critères, mise en forme conditionnelle, création de + graphiques, protection…) : [fiche #181](docs/features/xlsx-sheet-input-181.md). + - Tests JSDOM : `xlsx-viewer` passe de 136 à **146/146** (10 cas #181). + +- **#180 — Éditeur tableur : redimensionnement au curseur, grille thémée, menu + Fichier & barre épinglée** + - **Curseur de redimensionnement.** Survoler le bord d'un en-tête de colonne + (`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…) affiche désormais l'icône + `col-resize` / `row-resize`. Le curseur par défaut des cellules + (`cell`) et des en-têtes (`pointer`) masquait auparavant l'indicateur, y + compris pendant le glissement : les en-têtes concerned sont désormais + miroirés en ligne et la table porte une classe de repaint + (`.xlsx-resize-col` / `.xlsx-resize-row`). + - **Grille compatible tous les thèmes.** Les jetons de grille + (`--grid-*`) sont dérivés du thème actif (`--surface`, `--bg-hover`, + `--text-primary`, `--border`, bandeau zébré en `color-mix`) : les + 15 thèmes × 4 modes de l'application, y compris les modes générés + contrasté élevé / sépia et les thèmes importés, sont désormais + respectés, plus les couleurs figées du thème par défaut. + - **Menu Fichier.** Télécharger, CSV, Markdown, HTML et Imprimer sont + réunis dans un seul menu **Fichier**, placé à droite de la barre des + onglets de feuilles, avec le bouton **Enregistrer** juste à côté. Le + ruban conserve les commandes d'édition. Les identifiants des contrôles + (`#xlsx-download-btn`, `#xlsx-csv-btn`, `#xlsx-save-btn`) sont + inchangés. + - **Barre épinglée pleine largeur.** La barre de boutons est épinglée en + haut, au bord de la marge de la page, et l'éditeur occupe toute la + largeur du contenu (comme les visionneuses PDF / images / Excalidraw). + - **Indicateur retiré.** La pastille « Formules calculées localement » + disparaît : le calcul local est le comportement normal, pas une limite + worthy of a badge. + + +### Corrigé + +- **#182 — Éditeur tableur : les encadrés de la sélection s'empilaient et ne + suivaient plus le focus** + - **Cause racine.** Le sélecteur CSS de nettoyage des contours de plage était + écrit **sans le point** (`querySelectorAll("xlsx-sel-top,…")`). En CSS, un + nom nu désigne une **balise** (``), pas une classe : la requête + ne renvoyait **rien** et aucun contour n'était jamais effacé. Les encadrés + restaient donc en place et s'accumulaient à chaque changement de cellule. + Même famille de faute sur les en-têtes reflétés (`classList.remove` appelé + avec un point). + - **Comportement corrigé** : le contour suit désormais exactement la sélection — + une cellule seule est encadrée, une plage est encadrée sur ses quatre bords + réels, et **rien ne reste** sur les cellules quittées (navigation clavier, + clic, changement de cellule, changement de feuille). + - **Écart Sheets corrigé au passage** : pendant la saisie, les flèches + **valident et déplacent** la sélection (auparavant elles déplaçaient le + curseur dans le texte et la cellule semblait figée). `Alt`+flèche conserve + l'insertion d'un saut de ligne. + - Non-régression : `xlsx-viewer` passe de 149 à **152/152** (3 cas #182). + +- **#182 — Éditeur tableur : la sélection multiple ne se désélectionnait plus, et + les couleurs paraissaient inertes sur une plage** + - **Cause racine de la sélection « collée ».** La désélection reposait sur + l'événement `focus` de la cellule. Or `Maj`+clic appelle `preventDefault()`, + ce qui **empêche le navigateur de déplacer le focus** : `activeTd` et le + focus réel divergeaient. Un clic suivant sur la cellule déjà focalisée ne + déclenchait donc **aucun** `focus` et la plage restait affichée. Le clic + simple efface désormais la plage explicitement dans `mousedown`, sans + dépendre du focus ; après un `Maj`+clic le focus suit enfin la cellule + cliquée (sous `suppressSelectionReset`, pour ne pas écraser la plage). + - **Zone Nom** : après un glissement, elle affiche de nouveau la **plage** + (`A1:B2`) au lieu de la seule cellule d'origine (`A1`). + - **Couleurs sur une plage** : vérifié sur les trois chemins (menu Mise en + forme, nuanciers du ruban, sélection par `Maj`+flèches ou par glissement) — + la charge utile porte bien la plage (`A1:B2`) et les 4 cellules reçoivent + `color` **et** `background`. + - **Retour visuel manquant** : les deux nuanciers du ruban affichent désormais + une **pastille** qui prend la couleur choisie, ce qui donnait l'impression + que la couleur n'était pas appliquée. + - Non-régression : `xlsx-viewer` passe de 146 à **149/149** (3 cas #182). + +- **Visionneuse tableur / CSV : plus de ligne teintée au survol, la teinte de + fond est réservée aux sélections de 2 cellules et plus (Google Sheets)** + - Le survol ne colore **plus rien** : les règles `tr:hover` de la grille + XLSX (ligne entière en `--bg-hover`, y compris sur une cellule déjà + sélectionnée) et du tableau CSV sont retirées. + - Un clic simple ne change **plus le fond de la cellule** : elle ne garde que + son cadre accent (les quatre bords `xlsx-sel-*` + le contour + `xlsx-active`), comme dans Google Sheets. + - La teinte `.xlsx-selected` est désormais conditionnée par la classe + `.xlsx-range-multi`, posée/retirée sur `.xlsx-viewer` par + `applySelection()` selon l'étendue de la sélection — elle n'apparaît donc + qu'à partir de deux cellules. Les en-têtes de ligne / de colonne couverts + restent reflétés, comme dans Sheets. + +- **Visionneuse tableur : le clic droit écrasait la sélection multiple avant + l'ouverture du menu contextuel** + - **Cause.** Le `mousedown` du bouton droit laissait le navigateur déplacer + le focus sur la cellule ; le handler `focus` appelait `setActiveCell()` et + réduisait la plage à cette cellule **avant** que le menu ne s'ouvre. Les + entrées Copier / Coller / Effacer / Insérer / Supprimer ne s'appliquaient + donc plus qu'à une cellule (`Delete 1 row(s)` au lieu de + `Delete 2 row(s)`), et la plage disparaissait à l'écran. + - **Correctif.** `preventDefault()` sur le `mousedown` de bouton droit dans + la grille : le focus ne bouge plus, la sélection multiple survit jusqu'au + choix d'une option. Un clic droit **hors** plage déplace toujours la + sélection sur la cellule visée (comportement Sheets), via + `selectForTarget()` ; le clic gauche, lui, écrase toujours la plage (#182). + - Vérifié en conditions réelles sur l'instance Docker : la plage `A2:C3` est + conservée à l'ouverture du menu, le menu annonce 2 lignes / 3 colonnes, et + Copier place bien les 2 lignes × 3 colonnes dans le presse-papiers. + +- **#180 — 8 tests JSDOM réalignés** sur la sémantique « écriture verrouillée + puis application locale » des flux structure / mise en forme (le viewer + n'est plus re-lu après chaque écriture) : le second appel PUT est absent, + les assertions portent sur la grille peinte en place. + +- **Tableur — les sorties du menu Fichier étaient inertes.** Les entrées + Markdown, HTML et Imprimer portaient `data-xlsx-export` mais aucun + `data-xlsx-action`, et le répartiteur générique ne lit que ce dernier : le + clic ne produisait rien (les identifiants `#xlsx-download-btn` et + `#xlsx-csv-btn`, eux, fonctionnaient). Elles sont câblées sur + `export-md` / `export-html` / `print`, donc sur les fonctions déjà en place. + +- **Tableur — collision de classe sur `.xlsx-structure-menu`.** Les menus + déroulants de la nouvelle barre de menus portaient la même classe que le + menu Structure de la barre d'outils : toute requête `.xlsx-structure-menu` + tombait sur le premier nœud rencontré, donc sur un élément masqué. Les menus + de la barre prennent `.xlsx-menudrop` ; l'aspect reste partagé par le CSS. + +- **Tableur — curseur de redimensionnement absent sur la table.** La sonde + géométrique était conditionnée à la présence d'un en-tête sous le pointeur ; + sur la table elle-même aucun en-tête n'est nommé et le curseur ne + s'affichait pas. La table retombe désormais sur la sonde géométrique. + +- **Tableur — l'export emportait le quadrillage vide.** `sheetMatrix()` + renvoyait la grille telle que mise en page : avec les colonnes étendues à + A → Z, un classeur de deux colonnes exportait 26 colonnes de vide (CSV, + Markdown, HTML, impression). Les lignes et colonnes vides de queue sont + écartées — l'export porte le bloc utilisé, comme l'export d'un tableur. + +- **Tableur — fuite mémoire à chaque rendu.** `renderXlsxViewer()` ajoutait un + écouteur `click` **anonyme** sur `#content-area` : l'élément survit au + re-rendu et la fermeture de l'écouteur retenait la grille précédente en + entier. Ouvrir ou enregistrer un classeur plusieurs fois faisait croître le + tas sans borne. Gestionnaire nommé, démonté en tête de rendu. + +- **Tableur — le clic extérieur laissait le menu contextuel ouvert.** La fenêtre + de grâce de 250 ms, destinée au `click` résiduel d'un appui long tactile, + s'appliquait aussi au clic droit souris — qui n'en émet aucun : un clic + délibéré fait juste après l'ouverture était avalé par la grâce. Elle ne + couvre plus que l'ouverture tactile. + +- **Tableur — une entrée indisponible ne refermait pas la barre de menus.** Le + répartiteur annonçait « *X* n'est pas disponible dans ObsiGate » puis sortait + avant la fermeture : le menu restait ouvert. Tout choix referme désormais le + menu, y compris une entrée qui ne fait que l'annoncer. + +--- + ## [2.53.3] — 2026-10-07 ### Corrigé diff --git a/Dockerfile b/Dockerfile index e105833..b6451de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,7 @@ COPY VERSION ./VERSION # Using explicit UID/GID 1000 to match common host user and docker-compose settings RUN groupadd -g 1000 obsigate && useradd -u 1000 -g obsigate -d /app -s /sbin/nologin obsigate \ && mkdir -p /app/data \ + && chmod 777 /app/data \ && chown -R obsigate:obsigate /app USER obsigate diff --git a/README.fr.md b/README.fr.md index a02e013..5b7d5a4 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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.53.3-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.54.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.3). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.54.0). --- -*Projet : ObsiGate | Version : 2.53.3 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.54.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index a6b8540..199ab44 100644 --- a/README.md +++ b/README.md @@ -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.53.3-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.54.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.3). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.54.0). --- -*Project: ObsiGate | Version: 2.53.3 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.54.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 27b2061..99aed79 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.53.3 +2.54.0 diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py index 9f85217..2377c5f 100644 --- a/backend/routers/files_write.py +++ b/backend/routers/files_write.py @@ -323,9 +323,13 @@ def api_file_xlsx_style( ``ops`` is an ordered list applied in one locked, atomic rewrite: ``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``, - ``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``, - ``align`` and ``number_format``), ``merge``/``unmerge`` (``range``), - ``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``) + ``italic``, ``underline``, ``strike``, ``font_size`` (6-72), + ``font_color``/``fill_color`` as ``#rrggbb``, ``align`` + (left/center/right/justify), ``valign`` (top/middle/bottom), ``wrap``, + ``rotation`` (-90..90), ``border`` (all/outer/none) + ``border_style`` / + ``border_color`` and ``number_format``), ``merge``/``unmerge`` (``range``), + ``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``), + ``comment_set`` (``ref``, ``text``) / ``comment_clear`` (``ref``) and ``freeze`` (``cell``, empty to release). Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the @@ -548,6 +552,10 @@ async def api_file_create( Returns: FileCreateResponse confirming creation. + + Note: + A ``.xlsx`` path creates an empty workbook (one ``Feuille1`` sheet) built + with openpyxl: the payload is binary, so ``content`` is ignored (#186). """ if not check_vault_access(vault_name, current_user): raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") diff --git a/backend/services/mutations.py b/backend/services/mutations.py index 8d99261..5bfbebc 100644 --- a/backend/services/mutations.py +++ b/backend/services/mutations.py @@ -41,6 +41,28 @@ _EXCALIDRAW_SKELETON = ( '"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}' ) +# Title of the single sheet of a workbook created from scratch (#186). +_XLSX_DEFAULT_SHEET = "Feuille1" + + +def _empty_xlsx_bytes() -> bytes: + """Return the bytes of a valid, empty single-sheet workbook. + + An ``.xlsx`` is a ZIP archive (OPC), not a text file: writing ``""`` to it + produces a file no reader (and no spreadsheet application) can open. The + "Create a file" modal posts an empty text payload, so the archive itself is + built here with openpyxl. + """ + import io + + from openpyxl import Workbook + + wb = Workbook() + wb.active.title = _XLSX_DEFAULT_SHEET + buffer = io.BytesIO() + wb.save(buffer) + return buffer.getvalue() + def _rel(root: Path, path: Path) -> str: """Return *path* relative to *root* as a POSIX-style string.""" @@ -98,13 +120,14 @@ def create_file( *, overwrite: bool = False, ) -> dict[str, Any]: - """Create a text file in a vault. + """Create a file in a vault. Args: vault_name: Name of the vault. path: Vault-relative path of the new file. content: Initial content (an Excalidraw skeleton is injected for empty - ``.excalidraw`` files). + ``.excalidraw`` files). ``.xlsx`` is binary: the workbook is always + built from scratch and *content* is ignored for that extension. overwrite: When True, replace an existing file (with a backup) instead of raising ``already_exists``. @@ -130,17 +153,25 @@ def create_file( ) create_backup(file_path, vault_name, _rel(root, file_path)) + suffix = file_path.suffix.lower() try: file_path.parent.mkdir(parents=True, exist_ok=True) - if file_path.suffix.lower() == ".excalidraw" and not content.strip(): - content = _EXCALIDRAW_SKELETON - file_path.write_text(content, encoding="utf-8") + if suffix == ".xlsx": + # Binary OPC archive: a text write would leave an unopenable file + # (#186). Written as bytes, so the size below is the byte count. + payload = _empty_xlsx_bytes() + file_path.write_bytes(payload) + else: + if suffix == ".excalidraw" and not content.strip(): + content = _EXCALIDRAW_SKELETON + payload = content.encode("utf-8") + file_path.write_text(content, encoding="utf-8") except PermissionError as e: raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e rel_path = _rel(root, file_path) logger.info(f"File created: {vault_name}/{rel_path}") - return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)} + return {"success": True, "vault": vault_name, "path": rel_path, "size": len(payload)} def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]: @@ -673,11 +704,42 @@ def mutate_xlsx_structure( "Position 'at' / 'count' invalides", code="invalid", status=400 ) if op == "row_insert": - ws.insert_rows(at, count) + # Le modèle sparse d'openpyxl ignore les inserts + # au-delà de la dernière ligne utilisée (rien à + # décaler), et l'écrivain abandonne les cellules + # vides (None) au cycle suivant : on matérialise + # avec " " (un blanc qui persiste, relu tel quel, + # affiché vide, ignoré par la recherche) pour + # qu'une insertion « sous la dernière ligne » + # crée bien des lignes blanches, comme Excel. + if at > ws.max_row: + if at - ws.max_row > _INSERT_GAP_LIMIT: + raise ServiceError( + "Position trop éloignée de la fin de la feuille", + code="invalid", status=400, + ) + for r in range(at, at + count): + cell = ws.cell(row=r, column=1) + if cell.value is None: + cell.value = " " + else: + ws.insert_rows(at, count) elif op == "row_delete": ws.delete_rows(at, count) elif op == "col_insert": - ws.insert_cols(at, count) + # Idem côté colonnes : « à droite de la dernière ». + if at > ws.max_column: + if at - ws.max_column > _INSERT_GAP_LIMIT: + raise ServiceError( + "Position trop éloignée de la fin de la feuille", + code="invalid", status=400, + ) + for c in range(at, at + count): + cell = ws.cell(row=1, column=c) + if cell.value is None: + cell.value = " " + else: + ws.insert_cols(at, count) else: ws.delete_cols(at, count) applied.append(f"{op}:{sheet}@{at}x{count}") @@ -725,16 +787,34 @@ def mutate_xlsx_structure( # #156-A8 — write-side formatting. Every value is data-driven (the colours # come from the caller, never from a hardcoded palette) and the whole batch # rides the same lock, backup and atomic swap as the cell edits. +# Cap anti-DoS : une insertion au-delà de la fin matérialise des cellules, +# jamais un gouffre de lignes/colonnes. +_INSERT_GAP_LIMIT = 1000 _STYLE_KEYS = { "bold", "italic", "underline", + "strike", + "font_size", "font_color", "fill_color", "align", + "valign", + "wrap", + "rotation", + "border", + "border_style", + "border_color", "number_format", } -_STYLE_ALIGNS = {"left", "center", "right"} +_STYLE_ALIGNS = {"left", "center", "right", "justify"} +_STYLE_VALIGNS = {"top": "top", "middle": "center", "bottom": "bottom"} +_STYLE_BORDERS = {"all", "outer", "none"} +_STYLE_BORDER_STYLES = { + "thin", "medium", "thick", "dashed", "dotted", "double", "hair", + "mediumDashed", "dashDot", "mediumDashDot", "dashDotDot", + "mediumDashDotDot", "slantDashDot", +} MAX_STYLE_CELLS = 10_000 _HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$") @@ -760,14 +840,14 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None: """ import copy as copy_mod - from openpyxl.styles import Alignment, Color, PatternFill + from openpyxl.styles import Alignment, Border, Color, PatternFill, Side unknown = set(style) - _STYLE_KEYS if unknown: raise ServiceError( f"Style inconnu: {sorted(unknown)}", code="invalid", status=400 ) - if {"bold", "italic", "underline", "font_color"} & set(style): + if {"bold", "italic", "underline", "strike", "font_size", "font_color"} & set(style): font = copy_mod.copy(cell.font) if "bold" in style: font.bold = bool(style["bold"]) @@ -775,6 +855,22 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None: font.italic = bool(style["italic"]) if "underline" in style: font.underline = "single" if style["underline"] else None + if "strike" in style: + font.strike = bool(style["strike"]) + if "font_size" in style: + try: + size = float(style["font_size"]) + except (TypeError, ValueError): + raise ServiceError( + f"Taille de police invalide: {style['font_size']!r}", + code="invalid", status=400, + ) from None + if not 6 <= size <= 72: + raise ServiceError( + f"Taille de police hors bornes (6-72): {style['font_size']!r}", + code="invalid", status=400, + ) + font.size = size if "font_color" in style: rgb = _style_color(style["font_color"]) font.color = Color(rgb=rgb) if rgb else None @@ -797,7 +893,69 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None: horizontal=align, vertical=getattr(current, "vertical", None), wrap_text=getattr(current, "wrap_text", None), + text_rotation=getattr(current, "text_rotation", None), ) + if "valign" in style or "wrap" in style or "rotation" in style: + current = cell.alignment + vertical = getattr(current, "vertical", None) + wrap_text = getattr(current, "wrap_text", None) + text_rotation = getattr(current, "text_rotation", None) or 0 + if "valign" in style: + valign = style["valign"] + if valign not in _STYLE_VALIGNS: + raise ServiceError( + f"Alignement vertical invalide: {valign!r}", code="invalid", status=400 + ) + vertical = _STYLE_VALIGNS[valign] + if "wrap" in style: + wrap_text = bool(style["wrap"]) + if "rotation" in style: + try: + rotation = int(style["rotation"]) + except (TypeError, ValueError): + raise ServiceError( + f"Rotation invalide: {style['rotation']!r}", code="invalid", status=400 + ) from None + if not (-90 <= rotation <= 90 or rotation == 180): + raise ServiceError( + f"Rotation hors bornes (-90..90, 180): {rotation!r}", code="invalid", status=400 + ) + # OOXML stocke les angles horaires sur 91..180 (-45° → 135). + # 180 = texte vertical retourné (le lecteur le relit tel quel). + text_rotation = rotation if rotation >= 0 else 180 + rotation + cell.alignment = Alignment( + horizontal=getattr(current, "horizontal", None), + vertical=vertical, + wrap_text=wrap_text, + text_rotation=text_rotation, + ) + if {"border", "border_style", "border_color"} & set(style): + which = style.get("border", "all") + if which not in _STYLE_BORDERS: + raise ServiceError( + f"Bordure invalide: {which!r}", code="invalid", status=400 + ) + bstyle = style.get("border_style", "thin") + if bstyle not in _STYLE_BORDER_STYLES: + raise ServiceError( + f"Style de bordure invalide: {bstyle!r}", code="invalid", status=400 + ) + raw_color = style.get("border_color", "#000000") + if raw_color in (None, ""): + raw_color = "FF000000" + rgb = _style_color(raw_color) + if rgb is None: + raise ServiceError( + f"Couleur de bordure invalide: {style.get('border_color')!r}", + code="invalid", status=400, + ) + if which == "none": + cell.border = Border() + else: + # `all` et `outer` se rendent pareil cellule par cellule : les + # bords partagés coïncident, le contour de la plage reste net. + side = Side(style=bstyle, color=rgb) + cell.border = Border(left=side, right=side, top=side, bottom=side) if "number_format" in style: fmt = style["number_format"] or "General" if not isinstance(fmt, str) or len(fmt) > 120: @@ -821,12 +979,17 @@ def mutate_xlsx_style( ``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite: * ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with - ``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color`` - (``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and + ``bold``, ``italic``, ``underline``, ``strike``, ``font_size`` (6-72), + ``font_color`` / ``fill_color`` (``#rrggbb``, ``""`` clears), + ``align`` (left/center/right/justify), ``valign`` (top/middle/bottom), + ``wrap`` (bool), ``rotation`` (-90..90), ``border`` + (all/outer/none) + ``border_style`` / ``border_color`` and ``number_format`` ; * ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ; * ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ; * ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ; + * ``{"op": "comment_set", "sheet": "X", "ref": "B2", "text": "…"}`` + (``author`` optional) / ``{"op": "comment_clear", ...}`` ; * ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases). Same guards as the cell edits: per-file lock, ``.bak`` backup, @@ -960,7 +1123,17 @@ def mutate_xlsx_style( raise ServiceError( "Largeur invalide (0 à 255)", code="invalid", status=400 ) - ws.column_dimensions[col.strip().upper()].width = float(width) + width = float(width) + if width == 0: + # 0 = largeur par défaut (équivalent « Default + # Width » d'Excel / « Reset » de Sheets) : on + # retire la mesure explicite au lieu d'écrire une + # largeur de 0, qu'Excel lirait comme une colonne + # quasi masquée pendant que l'affichage reste en + # largeur automatique. + ws.column_dimensions.pop(col.strip().upper(), None) + else: + ws.column_dimensions[col.strip().upper()].width = width applied.append(f"col_width:{ws.title}!{col}={width}") elif op == "row_height": ws = _sheet(wb, action) @@ -972,7 +1145,14 @@ def mutate_xlsx_style( raise ServiceError( "Hauteur invalide (0 à 409)", code="invalid", status=400 ) - ws.row_dimensions[row].height = float(height) + height = float(height) + if height == 0: + # 0 = hauteur par défaut (même logique que la + # largeur ci-dessus) : la mesure explicite est + # retirée, la ligne redevient automatique. + ws.row_dimensions.pop(row, None) + else: + ws.row_dimensions[row].height = height applied.append(f"row_height:{ws.title}!{row}={height}") elif op == "freeze": ws = _sheet(wb, action) @@ -981,6 +1161,31 @@ def mutate_xlsx_style( _range(cell_ref) ws.freeze_panes = cell_ref.upper() or None applied.append(f"freeze:{ws.title}!{cell_ref or '-'}") + elif op in ("comment_set", "comment_clear"): + from openpyxl.comments import Comment + + ws = _sheet(wb, action) + ref = str(action.get("ref") or action.get("cell") or "") + min_col, min_row, max_col, max_row = _range(ref) + if (min_col, min_row) != (max_col, max_row): + raise ServiceError( + "Un commentaire vise une cellule (A1), pas une plage", + code="invalid", status=400, + ) + if op == "comment_clear": + ws.cell(row=min_row, column=min_col).comment = None + else: + text = str(action.get("text") or "") + if not text.strip() or len(text) > 2000: + raise ServiceError( + "Texte de commentaire invalide (1 à 2000 caractères)", + code="invalid", status=400, + ) + author = str(action.get("author") or "ObsiGate")[:64] + ws.cell(row=min_row, column=min_col).comment = Comment( + text, author + ) + applied.append(f"{op}:{ws.title}!{ref}") else: raise ServiceError( f"Action inconnue: {op!r}", code="invalid", status=400 diff --git a/backend/xlsx_reader.py b/backend/xlsx_reader.py index a5e87a9..2d4763e 100644 --- a/backend/xlsx_reader.py +++ b/backend/xlsx_reader.py @@ -129,8 +129,16 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]: fragments.append("font-style:italic") # #156-A8 — underline is written from the viewer too, so it is read back # (the toggle in the formatting menu needs to see its own effect). + decorations = [] if font and font.underline: - fragments.append("text-decoration:underline") + decorations.append("underline") + if font and getattr(font, "strike", False): + decorations.append("line-through") + if decorations: + fragments.append("text-decoration:" + " ".join(decorations)) + size = getattr(font, "size", None) if font else None + if isinstance(size, (int, float)) and size != 11: + fragments.append(f"font-size:{size:g}pt") fmt = cell.number_format if fmt and fmt not in ("General", "@"): # A custom number format is signalled typographically (mono font) @@ -140,29 +148,129 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]: 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) + if alignment is not None: + vertical = getattr(alignment, "vertical", None) + if vertical == "center": + fragments.append("vertical-align:middle") + elif vertical in ("top", "bottom"): + fragments.append(f"vertical-align:{vertical}") + if getattr(alignment, "wrap_text", None) is True: + fragments.append("white-space:normal") + elif getattr(alignment, "wrap_text", None) is False: + fragments.append("white-space:nowrap") + rotation = getattr(alignment, "text_rotation", None) or 0 + if rotation == 90 or rotation == 255: + fragments.append("writing-mode:vertical-rl") + elif rotation == 180: + fragments.append("writing-mode:vertical-rl;transform:scale(-1)") + else: + # Angles diagonaux (ex. ±45° du menu Format › Rotation) : OOXML + # stocke l'anti-horaire sur 0..90 et l'horaire sur 91..180 (-45° + # → 135, cf. mutations). Le CSS tourne en sens horaire : signe + # inversé pour un rendu fidèle. + excel = rotation if rotation <= 90 else -(180 - rotation) + if excel: + fragments.append(f"transform:rotate({-excel}deg)") + border = getattr(cell, "border", None) + if border is not None: + sides = [] + for side_name in ("left", "right", "top", "bottom"): + side = getattr(border, side_name, None) + style = getattr(side, "style", None) + if not style: + continue + color = getattr(getattr(side, "color", None), "rgb", None) + hexpart = "" + if isinstance(color, str) and len(color) == 8: + hexpart = f" #{color[2:].lower()}" + width = {"medium": "2px", "thick": "3px"}.get(style, "1px") + css_style = {"dashed": "dashed", "dotted": "dotted", "double": "double"}.get( + style, "solid" + ) + sides.append(f"border-{side_name}:{width} {css_style}{hexpart}") + fragments.extend(sides) + return fragments, (align if align in ("left", "right", "center", "justify") 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. +def _sheet_style_maps( + ws: Any, +) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]: + """Flat maps of one worksheet: css, align, comment text, number format. 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. + ``MAX_ROWS x MAX_COLS`` like the render itself. Comment texts are capped + (tooltip use) and formats only list non-General number formats. """ styles: dict[str, str] = {} aligns: dict[str, str] = {} + comments: dict[str, str] = {} + formats: 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": + comment = getattr(cell, "comment", None) + comment_text = getattr(comment, "text", None) if comment is not None else None + # Une cellule vide mais stylée (gras, diagonale, commentaire…) + # doit survivre à la relecture : `has_style` (et le commentaire) + # sont les seuls tests rapides qui la distinguent d'une cellule + # vraiment vierge — sinon le style semble « ne pas fonctionner » + # dès qu'on recharge le classeur. + if cell.value is None and cell.number_format == "General" and not cell.has_style: + if comment_text: + comments[cell.coordinate] = str(comment_text)[:500] 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 + if comment_text: + comments[cell.coordinate] = str(comment_text)[:500] + fmt = getattr(cell, "number_format", None) + if fmt and fmt not in ("General", "@", None): + formats[cell.coordinate] = str(fmt) + return styles, aligns, comments, formats + + +def _sheet_dim_maps(ws: Any) -> tuple[dict[str, float], dict[str, float]]: + """Explicit column widths (units) and row heights (pt), by letter/number. + + Only dimensions the file sets explicitly are listed (defaults are a + rendering concern of the client). Bounded like the render itself. + """ + from openpyxl.utils import column_index_from_string + + colwidths: dict[str, float] = {} + try: + for key, dim in (getattr(ws, "column_dimensions", {}) or {}).items(): + width = getattr(dim, "width", None) + if not isinstance(width, (int, float)): + continue + letters = str(key).split(":") + try: + cols = [column_index_from_string(a.strip()) for a in letters] + except ValueError: + continue + lo, hi = min(cols), max(cols) + for c in range(lo, min(hi, MAX_COLS) + 1): + if c >= 1: + from openpyxl.utils import get_column_letter + + colwidths[get_column_letter(c)] = float(width) + except Exception: + logger.debug("xlsx column widths unavailable", exc_info=True) + rowheights: dict[str, float] = {} + try: + for r, dim in (getattr(ws, "row_dimensions", {}) or {}).items(): + if not isinstance(r, int) or not 1 <= r <= MAX_ROWS: + continue + height = getattr(dim, "height", None) + if isinstance(height, (int, float)): + rowheights[str(r)] = float(height) + except Exception: + logger.debug("xlsx row heights unavailable", exc_info=True) + return colwidths, rowheights def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]: @@ -445,7 +553,8 @@ def invalidate_workbook_meta(file_path: Path | str) -> None: def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]: - """Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet. + """Return ``{sheet: {styles, aligns, comments, formats, colwidths, + rowheights, merges, freeze}}``. One normal (non-streaming) load serves the three A15 metadata maps: the fragments are the workbook's own values, a failure yields ``{}`` per sheet @@ -483,12 +592,17 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} try: for ws in wb.worksheets: - styles, aligns = _sheet_style_maps(ws) + styles, aligns, comments, formats = _sheet_style_maps(ws) + colwidths, rowheights = _sheet_dim_maps(ws) merged = getattr(ws, "merged_cells", None) ranges = getattr(merged, "ranges", None) or [] out[ws.title] = { "styles": styles, "aligns": aligns, + "comments": comments, + "formats": formats, + "colwidths": colwidths, + "rowheights": rowheights, "merges": [str(r) for r in ranges], "freeze": str(getattr(ws, "freeze_panes", None) or ""), } @@ -496,7 +610,14 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]: except Exception: logger.debug("xlsx meta unavailable", exc_info=True) for t in wb.sheetnames: - out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""}) + out.setdefault( + t, + { + "styles": {}, "aligns": {}, "comments": {}, "formats": {}, + "colwidths": {}, "rowheights": {}, + "merges": [], "freeze": "", + }, + ) return out finally: wb.close() @@ -565,6 +686,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]: "truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS, "styles": sheet_meta.get("styles", {}), "aligns": sheet_meta.get("aligns", {}), + "comments": sheet_meta.get("comments", {}), + "formats": sheet_meta.get("formats", {}), + "colwidths": sheet_meta.get("colwidths", {}), + "rowheights": sheet_meta.get("rowheights", {}), "merges": sheet_meta.get("merges", []), "freeze": sheet_meta.get("freeze", ""), } @@ -629,6 +754,10 @@ def read_sheet_window( "html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")), "styles": meta.get("styles", {}), "aligns": meta.get("aligns", {}), + "comments": meta.get("comments", {}), + "formats": meta.get("formats", {}), + "colwidths": meta.get("colwidths", {}), + "rowheights": meta.get("rowheights", {}), "merges": meta.get("merges", []), "freeze": meta.get("freeze", ""), } diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 3fae40a..106e37c 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.53.3" +version = "2.54.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 32e7267..8d8e875 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.53.3" +version = "2.54.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index adf284d..519265b 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.53.3", + "version": "2.54.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docker-compose.test-linux.yml b/docker-compose.test-linux.yml index 1b6cb2d..63e96b8 100644 --- a/docker-compose.test-linux.yml +++ b/docker-compose.test-linux.yml @@ -13,14 +13,14 @@ services: context: . image: obsigate:latest container_name: obsigate-test - user: "1000:1000" + user: "197609:197609" restart: unless-stopped ports: - "2021:8080" volumes: - - /tmp/obsigate-test-vault:/vaults/TestVault - - /tmp/obsigate-test-dir:/vaults/TestDir - - /tmp/obsigate-test-data:/app/data + - ./test_vault:/vaults/TestVault + - ./test_dir:/vaults/TestDir + - obsigate_data:/app/data environment: - VAULT_1_NAME=TestVault - VAULT_1_PATH=/vaults/TestVault @@ -51,3 +51,4 @@ services: volumes: ollama_data: + obsigate_data: diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index f254f50..995b6e8 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -134,24 +134,79 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf" ## 6. Tableurs Excel (XLSX) +### Créer un classeur + +Pour démarrer un tableur, clic droit sur un vault ou un dossier → +**Nouveau fichier** (ou **Nouveau fichier ici**), choisissez le type **Excel +(.xlsx)** et saisissez un nom : le classeur est créé avec une feuille +**Feuille1** et s'ouvre directement dans l'éditeur, prêt à être saisi. La même +liste de types est accessible depuis la palette de commandes (`Ctrl+Alt+Space`). + ### Affichage et édition Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par feuille, des onglets pour naviguer entre elles (toujours visibles, même à une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de -commandes regroupe les actions en sections (Formules · Insertion · Vue · -Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est -modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie. -**Enregistrer** envoie les cellules modifiées à +commandes reprend l'ordre de Google Sheets (recherche, annuler/rétablir, +impression, peinture, zoom, formats de nombre, police, couleurs, bordures, +fusion, alignements, retour ligne, rotation, options) et le bouton **⋮** +regroupe figeage, tri, filtre, dimensions, lien, commentaire ; les menus +**Mise en forme** / **Structure** restent disponibles. Elle est **épinglée en +haut** de la page et l'éditeur occupe toute la largeur du contenu, pour que +les commandes restent atteignables pendant le défilement d'un grand tableau. + +Les largeurs de colonnes et hauteurs de lignes s'ajustent en **glissant le +bord des en-têtes** (souris et tactile) : en plaçant le pointeur sur le bord +d'un en-tête de colonne (`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…), le +curseur change pour indiquer l'ajustement. + +### Sélectionner et saisir + +Comme dans Google Sheets et Excel, une cellule est **sélectionnée** ou **en cours +de saisie** — jamais les deux à la fois. + +- **Sélectionner** — un clic ou les flèches : la cellule s'entoure, sans curseur + clignotant. +- **Saisir** — un **double-clic** (le curseur se place sous le pointeur, la saisie + se fait en place), la **première frappe** (qui *remplace* le contenu au lieu + de s'y ajouter), ou `F2`. +- **Valider et descendre** — `Entrée` pendant la saisie enregistre et place le + focus **sur la cellule du dessous**. `Entrée` sur une cellule simplement + sélectionnée la bascule en saisie. `Tab` fait de même vers la droite. Les + **flèches** valident aussi et déplacent la sélection. +- **Quitter la saisie** — `Échap` restaure la valeur d'origine ; un clic ailleurs + conserve ce qui a été tapé. +- `Alt`+flèche insère un saut de ligne **dans** la cellule. + +**Sélection multiple** — `Maj`+clic ou `Maj`+flèches étendent la plage depuis la +cellule d'origine ; glisser à la souris fait de même. Un **clic simple** réduit +toujours la sélection à la cellule cliquée. La plage est **contourée** comme dans +Excel, et les en-têtes couverts (lettres et numéros de ligne) sont mis en +évidence. Depuis les **marges**, un clic sur un numéro de ligne ou une lettre de +colonne sélectionne la **ligne ou la colonne entière**, et glisser — ou +`Maj`+clic — étend à **plusieurs lignes ou colonnes** (`A1:Z5`). Une sélection +de plusieurs cellules affiche une mini-barre (lien, commentaire, +réinitialiser le filtre, somme). **Enregistrer**, placé à droite des onglets +de feuilles, envoie les cellules modifiées à `PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec **backup automatique** du fichier avant écriture, et une écriture **atomique** (le classeur n'est jamais laissé à moitié écrit). -Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux -pastilles d'état rappellent les limites de la vue : **« Lecture seule »** -pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate -affiche la formule telle qu'elle est enregistrée, Excel la recalcule à -l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran. +Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Une +pastille **« Lecture seule »** rappelle la seule limite de la vue : les +formats `.xls`/`.ods`. ObsiGate calcule par ailleurs les formules courantes +à l'écran (arithmétique, `SOMME`, `MOYENNE`, `SI`…) ; les fonctions non +prises en charge affichent la formule telle qu'enregistrée, Excel la +recalcule à l'ouverture. + +Le tableau suit le **thème de l'application** : fond, en-têtes, bordures et +lignes alternées sont dérivés du thème actif (tous les thèmes, y compris +contrasté élevé et sépia). + +La grille montre toujours les colonnes **A à Z** et **1000 lignes**, comme un +tableur : les lignes et colonnes inutilisées restent affichées et leurs +cellules vides sont **éditables** comme les autres. Seule la feuille affichée +est étendue, à son ouverture. ### Avertissement avant enregistrement @@ -262,18 +317,24 @@ la recherche (`Ctrl+F` du panneau) parcourt **toutes les feuilles** : le compteu indique le nombre de feuilles concernées et passer sur une correspondance **active l'onglet** qui la contient. -La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais -sur les valeurs calculées en cache) : +La sortie est rassemblée dans un seul menu **Fichier**, placé à droite des +onglets de feuilles, juste avant le bouton **Enregistrer**. Il propose cinq +actions, toujours sur le **contenu affiché** (et jamais sur les valeurs +calculées en cache) : -- **CSV** (bouton `CSV`) — exporte la **sélection** quand une plage de plusieurs - cellules est active (le nom du fichier reprend la plage, ex. - `Fruits-A1B2.csv`), sinon la feuille entière ; -- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document - HTML autonome, mêmes règles de sélection ; -- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule, - sans le ruban ni les panneaux de l'application. +- **Télécharger le fichier** — le classeur d'origine, tel qu'il est sur le + disque ; +- **CSV** — exporte la **sélection** quand une plage de plusieurs cellules est + active (le nom du fichier reprend la plage, ex. `Fruits-A1B2.csv`), sinon la + feuille entière ; +- **Markdown** et **HTML** — tableau markdown ou document HTML autonome, + mêmes règles de sélection ; +- **Imprimer** — imprime la feuille ou la sélection seule, sans le ruban ni + les panneaux de l'application. -Rien de tout cela ne modifie le classeur. +Le menu se ferme au clic extérieur, à la perte de focus, avec `Échap` ou au +défilement, et se parcourt aux flèches. 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 @@ -281,12 +342,23 @@ colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup automatique et confirmation, comme pour l'édition des cellules). **Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la -**sélection courante** (une cellule ou une plage) : +**sélection courante** (une cellule ou une plage), repris aussi dans la barre +d'outils (gras, italique, barré, souligné, taille, couleurs, bordures, +alignements, retour ligne, rotation) : -- **caractère** — gras, italique, souligné, effacer la mise en forme ; -- **alignement** — gauche, centré, droite ; -- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune - palette imposée) ; +- **caractère** — gras, italique, souligné, barré, taille de police, + effacer la mise en forme ; +- **alignement** — gauche, centré, droite, justifié (+ vertical, retour à la + ligne, rotation) ; +- **bordures** — toutes, extérieures, aucune ; +- **couleurs** — couleur de police et couleur de fond, via un **panneau façon + Google Sheets** : *Réinitialiser*, une palette de 80 pastilles (une ligne de + gris du noir au blanc, puis huit teintes du clair au foncé), une section + **Standard** de huit couleurs prédéfinies et une section **Personnalisé**. + Un clic sur une pastille referme le panneau et applique la couleur à la + cellule ou à la plage sélectionnée. Le bouton **+** ouvre le sélecteur du + système pour une teinte libre. « Mise en forme conditionnelle » et + « Couleurs en alternance » sont annoncées mais pas encore disponibles ; - **format de nombre** — général, nombre, pourcentage, devise, date, texte ; - **structure** — fusionner / défusionner les cellules, figer / libérer les volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie @@ -300,8 +372,10 @@ message « Réessayer »). Un `.csv` ne portant pas de mise en forme, le bouton n'y est pas proposé (il est également absent d'une grille en lecture seule). La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement. -Les **commentaires**, liens hypertexte, validation de données, mise en forme -conditionnelle et bordures restent hors périmètre. +Les **commentaires** se lisent (pastille) et s'éditent (mini-barre ou menu ⋮) ; +les liens s'insèrent comme formules `=LIEN(url;libellé)` (ouvr. `Ctrl+clic`). +Restent hors périmètre : validation de données, mise en forme conditionnelle, +graphiques et tableaux croisés dynamiques. **Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses **macros sont préservées** à l'enregistrement (y compris le chargement des @@ -354,11 +428,13 @@ une feuille, insérer/supprimer des lignes ou des colonnes). qu'enregistrées (aucune valeur calculée n'est recalculée). Un classeur reste la source de vérité : utilisez **Charger la suite** pour exporter au-delà du plafond. -- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles - qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie - commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE) - tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le - signale par un message. Excel reste la référence pour les valeurs calculées. +- **Moteur de formule local** : ObsiGate calcule à l'écran l'arithmétique, les + références, les plages et une trentaine de fonctions en français et en + anglais (`SOMME`, `MOYENNE`, `SI`, `LIEN`…). Une saisie commençant par `=` + ou `@` reste stockée comme **texte** (garde anti-DDE) tant que le bouton + `f(x)` n'est pas activé ; l'enregistrement vous le signale par un message. + Les fonctions non prises en charge affichent la valeur mise en cache par + Excel quand elle existe. Excel reste la référence pour les valeurs calculées. - L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les actions de structure ; en revanche une **suppression** (feuille, ligne, colonne) n'est pas annulable, faute d'inverse. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9ecd61b..88b834c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.53.3 | **Dernière mise à jour :** 2026-10-07 +> **Version :** 2.54.0 | **Dernière mise à jour :** 2026-10-08 > **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)** @@ -62,7 +62,7 @@ --- -## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2) +## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2) ### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats) @@ -112,7 +112,7 @@ --- -## 🔵 En cours — Refonte UI/UX tableur (P2) +## ✅ Terminé — Refonte UI/UX tableur (P2) ### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur) @@ -139,7 +139,7 @@ --- -## 🔵 En cours — Ergonomie tableur +## ✅ Terminé — Ergonomie tableur ### 155. Ergonomie tableur — menu contextuel & sélection type Excel @@ -157,12 +157,12 @@ --- -## 🔵 En cours — Polish menus tableur +## ✅ Terminé — Polish menus tableur ### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile - **Effort :** 1-2 jours | **Impact :** 🟡 -- **Statut :** 🔵 en cours depuis 2026-10-04 +- **Statut :** ✅ **livré le 2026-10-04** (v2.53.0) — ouvert le 2026-10-04 - **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md) - **Description :** la grille (#155), les menus Structure/Mise en forme/Export (#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent @@ -181,33 +181,236 @@ --- +## ✅ Terminé — Ergonomie & grille du tableur + +### 186. Création d'un fichier Excel depuis « Créer un fichier » + +- **Effort :** 0,5 jour | **Impact :** 🟢 +- **Statut :** ✅ **livré le 2026-10-05** - ouvert le 2026-10-05 +- **Fiche :** [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) +- **Description :** l'éditeur Excel étant disponible, la modale « Créer un fichier » ne + proposait que des formats texte (`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`, + `.ps1`) + Excalidraw : impossible de démarrer un classeur depuis l'UI. Il fallait + passer par l'assistant IA (`create_xlsx`) ou déposer un fichier existant. +- **Sous-tâches :** + - [x] **A1** Option **Excel (.xlsx)** dans la liste des types, et les 10 libellés du + `` du ruban et du menu + Mise en forme est remplacé par un panneau conforme aux maquettes : *Réinitialiser*, + palette 8 × 10, STANDARD, PERSONNALISÉ, pied de page. Un clic sur une pastille + referme le panneau et applique la couleur. +- **Sous-tâches :** + - [x] **A1** Module `frontend/js/xlsx/color-picker.js` : palette 8 × 10 + (8 gris puis 8 teintes × 9 nuances), **sortie en `#rrggbb`** + - [x] **A2** Coche de sélection blanche/noire selon la luminance + - [x] **A3** Câblage ruban + menu Mise en forme, fermeture au clic, application + sur cellule **ou plage**, réinitialisation, « + » / crayons vers le + sélecteur natif + - [x] **A4** Chrome du panneau thémé (variables CSS), i18n FR/EN (21 clés), + pied de page honnête (message explicite pour les fonctions absentes) + +--- + +## ✅ Terminé — Correctifs éditeur tableur + +### 182. Tableur — désélection fiable & couleurs sur une plage + +- **Effort :** 0,5 jour | **Impact :** 🟡 +- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05 +- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§6) +- **Description :** suite de #181 — la sélection multiple se dessinait mais ne + s'effaçait plus, et l'application d'une couleur de texte / de fond sur une + plage paraissait inerte. +- **Sous-tâches :** + - [x] **A1** Le clic simple efface toujours la plage, sans dépendre de + l'événement `focus` (cause : `Maj`+clic + `preventDefault()` figeait le + focus, donc aucun `focus` ne partait au clic suivant) + - [x] **A2** Après `Maj`+clic, le focus suit la cellule cliquée ; la zone Nom + affiche la plage après un glissement + - [x] **A3** Couleurs de texte et de fond vérifiées sur les 3 chemins + (menu Mise en forme, nuanciers du ruban, `Maj`+flèches et glissement) + - [x] **A4** Pastille de couleur sur les nuanciers du ruban (retour visuel) + - [x] **A5** **Contours de plage qui s'empilaient** : le sélecteur de + nettoyage oubliait le `.` des classes (`querySelectorAll("xlsx-sel-top")` + cherche une balise) — rien n'était effacé, les encadrés restaient et + s'ajoutaient à chaque changement de cellule. Même famille de faute sur + les en-têtes reflétés + - [x] **A6** Pendant la saisie, les flèches **valident et déplacent** la + sélection (au lieu de déplacer le curseur), comme Google Sheets ; + `Alt`+flèche garde le saut de ligne + - [x] Tests JSDOM `xlsx-viewer` **152/152** (+6), suites frontend vertes + +--- + +## ✅ Terminé — Polish éditeur tableur (mise en page & thèmes) + +### 181. Éditeur tableur — modèle de saisie Google Sheets (sélection ≠ édition) & écarts fonctionnels + +- **Effort :** 1 jour (A1-A3) + inventaire des écarts (A4) | **Impact :** 🟡 +- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05 +- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) +- **Description :** l'éditeur ne distingue pas **sélectionner** une cellule et + **l'éditer** : le curseur de texte est visible en permanence, `Entrée` valide + puis perd le focus, et l'indicateur de plage est discret. Aligner le modèle de + saisie sur Google Sheets / Excel (A1-A3) et **inventorier** précisément ce qui + manque encore face à un tableur de référence (A4) — le rapprochement exhaustif + n'est pas réalisable en une tranche. +- **Sous-tâches :** + - [x] **A1** Deux états distincts : *sélection* (clic / flèches, pas de + curseur via `caret-color: transparent`, contour visible) et *édition* + (double-clic en place, première frappe qui **remplace**, `F2`, + `Alt`+flèche pour un saut de ligne, `Échap` qui restaure) + - [x] **A2** Sélection multiple : glisser, `Maj`+clic, `Maj`+flèches, avec + contour de plage (`.xlsx-sel-*` sur les 4 bords réels) + en-têtes + `A`/`B`… et `1`/`2`… reflétés + - [x] **A3** `Entrée` contextuelle : en édition → valide et passe **à la + cellule du dessous** ; en sélection → bascule en **mode édition** + - [x] **A4** Inventaire des écarts vs Google Sheets / Excel et priorisation + (P1 poignée de recopie / remplacer / validation de données / filtre + auto / tri multi-critères ; P2 mise en forme conditionnelle / création + de graphiques / TCD éditables / protection ; P3 analyse de données, + historique, macros) — voir [fiche](./features/xlsx-sheet-input-181.md) + - [x] Tests JSDOM (`xlsx-viewer` **146/146**, +10 cas #181), suites frontend + vertes, build + déploiement de l'instance Docker locale + +--- + +### 180. Éditeur tableur — redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée + +- **Effort :** 1-2 jours | **Impact :** 🟡 +- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05 +- **Fiche :** [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md) +- **Description :** retours utilisateur après #179 — (1) le redimensionnement des + colonnes/lignes par les marges (`A, B, C…` / `1, 2, 3…`) doit afficher un + curseur iconique (`col-resize` / `row-resize`) quand le pointeur est sur le + bord ; (2) la grille doit suivre le thème de l'utilisateur (15 thèmes × + 4 modes + importés : tokens dérivés, plus de fond figé) ; (3) regrouper + Télécharger / CSV / Exporter dans un **menu Fichier** en haut à droite, au + niveau des onglets de feuilles, avec **Enregistrer** juste à côté ; + (4) retirer l'indicateur « Formules calculées localement » ; (5) épingler la + barre de boutons en haut, au bord de la marge, et étendre l'éditeur à la + largeur de la page. +- **Sous-tâches :** + - [x] **A1** Curseur de redimensionnement sur les marges d'en-têtes + (en-tête de bord miroité en ligne + classe de repaint `.xlsx-resize-col/row`) + - [x] **A2** Grille compatible tous thèmes (`--grid-*` dérivés dans + `themes.js`, replis CSS, modes contraste élevé / sépia, menu Export sur `--surface`) + - [x] **A3** Menu Fichier + Enregistrer au niveau des onglets (`.xlsx-sheetbar`, + identifiants des contrôles conservés) + - [x] **A4** Retrait de l'indicateur de formules + barre épinglée pleine largeur + (`position: sticky`, `:has(.xlsx-viewer)` en plein cadre) + - [x] Tests JSDOM (`xlsx-viewer` 136/136, `xlsx-menus` 12/12), E2E `xlsx-viewer.spec.js`, + i18n FR/EN, 8 tests réalignés sur l'application locale des écritures + +--- + ## ✅ Terminé — Navigation ### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet @@ -475,6 +678,14 @@ | 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) | | 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) | | 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) | +| 179 | Tableur — icônes des menus & boutons, fermeture au focus, polish mobile | 2.53.0 | [features/xlsx-menus-179.md](./features/xlsx-menus-179.md) | +| 180 | Tableur — redimensionnement au curseur, grille thémée, menu Fichier, barre épinglée | 2.54.0 | [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md) | +| 181 | Tableur — modèle de saisie Google Sheets (sélection ≠ édition) & inventaire des écarts | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) | +| 182 | Tableur — désélection fiable, contours non empilés, couleurs sur une plage | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) | +| 183 | Tableur — panneau de couleurs façon Google Sheets (texte et fond) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) | +| 184 | Tableur — peinture de format complète, multi-lignes/colonnes, grille A → Z × 1000 (croissance par blocs) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) | +| 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.md) | +| 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) | | 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) | | 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) | | BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) | diff --git a/docs/features/create-file-xlsx-186.md b/docs/features/create-file-xlsx-186.md new file mode 100644 index 0000000..2deb24e --- /dev/null +++ b/docs/features/create-file-xlsx-186.md @@ -0,0 +1,96 @@ +# #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](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) · +> [Guide Recherche, PDF, Excel & Excalidraw](../GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) · +> [#153 — Visionneuse XLSX](./xlsx-viewer.md) + +## 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 +`