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.
This commit is contained in:
+297
-1
@@ -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** (`<xlsx-sel-top>`), 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é
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -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*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -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*
|
||||
|
||||
@@ -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}'")
|
||||
|
||||
+216
-11
@@ -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():
|
||||
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,10 +704,41 @@ def mutate_xlsx_structure(
|
||||
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||
)
|
||||
if op == "row_insert":
|
||||
# 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":
|
||||
# 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)
|
||||
@@ -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
|
||||
|
||||
+139
-10
@@ -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", ""),
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.53.3"
|
||||
version = "2.54.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+227
-16
@@ -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
|
||||
`<select>` passés en i18n FR/EN (`modal.ext_*`) — ils étaient codés en dur
|
||||
- [x] **A2** `create_file()` backend : un `.xlsx` est une archive OPC, pas du texte —
|
||||
le classeur est **construit avec openpyxl** (une feuille `Feuille1`) au lieu
|
||||
d'écrire la charge utile vide, sinon le fichier est illisible par la visionneuse
|
||||
- [x] **A3** Tests : non-régression pytest (`test_api_main.py` — classeur relu par
|
||||
openpyxl et servi en `is_xlsx`), E2E `xlsx-viewer.spec.js` (option présente,
|
||||
création depuis le menu contextuel, ouverture dans la grille)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Assistant IA
|
||||
|
||||
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
- **Statut :** ✅ **livré le 2026-10-06** — ouvert le 2026-10-06
|
||||
- **Statut :** ✅ **livré le 2026-10-07** (v2.53.2) — ouvert le 2026-10-06
|
||||
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
|
||||
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
|
||||
quick actions `agent: true` basculaient déjà le mode en silence. Retiré au
|
||||
profit d'un agent toujours actif : toute requête texte part sur
|
||||
`/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les mutations
|
||||
gardent la confirmation two-step) ; les images seules restent sur
|
||||
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant textuelle).
|
||||
quick actions `agent: true` basculaient déjà le mode en silence. Un
|
||||
utilisateur pouvait donc voir l'assistant agir sans jamais avoir activé le
|
||||
bouton. Retiré au profit d'un agent toujours actif : toute requête texte
|
||||
part sur `/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les
|
||||
mutations gardent la confirmation two-step) ; les images seules restent sur
|
||||
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant
|
||||
textuelle).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
|
||||
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
|
||||
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
|
||||
- [x] **A2** Optimisations requêtes AI : `/agent` résout le provider comme
|
||||
`/chat` (avant : `req.provider` brut transmis au provider adapter →
|
||||
moteur réel ≠ étiquette SSE) ; schémas d'outils mis en cache (~27 ms de
|
||||
pydantic économisés par requête agent/MCP)
|
||||
- [x] **A2** Optimisations requêtes AI : `/agent` résout désormais le
|
||||
provider comme `/chat` (avant : `req.provider` brut transmis au
|
||||
provider adapter → moteur réel ≠ étiquette SSE) ; schémas d'outils
|
||||
mis en cache (~27 ms de pydantic économisés par requête agent/MCP)
|
||||
- [x] **A3** Tests : `ai.test.mjs` adapté (100/100) ; non-régression pytest
|
||||
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
|
||||
ruff/mypy 0 erreur ; build + instance Docker locale à jour
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Barre de menus & grille du tableur
|
||||
|
||||
### 185. Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-menus-185.md](./features/xlsx-menus-185.md)
|
||||
- **Description :** l'éditeur n'avait **aucune barre de menus** (ni Fichier, ni
|
||||
Modifier, ni Affichage, ni Insertion, ni Format, ni Données, ni Outils, ni
|
||||
Gemini, ni Extensions, ni Aide) : beaucoup de fonctions n'étaient atteignables
|
||||
que par clic droit. Le ruban gardait un chromer bordersé qui ne ressemblait à
|
||||
rien et la grille n'était bordée qu'en bas avec une couleur quasi identique au
|
||||
fond des cellules — le quadrillage se lisait comme une tache.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** `frontend/js/xlsx/menus.js` : les **dix menus de Google Sheets**,
|
||||
dans l'ordre et avec les libellés exacts (161 entrées) ; sections en
|
||||
accordéon ; le menu **Fichier** quitte la barre d'onglets pour la barre de
|
||||
menus en **conservant ses identifiants**, seul *Enregistrer* reste
|
||||
épinglé
|
||||
- [x] **A2** **Majorité des fonctions câblées** : Partager, Renommer,
|
||||
Corbeille, Historique, Détails ; Annuler/Rétablir/Couper/Copier/Coller,
|
||||
**Collage spécial (valeurs)**, Supprimer, Tout sélectionner,
|
||||
Rechercher-et-remplacer ; Figer, Afficher (barre de formule / grille /
|
||||
en-têtes / tableau de bord), Zoom, Plein écran ; Lignes / Colonnes /
|
||||
Cellules, Lien, Commentaire, Fonction ; Nombre, Thème, B/I/U/S, Taille,
|
||||
Alignement, Retour ligne, Rotation, **Couleurs alternées**, Effacer ;
|
||||
Trier, **Filtre**, Plages nommées, **Nettoyage (doublons / espaces)**,
|
||||
**Diviser le texte en colonnes** ; Autocomplétion, Accessibilité ;
|
||||
Gemini ; Aide, Mises à jour, Raccourcis, Signalement. Les 36 options
|
||||
absentes d'ObsiGate **sont listées et le disent** au clic
|
||||
- [x] **A3** **Ruban façon Sheets** : boutons plats 30 × 30 sans chromer,
|
||||
survol discret, groupes par filets ; nouveau bouton grille piloté par le
|
||||
menu Affichage
|
||||
- [x] **A4** **Grille unie et visible** : 1 px **sur les quatre côtés** de
|
||||
chaque cellule (en-têtes compris) et un seul `--grid-border` dérivé du
|
||||
thème (`color-mix` texte → surface) au lieu de `--border` invisible
|
||||
- [x] Tests JSDOM (`xlsx-menus` + `xlsx-viewer`), E2E `xlsx-viewer.spec.js`,
|
||||
i18n FR/EN (134 clés `xlsx.m*` / `xlsx.mi_*`)
|
||||
|
||||
---
|
||||
|
||||
### 184. Tableur — peinture de format, multi-lignes/colonnes, grille A→Z × 1000
|
||||
|
||||
- **Effort :** 1 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) (§8)
|
||||
- **Description :** « Peindre le format » échouait quand la source n'avait pas de
|
||||
format propre ; il manquait la sélection de plusieurs lignes/colonnes depuis
|
||||
les marges ; la grille s'arrêtait aux dimensions réelles du fichier au lieu
|
||||
d'aller jusqu'à A → Z et 1000 lignes.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Peinture de format **complète** (toutes propriétés) + source sans
|
||||
format ⇒ **réinitialisation** de la cible ; fuite du handler annulé
|
||||
- [x] **A2** Sélection multi-lignes / multi-colonnes par glissement sur les
|
||||
marges et `Maj`+clic ; le geste de redimensionnement garde la priorité
|
||||
- [x] **A3** Grille étendue à **A → Z × 1000 lignes**, cellules vides
|
||||
éditables, padding à l'activation de l'onglet, pas de doublons
|
||||
- [x] **A4** Performance : index de cellules `findTd`, court-circuits
|
||||
formule/styles, marqueur `data-wired`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Panneau de couleurs du tableur
|
||||
|
||||
### 183. Panneau de sélection de couleurs façon Google Sheets (texte et fond)
|
||||
|
||||
- **Effort :** 0,5-1 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) (§7)
|
||||
- **Description :** le sélecteur natif `<input type="color">` 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) |
|
||||
|
||||
@@ -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
|
||||
`<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()` |
|
||||
@@ -0,0 +1,119 @@
|
||||
# #185 — Tableur : barre de menus Google Sheets, boutons façon Sheets, grille unie
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#179](./xlsx-menus-179.md) ·
|
||||
> [#180](./xlsx-sheetbar-180.md) · [#183](./xlsx-sheet-input-181.md))
|
||||
|
||||
## 1. Constat
|
||||
|
||||
L'éditeur tableur avait un ruban de boutons et trois menus (Structure, Mise en
|
||||
forme, Export) mais **aucune barre de menus** : ni Fichier, ni Modifier, ni
|
||||
Affichage, ni Insertion, ni Format, ni Données, ni Outils, ni Gemini, ni
|
||||
Extensions, ni Aide. Beaucoup de fonctions existentes n'étaient atteignables que
|
||||
par un clic droit ou par un bouton à connaître. Le ruban lui-même gardait un
|
||||
chromer bordersé qui ne ressemblait à rien, et la grille n'était bordée qu'en
|
||||
bas avec une couleur (`--border`) quasi identique au fond des cellules : le
|
||||
quadillage se lisait comme une tache, pas comme une grille.
|
||||
|
||||
## 2. Livré
|
||||
|
||||
### A1 — Barre de menus (les 10 menus de Google Sheets)
|
||||
|
||||
`frontend/js/xlsx/menus.js` (nouveau) décrit **les dix menus, dans l'ordre et avec
|
||||
les libellés exacts de Google Sheets** (Fichier · Modifier · Affichage ·
|
||||
Insertion · Format · Données · Outils · Gemini · Extensions · Aide), puis
|
||||
`buildMenuBar()` produit la barre et ses dix menus déroulants. 161 entrées, dont
|
||||
**125 wirables** (67 actions distinctes) et **36 honestement déclarées
|
||||
indisponibles** (elles s'affichent mais répondent « *« X » n'est pas disponible
|
||||
dans ObsiGate* »** — jamais un clic muet). Les sections imbriquées (Nombre,
|
||||
Thème, Alignement, Zoom, Nettoyage…) sont des **accordéons** comme dans Sheets :
|
||||
une seule ouverte à la fois par niveau.
|
||||
|
||||
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`, `[data-xlsx-export]`). Seul
|
||||
**Enregistrer** reste épinglé à droite de la barre d'onglets.
|
||||
|
||||
### A2 — Fonctions réellement câblées
|
||||
|
||||
- **Fichier** : Partager, Télécharger (original · CSV · Markdown · HTML),
|
||||
Imprimer, **Renommer** (`PATCH /api/file/{vault}`), **Corbeille**
|
||||
(`DELETE /api/file/{vault}`), **Historique des versions** (visionneuse de
|
||||
sauvegardes), Détails (vault, chemin, feuilles, cellules, révision, ·
|
||||
modifications en attente via `/api/health` pour le numéro de version).
|
||||
- **Modifier** : Annuler, Rétablir, Couper, Copier, Coller, **Collage spécial →
|
||||
valeurs uniquement** (les formules sont évaluées puis collées comme texte),
|
||||
**Supprimer → lignes / colonnes / contenu**, Tout sélectionner,
|
||||
**Rechercher et remplacer** (toutes les feuilles).
|
||||
- **Affichage** : Figer (ici / ligne / colonne / libérer), **Afficher → barre de
|
||||
formule · grille · en-têtes · tableau de bord**, Grouper (déclaré), Feuilles
|
||||
masquées (déclaré), **Zoom** (50 → 200 %, même état que le ruban), **Plein
|
||||
écran**.
|
||||
- **Insertion** : Lignes (au-dessus / en dessous), Colonnes (à gauche / à
|
||||
droite), Cellules, Lien, Commentaire, **Fonction** (insère l'appel et place le
|
||||
curseur entre les parenthèses).
|
||||
- **Format** : Nombre (6 formats), **Thème** (liste des 15 thèmes construite à la
|
||||
demande), Gras / Italique / Souligné / Barré, Taille de police, Alignement
|
||||
(horizontal + vertical), Retour à la ligne, Rotation, **Couleurs alternées**
|
||||
(une écriture par ligne, lots de 50 — plafond du backend), Effacer la mise en
|
||||
forme.
|
||||
- **Données** : Trier la plage (croissant / décroissant / réinitialiser),
|
||||
**Créer un filtre** (sur la valeur de la cellule active), **Plages nommées**
|
||||
(panneau du tableau de bord), **Nettoyage → supprimer les doublons ·trimmer**,
|
||||
**Diviser le texte en colonnes**.
|
||||
- **Outils** : **Saisie semi-automatique** (bascule la liste de fonctions de la
|
||||
barre de formule), **Accessibilité** (cellules plus grandes, grille plus
|
||||
contrastée).
|
||||
- **Gemini** : les six entrées ouvrent le panneau de l'assistant IA.
|
||||
- **Aide** : aide intégrée, **Mises à jour** (version), **Raccourcis clavier**
|
||||
(liste), **Signaler un problème** (diagnostic copié dans le presse-papiers).
|
||||
|
||||
### A3 — Ruban façon Google Sheets
|
||||
|
||||
Boutons **plats** : 30 × 30, sans bordure ni fond au repos, survol discret, état
|
||||
actif teinté, groupes séparés par des filets verticaux. Les sélecteurs (zoom,
|
||||
format de nombre, taille de police) suivent le même traitement. **Enregistrer**
|
||||
reste le seul pilier plein. Nouveau bouton **grille** (`#xs-grid`), piloté par le
|
||||
menu Affichage et inversement.
|
||||
|
||||
### A4 — Grille unie et visible
|
||||
|
||||
- Chaque cellule est encadrée sur **les quatre côtés** par **la même** ligne
|
||||
1 px (les en-têtes compris : ils se distinguent par leur fond, plus par une
|
||||
bordure plus lourde).
|
||||
- `--grid-border` n'est plus `--border` (1-2 % d'écart avec le fond sur la plupart
|
||||
des thèmes) mais un `color-mix` **du texte vers la surface** calculé par
|
||||
`themes.js` : une seule valeur pour toute la grille, lisible en sombre comme
|
||||
en clair et dans les modes générés (contraste élevé, sépia).
|
||||
- Les valeurs de repli des 4 blocs `:root` ont été remontées en contraste.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-menus.test.mjs` : i18n amorcé, barre de menus (10 libellés,
|
||||
un déroulant par bouton, accordéon), ** exhaustivité des actions déclarées**,
|
||||
disparition des entrées d'édition en lecture seule, absence de HTML dans les
|
||||
libellés, ids historiques du menu Fichier ; ruban (save, « + », grille).
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` : barre présente au-dessus du ruban,
|
||||
entrée indisponible qui répond (et n'envoie rien), bascules Affichage, Zoom,
|
||||
écriture de structure (Insertion), écriture de style (Format ▸ Nombre),
|
||||
**Couleurs alternées** (3 lignes → 3 ops), Accessibilité, Raccourcis clavier,
|
||||
classeur en lecture seule, jeton `--grid-border`.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` : les 10 menus dans le bon ordre et **au-dessus**
|
||||
du ruban, accordéon Format ▸ Nombre, option indisponible, **grille 1 px sur
|
||||
les 4 côtés** puis masquée à 0 px.
|
||||
|
||||
## 4. Limites assumées
|
||||
|
||||
- Les 36 options Google Sheets qu'ObsiGate n'implémente pas (macros, Apps
|
||||
Script, TCD, graphiques, formulaires,/modules, AppSheet, Looker Studio,
|
||||
training…) sont **listées pour rester fidèle** à la demande, et **annoncées**
|
||||
comme indisponibles au clic. Elles ne sont pas masquées : la parité visuelle
|
||||
prime, l'honnêteté prime sur le silence.
|
||||
- **Déplacer** lignes/colonnes est déclaré indisponible : leivinguer
|
||||
(supprimer + insérer + réécrire) sur un classeur avec fusions, styles et
|
||||
formules n'est pas sûr en un seul geste.
|
||||
- Le menu **Thème** est construit à la demande (import dynamique de
|
||||
`themes.js`) : 15 entrées, pas de coût au rendu.
|
||||
- Les couleurs de la grille sont dérivées via `color-mix` : un moteur qui ne le
|
||||
supporte pas retombe sur `--grid-border` (feuille de style).
|
||||
@@ -0,0 +1,356 @@
|
||||
# #181 — Éditeur tableur : modèle de saisie Google Sheets & inventaire des écarts
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) ·
|
||||
> [#179](./xlsx-menus-179.md) · [#180](./xlsx-sheetbar-180.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
L'éditeur ne distinguait pas **sélectionner** une cellule et **l'éditer** :
|
||||
|
||||
- le curseur de texte clignotait en permanence dans la cellule focalisée
|
||||
(tout est `contenteditable`, `caret-color` par défaut) ;
|
||||
- `Entrée` validait puis faisait `blur()` — le focus **quittait la grille** ;
|
||||
- l'indicateur de plage était un simple lavis, sans contour, et les en-têtes
|
||||
de la plage sélectionnée n'étaient pas reflétés ;
|
||||
- la première frappe **ajoutait** au contenu existant au lieu de le remplacer.
|
||||
|
||||
Un utilisateur habitué à Google Sheets ou Excel perd le repère : il ne sait
|
||||
plus s'il est en train de naviguer ou de saisir.
|
||||
|
||||
## 2. Livré (A1-A3)
|
||||
|
||||
### A1 — Deux états distincts : sélection et édition
|
||||
|
||||
Toutes les cellules portent la classe `xlsx-cell` et démarrent en
|
||||
**sélection** (`data-editing=""`) : contour visible, **aucun curseur**. Le
|
||||
curseur est masqué via `caret-color: transparent` — donc sans laisser
|
||||
`contenteditable`, ce qui préserve le collage, le clic et la frappe native.
|
||||
|
||||
L'**édition** s'atteint par trois voies, comme dans Google Sheets :
|
||||
|
||||
| Geste | Effet |
|
||||
|---|---|
|
||||
| Clic / flèches | sélectionne (pas de curseur) |
|
||||
| **Double-clic** | édite **sur place** — `caretRangeFromPoint` place le curseur sous le pointeur |
|
||||
| **Première frappe** | édite et **remplace** le contenu (jamais d'ajout) |
|
||||
| `F2` | édite, curseur en fin de texte |
|
||||
| `Alt`+flèche | saut de ligne dans la cellule |
|
||||
| `Échap` | quitte l'édition et **restaure** la valeur |
|
||||
|
||||
`enterEditing()` / `exitEditing()` centralisent la bascule. Un garde
|
||||
`editingRequested` empêche le handler `focus` de la cellule d'annuler l'état
|
||||
d'édition que `enterEditing()` vient d'installer (il se déclenche au moment du
|
||||
`focus`).
|
||||
|
||||
### A2 — Sélection multiple et indicateur visuel
|
||||
|
||||
- `Maj`+flèches étend depuis l'ancre, glisser avec la souris aussi, plus
|
||||
`Maj`+clic — le mécanisme existait déjà, il est désormais complet et lisible.
|
||||
- Les cellules portent `.xlsx-selected` **et** quatre classes de bord
|
||||
(`.xlsx-sel-top/bottom/left/right`) : le bloc est **contouré** exactement
|
||||
comme dans Excel/Sheets, au lieu d'un lavis uniforme. Chaque cellule ne
|
||||
reçoit que les bords qu'elle porte réellement (les coins n'héritent pas d'un
|
||||
bord intérieur).
|
||||
- Les en-têtes couverts (lettres `A`, `B`… et numéros `1`, `2`…) reçoivent
|
||||
`.xlsx-hdr-selected` : la plage se lit aussi dans les marges.
|
||||
|
||||
### A3 — `Entrée` contextuelle
|
||||
|
||||
| État | `Entrée` |
|
||||
|---|---|
|
||||
| **édition** | valide, puis le focus passe **à la cellule du dessous** |
|
||||
| **sélection** | bascule la cellule courante en **mode édition** |
|
||||
|
||||
`Tab` suit la même logique (validation + déplacement latéral en édition,
|
||||
déplacement simple en sélection), et un clic simple après une édition abandonne
|
||||
l'état d'édition sans laisser de curseur.
|
||||
|
||||
## 3. A4 — Inventaire des écarts vs Google Sheets / Excel
|
||||
|
||||
**Rapprochement exhaustif impossible en une tranche.** L'éditeur couvre déjà
|
||||
l'essentiel ; l'inventaire ci-dessous est l'état réel vérifié dans le code au
|
||||
2026-10-05.
|
||||
|
||||
### 3.1 Déjà couvert
|
||||
|
||||
Édition de cellules · types et formats · recherche **et** remplacement ·
|
||||
trier / filtrer · gel des volets · fusions · annuler / rétablir · presse-papiers
|
||||
de plage · insertion / suppression de lignes, colonnes et feuilles ·
|
||||
graphiques KPI du tableau de bord · commentaires · liens hypertexte (formule
|
||||
`=LIEN`) · export (CSV, Markdown, HTML, impression) · impression ·
|
||||
collaboration temps réel · import de formats · lecture seule `.xls` / `.ods`.
|
||||
|
||||
### 3.2 Manquants, par priorité
|
||||
|
||||
**P1 — usage quotidien**
|
||||
|
||||
- **Poignée de recopie** (fill handle) : le petit carré en bas à droite de la
|
||||
plage, glisser pour remplir séries et formules. Très attendu, absent.
|
||||
- **Remplacer** (`Ctrl+H`) : seule la *recherche* existe.
|
||||
- **Validation de données** : listes déroulantes, bornes numériques.
|
||||
- **Filtre automatique** : l filtrage texte existe, pas les entêtes de filtre
|
||||
par colonne.
|
||||
- **Tri multi-critères** et options de tri (couleur, taille, valeurs
|
||||
personnalisées).
|
||||
- **Déplacer lignes / colonnes** par glisser-déposer (l'insertion existe).
|
||||
|
||||
**P2 — présentation et analyse**
|
||||
|
||||
- Mise en forme conditionnelle (couleurs, barres de données, jeu d'icônes).
|
||||
- **Graphiques** dans le tableur : KPI et relevé de graphiques existants sont
|
||||
lus, mais on ne **crée pas** de graphique.
|
||||
- Tableaux croisés : lus et affichés, non modifiables.
|
||||
- Groupement / dégroupement de lignes et colonnes.
|
||||
- Images et formes dans les cellules (ObsiGate a Excalidraw séparé).
|
||||
- Protection d'une feuille ou de cellules (lecture seule).
|
||||
- Masquer des lignes / colonnes.
|
||||
|
||||
**P3 — avancé**
|
||||
|
||||
- Analyse de données : supprimer les doublons, convertir du texte en colonnes,
|
||||
supprimer les espaces superflus.
|
||||
- Fusion des cellules à l'identique, annotations, suivi des modifications,
|
||||
historique des révisions.
|
||||
- Macros (les `.xlsm` sont lus et réécrits avec `keep_vba`).
|
||||
- Simulation de scénarios, recherche de cible (goal seek).
|
||||
- Options de tri avancées (insensible à la casse, séparateur décimal
|
||||
personnalisé).
|
||||
- Peintures de cellules, texte en art.
|
||||
|
||||
## 4. Tests
|
||||
|
||||
`tests/frontend/xlsx-viewer.test.mjs` — **146/146** (10 nouveaux cas #181) :
|
||||
sélection sans curseur au clic et aux flèches · double-clic qui édite en place ·
|
||||
première frappe qui **remplace** · deuxième frappe qui n'efface pas ·
|
||||
`Entrée` qui passe en édition · `Entrée` qui valide et descend sous la cellule ·
|
||||
extension `Maj`+flèches avec les quatre bords du contour et les en-têtes
|
||||
reflétés · glisser qui peint le même contour · clic simple qui fait tomber
|
||||
l'état d'édition · `Échap` qui restaure la valeur.
|
||||
|
||||
Suites frontend également vertes : `xlsx-menus` 12/12, `unit` 13, `editor-inline`
|
||||
44, `toolbar-order`, `mobile-editor` 35, `ai` 100, `pane-manager` 9,
|
||||
`validate-imports` 41 modules / 355 exports.
|
||||
|
||||
## 5. Point d'attention
|
||||
|
||||
Le `caret-color: transparent` repose sur la prise en charge navigateur
|
||||
(`caret-color` est largement disponible). Sur un très ancien moteur, le curseur
|
||||
resterait visible en sélection — l'état reste correct, seul l'indicateur
|
||||
disparaît.
|
||||
|
||||
## 6. #182 — Désélection & couleurs sur une plage
|
||||
|
||||
### 6.1 La sélection « collée »
|
||||
|
||||
**Symptôme** : après un `Maj`+clic ou un glissement, la plage restait affichée et
|
||||
un clic simple ne la supprimait pas.
|
||||
|
||||
**Cause racine** — la désélection reposait entièrement sur l'événement `focus` de
|
||||
la cellule, qui appelle `setActiveCell()` et donc réduit la plage à la cellule
|
||||
focalisée. Or le handler `Maj`+clic appelle `e.preventDefault()`, ce qui
|
||||
**supprime le déplacement de focus natif du navigateur**. Résultat : après un
|
||||
`Maj`+clic, `activeTd` valait la cellule cliquée mais `document.activeElement`
|
||||
restait sur l'ancienne. Le clic suivant **sur la cellule déjà focalisée** ne
|
||||
déclenchait donc aucun `focus` : la plage ne pouvait plus s'effacer.
|
||||
|
||||
**Correctif**
|
||||
|
||||
- le clic simple appelle `setActiveCell(td)` dans `mousedown` : la plage est
|
||||
effacée de façon déterministe, sans dépendre du focus ;
|
||||
- le `Maj`+clic déplace réellement le focus, sous `suppressSelectionReset` pour
|
||||
que le handler `focus` ne rétrécisse pas la plage qui vient d'être étendue ;
|
||||
- le glissement ne vole **pas** le focus (comme dans un navigateur) — c'est
|
||||
précisément ce vol qui aurait fait disparaître la plage tracée ;
|
||||
- `endDrag` restaure la zone Nom sur la **plage** (`A1:B2`) au lieu de la
|
||||
cellule d'origine.
|
||||
|
||||
Le même correctif a guéri le symptôme connexe « la mini-barre de sélection ne
|
||||
se cachait plus après un glissement ».
|
||||
|
||||
### 6.2 Couleurs de texte et de fond
|
||||
|
||||
Les trois chemins ont été **vérifiés** (charge utile `PUT …/xlsx/style` puis
|
||||
peinture locale) sur une plage `A1:B2` obtenue par `Maj`+flèches **et** par
|
||||
glissement :
|
||||
|
||||
| Chemin | Charge utile | Résultat |
|
||||
|---|---|---|
|
||||
| Menu **Mise en forme** → couleur | `range: "A1:B2"` | 4 cellules peintes |
|
||||
| Nuancier du ruban (police) | `range: "A1:B2"` | 4 cellules peintes |
|
||||
| Nuancier du ruban (fond) | `range: "A1:B2"` | 4 cellules peintes |
|
||||
|
||||
Le mécanisme d'écriture était donc correct ; ce qui manquait réellement, c'était
|
||||
le **retour visuel** : les deux nuanciers du ruban n'affichaient rien, d'où
|
||||
l'impression que la couleur n'était pas appliquée. Ils portent désormais une
|
||||
**pastille** (`.xlsx-color-swatch`) qui prend la couleur choisie.
|
||||
|
||||
### 6.3 Tests
|
||||
|
||||
`tests/frontend/xlsx-viewer.test.mjs` — **149/149** (+3 cas #182) :
|
||||
|
||||
- un clic simple réduit toujours une plage « collée » (y compris sur la cellule
|
||||
qui porte déjà le focus) ;
|
||||
- la zone Nom affiche la plage après un glissement ;
|
||||
- couleurs de texte et de fond appliquées aux 4 cellules d'une plage, charge
|
||||
utile vérifiée, pastilles synchronisées.
|
||||
|
||||
> Note d'implémentation : la peinture locale est **asynchrone** (elle suit
|
||||
> l'écriture `PUT`), un test qui lit les styles inline doit donc attendre le
|
||||
> tour de boucle d'événements.
|
||||
|
||||
### 6.4 Les contours qui s'empilaient (le point manquant)
|
||||
|
||||
**Symptôme** : en déplaçant le focus de cellule en cellule, les encadrés de
|
||||
l'ancienne cellule **restaient** et s'accumulaient — la grille finit quadrillée.
|
||||
|
||||
**Cause racine** — le sélecteur de nettoyage était écrit **sans le point** :
|
||||
|
||||
```js
|
||||
// ✘ avant : en CSS, un nom nu est un sélecteur de BALISE (<xlsx-sel-top>)
|
||||
panel.querySelectorAll("xlsx-sel-top,xlsx-sel-bottom,...")
|
||||
// ✔ après
|
||||
panel.querySelectorAll(".xlsx-sel-top,.xlsx-sel-bottom,...")
|
||||
```
|
||||
|
||||
La requête ne renvoyait **aucun** élément, donc `classList.remove(...)` n'était
|
||||
jamais appelé et aucun contour n'était effacé. La même famille de faute
|
||||
touchait `classList.remove(".xlsx-hdr-selected")` (un point passé comme jeton
|
||||
de classe ne correspond à rien) : les en-têtes reflétés n'étaient jamais effacés
|
||||
non plus.
|
||||
|
||||
Le test de non-régression compte les contours dans toute la grille après une
|
||||
randonnée au clavier et exige qu'**une seule** cellule soit encadrée.
|
||||
|
||||
### 6.5 Flèches pendant la saisie
|
||||
|
||||
Dans Google Sheets comme dans Excel, une flèche pendant la saisie **valide la
|
||||
cellule et déplace la sélection** ; le curseur n'est pas parcouru dans le texte
|
||||
(`Alt`+flèche sert à insérer un saut de ligne). Notre implémentation laissait
|
||||
le curseur se déplacer et la cellule restait figée sous l'encadré — ce qui
|
||||
amplifiait la confusion. Corrigé.
|
||||
|
||||
## 7. #183 — Panneau de couleurs façon Google Sheets
|
||||
|
||||
### 7.1 Ce qui remplace quoi
|
||||
|
||||
Le `<input type="color">` natif (ruban **A** / seau, et les deux lignes du menu
|
||||
**Mise en forme**) est remplacé par un bouton ouvrant un panneau construit par
|
||||
`frontend/js/xlsx/color-picker.js` :
|
||||
|
||||
| Section | Contenu |
|
||||
|---|---|
|
||||
| En-tête | icône + **Réinitialiser** (gomme pour le fond, pinceau pour le texte) |
|
||||
| Palette | **8 × 10 = 80** pastilles — ligne 1 : 8 gris du noir au blanc ; lignes 2-10 : 8 teintes (rouge, orange, jaune, vert clair, cyan, bleu, violet, magenta) × 9 nuances, du clair au foncé |
|
||||
| **STANDARD** | 8 couleurs prédéfinies + crayon |
|
||||
| **PERSONNALISÉ** | pastille de la couleur personnalisée, bouton « + », crayon |
|
||||
| Pied | **Mise en forme conditionnelle**, et **Couleurs en alternance** (n'apparaît qu'une fois une couleur choisie, comme sur la maquette) |
|
||||
|
||||
Un clic sur une pastille **referme le panneau** et applique la couleur à la
|
||||
cellule ou à la plage sélectionnée. La coche de sélection est blanche ou noire
|
||||
selon la luminance de la pastille (`luminanceOf`).
|
||||
|
||||
### 7.2 Le piège d'encodage
|
||||
|
||||
La palette est calculée en HSL (lisible) mais **doit sortir en `#rrggbb`** :
|
||||
`normHex()` côté viewer n'accepte que l'hexadécimal à 6 chiffres, et une
|
||||
valeur `hsl(0, 0%, 1429%)` était donc rejetée **sans erreur** — la pastille se
|
||||
peignait à l'écran mais la couleur n'était jamais écrite sur la cellule.
|
||||
`hslToHex()` est doncexporté et testé par le contrôle de fumée.
|
||||
|
||||
### 7.3 Choix assumés
|
||||
|
||||
- **« + » et les crayons ouvrent le sélecteur natif** : le panneau propose 88
|
||||
couleurs curées, le natif reste le seul moyen d'atteindre une teinte
|
||||
arbitraire.
|
||||
- **Les deux entrées de pied de page sont annoncées, pas fonctionnelles** :
|
||||
« Mise en forme conditionnelle » et « Couleurs en alternance » ne sont pas
|
||||
livrées (cf. §3.2). Elles appellent `onUnavailable` et affichent un message
|
||||
explicite plutôt que d'être des boutons inertes.
|
||||
- **Chrome thémé** : uniquement des variables CSS, donc le panneau suit le
|
||||
thème actif ; seules les pastilles portent une couleur en ligne, ce sont des
|
||||
données.
|
||||
|
||||
## 8. #184 — Peinture de format, multi-lignes/colonnes, grille A → Z × 1000
|
||||
|
||||
### 8.1 Peindre le format
|
||||
|
||||
**Symptôme** : l'outil refusait toute cellule source sans mise en forme
|
||||
explicite (« *Veuillez sélectionner une cellule formatée* »). Peindre une
|
||||
cellule simple sur une cellule colorée et en grand corps ne produisait donc
|
||||
**rien** — c'est le cas le plus courant en pratique.
|
||||
|
||||
**Deux défauts distincts**
|
||||
|
||||
1. **Copie partielle** : seuls gras/italique/souligné/barré, couleurs, taille,
|
||||
alignement et format de nombre étaient capturés. valign, retour à la ligne,
|
||||
rotation et **bordures** ne l'étaient pas — une cellule bordée perdait ses
|
||||
bordures en « peignant » la même mise en forme ailleurs.
|
||||
2. **Source « neutre » rejetée** : sans propriété capturée, l'arme était
|
||||
annulée. Dans Sheets/Excel, une cellule sans mise en forme est une source
|
||||
**valide** : elle *réinitialise* la cible.
|
||||
|
||||
**Correctif** — `captureFormat()` renvoie toujours un jeu **complet** de
|
||||
propriétés (y compris `border: "none"`, `font_color: ""`, `fill_color: ""`,
|
||||
`number_format: "General"`), donc la cible finit toujours identique à la source,
|
||||
et une source neutre vide la cible.
|
||||
|
||||
**Fuite d'écouteur** : `Échap` ou un second clic sur le bouton désarmaient
|
||||
l'arme mais laissaient le gestionnaire « appliquer au prochain clic » en place —
|
||||
le clic suivant dans la grille repeignait une vieille mise en forme. Le
|
||||
gestionnaire est maintenant retiré par `stopPaint()`.
|
||||
|
||||
### 8.2 Sélection de plusieurs lignes / colonnes
|
||||
|
||||
Cliquer un numéro de ligne sélectionnait déjà la ligne entière ; il manquait
|
||||
l'extension à **plusieurs** lignes. Le mécanisme :
|
||||
|
||||
- `mousedown` sur un en-tête sélectionne la ligne / colonne **entière** ;
|
||||
- glisser au-dessus d'autres en-tête étend (`A1:Z5`), `Maj`+clic aussi
|
||||
(`A1:Z5`) — l'ancre est mémorisée après le glisser ;
|
||||
- la sélection réutilise le modèle `{anchor, focus}` d'une plage, donc
|
||||
*copier*, *effacer*, *insérer/supprimer*, le menu contextuel et la
|
||||
suppression de contenu fonctionnent sans code supplémentaire ;
|
||||
- le `click` sur en-tête a été **retiré** : il écrasait la multi-sélection
|
||||
juste après le glisser ;
|
||||
- une pression **près du bord** de l'en-tête reste le geste de
|
||||
**redimensionnement** (partage explicite avec `wireResize`).
|
||||
|
||||
### 8.3 Grille A → Z, et 1000 lignes à la demande
|
||||
|
||||
La grille de la feuille affichée est complétée jusqu'à **26 colonnes** (A → Z)
|
||||
d'emblée ; les **lignes s'ajoutent par blocs de 100** quand le lecteur approche
|
||||
du bas, jusqu'à 1000. Les cellules vides sont de vraies cellules éditables
|
||||
(`contentEditable`, `tabIndex`, écouteurs, `data-cell`). L'extension a lieu à
|
||||
l'activation d'un onglet, pas au montage : la faire pour toutes les feuilles
|
||||
multiplierait les cellules par le nombre d'onglets.
|
||||
|
||||
**Une feuille tronquée garde son chargement serveur** (« Charger la suite »,
|
||||
fenêtre par fenêtre) : ses lignes manquantes sont des données, pas du
|
||||
remplissage. Étendre la grille par-dessus aurait dupliqué les lignes et
|
||||
supprimait le pied de page — régression de **#153-A9bis**, corrigée en gardant
|
||||
les deux mécanismes séparés (les colonnes et les blocs vides pour une feuille
|
||||
complète, la fenêtre serveur pour une feuille tronquée).
|
||||
|
||||
**Pourquoi pas les 1000 lignes d'un coup.** Matérialiser A → Z × 1000 revient à
|
||||
**~26 000 cellules câblées** pour la seule feuille visible. Mesuré : ~0,3 Go
|
||||
retenus par rendu, non récupérables même après `innerHTML = ""` et GC forcé — la
|
||||
suite JSDOM `xlsx-viewer` (163 cas) épuisait le tas vers le 45ᵉ montage. La
|
||||
croissance par blocs offre la même étendue sans payer le coût d'un coup.
|
||||
|
||||
**Trois goulots levés** — toujours utiles sur la grille atteinte :
|
||||
|
||||
| Goulot | Avant | Après |
|
||||
|---|---|---|
|
||||
| `findTd` | un `querySelector` par appel → 26 000 requêtes pour une colonne | **index `ref → td`** avec auto-réparation |
|
||||
| `refreshPanelFormulas` | `cloneNode` sur chaque cellule, à chaque frappe | court-circuit sur cellule vide sans formule |
|
||||
| `applySheetMeta` | toutes les cellules touchées | court-circuit sur celles que le classeur ne style pas |
|
||||
|
||||
**Sorties.** L'export (CSV / Markdown / HTML / impression) écarte les lignes et
|
||||
colonnes vides de queue : sans cela un classeur de deux colonnes exportait les
|
||||
26 colonnes du quadrillage.
|
||||
|
||||
> **Reste à faire.** La virtualisation par fenêtre (ne garder que les lignes
|
||||
> visibles et recycler au défilement) remplacerait la croissance par blocs si la
|
||||
> grille de 1000 lignes redevenait lourde dans le navigateur.
|
||||
@@ -0,0 +1,107 @@
|
||||
# #180 — Éditeur tableur : redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-08 (v2.54.0) — ouvert le 2026-10-05 | **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#154](./xlsx-ui-redesign.md) · [#155](./xlsx-context-menu.md) ·
|
||||
> [#179](./xlsx-menus-179.md) · [#156](./xlsx-editor-completeness.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
Retours utilisateur après #179, quatre défauts d'ergonomie et un défaut de thème :
|
||||
|
||||
1. **Le curseur de redimensionnement n'apparaît pas.** Le redimensionnement
|
||||
par les marges existe depuis #156-A8 (`wireResize`), mais l'indicateur est
|
||||
invisible : `.xlsx-table td[contenteditable]` impose `cursor: cell` et les
|
||||
en-têtes reçoivent `cursor: pointer` **en ligne** (`paintHeaders()`). Les
|
||||
deux beat la règle `table.style.cursor` posée par le `mousemove`, y compris
|
||||
pendant le glissement. L'utilisateur n'a aucun retour sur la zone cliquable.
|
||||
2. **La grille ne suit pas le thème.** `--grid-*` n'est déclaré que pour le
|
||||
thème par défaut (dark + light) dans `style.css`. Les 13 autres thèmes et
|
||||
les modes générés *contraste élevé* / *sépia* n'en fournissent pas : la
|
||||
grille y reste en couleurs GitHub. `themes.js` applique pourtant bien les
|
||||
variables via `setProperty`, sans les jetons de grille.
|
||||
3. **Les sorties de fichier sont dispersées.** Télécharger, CSV et Exporter
|
||||
occupent trois commandes du ruban, à gauche du bouton Enregistrer.
|
||||
4. **La barre n'est pas épinglée** et l'éditeur ne prend pas toute la largeur
|
||||
du contenu quand la sidebar est masquée (contrairement aux viewers
|
||||
PDF / images / Excalidraw, cf. la règle `:has(...)` de `.content-area`).
|
||||
5. La pastille « Formules calculées localement » occupe une ligne entière
|
||||
pour annoncer un comportement normal, pas une limite.
|
||||
|
||||
## 2. Livré (A1-A4)
|
||||
|
||||
### A1 — Curseur de redimensionnement sur les marges
|
||||
|
||||
`wireResize()` (viewer.js) remplace le `table.style.cursor = …` isolé par
|
||||
`paintResizeCursor(kind, th)` :
|
||||
|
||||
- la table reçoit `.xlsx-resize-col` / `.xlsx-resize-row`, repint par le CSS
|
||||
sur la table **et** ses cellules (surplus des deux curseurs concurrents) ;
|
||||
- l'en-tête de bord est **miroité en ligne** (`th.style.cursor`), seul moyen
|
||||
de passer devant le `cursor: pointer` inline — l'ancienne valeur est
|
||||
mémorisée puis restaurée quand le pointeur quitte le bord (`mouseleave`) ;
|
||||
- l'état est remis à zéro au `mouseup` global : le curseur ne reste pas
|
||||
« coincé » après un glissement.
|
||||
|
||||
### A2 — Grille compatible tous les thèmes
|
||||
|
||||
- `themes.js` : `applyTheme()` dérive les jetons `--grid-bg`,
|
||||
`--grid-header-bg`, `--grid-header-text`, `--grid-border` et `--grid-zebra`
|
||||
des surfaces du thème actif. Le zébré passe par `color-mix(… 4%, transparent)`
|
||||
pour rester subtil sur fond clair **et** sombre. Un thème importé sans ces
|
||||
jetons est donc couvert automatiquement.
|
||||
- `style.css` : jetons de repli (`var(--grid-bg, var(--surface))`, …) pour que
|
||||
la grille reste lisible même sans `applyTheme()`, plus les deux modes
|
||||
*contraste élevé* et *sépia* déclarés explicitement.
|
||||
- Le fond du menu Export passe de `var(--bg, #fff)` (repli clair cassé en
|
||||
thème sombre) à `var(--surface)`.
|
||||
|
||||
### A3 — Menu Fichier + Enregistrer au niveau des onglets
|
||||
|
||||
- `buildCommandBar()` renvoie un fragment `fileHtml` supplémentaire : menu
|
||||
**Fichier** (`download`, `file-spreadsheet`, `file-text`, `code`,
|
||||
`printer`) + bouton **Enregistrer** primary. Les trois commandes de sortie
|
||||
sortent du ruban, qui ne garde que l'édition.
|
||||
- La coquille (`viewer.js`) rend une barre de feuilles `.xlsx-sheetbar` :
|
||||
onglets à gauche, `.xlsx-sheetbar-end` (menu + Enregistrer) à droite.
|
||||
- Câblage : `trackDismissable()` (même contrat que #179), navigation clavier,
|
||||
fermeture au clic sur Télécharger / CSV, `aria-haspopup` / `aria-expanded`.
|
||||
- **Identifiants stables** : `#xlsx-download-btn`, `#xlsx-csv-btn`,
|
||||
`#xlsx-save-btn` conservés — seuls le conteneur (`#xlsx-file-btn` /
|
||||
`#xlsx-file-menu`) et la classe (`.xlsx-file-menu`) sont nouveaux.
|
||||
|
||||
### A4 — Retrait de la pastille + barre épinglée pleine largeur
|
||||
|
||||
- La pastille « Formules calculées localement » est retirée ; la barre de
|
||||
statut ne subsiste que pour la pastille *lecture seule* (format `.xls` /
|
||||
`.ods`), et disparaît entièrement quand elle n'a rien à afficher.
|
||||
- `.xlsx-toolbar` devient `position: sticky; top: 0` avec des marges négatives
|
||||
qui neutralizing le padding de `.content-area` : la barre se colle au bord
|
||||
haut de la page et l'éditeur occupe la largeur totale du contenu.
|
||||
- `.content-area:has(.xlsx-viewer)` rejoint les viewers plein cadre.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` — 136/136 :
|
||||
- barre de feuilles (onglets / menu / Enregistrer à droite, onglets sortis
|
||||
du ruban), contenu exact du menu Fichier (5 icônes dans l'ordre), toggle ;
|
||||
- curseur `col-resize` / `row-resize` sur les deux types de bord, classes de
|
||||
repaint, restauration du curseur d'en-tête à la sortie ;
|
||||
- jetons `--grid-*` pour 4 thèmes (Spotify dark, Notion light, GitHub
|
||||
contrasté élevé, GitHub sépia) + zébré `color-mix` ;
|
||||
- absence de pastille formules (xlsx **et** csv).
|
||||
- `tests/frontend/xlsx-menus.test.mjs` — 12/12 : icônes de `fileHtml`.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` : le menu Fichier s'ouvre, expose les 5
|
||||
entrées, se ferme au clic extérieur ; Enregistrer présent dans
|
||||
`.xlsx-sheetbar-end`.
|
||||
- 8 tests préexistants réalignés sur la sémantique « écriture verrouillée
|
||||
puis application locale » (le viewer n'est plus re-lu après une écriture
|
||||
structure / mise en forme) : le second appel PUT n'a plus lieu, les
|
||||
assertions portent sur la grille peinte en place.
|
||||
|
||||
## 4. Points d'attention
|
||||
|
||||
- La copie des cellules vers le presse-papiers système et l'export sont
|
||||
inchangés : seul le **point d'entrée** des sorties de fichier a bougé.
|
||||
- `viewer.js` reste le propriétaire du câblage ; `command-bar.js` reste un
|
||||
constructeur de chaînes pur, donc testable sans DOM.
|
||||
@@ -587,6 +587,26 @@ function applyTheme(themeKey, mode) {
|
||||
Object.keys(vars).forEach(function(key) {
|
||||
root.style.setProperty(key, vars[key]);
|
||||
});
|
||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
||||
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
|
||||
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
||||
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
||||
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||
// cell background on most themes, so the grid was invisible). The line is now
|
||||
// a blend of the text colour into the sheet surface: the SAME single value
|
||||
// for every line, and legible on both dark and light grounds.
|
||||
var _gridSurface = vars['--surface'] || vars['--bg-secondary'] || '#000000';
|
||||
var _gridInk = vars['--text-primary'] || vars['--text'] || '#888888';
|
||||
var _grid = {
|
||||
'--grid-bg': _gridSurface,
|
||||
'--grid-header-bg': vars['--bg-hover'] || _gridSurface,
|
||||
'--grid-header-text': _gridInk,
|
||||
'--grid-border': 'color-mix(in srgb, ' + _gridInk + ' 22%, ' + _gridSurface + ')',
|
||||
'--grid-zebra': 'color-mix(in srgb, ' + _gridInk + ' 4%, transparent)'
|
||||
};
|
||||
Object.keys(_grid).forEach(function(key) {
|
||||
if (_grid[key]) root.style.setProperty(key, _grid[key]);
|
||||
});
|
||||
// Store resolved vars on the theme for export/preview
|
||||
if (!theme.modes[mode]) theme.modes[mode] = vars;
|
||||
|
||||
|
||||
+10
-9
@@ -795,15 +795,16 @@ const FileOperations = {
|
||||
<div class="modal-form-group">
|
||||
<label class="modal-label">${t('modal.file_type')}</label>
|
||||
<select class="modal-select" id="file-ext-select">
|
||||
<option value=".md">Markdown (.md)</option>
|
||||
<option value=".txt">Texte (.txt)</option>
|
||||
<option value=".py">Python (.py)</option>
|
||||
<option value=".js">JavaScript (.js)</option>
|
||||
<option value=".json">JSON (.json)</option>
|
||||
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
|
||||
<option value=".yaml">YAML (.yaml)</option>
|
||||
<option value=".sh">Shell (.sh)</option>
|
||||
<option value=".ps1">PowerShell (.ps1)</option>
|
||||
<option value=".md">${t('modal.ext_md')}</option>
|
||||
<option value=".txt">${t('modal.ext_txt')}</option>
|
||||
<option value=".py">${t('modal.ext_py')}</option>
|
||||
<option value=".js">${t('modal.ext_js')}</option>
|
||||
<option value=".json">${t('modal.ext_json')}</option>
|
||||
<option value=".xlsx">${t('modal.ext_xlsx')}</option>
|
||||
<option value=".excalidraw">${t('modal.ext_excalidraw')}</option>
|
||||
<option value=".yaml">${t('modal.ext_yaml')}</option>
|
||||
<option value=".sh">${t('modal.ext_sh')}</option>
|
||||
<option value=".ps1">${t('modal.ext_ps1')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -695,6 +695,21 @@ async function reloadExternalWrite(vault, path, force = false) {
|
||||
}
|
||||
// Not editing: refresh the read view when it shows the written document.
|
||||
if (state.currentVault === vault && state.currentPath === path) {
|
||||
// The spreadsheet viewer applies its own writes dynamically (values,
|
||||
// styles and structure stay in the DOM): reloading it here would wipe the
|
||||
// grid behind a loading spinner on every save. Skip the reload when the
|
||||
// event is the watcher echo of our own write (recent stamp) or when
|
||||
// unsaved edits are still on screen (they would be clobbered). Forced
|
||||
// reloads (assistant writes) still go through.
|
||||
const xlsxOpen = !force
|
||||
&& document.querySelector("#content-area .xlsx-viewer");
|
||||
if (xlsxOpen) {
|
||||
const own = window.__xlsxLastOwnWrite;
|
||||
const recentOwn = own && own.vault === vault && own.path === path
|
||||
&& (Date.now() - (own.at || 0)) < 15000;
|
||||
const hasDirty = Boolean(document.querySelector("#content-area td.xlsx-dirty"));
|
||||
if (recentOwn || hasDirty) return;
|
||||
}
|
||||
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
|
||||
// refresh) and discard the in-editor scene. Its own save already persisted
|
||||
// the file, so there is nothing to reload here.
|
||||
|
||||
+3607
-163
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
/* ObsiGate — XLSX colour picker panel (#183).
|
||||
*
|
||||
* Replaces the native `<input type="color">` of the spreadsheet ribbon and of
|
||||
* the Mise en forme menu with a Google-Sheets-like panel:
|
||||
*
|
||||
* ┌ header ─ « Réinitialiser » ───────────────────┐
|
||||
* │ palette 8 x 10 : ligne 1 = échelles de gris, │
|
||||
* │ lignes 2-10 = 8 teintes x 9 │
|
||||
* │ nuances (clair -> foncé) │
|
||||
* │ STANDARD 8 pastilles prédéfinies │
|
||||
* │ PERSONNALISÉ pastille + bouton « + » + crayon │
|
||||
* │ pied Mise en forme conditionnelle, │
|
||||
* │ Couleurs en alternance │
|
||||
* └────────────────────────────────────────────────┘
|
||||
*
|
||||
* Picking a colour closes the panel and applies it (the caller owns the
|
||||
* write). The panel markup is built here, positioning and dismissal are the
|
||||
* viewer's (it already owns `anchorUnder` + `trackDismissable` for the other
|
||||
* spreadsheet menus), so this module stays a pure builder.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
// 8 columns = 8 hues, in the Google Sheets order: red, orange, yellow, light
|
||||
// green, cyan, blue, violet, magenta.
|
||||
const HUES = [0, 30, 55, 100, 180, 215, 270, 320];
|
||||
// 9 value rows, light (top) -> dark (bottom).
|
||||
const ROWS = 9;
|
||||
const GREYS = 8;
|
||||
|
||||
/**
|
||||
* HSL → `#rrggbb`.
|
||||
*
|
||||
* The palette is generated in HSL for readability, but it MUST leave this
|
||||
* module as hex: the write path (`normHex` in viewer.js) and the backend only
|
||||
* accept `#rrggbb`, so an `hsl(...)` swatch would be silently dropped on save.
|
||||
*/
|
||||
export function hslToHex(h, s, l) {
|
||||
const hue = ((h % 360) + 360) % 360;
|
||||
const sat = Math.min(1, Math.max(0, s));
|
||||
const lig = Math.min(1, Math.max(0, l));
|
||||
const c = (1 - Math.abs(2 * lig - 1)) * sat;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = lig - c / 2;
|
||||
const seg = Math.floor(hue / 60) % 6;
|
||||
const rgb = [
|
||||
[c, x, 0], [x, c, 0], [0, c, x],
|
||||
[0, x, c], [x, 0, c], [c, 0, x],
|
||||
][seg] || [0, 0, 0];
|
||||
return `#${rgb.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
/** Relative luminance (WCAG-ish) of a `#rrggbb` string, 0 (black) → 1 (white). */
|
||||
export function luminanceOf(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "").trim());
|
||||
if (!m) return 0;
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = ((n >> 16) & 255) / 255;
|
||||
const g = ((n >> 8) & 255) / 255;
|
||||
const b = (n & 255) / 255;
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 80 swatches: a first row of greys then 9 x 8 hues.
|
||||
* @returns {Array<Array<string>>} 10 rows of 8 CSS colours
|
||||
*/
|
||||
export function buildPalette() {
|
||||
const rows = [];
|
||||
const greys = [];
|
||||
for (let i = 0; i < GREYS; i++) {
|
||||
// black -> white in 8 steps (l stays a 0..1 ratio, converted by hslToHex)
|
||||
greys.push(hslToHex(0, 0, i / (GREYS - 1)));
|
||||
}
|
||||
rows.push(greys);
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const l = 0.90 - r * (0.60 / (ROWS - 1)); // 0.90 -> 0.30
|
||||
const s = 0.60 + r * (0.25 / (ROWS - 1)); // 0.60 -> 0.85
|
||||
rows.push(HUES.map((h) => hslToHex(h, s, l)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The 8 predefined colours of the STANDARD row. */
|
||||
export const STANDARD_COLOURS = [
|
||||
{ key: "black", hex: "#000000" },
|
||||
{ key: "white", hex: "#ffffff" },
|
||||
{ key: "blue", hex: "#1a73e8" },
|
||||
{ key: "red", hex: "#d93025" },
|
||||
{ key: "yellow", hex: "#f4b400" },
|
||||
{ key: "green", hex: "#188038" },
|
||||
{ key: "orange", hex: "#e8710a" },
|
||||
{ key: "cyan", hex: "#00bcd4" },
|
||||
];
|
||||
|
||||
const CHECK_SVG =
|
||||
'<svg class="xlsx-colour-check" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true" focusable="false">'
|
||||
+ '<path fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/></svg>';
|
||||
|
||||
const icon = (name) =>
|
||||
`<i data-lucide="${name}" class="xlsx-colour-icon" aria-hidden="true"></i>`;
|
||||
|
||||
const button = (cls, label, ariaLabel) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `btn-action ${cls}`;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", ariaLabel || label);
|
||||
return b;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single round swatch.
|
||||
* @param {string} colour - CSS colour
|
||||
* @param {string} label - tooltip / aria-label
|
||||
* @param {boolean} selected - draw the check mark
|
||||
*/
|
||||
const swatchButton = (colour, label, selected) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "xlsx-colour-swatch-btn";
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.style.background = colour;
|
||||
b.dataset.colour = colour;
|
||||
if (selected) {
|
||||
b.classList.add("selected");
|
||||
b.setAttribute("aria-pressed", "true");
|
||||
// White check on dark grounds, black on light ones — otherwise the mark
|
||||
// disappears (the same contrast rule as the design).
|
||||
b.style.color = luminanceOf(colour) > 0.55 ? "#000000" : "#ffffff";
|
||||
b.innerHTML = CHECK_SVG;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the panel.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {"font"|"fill"} opts.kind - drives the panel label and the footer
|
||||
* @param {string} [opts.value] - current colour (`""` = none)
|
||||
* @param {string} [opts.custom] - colour shown in the PERSONNALISÉ slot
|
||||
* @param {Function} opts.onPick - (cssColour) => void; the panel is closed by
|
||||
* the caller right after
|
||||
* @param {Function} [opts.onReset] - clear the colour entirely
|
||||
* @param {Function} [opts.onCustom] - open the native picker for a custom
|
||||
* colour, called with no argument
|
||||
* @param {Function} [opts.onUnavailable] - (i18nLabel) => void for the footer
|
||||
* entries that ObsiGate does not implement yet
|
||||
* @returns {{ el: HTMLElement, sync: Function }}
|
||||
*/
|
||||
export function createColourPicker({
|
||||
kind = "font",
|
||||
value = "",
|
||||
custom = "",
|
||||
onPick,
|
||||
onReset,
|
||||
onCustom,
|
||||
onUnavailable,
|
||||
} = {}) {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "xlsx-colour-panel";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute(
|
||||
"aria-label",
|
||||
kind === "fill" ? t("xlsx.colour_panel_fill") : t("xlsx.colour_panel_font")
|
||||
);
|
||||
const isFill = kind === "fill";
|
||||
const norm = (c) => String(c || "").trim().toLowerCase();
|
||||
|
||||
// ── Header : Réinitialiser ────────────────────────────────────────────────
|
||||
const head = document.createElement("div");
|
||||
head.className = "xlsx-colour-head";
|
||||
const reset = button(
|
||||
"xlsx-colour-reset",
|
||||
t("xlsx.colour_reset"),
|
||||
isFill ? t("xlsx.colour_reset_fill") : t("xlsx.colour_reset_font")
|
||||
);
|
||||
reset.innerHTML = `${icon(isFill ? "eraser" : "paintbrush")}<span>${t("xlsx.colour_reset")}</span>`;
|
||||
reset.addEventListener("click", () => {
|
||||
if (onReset) onReset();
|
||||
});
|
||||
head.appendChild(reset);
|
||||
panel.appendChild(head);
|
||||
|
||||
// ── Palette 8 x 10 ───────────────────────────────────────────────────────
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "xlsx-colour-grid";
|
||||
grid.setAttribute("role", "group");
|
||||
grid.setAttribute("aria-label", t("xlsx.colour_palette"));
|
||||
const current = norm(value);
|
||||
buildPalette().forEach((row) => {
|
||||
row.forEach((colour) => {
|
||||
const b = swatchButton(colour, colour, norm(colour) === current);
|
||||
b.addEventListener("click", () => {
|
||||
if (onPick) onPick(colour);
|
||||
});
|
||||
grid.appendChild(b);
|
||||
});
|
||||
});
|
||||
panel.appendChild(grid);
|
||||
|
||||
// ── STANDARD ─────────────────────────────────────────────────────────────
|
||||
const standard = document.createElement("div");
|
||||
standard.className = "xlsx-colour-section";
|
||||
const stdHead = document.createElement("div");
|
||||
stdHead.className = "xlsx-colour-title";
|
||||
stdHead.appendChild(document.createTextNode(t("xlsx.colour_standard")));
|
||||
const stdEdit = button(
|
||||
"xlsx-colour-edit",
|
||||
t("xlsx.colour_edit_standard"),
|
||||
t("xlsx.colour_edit_standard")
|
||||
);
|
||||
stdEdit.innerHTML = icon("pencil");
|
||||
stdEdit.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
stdHead.appendChild(stdEdit);
|
||||
standard.appendChild(stdHead);
|
||||
|
||||
const stdRow = document.createElement("div");
|
||||
stdRow.className = "xlsx-colour-row";
|
||||
STANDARD_COLOURS.forEach(({ key, hex }) => {
|
||||
const label = t(`xlsx.colour_${key}`);
|
||||
const b = swatchButton(hex, label, norm(hex) === current);
|
||||
b.addEventListener("click", () => {
|
||||
if (onPick) onPick(hex);
|
||||
});
|
||||
stdRow.appendChild(b);
|
||||
});
|
||||
standard.appendChild(stdRow);
|
||||
panel.appendChild(standard);
|
||||
|
||||
// ── PERSONNALISÉ ─────────────────────────────────────────────────────────
|
||||
const customSection = document.createElement("div");
|
||||
customSection.className = "xlsx-colour-section";
|
||||
const cusHead = document.createElement("div");
|
||||
cusHead.className = "xlsx-colour-title";
|
||||
cusHead.appendChild(document.createTextNode(t("xlsx.colour_custom")));
|
||||
customSection.appendChild(cusHead);
|
||||
|
||||
const cusRow = document.createElement("div");
|
||||
cusRow.className = "xlsx-colour-row";
|
||||
const customColour = custom || value;
|
||||
const cusSwatch = swatchButton(
|
||||
customColour || "transparent",
|
||||
t("xlsx.colour_custom"),
|
||||
Boolean(customColour) && norm(customColour) === current
|
||||
);
|
||||
cusSwatch.addEventListener("click", () => {
|
||||
if (customColour && onPick) onPick(customColour);
|
||||
});
|
||||
cusRow.appendChild(cusSwatch);
|
||||
|
||||
const add = button("xlsx-colour-add", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
|
||||
add.innerHTML = icon("plus");
|
||||
add.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
cusRow.appendChild(add);
|
||||
|
||||
const cusEdit = button("xlsx-colour-edit", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
|
||||
cusEdit.innerHTML = icon("pencil");
|
||||
cusEdit.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
cusRow.appendChild(cusEdit);
|
||||
customSection.appendChild(cusRow);
|
||||
panel.appendChild(customSection);
|
||||
|
||||
// ── Footer ───────────────────────────────────────────────────────────────
|
||||
// Both entries are announced by ObsiGate but not implemented yet: they call
|
||||
// back with their label so the caller can tell the user instead of leaving a
|
||||
// dead button. « Couleurs en alternance » only shows once a colour is set,
|
||||
// exactly like the reference screenshots.
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "xlsx-colour-foot";
|
||||
const footEntry = (label, key) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "xlsx-colour-foot-item";
|
||||
b.textContent = label;
|
||||
b.dataset.colourAction = key;
|
||||
b.addEventListener("click", () => {
|
||||
if (onUnavailable) onUnavailable(label);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
foot.appendChild(footEntry(t("xlsx.colour_conditional"), "conditional"));
|
||||
if (value) foot.appendChild(footEntry(t("xlsx.colour_banded"), "banded"));
|
||||
panel.appendChild(foot);
|
||||
|
||||
/** Repaint the check mark after the value changed (custom colour applied). */
|
||||
const sync = (next, nextCustom) => {
|
||||
const want = norm(next);
|
||||
panel.querySelectorAll(".xlsx-colour-swatch-btn").forEach((b) => {
|
||||
const on = norm(b.dataset.colour) === want;
|
||||
b.classList.toggle("selected", on);
|
||||
b.setAttribute("aria-pressed", String(on));
|
||||
b.innerHTML = on
|
||||
? (b.style.color = luminanceOf(b.dataset.colour) > 0.55 ? "#000000" : "#ffffff", CHECK_SVG)
|
||||
: "";
|
||||
});
|
||||
if (nextCustom) {
|
||||
const slot = panel.querySelector(".xlsx-colour-section:nth-of-type(3) .xlsx-colour-row .xlsx-colour-swatch-btn");
|
||||
if (slot) slot.style.background = nextCustom;
|
||||
}
|
||||
};
|
||||
|
||||
return { el: panel, sync };
|
||||
}
|
||||
+128
-25
@@ -1,19 +1,29 @@
|
||||
/* ObsiGate — XLSX command bar builder (#154-A5).
|
||||
/* ObsiGate — XLSX command bar builder (Sheets-like ribbon).
|
||||
*
|
||||
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
|
||||
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
|
||||
* is testable and the viewer only wires behaviour.
|
||||
*
|
||||
* Order follows Google Sheets: search, undo/redo, print, paint, zoom, number
|
||||
* formats, font size, B/I/S/U, colours, borders, merge, alignments, wrap,
|
||||
* rotation, overflow menu — then the ObsiGate-specific groups (structure,
|
||||
* format, view, gridlines) kept with their stable ids. File outputs
|
||||
* (Download / CSV / Markdown / HTML / Print) moved to the **Fichier** menu of
|
||||
* the menu bar (#185, frontend/js/xlsx/menus.js); only Save stays on the
|
||||
* sheet-tab strip, pinned at its right end.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
const IC = (name) => `<i data-lucide="${name}" style="width:14px;height:14px" aria-hidden="true"></i>`;
|
||||
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {Array<{name: string}>} ctx.sheets
|
||||
* @param {boolean} ctx.isCsv
|
||||
* @param {boolean} ctx.readOnly
|
||||
* @param {boolean} ctx.editable - not a CSV and not read-only
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string, fileHtml: string }}
|
||||
*/
|
||||
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
||||
@@ -26,19 +36,23 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}"><i data-lucide="plus" style="width:14px;height:14px" aria-hidden="true"></i></button>`
|
||||
: ""}</div>`;
|
||||
|
||||
// Status pills make the viewer's limits visible up front.
|
||||
// Status pills make the viewer's limits visible up front (#180: the
|
||||
// "formulas computed locally" pill is gone — local computation is the
|
||||
// normal behaviour, not a limit worth a badge).
|
||||
const statusPills = [
|
||||
readOnly
|
||||
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
|
||||
: "",
|
||||
!isCsv
|
||||
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
|
||||
: "",
|
||||
].filter(Boolean).join("");
|
||||
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
|
||||
|
||||
// Command groups, separated by thin rules.
|
||||
// Command groups, separated by thin rules (Sheets order).
|
||||
const actionGroups = [];
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="search">
|
||||
<button class="btn-action" id="xs-search" type="button" title="${escapeHtml(t("xlsx.tb_search"))}" aria-label="${escapeHtml(t("xlsx.tb_search"))}">
|
||||
${IC("search")}
|
||||
</button>
|
||||
</span>`);
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
|
||||
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
|
||||
@@ -48,6 +62,97 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="print">
|
||||
<button class="btn-action" id="xs-print" type="button" title="${escapeHtml(t("xlsx.print"))}" aria-label="${escapeHtml(t("xlsx.print"))}">
|
||||
${IC("printer")}
|
||||
</button>
|
||||
</span>`);
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="paint">
|
||||
<button class="btn-action" id="xs-paint" type="button" title="${escapeHtml(t("xlsx.tb_paint"))}" aria-label="${escapeHtml(t("xlsx.tb_paint"))}" aria-pressed="false">
|
||||
${IC("paint-roller")}
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="zoom">
|
||||
<select class="xlsx-select" id="xs-zoom" title="${escapeHtml(t("xlsx.tb_zoom"))}" aria-label="${escapeHtml(t("xlsx.tb_zoom"))}">
|
||||
${[50, 75, 90, 100, 125, 150, 200].map((z) =>
|
||||
`<option value="${z}"${z === 100 ? " selected" : ""}>${z} %</option>`).join("")}
|
||||
</select>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="number">
|
||||
<button class="btn-action" id="xs-cur" type="button" title="${escapeHtml(t("xlsx.tb_currency"))}" aria-label="${escapeHtml(t("xlsx.tb_currency"))}">
|
||||
${IC("dollar-sign")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-pct" type="button" title="${escapeHtml(t("xlsx.tb_percent"))}" aria-label="${escapeHtml(t("xlsx.tb_percent"))}">
|
||||
${IC("percent")}
|
||||
</button>
|
||||
<button class="btn-action xlsx-dec" id="xs-decminus" type="button" title="${escapeHtml(t("xlsx.tb_dec_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_minus"))}">.0←</button>
|
||||
<button class="btn-action xlsx-dec" id="xs-decplus" type="button" title="${escapeHtml(t("xlsx.tb_dec_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_plus"))}">.00→</button>
|
||||
<button class="btn-action" id="xs-num123" type="button" title="${escapeHtml(t("xlsx.tb_number"))}" aria-label="${escapeHtml(t("xlsx.tb_number"))}">123</button>
|
||||
<select class="xlsx-select" id="xs-numfmt" title="${escapeHtml(t("xlsx.tb_numfmt"))}" aria-label="${escapeHtml(t("xlsx.tb_numfmt"))}">
|
||||
<option value="" selected>${escapeHtml(t("xlsx.tb_numfmt"))}</option>
|
||||
<option value="General">${escapeHtml(t("xlsx.format_num_general"))}</option>
|
||||
<option value="0.00">${escapeHtml(t("xlsx.format_num_number"))}</option>
|
||||
<option value="0.0%">${escapeHtml(t("xlsx.format_num_percent"))}</option>
|
||||
<option value="#,##0.00 "€"">${escapeHtml(t("xlsx.format_num_currency"))}</option>
|
||||
<option value="DD/MM/YYYY">${escapeHtml(t("xlsx.format_num_date"))}</option>
|
||||
<option value="@">${escapeHtml(t("xlsx.format_num_text"))}</option>
|
||||
</select>
|
||||
</span>`);
|
||||
// #183 — colour commands open the Sheets-like panel built by
|
||||
// xlsx/color-picker.js (the native <input type="color"> is kept hidden and
|
||||
// only driven by the panel's « + » / crayon buttons).
|
||||
const colourPick = (id, label, iconName, swatchId) =>
|
||||
`<span class="xlsx-colorpick-wrap">`
|
||||
+ `<button class="btn-action xlsx-colorpick" id="${id}" type="button"`
|
||||
+ ` data-xlsx-colour="${swatchId === "xs-fgcolor" ? "font" : "fill"}"`
|
||||
+ ` title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}"`
|
||||
+ ` aria-haspopup="dialog" aria-expanded="false">`
|
||||
+ (iconName === "fill" ? IC("paint-bucket") : '<span class="xlsx-colorletter" aria-hidden="true">A</span>')
|
||||
+ `<span class="xlsx-color-swatch" data-swatch-for="${swatchId}" aria-hidden="true"></span>`
|
||||
+ `</button>`
|
||||
+ `<input type="color" class="xlsx-color-native" data-xlsx-native-for="${swatchId}" tabindex="-1" aria-hidden="true">`
|
||||
+ `</span>`;
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="font">
|
||||
<button class="btn-action" id="xs-fsminus" type="button" title="${escapeHtml(t("xlsx.tb_font_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_minus"))}">−</button>
|
||||
<select class="xlsx-select xlsx-fontsize" id="xs-fssize" title="${escapeHtml(t("xlsx.tb_font_size"))}" aria-label="${escapeHtml(t("xlsx.tb_font_size"))}">
|
||||
${[6, 7, 8, 9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) =>
|
||||
`<option value="${s}"${s === 11 ? " selected" : ""}>${s}</option>`).join("")}
|
||||
</select>
|
||||
<button class="btn-action" id="xs-fsplus" type="button" title="${escapeHtml(t("xlsx.tb_font_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_plus"))}">+</button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-b" type="button" title="${escapeHtml(t("xlsx.format_bold"))}" aria-label="${escapeHtml(t("xlsx.format_bold"))}" aria-pressed="false"><b>B</b></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-i" type="button" title="${escapeHtml(t("xlsx.format_italic"))}" aria-label="${escapeHtml(t("xlsx.format_italic"))}" aria-pressed="false"><i>I</i></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-s" type="button" title="${escapeHtml(t("xlsx.format_strike"))}" aria-label="${escapeHtml(t("xlsx.format_strike"))}" aria-pressed="false"><s>S</s></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-u" type="button" title="${escapeHtml(t("xlsx.format_underline"))}" aria-label="${escapeHtml(t("xlsx.format_underline"))}" aria-pressed="false"><u>U</u></button>
|
||||
${colourPick("xs-fgcolor", t("xlsx.format_font_color"), "font", "xs-fgcolor")}
|
||||
${colourPick("xs-bgcolor", t("xlsx.format_fill_color"), "fill", "xs-bgcolor")}
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="arrange">
|
||||
<button class="btn-action" id="xs-borders" type="button" title="${escapeHtml(t("xlsx.tb_borders"))}" aria-label="${escapeHtml(t("xlsx.tb_borders"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("grid-2x2")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-merge" type="button" title="${escapeHtml(t("xlsx.tb_merge"))}" aria-label="${escapeHtml(t("xlsx.tb_merge"))}">
|
||||
${IC("combine")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-alignh" type="button" title="${escapeHtml(t("xlsx.tb_alignh"))}" aria-label="${escapeHtml(t("xlsx.tb_alignh"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("align-left")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-alignv" type="button" title="${escapeHtml(t("xlsx.tb_alignv"))}" aria-label="${escapeHtml(t("xlsx.tb_alignv"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("align-center-vertical")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-wrap" type="button" title="${escapeHtml(t("xlsx.tb_wrap"))}" aria-label="${escapeHtml(t("xlsx.tb_wrap"))}" aria-pressed="false">
|
||||
${IC("wrap-text")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-rot" type="button" title="${escapeHtml(t("xlsx.tb_rotation"))}" aria-label="${escapeHtml(t("xlsx.tb_rotation"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("rotate-cw")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-more" type="button" title="${escapeHtml(t("xlsx.tb_more"))}" aria-label="${escapeHtml(t("xlsx.tb_more"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("more-vertical")}
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
@@ -64,26 +169,24 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
// #185 — the ribbon now reads like Google Sheets': flat icon buttons, no
|
||||
// chrome, grouped by thin vertical rules. The gridline switch belongs to it
|
||||
// (the View ▸ Show menu toggles the very same state).
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="grid">
|
||||
<button class="btn-action active" id="xs-grid" type="button" aria-pressed="true"
|
||||
title="${escapeHtml(t("xlsx.mi_show_grid"))}" aria-label="${escapeHtml(t("xlsx.mi_show_grid"))}">
|
||||
${IC("grid-3x3")}
|
||||
</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>
|
||||
<span class="export-dropdown xlsx-export-dropdown">
|
||||
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
|
||||
</button>
|
||||
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="md"><i data-lucide="file-text" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_md"))}</span></button>
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="html"><i data-lucide="code" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_html"))}</span></button>
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="print"><i data-lucide="printer" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.print"))}</span></button>
|
||||
</div>
|
||||
</span>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled><i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>
|
||||
</span>`);
|
||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
||||
|
||||
return { tabs, actionsHtml, statusBar };
|
||||
// #185 — file outputs moved to the **Fichier** menu of the menu bar
|
||||
// (frontend/js/xlsx/menus.js), which keeps the `#xlsx-download-btn` /
|
||||
// `#xlsx-csv-btn` ids and the `[data-xlsx-export]` markers. Only **Save**
|
||||
// stays on the sheet-tab strip, pinned at its right end.
|
||||
const fileHtml =
|
||||
`<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>`
|
||||
+ `<i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>`;
|
||||
|
||||
return { tabs, actionsHtml, statusBar, fileHtml };
|
||||
}
|
||||
|
||||
@@ -14,14 +14,15 @@ import { safeCreateIcons } from '../utils.js';
|
||||
|
||||
let _menu = null;
|
||||
let _openedAt = 0;
|
||||
// Only a touch long-press can emit a click right after the menu opened.
|
||||
let _openedByTouch = false;
|
||||
let _dismissBound = false;
|
||||
|
||||
export function closeContextMenu() {
|
||||
// BUG-108: drop the reference BEFORE touching the DOM — removing a menu
|
||||
// that holds the focus fires blur/focusout synchronously, which re-enters
|
||||
// this very function; the second remove() on the mid-flight node throws
|
||||
// NotFoundError and aborts the caller's click handler before its action
|
||||
// runs (contextual « Coller » silently dead, e2e xlsx-viewer A5).
|
||||
// Drop the reference BEFORE touching the DOM: removing a menu that holds
|
||||
// the focus fires blur/focusout synchronously, which re-enters this very
|
||||
// function — a second remove() on the mid-flight node throws NotFoundError
|
||||
// and aborts the caller's click handler before its action runs.
|
||||
const menu = _menu;
|
||||
_menu = null;
|
||||
if (menu) menu.remove();
|
||||
@@ -36,9 +37,11 @@ function bindDismiss() {
|
||||
_dismissBound = true;
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!_menu) return;
|
||||
// Ignore the very gesture that opened the menu (touch long-press can emit
|
||||
// a click right after).
|
||||
if (Date.now() - _openedAt < 250) return;
|
||||
// Ignore the very gesture that opened the menu — but only for a touch
|
||||
// long-press, which is the one that emits a trailing click. A mouse
|
||||
// right-click does not, so a deliberate outside click made right after
|
||||
// must close the menu instead of being swallowed.
|
||||
if (_openedByTouch && Date.now() - _openedAt < 250) return;
|
||||
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
|
||||
closeContextMenu();
|
||||
});
|
||||
@@ -56,8 +59,12 @@ function bindDismiss() {
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("blur", closeContextMenu);
|
||||
// A scroll or resize detaches the viewport-anchored menu from its cell.
|
||||
window.addEventListener("scroll", closeContextMenu, true);
|
||||
// A scroll detaches the viewport-anchored menu from its cell — except the
|
||||
// tail of the smooth scroll that revealed the right-clicked cell (global
|
||||
// `scroll-behavior: smooth` keeps firing after the menu opened).
|
||||
window.addEventListener("scroll", () => {
|
||||
if (Date.now() - _openedAt > 400) closeContextMenu();
|
||||
}, true);
|
||||
window.addEventListener("resize", closeContextMenu);
|
||||
}
|
||||
}
|
||||
@@ -111,25 +118,50 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
|
||||
if (onClose) onClose();
|
||||
else menuEl.remove();
|
||||
};
|
||||
// A re-render can detach the menu without dismissing it: unbind silently
|
||||
// instead of leaking listeners on a dead node.
|
||||
const dropIfDetached = () => {
|
||||
if (!menuEl.isConnected) {
|
||||
if (!done) { done = true; unbind(); }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const onDocClick = (e) => {
|
||||
if (done) return;
|
||||
if (!menuEl.isConnected) { done = true; unbind(); return; }
|
||||
if (done || dropIfDetached()) return;
|
||||
const t = e.target;
|
||||
if (menuEl.contains(t)) return;
|
||||
if (toggle && (toggle === t || toggle.contains(t))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape" && menuEl.isConnected) dismiss();
|
||||
if (dropIfDetached()) return;
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
const onFocusOut = (e) => {
|
||||
if (done || !menuEl.isConnected) return;
|
||||
if (done || dropIfDetached()) return;
|
||||
const next = e.relatedTarget;
|
||||
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (menuEl.isConnected) dismiss();
|
||||
// Toolbar-anchored menus travel WITH their button: a scroll inside the
|
||||
// hosting toolbar (focus scroll-into-view, horizontal scroll, the menu's
|
||||
// own overflow) must not dismiss them — only an outside scroll detaches.
|
||||
// Same grace as the grid menu for the smooth-scroll tail of the opening.
|
||||
// A body-level menu has no travelling anchor: every scroll detaches it.
|
||||
const rawAnchor = (toggle && toggle.closest && toggle.closest(".xlsx-toolbar"))
|
||||
|| menuEl.parentElement
|
||||
|| null;
|
||||
const anchor = rawAnchor && rawAnchor !== document && rawAnchor !== document.body
|
||||
? rawAnchor
|
||||
: null;
|
||||
const openedAt = Date.now();
|
||||
const onScroll = (e) => {
|
||||
if (dropIfDetached()) return;
|
||||
if (Date.now() - openedAt < 400) return;
|
||||
const t = e && e.target;
|
||||
if (t && anchor && (t === anchor || (anchor.contains && anchor.contains(t)))) return;
|
||||
dismiss();
|
||||
};
|
||||
document.addEventListener("click", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
@@ -147,7 +179,7 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
|
||||
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @returns {HTMLElement} the menu element
|
||||
*/
|
||||
export function openContextMenu(x, y, items) {
|
||||
export function openContextMenu(x, y, items, { byTouch = false } = {}) {
|
||||
bindDismiss();
|
||||
closeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
@@ -198,6 +230,7 @@ export function openContextMenu(x, y, items) {
|
||||
|
||||
_menu = menu;
|
||||
_openedAt = Date.now();
|
||||
_openedByTouch = byTouch;
|
||||
// Keyboard users land on the first entry right away; mouse users are
|
||||
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
|
||||
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
/* ObsiGate — minimal client-side spreadsheet formula engine.
|
||||
*
|
||||
* Evaluates `=…` / `@…` cell content locally (same sheet, plus loaded
|
||||
* cross-sheet panels) so functions work without a round-trip: arithmetic,
|
||||
* comparisons, %, cell/range references and ~30 FR/EN functions.
|
||||
*
|
||||
* Safe by construction: own tokenizer + recursive-descent parser, no eval().
|
||||
* Anything the parser rejects keeps its original rendering (Excel stays the
|
||||
* source of truth on save); evaluation failures surface as Excel-style error
|
||||
* codes instead.
|
||||
*
|
||||
* Limits (documented): same-workbook only, no whole-column (A:A) references,
|
||||
* TODAY()/NOW() refresh on edit/render (no timer), array results show their
|
||||
* top-left value.
|
||||
*/
|
||||
|
||||
const MAX_COL = 18278; // XFD
|
||||
const MAX_ROW = 1048576;
|
||||
|
||||
/** FR aliases accepted next to the EN canonical names. */
|
||||
const ALIASES = {
|
||||
SOMME: "SUM", MOYENNE: "AVERAGE", NB: "COUNT", NBVAL: "COUNTA",
|
||||
PRODUIT: "PRODUCT", MEDIANE: "MEDIAN", ARRONDI: "ROUND", ENT: "INT",
|
||||
RACINE: "SQRT", PUISSANCE: "POWER", SI: "IF", ET: "AND", OU: "OR",
|
||||
NON: "NOT", SIERREUR: "IFERROR", CONCATENER: "CONCAT",
|
||||
MAJUSCULES: "UPPER", MINUSCULES: "LOWER", NBCAR: "LEN",
|
||||
SUPPRESPACE: "TRIM", GAUCHE: "LEFT", DROITE: "RIGHT", STXT: "MID",
|
||||
AUJOURDHUI: "TODAY", MAINTENANT: "NOW", JOUR: "DAY", MOIS: "MONTH",
|
||||
ANNEE: "YEAR", LIEN: "HYPERLINK",
|
||||
};
|
||||
|
||||
const ERRORS = {
|
||||
fr: { DIV: "#DIV/0!", VALUE: "#VALEUR!", NAME: "#NOM?", REF: "#REF!", CYCLE: "#CYCLE!" },
|
||||
en: { DIV: "#DIV/0!", VALUE: "#VALUE!", NAME: "#NAME?", REF: "#REF!", CIRC: "#CIRC!" },
|
||||
};
|
||||
|
||||
export function errorCodes(locale) {
|
||||
return ERRORS[locale === "fr" ? "fr" : "en"];
|
||||
}
|
||||
|
||||
export function isFormulaText(s) {
|
||||
if (typeof s !== "string") return false;
|
||||
const t = s.trim();
|
||||
return t.length > 1 && (t[0] === "=" || t[0] === "@");
|
||||
}
|
||||
|
||||
/** Column letters (no $) → 1-based index, or 0 when invalid. */
|
||||
export function colToIndex(letters) {
|
||||
let n = 0;
|
||||
const up = String(letters || "").toUpperCase();
|
||||
if (!/^[A-Z]{1,3}$/.test(up)) return 0;
|
||||
for (const ch of up) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n > MAX_COL ? 0 : n;
|
||||
}
|
||||
|
||||
export function indexToCol(n) {
|
||||
let s = "";
|
||||
while (n > 0) {
|
||||
const m = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + m) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Parse "[$]A[$]1" (already stripped of any sheet prefix) → {col,row} | null. */
|
||||
export function parseA1(ref) {
|
||||
const m = /^\$?([A-Za-z]{1,3})\$?([0-9]+)$/.exec(String(ref || "").trim());
|
||||
if (!m) return null;
|
||||
const col = colToIndex(m[1]);
|
||||
const row = Number(m[2]);
|
||||
if (!col || !(row >= 1 && row <= MAX_ROW)) return null;
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function tokenize(src, sep) {
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
while (i < n) {
|
||||
const c = src[i];
|
||||
if (isSpace(c)) { i++; continue; }
|
||||
if (c === '"') {
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n) {
|
||||
if (src[j] === '"') {
|
||||
if (src[j + 1] === '"') { out += '"'; j += 2; }
|
||||
else break;
|
||||
} else { out += src[j]; j++; }
|
||||
}
|
||||
if (j >= n) throw new Error("string");
|
||||
tokens.push({ t: "str", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
// Quoted sheet name: 'Mon Sheet'!A1
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n && src[j] !== "'") { out += src[j]; j++; }
|
||||
if (j >= n) throw new Error("sheet");
|
||||
tokens.push({ t: "sheet", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
const numMatch = /^[0-9]+(?:[.,][0-9]+)?/.exec(src.slice(i));
|
||||
if (numMatch && (sep === ";" || !numMatch[0].includes(","))) {
|
||||
tokens.push({ t: "num", v: numMatch[0].replace(",", ".") });
|
||||
i += numMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
const absRef = /^\$?[A-Za-z]{1,3}\$?[0-9]+/.exec(src.slice(i));
|
||||
if (absRef) {
|
||||
tokens.push({ t: "ref", v: absRef[0] });
|
||||
i += absRef[0].length;
|
||||
continue;
|
||||
}
|
||||
const two = src.slice(i, i + 2);
|
||||
if (["<=", ">=", "<>"].includes(two)) {
|
||||
tokens.push({ t: "op", v: two === "<>" ? "!=" : two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("+-*/^%=()<>,;:&!".includes(c)) {
|
||||
tokens.push({ t: c === "," || c === ";" ? "sep" : "op", v: c });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const word = /^[A-Za-zÀ-ÿ_][A-Za-zÀ-ÿ0-9_.]*/.exec(src.slice(i));
|
||||
if (word) {
|
||||
const w = word[0];
|
||||
// A bare cell reference (A1, $B$12) — but not TRUE/FALSE yet (bools).
|
||||
if (/^\$?[A-Za-z]{1,3}\$?[0-9]+$/.test(w)) {
|
||||
tokens.push({ t: "ref", v: w });
|
||||
} else {
|
||||
const up = w.toUpperCase();
|
||||
if (up === "TRUE" || up === "VRAI") tokens.push({ t: "bool", v: true });
|
||||
else if (up === "FALSE" || up === "FAUX") tokens.push({ t: "bool", v: false });
|
||||
else tokens.push({ t: "word", v: w });
|
||||
}
|
||||
i += w.length;
|
||||
continue;
|
||||
}
|
||||
throw new Error("char:" + c);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── Parser (recursive descent, builds a small AST) ─────────────────────────
|
||||
|
||||
function makeParser(tokens, sep) {
|
||||
let pos = 0;
|
||||
const peek = () => tokens[pos] || null;
|
||||
const next = () => tokens[pos++] || null;
|
||||
const expectOp = (v) => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "op" || t.v !== v) throw new Error("expected " + v);
|
||||
};
|
||||
const expectSep = () => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "sep") throw new Error("expected separator");
|
||||
};
|
||||
|
||||
function parseExpr() { return parseComparison(); }
|
||||
function parseComparison() {
|
||||
let left = parseConcat();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || !["=", "!=", "<", "<=", ">", ">="].includes(t.v)) return left;
|
||||
next();
|
||||
left = [t.v, left, parseConcat()];
|
||||
}
|
||||
}
|
||||
function parseConcat() {
|
||||
let left = parseAdd();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || t.v !== "&") return left;
|
||||
next();
|
||||
left = ["&", left, parseAdd()];
|
||||
}
|
||||
}
|
||||
function parseAdd() {
|
||||
let left = parseMul();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "+" && t.v !== "-")) return left;
|
||||
next();
|
||||
left = [t.v, left, parseMul()];
|
||||
}
|
||||
}
|
||||
function parseMul() {
|
||||
let left = parsePower();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "*" && t.v !== "/")) return left;
|
||||
next();
|
||||
left = [t.v, left, parsePower()];
|
||||
}
|
||||
}
|
||||
// Excel-compatible: a leading sign binds to the base (-2^2 = (-2)^2 = 4),
|
||||
// and to the exponent on the right (2^-2).
|
||||
function parseSigned() {
|
||||
let neg = false;
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && (t.v === "+" || t.v === "-")) {
|
||||
if (t.v === "-") neg = !neg;
|
||||
next();
|
||||
} else break;
|
||||
}
|
||||
const node = parsePostfix();
|
||||
return neg ? ["neg", node] : node;
|
||||
}
|
||||
function parsePower() {
|
||||
const base = parseSigned();
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "^") {
|
||||
next();
|
||||
return ["^", base, parseSigned()];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
function parsePostfix() {
|
||||
let node = parsePrimary();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "%") { next(); node = ["%", node]; }
|
||||
else return node;
|
||||
}
|
||||
}
|
||||
function parsePrimary() {
|
||||
const t = next();
|
||||
if (!t) throw new Error("empty");
|
||||
if (t.t === "num") return ["n", Number(t.v)];
|
||||
if (t.t === "str") return ["s", t.v];
|
||||
if (t.t === "bool") return ["b", t.v];
|
||||
if (t.t === "ref") {
|
||||
const after = peek();
|
||||
if (after && after.t === "op" && after.v === ":") {
|
||||
next();
|
||||
const t2 = next();
|
||||
if (!t2 || t2.t !== "ref") throw new Error("range");
|
||||
return ["range", normRef(t.v), normRef(t2.v)];
|
||||
}
|
||||
return ["ref", normRef(t.v)];
|
||||
}
|
||||
if (t.t === "word" || t.t === "sheet") {
|
||||
const isSheet = t.t === "sheet";
|
||||
let name = t.v;
|
||||
if (!isSheet) {
|
||||
// Unquoted sheet name: Name!A1
|
||||
const bang = peek();
|
||||
if (bang && bang.t === "op" && bang.v === "!") {
|
||||
next();
|
||||
} else {
|
||||
// Function call?
|
||||
const paren = peek();
|
||||
if (paren && paren.t === "op" && paren.v === "(") {
|
||||
next();
|
||||
const args = [];
|
||||
const closing = peek();
|
||||
if (!(closing && closing.t === "op" && closing.v === ")")) {
|
||||
for (;;) {
|
||||
args.push(parseExpr());
|
||||
const s = peek();
|
||||
if (s && s.t === "sep") { next(); continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
expectOp(")");
|
||||
return ["call", canonName(name), args];
|
||||
}
|
||||
throw new Error("name:" + name);
|
||||
}
|
||||
} else {
|
||||
const bang = peek();
|
||||
if (!bang || bang.t !== "op" || bang.v !== "!") throw new Error("sheetref");
|
||||
next();
|
||||
}
|
||||
const r1 = next();
|
||||
if (!r1 || r1.t !== "ref") throw new Error("sheetref");
|
||||
const colon = peek();
|
||||
if (colon && colon.t === "op" && colon.v === ":") {
|
||||
next();
|
||||
const r2 = next();
|
||||
if (!r2 || r2.t !== "ref") throw new Error("sheetrange");
|
||||
return ["sheetrange", name, normRef(r1.v), normRef(r2.v)];
|
||||
}
|
||||
return ["sheetref", name, normRef(r1.v)];
|
||||
}
|
||||
if (t.t === "op" && t.v === "(") {
|
||||
const e = parseExpr();
|
||||
expectOp(")");
|
||||
return e;
|
||||
}
|
||||
throw new Error("primary");
|
||||
}
|
||||
function normRef(r) {
|
||||
return r.replace(/\$/g, "").toUpperCase();
|
||||
}
|
||||
function canonName(name) {
|
||||
const up = String(name).toUpperCase();
|
||||
return ALIASES[up] || up;
|
||||
}
|
||||
return { parseExpr, atEnd: () => pos >= tokens.length };
|
||||
}
|
||||
|
||||
export function parseFormula(text) {
|
||||
const body = String(text || "").trim().replace(/^[=@]/, "");
|
||||
const sep = body.includes(";") ? ";" : ",";
|
||||
// A comma is a decimal mark (FR style) when ';' is the separator, or when
|
||||
// the expression holds no letters at all ("=1,5+1" can only mean 1.5 + 1).
|
||||
const commaDecimal = sep === ";" || !/[A-Za-zÀ-ÿ]/.test(body);
|
||||
const { parseExpr, atEnd } = makeParser(tokenize(body, commaDecimal ? ";" : sep), sep);
|
||||
const ast = parseExpr();
|
||||
if (!atEnd()) throw new Error("trailing");
|
||||
return ast;
|
||||
}
|
||||
|
||||
// ── Evaluator ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Engine scope: resolve raw cell values + locale + cycle guard. */
|
||||
export function createScope({ readRaw, sheet, locale = "en", seen = null } = {}) {
|
||||
const scope = {
|
||||
sheet: sheet || "",
|
||||
locale: locale === "fr" ? "fr" : "en",
|
||||
seen: seen || new Set(),
|
||||
memo: new Map(),
|
||||
/** Engine value of `sheet!ref` (formulas recurse, cycles guarded). */
|
||||
getCell(targetSheet, ref) {
|
||||
const key = `${targetSheet || scope.sheet}!${String(ref).toUpperCase()}`;
|
||||
const raw = readRaw ? readRaw(targetSheet || scope.sheet, ref) : null;
|
||||
return evalRefValue(raw, scope, key);
|
||||
},
|
||||
};
|
||||
return scope;
|
||||
}
|
||||
|
||||
const err = (code) => ({ t: "e", code });
|
||||
const isErr = (v) => v && v.t === "e";
|
||||
const isBlank = (v) => !v || v.t === "blank";
|
||||
|
||||
function toNumber(v) {
|
||||
if (!v || v.t === "blank") return 0;
|
||||
if (v.t === "n") return v.v;
|
||||
if (v.t === "b") return v.v ? 1 : 0;
|
||||
if (v.t === "s") {
|
||||
const s = v.v.trim().replace(",", ".");
|
||||
if (s === "") return 0;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toText(v, locale) {
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function numText(n) {
|
||||
if (!Number.isFinite(n)) return String(n);
|
||||
if (Number.isInteger(n) && Math.abs(n) < 1e15) return String(n);
|
||||
return String(parseFloat(n.toPrecision(10)));
|
||||
}
|
||||
|
||||
function flattenArgs(vals) {
|
||||
const out = [];
|
||||
const walk = (v) => {
|
||||
if (v && v.t === "a") v.rows.forEach((r) => r.forEach(walk));
|
||||
else out.push(v);
|
||||
};
|
||||
vals.forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
function evalRefValue(raw, scope, key) {
|
||||
// Raw storage text → engine value (formulas recurse, cycles guarded).
|
||||
if (raw === null || raw === undefined) return { t: "blank" };
|
||||
if (typeof raw === "number") return { t: "n", v: raw };
|
||||
if (typeof raw === "boolean") return { t: "b", v: raw };
|
||||
const s = String(raw);
|
||||
if (scope.memo.has(key)) return scope.memo.get(key);
|
||||
let out;
|
||||
if (isFormulaText(s)) {
|
||||
if (scope.seen.has(key)) {
|
||||
out = err("CYCLE");
|
||||
} else {
|
||||
scope.seen.add(key);
|
||||
try {
|
||||
out = evaluate(parseFormula(s), scope);
|
||||
} catch (_) {
|
||||
out = err("NAME");
|
||||
}
|
||||
scope.seen.delete(key);
|
||||
}
|
||||
} else if (s.trim() === "") {
|
||||
out = { t: "blank" };
|
||||
} else {
|
||||
const n = Number(s.trim().replace(",", "."));
|
||||
out = s.trim() !== "" && Number.isFinite(n) && /^[-+0-9.,\s]+$/.test(s.trim())
|
||||
? { t: "n", v: n }
|
||||
: { t: "s", v: s };
|
||||
}
|
||||
scope.memo.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function rangeValues(r1, r2, scope) {
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rStart = Math.min(a.row, b.row);
|
||||
const rEnd = Math.max(a.row, b.row);
|
||||
const cStart = Math.min(a.col, b.col);
|
||||
const cEnd = Math.max(a.col, b.col);
|
||||
if ((rEnd - rStart + 1) * (cEnd - cStart + 1) > 100000) return err("VALUE");
|
||||
const rows = [];
|
||||
for (let r = rStart; r <= rEnd; r++) {
|
||||
const line = [];
|
||||
for (let c = cStart; c <= cEnd; c++) {
|
||||
const ref = indexToCol(c) + r;
|
||||
line.push(scope.getCell(scope.sheet, ref));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
|
||||
function firstOf(v) {
|
||||
if (v && v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return cell;
|
||||
}
|
||||
return { t: "blank" };
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function cmpValues(a, b) {
|
||||
// Excel-ish: numbers < text; blank sorts with text "".
|
||||
const rank = (v) => (!v || v.t === "blank" ? 0 : v.t === "n" ? 1 : v.t === "b" ? 2 : 3);
|
||||
const ra = rank(a);
|
||||
const rb = rank(b);
|
||||
if (ra !== rb) return ra - rb;
|
||||
if (ra === 1) return a.v - b.v;
|
||||
if (ra === 2) return (a.v ? 1 : 0) - (b.v ? 1 : 0);
|
||||
const sa = toText(a);
|
||||
const sb = toText(b);
|
||||
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||
}
|
||||
|
||||
const FUNCTIONS = {
|
||||
SUM: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc + n;
|
||||
}, 0)),
|
||||
AVERAGE: (vals, scope) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
if (!nums.length) return err("DIV");
|
||||
return num(nums.reduce((a, b) => a + b, 0) / nums.length);
|
||||
},
|
||||
MIN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.min(...nums)) : num(0);
|
||||
},
|
||||
MAX: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.max(...nums)) : num(0);
|
||||
},
|
||||
COUNT: (vals) => num(flattenArgs(vals).filter((v) => v && v.t === "n").length),
|
||||
COUNTA: (vals) => num(flattenArgs(vals).filter((v) => !isBlank(v)).length),
|
||||
PRODUCT: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc * n;
|
||||
}, 1)),
|
||||
MEDIAN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null).sort((a, b) => a - b);
|
||||
if (!nums.length) return err("DIV");
|
||||
const m = Math.floor(nums.length / 2);
|
||||
return num(nums.length % 2 ? nums[m] : (nums[m - 1] + nums[m]) / 2);
|
||||
},
|
||||
ABS: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.abs(n));
|
||||
},
|
||||
ROUND: ([v, d]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
const digits = d === undefined ? 0 : toNumber(firstOf(d));
|
||||
if (n === null || digits === null) return err("VALUE");
|
||||
const f = 10 ** Math.trunc(digits);
|
||||
return num(Math.round(n * f) / f);
|
||||
},
|
||||
INT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.floor(n));
|
||||
},
|
||||
SQRT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
if (n === null) return err("VALUE");
|
||||
if (n < 0) return err("VALUE");
|
||||
return num(Math.sqrt(n));
|
||||
},
|
||||
POWER: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null) return err("VALUE");
|
||||
const r = x ** y;
|
||||
return Number.isFinite(r) ? num(r) : err("VALUE");
|
||||
},
|
||||
MOD: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null || y === 0) return y === 0 ? err("DIV") : err("VALUE");
|
||||
return num(((x % y) + y) % y);
|
||||
},
|
||||
CONCAT: (vals, scope) => ({ t: "s", v: flattenArgs(vals).map((v) => toText(v, scope.locale)).join("") }),
|
||||
UPPER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toUpperCase() }),
|
||||
LOWER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toLowerCase() }),
|
||||
LEN: ([v], scope) => num([...toText(firstOf(v), scope.locale)].length),
|
||||
TRIM: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).trim().replace(/\s+/g, " ") }),
|
||||
LEFT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: s.slice(0, Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
RIGHT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: k <= 0 ? "" : s.slice(-Math.trunc(k)) };
|
||||
},
|
||||
MID: ([v, start, len]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const a = toNumber(firstOf(start));
|
||||
const k = len === undefined ? s.length : toNumber(firstOf(len));
|
||||
if (a === null || k === null || a < 1) return err("VALUE");
|
||||
return { t: "s", v: s.slice(Math.trunc(a) - 1, Math.trunc(a) - 1 + Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
TODAY: () => ({ t: "s", v: new Date().toLocaleDateString() }),
|
||||
NOW: () => ({ t: "s", v: new Date().toLocaleString() }),
|
||||
DAY: ([v]) => datePart(v, "day"),
|
||||
MONTH: ([v]) => datePart(v, "month"),
|
||||
YEAR: ([v]) => datePart(v, "year"),
|
||||
NOT: ([v]) => ({ t: "b", v: !toBool(firstOf(v)) }),
|
||||
HYPERLINK: ([url, label], scope) => {
|
||||
const u = toText(firstOf(url), scope.locale).trim();
|
||||
if (!/^https?:\/\//i.test(u)) return err("VALUE");
|
||||
const text = label === undefined ? u : toText(firstOf(label), scope.locale);
|
||||
return { t: "link", url: u, label: text };
|
||||
},
|
||||
};
|
||||
|
||||
function num(n) {
|
||||
return { t: "n", v: n };
|
||||
}
|
||||
|
||||
function toBool(v) {
|
||||
if (!v || v.t === "blank") return false;
|
||||
if (v.t === "b") return v.v;
|
||||
if (v.t === "n") return v.v !== 0;
|
||||
const s = v.t === "s" ? v.v.trim().toUpperCase() : "";
|
||||
if (["TRUE", "VRAI", "1", "YES", "OUI"].includes(s)) return true;
|
||||
if (["FALSE", "FAUX", "0", "", "NO", "NON"].includes(s)) return false;
|
||||
return s !== "";
|
||||
}
|
||||
|
||||
function datePart(v, part) {
|
||||
const first = firstOf(v);
|
||||
let d = null;
|
||||
if (first && first.t === "s") {
|
||||
const parsed = new Date(first.v);
|
||||
if (!Number.isNaN(parsed.getTime())) d = parsed;
|
||||
} else if (first && first.t === "n") {
|
||||
// Excel serial date (1900 system).
|
||||
const base = Date.UTC(1899, 11, 30);
|
||||
d = new Date(base + Math.trunc(first.v) * 86400000);
|
||||
}
|
||||
if (!d) return err("VALUE");
|
||||
if (part === "day") return num(d.getDate());
|
||||
if (part === "month") return num(d.getMonth() + 1);
|
||||
return num(d.getFullYear());
|
||||
}
|
||||
|
||||
function callFunction(name, argNodes, scope) {
|
||||
if (name === "IF") {
|
||||
if (argNodes.length < 2 || argNodes.length > 3) return err("NAME");
|
||||
const cond = evaluate(argNodes[0], scope);
|
||||
if (isErr(cond)) return cond;
|
||||
const branch = toBool(cond) ? argNodes[1] : argNodes[2];
|
||||
if (!branch) return { t: "b", v: false };
|
||||
return evaluate(branch, scope);
|
||||
}
|
||||
if (name === "AND" || name === "OR") {
|
||||
let out = name === "AND";
|
||||
for (const node of argNodes) {
|
||||
const v = evaluate(node, scope);
|
||||
if (isErr(v)) return v;
|
||||
const b = toBool(v);
|
||||
if (name === "AND" && !b) return { t: "b", v: false };
|
||||
if (name === "OR" && b) return { t: "b", v: true };
|
||||
out = b;
|
||||
}
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
if (name === "IFERROR") {
|
||||
if (!argNodes.length || argNodes.length > 2) return err("NAME");
|
||||
const v = evaluate(argNodes[0], scope);
|
||||
if (!isErr(v)) return v;
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") return v;
|
||||
return argNodes[1] ? evaluate(argNodes[1], scope) : { t: "blank" };
|
||||
}
|
||||
const fn = FUNCTIONS[name];
|
||||
if (!fn) return err("NAME");
|
||||
const vals = argNodes.map((node) => evaluate(node, scope));
|
||||
for (const v of vals) {
|
||||
if (isErr(v)) {
|
||||
// Errors propagate, except through aggregations over ranges.
|
||||
if (["SUM", "AVERAGE", "MIN", "MAX", "COUNT", "COUNTA", "PRODUCT", "MEDIAN", "CONCAT"].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return fn(vals, scope);
|
||||
}
|
||||
|
||||
export function evaluate(node, scope) {
|
||||
if (Array.isArray(node)) {
|
||||
const [kind, ...rest] = node;
|
||||
switch (kind) {
|
||||
case "n": return { t: "n", v: rest[0] };
|
||||
case "s": return { t: "s", v: rest[0] };
|
||||
case "b": return { t: "b", v: rest[0] };
|
||||
case "neg": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(-n);
|
||||
}
|
||||
case "%": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(n / 100);
|
||||
}
|
||||
case "ref": {
|
||||
const p = parseA1(rest[0]);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(scope.sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "range": return rangeValues(rest[0], rest[1], scope);
|
||||
case "sheetref": {
|
||||
const [, sheet, ref] = node;
|
||||
const p = parseA1(ref);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "sheetrange": {
|
||||
const [, sheet, r1, r2] = node;
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rows = [];
|
||||
for (let r = Math.min(a.row, b.row); r <= Math.max(a.row, b.row); r++) {
|
||||
const line = [];
|
||||
for (let c = Math.min(a.col, b.col); c <= Math.max(a.col, b.col); c++) {
|
||||
line.push(scope.getCell(sheet, indexToCol(c) + r));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
case "call": return callFunction(rest[0], rest[1], scope);
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
case "^":
|
||||
case "&": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
if (kind === "&") return { t: "s", v: toText(l, scope.locale) + toText(r, scope.locale) };
|
||||
const a = toNumber(l);
|
||||
const b = toNumber(r);
|
||||
if (a === null || b === null) return err("VALUE");
|
||||
if (kind === "+") return num(a + b);
|
||||
if (kind === "-") return num(a - b);
|
||||
if (kind === "*") return num(a * b);
|
||||
if (kind === "/") return b === 0 ? err("DIV") : num(a / b);
|
||||
if (kind === "^") {
|
||||
const v = a ** b;
|
||||
return Number.isFinite(v) ? num(v) : err("VALUE");
|
||||
}
|
||||
return err("VALUE");
|
||||
}
|
||||
case "=":
|
||||
case "!=":
|
||||
case "<":
|
||||
case "<=":
|
||||
case ">":
|
||||
case ">=": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
const c = cmpValues(l, r);
|
||||
const out = kind === "=" ? c === 0 : kind === "!=" ? c !== 0
|
||||
: kind === "<" ? c < 0 : kind === "<=" ? c <= 0
|
||||
: kind === ">" ? c > 0 : c >= 0;
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
default:
|
||||
return err("NAME");
|
||||
}
|
||||
}
|
||||
return err("NAME");
|
||||
}
|
||||
|
||||
/** Render an evaluated value for display (localized booleans/errors). */
|
||||
export function displayValue(v, locale = "en") {
|
||||
const codes = errorCodes(locale);
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
if (v.t === "e") {
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") {
|
||||
return locale === "fr" ? codes.CYCLE : codes.CIRC;
|
||||
}
|
||||
return codes[v.code] || codes.VALUE;
|
||||
}
|
||||
if (v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return displayValue(cell, locale);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* ObsiGate — XLSX menu bar (the ten Google Sheets menus).
|
||||
*
|
||||
* Pure declarative data + a string builder, in the same spirit as
|
||||
* `command-bar.js`: the menu tree lives here and stays testable on its own,
|
||||
* while `viewer.js` only resolves each entry's symbolic `action` against its
|
||||
* own flows. No DOM here beyond `innerHTML` of the shell.
|
||||
*
|
||||
* Entry shape
|
||||
* -----------
|
||||
* { labelKey, icon, action, children, unavailable, danger, needs, sep }
|
||||
* - `labelKey` i18n key of the visible label (never raw text).
|
||||
* - `icon` Lucide icon name, hydrated later by `safeCreateIcons()`.
|
||||
* - `action` symbolic name resolved by the viewer. An entry with no
|
||||
* action — or flagged `unavailable` — is rendered honestly and
|
||||
* reports that ObsiGate does not implement it: a documented
|
||||
* dead end, never a silent no-op.
|
||||
* - `children` a Sheets-style accordion section (Nombre, Thème, Zoom, …):
|
||||
* collapsed, expanded by a click on its parent row.
|
||||
* - `needs` `"editable"` (hide unless the workbook can be written) or
|
||||
* `"notReadOnly"` (hide only for .xls/.ods).
|
||||
* - `sep` a thin rule instead of an entry.
|
||||
*
|
||||
* The Fichier menu keeps the historical `#xlsx-file-btn` / `#xlsx-file-menu` /
|
||||
* `#xlsx-download-btn` / `#xlsx-csv-btn` ids and the `[data-xlsx-export]`
|
||||
* markers: the File menu moved from the sheet strip to the menu bar, but every
|
||||
* consumer (viewer wiring, tests, E2E) keeps working untouched.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
const IC = (name) => `<i data-lucide="${name}" class="xlsx-menu-icon" aria-hidden="true"></i>`;
|
||||
|
||||
/** Google Sheets' menu bar: same order, same entries, same French labels. */
|
||||
export const SHEETS_MENUS = [
|
||||
{
|
||||
id: "file",
|
||||
labelKey: "xlsx.mbar_file",
|
||||
btnId: "xlsx-file-btn",
|
||||
dropId: "xlsx-file-menu",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_new", icon: "file-plus", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_open", icon: "folder-open", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_import", icon: "file-input", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_make_copy", icon: "copy", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_share", icon: "share-2", action: "share" },
|
||||
{ labelKey: "xlsx.mi_email", icon: "mail", unavailable: true },
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_download",
|
||||
icon: "download",
|
||||
children: [
|
||||
{ labelKey: "viewer.download", icon: "download", id: "xlsx-download-btn", action: "download" },
|
||||
{ labelKey: "xlsx.export_csv", icon: "file-spreadsheet", id: "xlsx-csv-btn", action: "export-csv", titleKey: "xlsx.csv_export" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.export_md", icon: "file-text", export: "md", action: "export-md" },
|
||||
{ labelKey: "xlsx.export_html", icon: "code", export: "html", action: "export-html" },
|
||||
{ labelKey: "xlsx.print", icon: "printer", export: "print", action: "print" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_rename", icon: "pencil", action: "rename" },
|
||||
{ labelKey: "xlsx.mi_trash", icon: "trash-2", action: "trash", danger: true },
|
||||
{ labelKey: "xlsx.mi_history", icon: "history", action: "history" },
|
||||
{ labelKey: "xlsx.mi_offline", icon: "cloud-off", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_details", icon: "info", action: "details" },
|
||||
{ labelKey: "xlsx.mi_settings", icon: "settings", action: "settings" },
|
||||
{ labelKey: "xlsx.print", icon: "printer", action: "print" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
labelKey: "xlsx.mbar_edit",
|
||||
items: [
|
||||
{ labelKey: "xlsx.undo", icon: "undo-2", action: "undo", needs: "notReadOnly" },
|
||||
{ labelKey: "xlsx.redo", icon: "redo-2", action: "redo", needs: "notReadOnly" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.cut", icon: "scissors", action: "cut", needs: "editable" },
|
||||
{ labelKey: "xlsx.copy", icon: "copy", action: "copy" },
|
||||
{ labelKey: "xlsx.paste", icon: "clipboard-paste", action: "paste", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_paste_special",
|
||||
icon: "clipboard-paste",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_paste_all", icon: "clipboard-paste", action: "paste" },
|
||||
{ labelKey: "xlsx.mi_paste_values", icon: "pilcrow", action: "paste-values" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_move",
|
||||
icon: "move",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_move_rows", icon: "rows", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_move_cols", icon: "columns", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_delete",
|
||||
icon: "trash-2",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_delete_rows", icon: "rows", action: "delete-rows", danger: true },
|
||||
{ labelKey: "xlsx.mi_delete_cols", icon: "columns", action: "delete-cols", danger: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.clear_contents", icon: "eraser", action: "clear-contents" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_select_all", icon: "check-check", action: "select-all" },
|
||||
{ labelKey: "xlsx.mi_replace", icon: "replace", action: "find-replace", needs: "editable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
labelKey: "xlsx.mbar_view",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_freeze",
|
||||
icon: "snowflake",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_freeze_here", icon: "snowflake", action: "freeze" },
|
||||
{ labelKey: "xlsx.mi_freeze_row", icon: "rows", action: "freeze-row" },
|
||||
{ labelKey: "xlsx.mi_freeze_col", icon: "columns", action: "freeze-col" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.format_unfreeze", icon: "sun", action: "unfreeze" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_show",
|
||||
icon: "eye",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_show_formula", icon: "chevrons-right", action: "toggle-formula-bar" },
|
||||
{ labelKey: "xlsx.mi_show_grid", icon: "grid-3x3", action: "toggle-grid" },
|
||||
{ labelKey: "xlsx.mi_show_headers", icon: "rows-3", action: "toggle-headers" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_show_dashboard", icon: "layout-dashboard", action: "dashboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_group",
|
||||
icon: "group",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_group_rows", icon: "rows", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_group_cols", icon: "columns", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_ungroup_rows", icon: "ungroup", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_ungroup_cols", icon: "ungroup", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_hidden_sheets",
|
||||
icon: "eye-off",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_hide_sheet", icon: "eye-off", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_unhide_sheets", icon: "eye", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_zoom",
|
||||
icon: "zoom-in",
|
||||
children: [50, 75, 90, 100, 125, 150, 200].map((z) => ({
|
||||
labelKey: null,
|
||||
label: `${z} %`,
|
||||
icon: z === 100 ? "crosshair" : null,
|
||||
action: "zoom",
|
||||
arg: z,
|
||||
})),
|
||||
},
|
||||
{ labelKey: "xlsx.mi_fullscreen", icon: "fullscreen", action: "fullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "insert",
|
||||
labelKey: "xlsx.mbar_insert",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_insert_rows",
|
||||
icon: "rows",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.insert_row_above", icon: "arrow-up", action: "insert-rows-above" },
|
||||
{ labelKey: "xlsx.insert_row_below", icon: "arrow-down", action: "insert-rows-below" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_insert_cols",
|
||||
icon: "columns",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.insert_col_left", icon: "arrow-left", action: "insert-cols-left" },
|
||||
{ labelKey: "xlsx.insert_col_right", icon: "arrow-right", action: "insert-cols-right" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_insert_cells", icon: "grid-2x2-plus", action: "insert-cells", needs: "editable" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_image", icon: "image", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_chart", icon: "bar-chart-3", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_drawing", icon: "pen-tool", unavailable: true },
|
||||
{ labelKey: "xlsx.link_add", icon: "link", action: "link", needs: "editable" },
|
||||
{ labelKey: "xlsx.comment_add", icon: "message-square", action: "comment", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_function",
|
||||
icon: "function-square",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_function_hint",
|
||||
action: "function",
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_pivot", icon: "table-2", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_smart_chips", icon: "tags", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_form", icon: "clipboard-list", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
labelKey: "xlsx.mbar_format",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_number",
|
||||
icon: "hash",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_num_general", icon: "type", action: "numfmt", arg: "General" },
|
||||
{ labelKey: "xlsx.format_num_number", icon: "hash", action: "numfmt", arg: "0.00" },
|
||||
{ labelKey: "xlsx.format_num_percent", icon: "percent", action: "numfmt", arg: "0.0%" },
|
||||
{ labelKey: "xlsx.format_num_currency", icon: "euro", action: "numfmt", arg: '#,##0.00 "€"' },
|
||||
{ labelKey: "xlsx.format_num_date", icon: "calendar", action: "numfmt", arg: "DD/MM/YYYY" },
|
||||
{ labelKey: "xlsx.format_num_text", icon: "pilcrow", action: "numfmt", arg: "@" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_theme",
|
||||
icon: "swatch-book",
|
||||
// Filled on demand by the viewer (the theme list is built from
|
||||
// themes.js, so it must not be hard-coded here).
|
||||
children: [],
|
||||
action: "theme-menu",
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.format_bold", icon: "bold", action: "bold", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_italic", icon: "italic", action: "italic", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_underline", icon: "underline", action: "underline", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_strike", icon: "strikethrough", action: "strike", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_font_size",
|
||||
icon: "type",
|
||||
needs: "editable",
|
||||
children: [9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) => ({
|
||||
labelKey: null,
|
||||
label: String(s),
|
||||
icon: null,
|
||||
action: "fontsize",
|
||||
arg: s,
|
||||
})),
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_align",
|
||||
icon: "align-justify",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{
|
||||
labelKey: "xlsx.mi_align_h",
|
||||
icon: "align-left",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_align_left", icon: "align-left", action: "align", arg: "left" },
|
||||
{ labelKey: "xlsx.format_align_center", icon: "align-center", action: "align", arg: "center" },
|
||||
{ labelKey: "xlsx.format_align_right", icon: "align-right", action: "align", arg: "right" },
|
||||
{ labelKey: "xlsx.format_align_justify", icon: "align-justify", action: "align", arg: "justify" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_align_v",
|
||||
icon: "align-center-vertical",
|
||||
children: [
|
||||
{ labelKey: "xlsx.valign_top", icon: "align-start-vertical", action: "valign", arg: "top" },
|
||||
{ labelKey: "xlsx.valign_middle", icon: "align-center-vertical", action: "valign", arg: "middle" },
|
||||
{ labelKey: "xlsx.valign_bottom", icon: "align-end-vertical", action: "valign", arg: "bottom" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_wrap",
|
||||
icon: "wrap-text",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_wrap_on", icon: "wrap-text", action: "wrap-on" },
|
||||
{ labelKey: "xlsx.mi_wrap_off", icon: "remove-formatting", action: "wrap-off" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_rotation",
|
||||
icon: "rotate-cw",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.rotation_0", icon: "minus", action: "rotate", arg: 0 },
|
||||
{ labelKey: "xlsx.rotation_45", icon: "trending-up", action: "rotate", arg: 45 },
|
||||
{ labelKey: "xlsx.rotation_315", icon: "trending-down", action: "rotate", arg: -45 },
|
||||
{ labelKey: "xlsx.rotation_90", icon: "arrow-up", action: "rotate", arg: 90 },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
// Tailles de colonnes / de lignes — même emplacement que Google Sheets
|
||||
// (Format ▸ Redimensionner) et Excel (Format ▸ Colonne / Ligne) :
|
||||
// saisie manuelle (unités Excel : caractères / points) + ajustement
|
||||
// automatique au contenu (AutoFit / « Fit to data »).
|
||||
{
|
||||
labelKey: "xlsx.mi_resize",
|
||||
icon: "move-horizontal",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_col_width", icon: "move-horizontal", action: "col-width" },
|
||||
{ labelKey: "xlsx.format_col_fit", icon: "maximize-2", action: "col-fit" },
|
||||
{ labelKey: "xlsx.format_row_height", icon: "move-vertical", action: "row-height" },
|
||||
{ labelKey: "xlsx.format_row_fit", icon: "maximize-2", action: "row-fit" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_conditional", icon: "git-compare", unavailable: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_banded",
|
||||
icon: "rows-3",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_banded_hint",
|
||||
action: "banded",
|
||||
},
|
||||
{ labelKey: "xlsx.format_clear", icon: "remove-formatting", action: "clear-format", needs: "editable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
labelKey: "xlsx.mbar_data",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_sort",
|
||||
icon: "arrow-up-down",
|
||||
children: [
|
||||
{ labelKey: "xlsx.sort_asc", icon: "arrow-up", action: "sort-asc" },
|
||||
{ labelKey: "xlsx.sort_desc", icon: "arrow-down", action: "sort-desc" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.sort_reset", icon: "rotate-ccw", action: "sort-reset" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_filter", icon: "filter", titleKey: "xlsx.mi_filter_hint", action: "filter-column" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_validation", icon: "list-checks", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_named", icon: "bookmark", action: "named-ranges" },
|
||||
{ labelKey: "xlsx.mi_protect", icon: "lock", unavailable: true },
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_cleanup",
|
||||
icon: "wand-2",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_cleanup_dupes", icon: "copy-x", action: "cleanup-dupes" },
|
||||
{ labelKey: "xlsx.mi_cleanup_trim", icon: "scissors", action: "cleanup-trim" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_split",
|
||||
icon: "columns-2",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_split_hint",
|
||||
action: "split-text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
labelKey: "xlsx.mbar_tools",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_form_create", icon: "clipboard-list", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_spellcheck", icon: "spell-check", unavailable: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_autocomplete",
|
||||
icon: "text-cursor-input",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_autocomplete_on", icon: "check", action: "autocomplete", arg: true },
|
||||
{ labelKey: "xlsx.mi_autocomplete_off", icon: "x", action: "autocomplete", arg: false },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_notifications", icon: "bell", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_macros", icon: "workflow", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_script_editor", icon: "code", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_a11y", icon: "accessibility", action: "a11y" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
labelKey: "xlsx.mbar_gemini",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_ask_gemini", icon: "sparkles", action: "gemini" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_gem_table", icon: "table-2", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_formula", icon: "function-square", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_insights", icon: "lightbulb", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_chart", icon: "bar-chart-3", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_gem_summarize", icon: "file-text", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_gem_actions", icon: "wand-sparkles", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_more", icon: "ellipsis", action: "gemini" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "extensions",
|
||||
labelKey: "xlsx.mbar_extensions",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_addons", icon: "blocks", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_apps_script", icon: "code", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_appsheet", icon: "app-window", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_looker", icon: "bar-chart", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
labelKey: "xlsx.mbar_help",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_help_sheets", icon: "life-buoy", action: "help" },
|
||||
{ labelKey: "xlsx.mi_training", icon: "graduation-cap", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_whatsnew", icon: "sparkles", action: "whatsnew" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_shortcuts", icon: "keyboard", action: "shortcuts" },
|
||||
{ labelKey: "xlsx.mi_report", icon: "bug", action: "report" },
|
||||
{ labelKey: "xlsx.mi_abuse", icon: "flag", unavailable: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Drop an entry when the workbook cannot honour it. */
|
||||
const allowed = (entry, { editable, readOnly }) => {
|
||||
if (entry.needs === "editable") return editable;
|
||||
if (entry.needs === "notReadOnly") return !readOnly;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Label of one entry: an explicit label, else the i18n key, else nothing. */
|
||||
const labelOf = (entry) => (entry.label !== undefined
|
||||
? entry.label
|
||||
: (entry.labelKey ? t(entry.labelKey) : ""));
|
||||
|
||||
/** One clickable row. `depth` > 0 = nested inside an accordion section. */
|
||||
const rowHtml = (entry, depth, ctx) => {
|
||||
if (entry.sep) return `<div class="xlsx-structure-sep" role="separator"></div>`;
|
||||
const unavailable = entry.unavailable || (!entry.children && !entry.action && !entry.export);
|
||||
const id = entry.id ? ` id="${entry.id}"` : "";
|
||||
const exportAttr = entry.export ? ` data-xlsx-export="${entry.export}"` : "";
|
||||
const argAttr = entry.arg !== undefined ? ` data-xlsx-arg="${escapeHtml(String(entry.arg))}"` : "";
|
||||
const title = entry.titleKey ? ` title="${escapeHtml(t(entry.titleKey))}"` : "";
|
||||
const cls = [
|
||||
"btn-action",
|
||||
"xlsx-structure-item",
|
||||
"xlsx-menu-item",
|
||||
unavailable ? "xlsx-menu-item-na" : "",
|
||||
entry.danger ? "xlsx-context-danger" : "",
|
||||
depth ? `xlsx-menu-item-d${depth}` : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const caret = entry.children
|
||||
? `<i data-lucide="chevron-right" class="xlsx-menu-caret" aria-hidden="true"></i>`
|
||||
: "";
|
||||
const body = entry.children
|
||||
// A section may ALSO declare an action: the viewer runs it instead of
|
||||
// toggling, which is how the empty « Thème » section gets filled.
|
||||
? `<button type="button" class="${cls}" role="menuitem" aria-haspopup="menu" aria-expanded="false"`
|
||||
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}${title}${id}>`
|
||||
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span>${caret}</button>`
|
||||
+ `<div class="xlsx-menu-sub" hidden>${entry.children
|
||||
.filter((c) => allowed(c, ctx))
|
||||
.map((c) => rowHtml(c, depth + 1, ctx))
|
||||
.join("")}</div>`
|
||||
: `<button type="button" class="${cls}" role="menuitem"`
|
||||
+ `${unavailable ? ' data-xlsx-unavailable="1"' : ""}`
|
||||
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}`
|
||||
+ `${argAttr}${exportAttr}${title}${id}>`
|
||||
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span></button>`;
|
||||
return body;
|
||||
};
|
||||
|
||||
/** One dropdown: the filtered rows of a menu. */
|
||||
const menuItemsHtml = (menu, ctx) => menu.items
|
||||
.filter((entry) => allowed(entry, ctx))
|
||||
.map((entry) => rowHtml(entry, 0, ctx))
|
||||
.join("");
|
||||
|
||||
/**
|
||||
* Build the menu bar: one button per menu plus its (hidden) dropdown.
|
||||
*
|
||||
* @param {object} ctx
|
||||
* @param {boolean} ctx.editable - .xlsx that can be written
|
||||
* @param {boolean} ctx.readOnly - .xls / .ods
|
||||
* @returns {string} the `.xlsx-menubar` markup
|
||||
*/
|
||||
export function buildMenuBar({ editable = true, readOnly = false } = {}) {
|
||||
const ctx = { editable, readOnly };
|
||||
const bars = SHEETS_MENUS.map((menu) => {
|
||||
const btnId = menu.btnId || `xlsx-menu-btn-${menu.id}`;
|
||||
const dropId = menu.dropId || `xlsx-menu-${menu.id}`;
|
||||
const label = escapeHtml(t(menu.labelKey));
|
||||
return `<span class="xlsx-menuwrap${menu.id === "gemini" ? " xlsx-menuwrap-gemini" : ""}">`
|
||||
+ `<button type="button" class="xlsx-menubtn" id="${btnId}" role="menuitem"`
|
||||
+ ` aria-haspopup="menu" aria-expanded="false" aria-controls="${dropId}"`
|
||||
+ ` data-xlsx-menu="${menu.id}">${label}</button>`
|
||||
+ `<div class="xlsx-menudrop" id="${dropId}" role="menu"`
|
||||
+ ` aria-label="${label}" style="display:none">`
|
||||
+ `${menuItemsHtml(menu, ctx)}`
|
||||
+ `</div></span>`;
|
||||
});
|
||||
return `<div class="xlsx-menubar" role="menubar" aria-label="${escapeHtml(t("xlsx.mbar_label"))}">`
|
||||
+ `${bars.join("")}</div>`;
|
||||
}
|
||||
@@ -24,9 +24,36 @@ export function columnName(col) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// #184 — cell lookup cache. `findTd` used to run a `querySelector` per call;
|
||||
// painting the grid out to A → Z × 1000 rows means a single "select column"
|
||||
// asks for 26 000 cells, which was quadratic on the DOM. The index is built
|
||||
// once per panel and rebuilt on demand whenever an entry no longer matches its
|
||||
// own `data-cell` (the signature of a renumbered grid after a structure op).
|
||||
const _cellIndex = new WeakMap();
|
||||
|
||||
function buildCellIndex(panel) {
|
||||
const map = new Map();
|
||||
panel.querySelectorAll("td[data-cell]").forEach((td) => {
|
||||
map.set(td.dataset.cell, td);
|
||||
});
|
||||
_cellIndex.set(panel, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Drop a panel's lookup cache (bulk DOM changes). */
|
||||
export function invalidateCellIndex(panel) {
|
||||
if (panel) _cellIndex.delete(panel);
|
||||
}
|
||||
|
||||
/** Find a rendered cell inside a sheet panel by (row, col). */
|
||||
export function findTd(panel, row, col) {
|
||||
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||
const ref = `${columnName(col)}${row}`;
|
||||
const cached = _cellIndex.get(panel);
|
||||
const hit = cached && cached.get(ref);
|
||||
if (hit && hit.dataset.cell === ref) return hit;
|
||||
// Miss, or a stale entry (the row was renumbered): rebuild once and retry.
|
||||
const fresh = buildCellIndex(panel).get(ref);
|
||||
return fresh && fresh.dataset.cell === ref ? fresh : null;
|
||||
}
|
||||
|
||||
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
|
||||
|
||||
+242
-4
@@ -1461,6 +1461,16 @@
|
||||
"modal.delete_permanently": "Delete permanently",
|
||||
"modal.delete_warning": "Warning!",
|
||||
"modal.dir_to_delete": "Folder to delete:",
|
||||
"modal.ext_excalidraw": "Excalidraw (.excalidraw)",
|
||||
"modal.ext_js": "JavaScript (.js)",
|
||||
"modal.ext_json": "JSON (.json)",
|
||||
"modal.ext_md": "Markdown (.md)",
|
||||
"modal.ext_ps1": "PowerShell (.ps1)",
|
||||
"modal.ext_py": "Python (.py)",
|
||||
"modal.ext_sh": "Shell (.sh)",
|
||||
"modal.ext_txt": "Plain text (.txt)",
|
||||
"modal.ext_xlsx": "Excel (.xlsx)",
|
||||
"modal.ext_yaml": "YAML (.yaml)",
|
||||
"modal.file_name": "File name",
|
||||
"modal.file_to_delete": "File to delete:",
|
||||
"modal.file_type": "File type",
|
||||
@@ -1847,8 +1857,8 @@
|
||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||
"xlsx.readonly_badge": "Read-only",
|
||||
"xlsx.readonly_hint": "This format (.xls / .ods) cannot be edited in ObsiGate — convert it to .xlsx to edit.",
|
||||
"xlsx.formulas_note": "Formulas not recalculated",
|
||||
"xlsx.formulas_note_title": "ObsiGate shows the formula as stored: Excel recalculates it on open. Dependent cells do not refresh on screen.",
|
||||
"xlsx.formulas_note": "Formulas computed locally",
|
||||
"xlsx.formulas_note_title": "ObsiGate computes common formulas on screen (arithmetic, SUM, AVERAGE, IF…). Unsupported functions show the formula as stored: Excel recalculates it on open.",
|
||||
"xlsx.truncated_title": "Truncated sheet",
|
||||
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
||||
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
||||
@@ -1876,8 +1886,31 @@
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
|
||||
"xlsx.csv_export": "Export the sheet or the selection as CSV",
|
||||
"xlsx.colour_panel_font": "Text colour",
|
||||
"xlsx.colour_panel_fill": "Fill colour",
|
||||
"xlsx.colour_palette": "Colour palette",
|
||||
"xlsx.colour_reset": "Reset",
|
||||
"xlsx.colour_reset_font": "Reset the text colour",
|
||||
"xlsx.colour_reset_fill": "Reset the fill colour",
|
||||
"xlsx.colour_standard": "Standard",
|
||||
"xlsx.colour_custom": "Custom",
|
||||
"xlsx.colour_edit_standard": "Pick a custom colour",
|
||||
"xlsx.colour_custom_pick": "Add a custom colour",
|
||||
"xlsx.colour_conditional": "Conditional formatting",
|
||||
"xlsx.colour_banded": "Banded colours",
|
||||
"xlsx.colour_unavailable": "“{feature}” is not available in ObsiGate yet.",
|
||||
"xlsx.colour_black": "Black",
|
||||
"xlsx.colour_white": "White",
|
||||
"xlsx.colour_blue": "Blue",
|
||||
"xlsx.colour_red": "Red",
|
||||
"xlsx.colour_yellow": "Yellow",
|
||||
"xlsx.colour_green": "Green",
|
||||
"xlsx.colour_orange": "Orange",
|
||||
"xlsx.colour_cyan": "Cyan",
|
||||
"xlsx.export_btn": "Export",
|
||||
"xlsx.export_csv": "Export as CSV",
|
||||
"xlsx.export_title": "Export the selection or the sheet",
|
||||
"xlsx.file_menu": "File",
|
||||
"xlsx.export_md": "Export as Markdown",
|
||||
"xlsx.export_html": "Export as HTML",
|
||||
"xlsx.print": "Print",
|
||||
@@ -1885,6 +1918,47 @@
|
||||
"xlsx.export_scope_selection": "the selection",
|
||||
"xlsx.export_done": "Exported {scope}",
|
||||
"xlsx.format_btn": "Formatting",
|
||||
"xlsx.format_strike": "Strikethrough",
|
||||
"xlsx.format_align_justify": "Justified",
|
||||
"xlsx.tb_search": "Search in sheet",
|
||||
"xlsx.tb_paint": "Paint format",
|
||||
"xlsx.paint_pick_target": "Click the target cell or range",
|
||||
"xlsx.tb_zoom": "Zoom",
|
||||
"xlsx.tb_currency": "Currency format",
|
||||
"xlsx.tb_percent": "Percent format",
|
||||
"xlsx.tb_dec_minus": "Decrease decimals",
|
||||
"xlsx.tb_dec_plus": "Increase decimals",
|
||||
"xlsx.tb_number": "Number format",
|
||||
"xlsx.tb_numfmt": "Number format",
|
||||
"xlsx.tb_font_minus": "Decrease font size",
|
||||
"xlsx.tb_font_plus": "Increase font size",
|
||||
"xlsx.tb_font_size": "Font size",
|
||||
"xlsx.tb_borders": "Borders",
|
||||
"xlsx.border_all": "All borders",
|
||||
"xlsx.border_outer": "Outer borders",
|
||||
"xlsx.border_none": "No borders",
|
||||
"xlsx.tb_merge": "Merge cells",
|
||||
"xlsx.tb_alignh": "Horizontal alignment",
|
||||
"xlsx.tb_alignv": "Vertical alignment",
|
||||
"xlsx.valign_top": "Top",
|
||||
"xlsx.valign_middle": "Middle",
|
||||
"xlsx.valign_bottom": "Bottom",
|
||||
"xlsx.tb_wrap": "Text wrapping",
|
||||
"xlsx.tb_rotation": "Text rotation",
|
||||
"xlsx.rotation_0": "Horizontal",
|
||||
"xlsx.rotation_45": "Diagonal up",
|
||||
"xlsx.rotation_315": "Diagonal down",
|
||||
"xlsx.rotation_90": "Vertical",
|
||||
"xlsx.tb_more": "More options",
|
||||
"xlsx.comment_add": "Comment",
|
||||
"xlsx.comment_prompt": "Comment text (empty to delete):",
|
||||
"xlsx.link_add": "Insert link",
|
||||
"xlsx.link_url": "Link address (https://…):",
|
||||
"xlsx.link_label": "Label:",
|
||||
"xlsx.link_invalid": "Invalid address (https://… expected)",
|
||||
"xlsx.sum_insert": "Sum",
|
||||
"xlsx.sum_empty": "Nothing to total (numbers above or a selection)",
|
||||
"xlsx.paint_pick_source": "Select a formatted source cell first",
|
||||
"xlsx.format_bold": "Bold",
|
||||
"xlsx.format_italic": "Italic",
|
||||
"xlsx.format_underline": "Underline",
|
||||
@@ -1906,9 +1980,13 @@
|
||||
"xlsx.format_freeze": "Freeze panes here",
|
||||
"xlsx.format_unfreeze": "Unfreeze panes",
|
||||
"xlsx.format_col_width": "Column width…",
|
||||
"xlsx.format_col_prompt": "Width of column {col} (0 to 255)",
|
||||
"xlsx.format_col_prompt": "Width of column {col} — in characters (0 to 255, 0 = default width)",
|
||||
"xlsx.format_row_height": "Row height…",
|
||||
"xlsx.format_row_prompt": "Height of row {row} (0 to 409)",
|
||||
"xlsx.format_row_prompt": "Height of row {row} — in points (0 to 409, 0 = default height)",
|
||||
"xlsx.format_col_fit": "Fit column width to data",
|
||||
"xlsx.format_row_fit": "Fit row height to data",
|
||||
"xlsx.mi_resize": "Resize",
|
||||
"xlsx.fit_nothing": "Nothing to fit (empty content or already fitted)",
|
||||
"xlsx.format_applied": "Formatting saved",
|
||||
"xlsx.format_error": "Formatting failed",
|
||||
"xlsx.sort_asc": "Sort column A→Z",
|
||||
@@ -1969,6 +2047,166 @@
|
||||
"xlsx.feature_signature": "digital signature",
|
||||
"xlsx.feature_rich_comments": "rich comments",
|
||||
"xlsx.feature_macros": "macros",
|
||||
|
||||
"xlsx.mbar_label": "Spreadsheet menus",
|
||||
"xlsx.cmdbar_label": "Spreadsheet command bar",
|
||||
"xlsx.mbar_file": "File",
|
||||
"xlsx.mbar_edit": "Edit",
|
||||
"xlsx.mbar_view": "View",
|
||||
"xlsx.mbar_insert": "Insert",
|
||||
"xlsx.mbar_format": "Format",
|
||||
"xlsx.mbar_data": "Data",
|
||||
"xlsx.mbar_tools": "Tools",
|
||||
"xlsx.mbar_gemini": "Gemini",
|
||||
"xlsx.mbar_extensions": "Extensions",
|
||||
"xlsx.mbar_help": "Help",
|
||||
"xlsx.menu_unavailable": "“{item}” is not available in ObsiGate.",
|
||||
"xlsx.menu_unavailable_hint": "This option does not exist in ObsiGate's spreadsheet editor: it is listed to stay faithful to Google Sheets.",
|
||||
|
||||
"xlsx.mi_new": "New",
|
||||
"xlsx.mi_open": "Open",
|
||||
"xlsx.mi_import": "Import",
|
||||
"xlsx.mi_make_copy": "Make a copy",
|
||||
"xlsx.mi_share": "Share",
|
||||
"xlsx.mi_email": "Email",
|
||||
"xlsx.mi_download": "Download",
|
||||
"xlsx.mi_rename": "Rename",
|
||||
"xlsx.mi_trash": "Move to trash",
|
||||
"xlsx.mi_history": "Version history",
|
||||
"xlsx.mi_offline": "Make available offline",
|
||||
"xlsx.mi_details": "Details",
|
||||
"xlsx.mi_settings": "Settings",
|
||||
|
||||
"xlsx.mi_paste_special": "Paste special",
|
||||
"xlsx.mi_paste_all": "Paste all",
|
||||
"xlsx.mi_paste_values": "Paste values only",
|
||||
"xlsx.mi_move": "Move",
|
||||
"xlsx.mi_move_rows": "Selected rows down",
|
||||
"xlsx.mi_move_cols": "Selected columns right",
|
||||
"xlsx.mi_delete": "Delete",
|
||||
"xlsx.mi_delete_rows": "Delete selected rows",
|
||||
"xlsx.mi_delete_cols": "Delete selected columns",
|
||||
"xlsx.mi_select_all": "Select all",
|
||||
"xlsx.mi_replace": "Find and replace",
|
||||
"xlsx.mi_replace_find": "Find",
|
||||
"xlsx.mi_replace_with": "Replace with",
|
||||
"xlsx.mi_replace_one": "Replace",
|
||||
"xlsx.mi_replace_all": "Replace all",
|
||||
"xlsx.mi_replace_none": "No match found",
|
||||
"xlsx.mi_replace_done": "{n} cell(s) replaced",
|
||||
|
||||
"xlsx.mi_freeze": "Freeze",
|
||||
"xlsx.mi_freeze_here": "Freeze up to active cell",
|
||||
"xlsx.mi_freeze_row": "Freeze active row",
|
||||
"xlsx.mi_freeze_col": "Freeze active column",
|
||||
"xlsx.mi_show": "Show",
|
||||
"xlsx.mi_show_formula": "Formula bar",
|
||||
"xlsx.mi_show_grid": "Gridlines",
|
||||
"xlsx.mi_show_headers": "Headers",
|
||||
"xlsx.mi_show_dashboard": "Dashboard",
|
||||
"xlsx.mi_group": "Group",
|
||||
"xlsx.mi_group_rows": "Group selected rows",
|
||||
"xlsx.mi_group_cols": "Group selected columns",
|
||||
"xlsx.mi_ungroup_rows": "Ungroup rows",
|
||||
"xlsx.mi_ungroup_cols": "Ungroup columns",
|
||||
"xlsx.mi_hidden_sheets": "Hidden sheets",
|
||||
"xlsx.mi_hide_sheet": "Hide active sheet",
|
||||
"xlsx.mi_unhide_sheets": "Show hidden sheets",
|
||||
"xlsx.mi_zoom": "Zoom",
|
||||
"xlsx.mi_fullscreen": "Full screen",
|
||||
"xlsx.mi_exit_fullscreen": "Exit full screen",
|
||||
|
||||
"xlsx.mi_insert_rows": "Rows",
|
||||
"xlsx.mi_insert_cols": "Columns",
|
||||
"xlsx.mi_insert_cells": "Cells",
|
||||
"xlsx.mi_image": "Image",
|
||||
"xlsx.mi_chart": "Chart",
|
||||
"xlsx.mi_drawing": "Drawing",
|
||||
"xlsx.mi_function": "Function",
|
||||
"xlsx.mi_function_hint": "Inserts the function and puts the caret inside the parentheses",
|
||||
"xlsx.mi_pivot": "Pivot table",
|
||||
"xlsx.mi_smart_chips": "Smart chips",
|
||||
"xlsx.mi_form": "Form",
|
||||
|
||||
"xlsx.mi_number": "Number",
|
||||
"xlsx.mi_theme": "Theme",
|
||||
"xlsx.mi_font_size": "Font size",
|
||||
"xlsx.mi_align": "Alignment",
|
||||
"xlsx.mi_align_h": "Horizontal alignment",
|
||||
"xlsx.mi_align_v": "Vertical alignment",
|
||||
"xlsx.mi_wrap": "Text wrapping",
|
||||
"xlsx.mi_wrap_on": "On",
|
||||
"xlsx.mi_wrap_off": "Off",
|
||||
"xlsx.mi_rotation": "Text rotation",
|
||||
"xlsx.mi_conditional": "Conditional formatting",
|
||||
"xlsx.mi_banded": "Banded colours",
|
||||
"xlsx.mi_banded_hint": "Alternates the fill colour every other row in the selection",
|
||||
"xlsx.mi_banded_done": "Banded colours applied to {n} row(s)",
|
||||
"xlsx.mi_banded_default": "#eef3fb",
|
||||
|
||||
"xlsx.mi_sort": "Sort range",
|
||||
"xlsx.mi_filter": "Create filter",
|
||||
"xlsx.mi_filter_hint": "Filters the rows on the active cell's value",
|
||||
"xlsx.mi_validation": "Data validation",
|
||||
"xlsx.mi_named": "Named ranges",
|
||||
"xlsx.mi_protect": "Protect sheets and ranges",
|
||||
"xlsx.mi_cleanup": "Data cleanup",
|
||||
"xlsx.mi_cleanup_dupes": "Remove duplicates",
|
||||
"xlsx.mi_cleanup_trim": "Trim extra spaces",
|
||||
"xlsx.mi_cleanup_dupes_none": "No duplicate in the selection",
|
||||
"xlsx.mi_cleanup_dupes_done": "{n} duplicate row(s) removed",
|
||||
"xlsx.mi_cleanup_trim_done": "{n} cell(s) cleaned",
|
||||
"xlsx.mi_split": "Split text to columns",
|
||||
"xlsx.mi_split_hint": "Splits the active cell's content on a separator",
|
||||
"xlsx.mi_split_sep": "Separator",
|
||||
"xlsx.mi_split_done": "{n} column(s) filled from the active cell",
|
||||
|
||||
"xlsx.mi_form_create": "Create a form",
|
||||
"xlsx.mi_spellcheck": "Spelling",
|
||||
"xlsx.mi_autocomplete": "Autocomplete",
|
||||
"xlsx.mi_autocomplete_on": "Autocomplete on",
|
||||
"xlsx.mi_autocomplete_off": "Autocomplete off",
|
||||
"xlsx.mi_notifications": "Notification rules",
|
||||
"xlsx.mi_macros": "Macros",
|
||||
"xlsx.mi_script_editor": "Script editor",
|
||||
"xlsx.mi_a11y": "Accessibility",
|
||||
"xlsx.mi_a11y_on": "Enhanced accessibility on (bigger cells, higher-contrast grid)",
|
||||
"xlsx.mi_a11y_off": "Enhanced accessibility off",
|
||||
|
||||
"xlsx.mi_ask_gemini": "Ask Gemini",
|
||||
"xlsx.mi_gem_table": "Create tables",
|
||||
"xlsx.mi_gem_formula": "Create formulas",
|
||||
"xlsx.mi_gem_insights": "Analysis and insights",
|
||||
"xlsx.mi_gem_chart": "Create charts",
|
||||
"xlsx.mi_gem_summarize": "Summarize emails and files",
|
||||
"xlsx.mi_gem_actions": "Perform actions",
|
||||
"xlsx.mi_gem_more": "More options",
|
||||
|
||||
"xlsx.mi_addons": "Add-ons",
|
||||
"xlsx.mi_apps_script": "Apps Script",
|
||||
"xlsx.mi_appsheet": "AppSheet",
|
||||
"xlsx.mi_looker": "Looker Studio",
|
||||
|
||||
"xlsx.mi_help_sheets": "Spreadsheets help",
|
||||
"xlsx.mi_training": "Training",
|
||||
"xlsx.mi_whatsnew": "What's new",
|
||||
"xlsx.mi_shortcuts": "Keyboard shortcuts",
|
||||
"xlsx.mi_report": "Report a problem",
|
||||
"xlsx.mi_abuse": "Report abuse",
|
||||
|
||||
"xlsx.details_title": "File details",
|
||||
"xlsx.details_body": "Vault: {vault}\nFile: {path}\nSheets: {sheets}\nDisplayed cells: {cells}\nLast revision: {revision}\nUnsaved changes: {dirty}",
|
||||
"xlsx.shortcuts_title": "Keyboard shortcuts",
|
||||
"xlsx.shortcuts_body": "Ctrl/Cmd + Z — Undo\nCtrl/Cmd + Y — Redo\nCtrl/Cmd + C / X / V — Copy / Cut / Paste\nCtrl/Cmd + A — Select all\nCtrl/Cmd + F — Search in sheet\nEnter — Commit and move down\nF2 — Edit cell\nAlt + arrow — Line break inside the cell\nEscape — Leave edit mode\nDelete — Clear the selection\nDouble-click — Edit in place\nShift + click — Extend selection",
|
||||
"xlsx.version_title": "What's new",
|
||||
"xlsx.version_body": "ObsiGate {version} — {date}\n{status}",
|
||||
"xlsx.report_copied": "Diagnostic copied to the clipboard.",
|
||||
"xlsx.themes_title": "Sheet theme",
|
||||
"xlsx.function_prompt": "Function to insert",
|
||||
"xlsx.rename_prompt": "New file name",
|
||||
"xlsx.trash_confirm": "Move “{name}” to trash? The workbook will close.",
|
||||
"xlsx.rename_done": "File renamed to {name}",
|
||||
"xlsx.trash_done": "File moved to trash",
|
||||
"viewer.download_md": "Download as .md",
|
||||
"viewer.download_file": "Download file",
|
||||
"viewer.pretty": "Pretty",
|
||||
|
||||
+242
-4
@@ -1461,6 +1461,16 @@
|
||||
"modal.delete_permanently": "Supprimer définitivement",
|
||||
"modal.delete_warning": "Attention !",
|
||||
"modal.dir_to_delete": "Dossier à supprimer:",
|
||||
"modal.ext_excalidraw": "Excalidraw (.excalidraw)",
|
||||
"modal.ext_js": "JavaScript (.js)",
|
||||
"modal.ext_json": "JSON (.json)",
|
||||
"modal.ext_md": "Markdown (.md)",
|
||||
"modal.ext_ps1": "PowerShell (.ps1)",
|
||||
"modal.ext_py": "Python (.py)",
|
||||
"modal.ext_sh": "Shell (.sh)",
|
||||
"modal.ext_txt": "Texte (.txt)",
|
||||
"modal.ext_xlsx": "Excel (.xlsx)",
|
||||
"modal.ext_yaml": "YAML (.yaml)",
|
||||
"modal.file_name": "Nom du fichier",
|
||||
"modal.file_to_delete": "Fichier à supprimer:",
|
||||
"modal.file_type": "Type de fichier",
|
||||
@@ -1847,8 +1857,8 @@
|
||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||
"xlsx.readonly_badge": "Lecture seule",
|
||||
"xlsx.readonly_hint": "Ce format (.xls / .ods) n'est pas modifiable dans ObsiGate — convertissez-le en .xlsx pour l'éditer.",
|
||||
"xlsx.formulas_note": "Formules non recalculées",
|
||||
"xlsx.formulas_note_title": "ObsiGate affiche la formule telle qu'elle est enregistrée : Excel la recalcule à l'ouverture. Les cellules dépendantes ne se rafraîchissent pas à l'écran.",
|
||||
"xlsx.formulas_note": "Formules calculées localement",
|
||||
"xlsx.formulas_note_title": "ObsiGate calcule 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.",
|
||||
"xlsx.truncated_title": "Feuille tronquée",
|
||||
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
||||
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
|
||||
@@ -1876,8 +1886,31 @@
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
|
||||
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
|
||||
"xlsx.colour_panel_font": "Couleur du texte",
|
||||
"xlsx.colour_panel_fill": "Couleur de fond",
|
||||
"xlsx.colour_palette": "Palette de couleurs",
|
||||
"xlsx.colour_reset": "Réinitialiser",
|
||||
"xlsx.colour_reset_font": "Réinitialiser la couleur du texte",
|
||||
"xlsx.colour_reset_fill": "Réinitialiser la couleur de fond",
|
||||
"xlsx.colour_standard": "Standard",
|
||||
"xlsx.colour_custom": "Personnalisé",
|
||||
"xlsx.colour_edit_standard": "Choisir une couleur personnalisée",
|
||||
"xlsx.colour_custom_pick": "Ajouter une couleur personnalisée",
|
||||
"xlsx.colour_conditional": "Mise en forme conditionnelle",
|
||||
"xlsx.colour_banded": "Couleurs en alternance",
|
||||
"xlsx.colour_unavailable": "« {feature} » n'est pas encore disponible dans ObsiGate.",
|
||||
"xlsx.colour_black": "Noir",
|
||||
"xlsx.colour_white": "Blanc",
|
||||
"xlsx.colour_blue": "Bleu",
|
||||
"xlsx.colour_red": "Rouge",
|
||||
"xlsx.colour_yellow": "Jaune",
|
||||
"xlsx.colour_green": "Vert",
|
||||
"xlsx.colour_orange": "Orange",
|
||||
"xlsx.colour_cyan": "Cyan",
|
||||
"xlsx.export_btn": "Exporter",
|
||||
"xlsx.export_csv": "Exporter en CSV",
|
||||
"xlsx.export_title": "Exporter la sélection ou la feuille",
|
||||
"xlsx.file_menu": "Fichier",
|
||||
"xlsx.export_md": "Exporter en Markdown",
|
||||
"xlsx.export_html": "Exporter en HTML",
|
||||
"xlsx.print": "Imprimer",
|
||||
@@ -1885,6 +1918,47 @@
|
||||
"xlsx.export_scope_selection": "la sélection",
|
||||
"xlsx.export_done": "Export de {scope} terminé",
|
||||
"xlsx.format_btn": "Mise en forme",
|
||||
"xlsx.format_strike": "Barré",
|
||||
"xlsx.format_align_justify": "Justifié",
|
||||
"xlsx.tb_search": "Rechercher dans la feuille",
|
||||
"xlsx.tb_paint": "Peindre le format",
|
||||
"xlsx.paint_pick_target": "Cliquez sur la cellule ou la plage cible du format",
|
||||
"xlsx.tb_zoom": "Zoom",
|
||||
"xlsx.tb_currency": "Format monétaire",
|
||||
"xlsx.tb_percent": "Format pourcentage",
|
||||
"xlsx.tb_dec_minus": "Diminuer les décimales",
|
||||
"xlsx.tb_dec_plus": "Augmenter les décimales",
|
||||
"xlsx.tb_number": "Format nombre",
|
||||
"xlsx.tb_numfmt": "Format de nombre",
|
||||
"xlsx.tb_font_minus": "Réduire la taille de police",
|
||||
"xlsx.tb_font_plus": "Augmenter la taille de police",
|
||||
"xlsx.tb_font_size": "Taille de police",
|
||||
"xlsx.tb_borders": "Bordures",
|
||||
"xlsx.border_all": "Toutes les bordures",
|
||||
"xlsx.border_outer": "Bordures extérieures",
|
||||
"xlsx.border_none": "Aucune bordure",
|
||||
"xlsx.tb_merge": "Fusionner les cellules",
|
||||
"xlsx.tb_alignh": "Alignement horizontal",
|
||||
"xlsx.tb_alignv": "Alignement vertical",
|
||||
"xlsx.valign_top": "En haut",
|
||||
"xlsx.valign_middle": "Au milieu",
|
||||
"xlsx.valign_bottom": "En bas",
|
||||
"xlsx.tb_wrap": "Retour à la ligne automatique",
|
||||
"xlsx.tb_rotation": "Rotation du texte",
|
||||
"xlsx.rotation_0": "Horizontal",
|
||||
"xlsx.rotation_45": "Diagonale vers le haut",
|
||||
"xlsx.rotation_315": "Diagonale vers le bas",
|
||||
"xlsx.rotation_90": "Vertical",
|
||||
"xlsx.tb_more": "Plus d'options",
|
||||
"xlsx.comment_add": "Commentaire",
|
||||
"xlsx.comment_prompt": "Texte du commentaire (vide pour supprimer) :",
|
||||
"xlsx.link_add": "Insérer un lien",
|
||||
"xlsx.link_url": "Adresse du lien (https://…) :",
|
||||
"xlsx.link_label": "Libellé :",
|
||||
"xlsx.link_invalid": "Adresse invalide (https://… attendue)",
|
||||
"xlsx.sum_insert": "Somme",
|
||||
"xlsx.sum_empty": "Aucune plage à totaliser (nombres au-dessus ou sélection)",
|
||||
"xlsx.paint_pick_source": "Sélectionnez d'abord une cellule source mise en forme",
|
||||
"xlsx.format_bold": "Gras",
|
||||
"xlsx.format_italic": "Italique",
|
||||
"xlsx.format_underline": "Souligné",
|
||||
@@ -1906,9 +1980,13 @@
|
||||
"xlsx.format_freeze": "Figer les volets ici",
|
||||
"xlsx.format_unfreeze": "Libérer les volets",
|
||||
"xlsx.format_col_width": "Largeur de colonne…",
|
||||
"xlsx.format_col_prompt": "Largeur de la colonne {col} (0 à 255)",
|
||||
"xlsx.format_col_prompt": "Largeur de la colonne {col} — en caractères (0 à 255, 0 = largeur par défaut)",
|
||||
"xlsx.format_row_height": "Hauteur de ligne…",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} — en points (0 à 409, 0 = hauteur par défaut)",
|
||||
"xlsx.format_col_fit": "Ajuster la largeur au contenu",
|
||||
"xlsx.format_row_fit": "Ajuster la hauteur au contenu",
|
||||
"xlsx.mi_resize": "Redimensionner",
|
||||
"xlsx.fit_nothing": "Rien à ajuster (contenu vide ou déjà à la bonne taille)",
|
||||
"xlsx.format_applied": "Mise en forme enregistrée",
|
||||
"xlsx.format_error": "Échec de la mise en forme",
|
||||
"xlsx.sort_asc": "Trier la colonne A→Z",
|
||||
@@ -1969,6 +2047,166 @@
|
||||
"xlsx.feature_signature": "signature numérique",
|
||||
"xlsx.feature_rich_comments": "commentaires enrichis",
|
||||
"xlsx.feature_macros": "macros",
|
||||
|
||||
"xlsx.mbar_label": "Menus du tableur",
|
||||
"xlsx.cmdbar_label": "Barre de commandes du tableur",
|
||||
"xlsx.mbar_file": "Fichier",
|
||||
"xlsx.mbar_edit": "Modifier",
|
||||
"xlsx.mbar_view": "Affichage",
|
||||
"xlsx.mbar_insert": "Insertion",
|
||||
"xlsx.mbar_format": "Format",
|
||||
"xlsx.mbar_data": "Données",
|
||||
"xlsx.mbar_tools": "Outils",
|
||||
"xlsx.mbar_gemini": "Gemini",
|
||||
"xlsx.mbar_extensions": "Extensions",
|
||||
"xlsx.mbar_help": "Aide",
|
||||
"xlsx.menu_unavailable": "« {item} » n'est pas disponible dans ObsiGate.",
|
||||
"xlsx.menu_unavailable_hint": "Cette option n'existe pas dans l'éditeur tableur d'ObsiGate : elle est listée pour rester fidèle à Google Sheets.",
|
||||
|
||||
"xlsx.mi_new": "Nouveau",
|
||||
"xlsx.mi_open": "Ouvrir",
|
||||
"xlsx.mi_import": "Importer",
|
||||
"xlsx.mi_make_copy": "Créer une copie",
|
||||
"xlsx.mi_share": "Partager",
|
||||
"xlsx.mi_email": "Envoyer par e-mail",
|
||||
"xlsx.mi_download": "Télécharger",
|
||||
"xlsx.mi_rename": "Renommer",
|
||||
"xlsx.mi_trash": "Déplacer vers la corbeille",
|
||||
"xlsx.mi_history": "Historique des versions",
|
||||
"xlsx.mi_offline": "Rendre disponible hors connexion",
|
||||
"xlsx.mi_details": "Détails",
|
||||
"xlsx.mi_settings": "Paramètres",
|
||||
|
||||
"xlsx.mi_paste_special": "Collage spécial",
|
||||
"xlsx.mi_paste_all": "Tout coller",
|
||||
"xlsx.mi_paste_values": "Coller les valeurs uniquement",
|
||||
"xlsx.mi_move": "Déplacer",
|
||||
"xlsx.mi_move_rows": "Lignes sélectionnées vers le bas",
|
||||
"xlsx.mi_move_cols": "Colonnes sélectionnées vers la droite",
|
||||
"xlsx.mi_delete": "Supprimer",
|
||||
"xlsx.mi_delete_rows": "Supprimer les lignes sélectionnées",
|
||||
"xlsx.mi_delete_cols": "Supprimer les colonnes sélectionnées",
|
||||
"xlsx.mi_select_all": "Tout sélectionner",
|
||||
"xlsx.mi_replace": "Rechercher et remplacer",
|
||||
"xlsx.mi_replace_find": "Rechercher",
|
||||
"xlsx.mi_replace_with": "Remplacer par",
|
||||
"xlsx.mi_replace_one": "Remplacer",
|
||||
"xlsx.mi_replace_all": "Tout remplacer",
|
||||
"xlsx.mi_replace_none": "Aucune occurrence trouvée",
|
||||
"xlsx.mi_replace_done": "{n} cellule(s) remplacée(s)",
|
||||
|
||||
"xlsx.mi_freeze": "Figer",
|
||||
"xlsx.mi_freeze_here": "Figer jusqu'à la cellule active",
|
||||
"xlsx.mi_freeze_row": "Figer la ligne active",
|
||||
"xlsx.mi_freeze_col": "Figer la colonne active",
|
||||
"xlsx.mi_show": "Afficher",
|
||||
"xlsx.mi_show_formula": "Barre de formule",
|
||||
"xlsx.mi_show_grid": "Grille",
|
||||
"xlsx.mi_show_headers": "En-têtes",
|
||||
"xlsx.mi_show_dashboard": "Tableau de bord",
|
||||
"xlsx.mi_group": "Grouper",
|
||||
"xlsx.mi_group_rows": "Grouper les lignes sélectionnées",
|
||||
"xlsx.mi_group_cols": "Grouper les colonnes sélectionnées",
|
||||
"xlsx.mi_ungroup_rows": "Dégrouper les lignes",
|
||||
"xlsx.mi_ungroup_cols": "Dégrouper les colonnes",
|
||||
"xlsx.mi_hidden_sheets": "Feuilles masquées",
|
||||
"xlsx.mi_hide_sheet": "Masquer la feuille active",
|
||||
"xlsx.mi_unhide_sheets": "Afficher les feuilles masquées",
|
||||
"xlsx.mi_zoom": "Zoom",
|
||||
"xlsx.mi_fullscreen": "Plein écran",
|
||||
"xlsx.mi_exit_fullscreen": "Quitter le plein écran",
|
||||
|
||||
"xlsx.mi_insert_rows": "Lignes",
|
||||
"xlsx.mi_insert_cols": "Colonnes",
|
||||
"xlsx.mi_insert_cells": "Cellules",
|
||||
"xlsx.mi_image": "Image",
|
||||
"xlsx.mi_chart": "Graphique",
|
||||
"xlsx.mi_drawing": "Dessin",
|
||||
"xlsx.mi_function": "Fonction",
|
||||
"xlsx.mi_function_hint": "Insère la fonction et place le curseur dans les parenthèses",
|
||||
"xlsx.mi_pivot": "Tableau croisé dynamique",
|
||||
"xlsx.mi_smart_chips": "Chips intelligents",
|
||||
"xlsx.mi_form": "Formulaire",
|
||||
|
||||
"xlsx.mi_number": "Nombre",
|
||||
"xlsx.mi_theme": "Thème",
|
||||
"xlsx.mi_font_size": "Taille de police",
|
||||
"xlsx.mi_align": "Alignement",
|
||||
"xlsx.mi_align_h": "Alignement horizontal",
|
||||
"xlsx.mi_align_v": "Alignement vertical",
|
||||
"xlsx.mi_wrap": "Retour à la ligne",
|
||||
"xlsx.mi_wrap_on": "Activer",
|
||||
"xlsx.mi_wrap_off": "Désactiver",
|
||||
"xlsx.mi_rotation": "Rotation du texte",
|
||||
"xlsx.mi_conditional": "Mise en forme conditionnelle",
|
||||
"xlsx.mi_banded": "Couleurs alternées",
|
||||
"xlsx.mi_banded_hint": "Alterne la couleur de fond une ligne sur deux dans la sélection",
|
||||
"xlsx.mi_banded_done": "Couleurs alternées appliquées à {n} ligne(s)",
|
||||
"xlsx.mi_banded_default": "#eef3fb",
|
||||
|
||||
"xlsx.mi_sort": "Trier la plage",
|
||||
"xlsx.mi_filter": "Créer un filtre",
|
||||
"xlsx.mi_filter_hint": "Filtre les lignes selon la valeur de la cellule active",
|
||||
"xlsx.mi_validation": "Validation des données",
|
||||
"xlsx.mi_named": "Plages nommées",
|
||||
"xlsx.mi_protect": "Protéger les feuilles et les plages",
|
||||
"xlsx.mi_cleanup": "Nettoyage des données",
|
||||
"xlsx.mi_cleanup_dupes": "Supprimer les doublons",
|
||||
"xlsx.mi_cleanup_trim": "Supprimer les espaces superflus",
|
||||
"xlsx.mi_cleanup_dupes_none": "Aucun doublon dans la sélection",
|
||||
"xlsx.mi_cleanup_dupes_done": "{n} ligne(s) en doublon supprimée(s)",
|
||||
"xlsx.mi_cleanup_trim_done": "{n} cellule(s) nettoyée(s)",
|
||||
"xlsx.mi_split": "Diviser le texte en colonnes",
|
||||
"xlsx.mi_split_hint": "Sépare le contenu de la cellule active sur un séparateur",
|
||||
"xlsx.mi_split_sep": "Séparateur",
|
||||
"xlsx.mi_split_done": "{n} colonne(s) remplie(s) à partir de la cellule active",
|
||||
|
||||
"xlsx.mi_form_create": "Créer un formulaire",
|
||||
"xlsx.mi_spellcheck": "Vérifier l'orthographe",
|
||||
"xlsx.mi_autocomplete": "Saisie semi-automatique",
|
||||
"xlsx.mi_autocomplete_on": "Saisie semi-automatique activée",
|
||||
"xlsx.mi_autocomplete_off": "Saisie semi-automatique désactivée",
|
||||
"xlsx.mi_notifications": "Règles de notification",
|
||||
"xlsx.mi_macros": "Macros",
|
||||
"xlsx.mi_script_editor": "Éditeur de script",
|
||||
"xlsx.mi_a11y": "Accessibilité",
|
||||
"xlsx.mi_a11y_on": "Accessibilité renforcée activée (cellules plus grandes, grille plus contrastée)",
|
||||
"xlsx.mi_a11y_off": "Accessibilité renforcée désactivée",
|
||||
|
||||
"xlsx.mi_ask_gemini": "Demander à Gemini",
|
||||
"xlsx.mi_gem_table": "Créer des tableaux",
|
||||
"xlsx.mi_gem_formula": "Créer des formules",
|
||||
"xlsx.mi_gem_insights": "Analyse et insights",
|
||||
"xlsx.mi_gem_chart": "Créer des graphiques",
|
||||
"xlsx.mi_gem_summarize": "Résumer des e-mails et des fichiers",
|
||||
"xlsx.mi_gem_actions": "Effectuer des actions",
|
||||
"xlsx.mi_gem_more": "Plus d'options",
|
||||
|
||||
"xlsx.mi_addons": "Modules complémentaires",
|
||||
"xlsx.mi_apps_script": "Apps Script",
|
||||
"xlsx.mi_appsheet": "AppSheet",
|
||||
"xlsx.mi_looker": "Looker Studio",
|
||||
|
||||
"xlsx.mi_help_sheets": "Aide sur les feuilles de calcul",
|
||||
"xlsx.mi_training": "Formation",
|
||||
"xlsx.mi_whatsnew": "Mises à jour",
|
||||
"xlsx.mi_shortcuts": "Raccourcis clavier",
|
||||
"xlsx.mi_report": "Signaler un problème",
|
||||
"xlsx.mi_abuse": "Signaler un abus",
|
||||
|
||||
"xlsx.details_title": "Détails du fichier",
|
||||
"xlsx.details_body": "Vault : {vault}\nFichier : {path}\nFeuilles : {sheets}\nCellules affichées : {cells}\nDernière révision : {revision}\nModification en attente : {dirty}",
|
||||
"xlsx.shortcuts_title": "Raccourcis clavier",
|
||||
"xlsx.shortcuts_body": "Ctrl/Cmd + Z — Annuler\nCtrl/Cmd + Y — Rétablir\nCtrl/Cmd + C / X / V — Copier / Couper / Coller\nCtrl/Cmd + A — Tout sélectionner\nCtrl/Cmd + F — Rechercher dans la feuille\nEntrée — Valider et descendre d'une cellule\nF2 — Modifier la cellule\nAlt + flèche — Saut de ligne dans la cellule\nÉchap — Quitter la saisie\nSuppr — Effacer le contenu de la sélection\nDouble-clic — Modifier la cellule en place\nMaj + clic — Étendre la sélection",
|
||||
"xlsx.version_title": "Mises à jour",
|
||||
"xlsx.version_body": "ObsiGate {version} — {date}\n{status}",
|
||||
"xlsx.report_copied": "Diagnostic copié dans le presse-papiers.",
|
||||
"xlsx.themes_title": "Thème de la feuille",
|
||||
"xlsx.function_prompt": "Fonction à insérer",
|
||||
"xlsx.rename_prompt": "Nouveau nom du fichier",
|
||||
"xlsx.trash_confirm": "Déplacer « {name} » vers la corbeille ? Le classeur sera fermé.",
|
||||
"xlsx.rename_done": "Fichier renommé en {name}",
|
||||
"xlsx.trash_done": "Fichier déplacé vers la corbeille",
|
||||
"viewer.download_md": "Télécharger en .md",
|
||||
"viewer.download_file": "Télécharger le fichier",
|
||||
"viewer.pretty": "Pretty",
|
||||
|
||||
+732
-52
@@ -62,7 +62,7 @@
|
||||
--grid-bg: #161b22;
|
||||
--grid-header-bg: #1f2430;
|
||||
--grid-header-text: #e6edf3;
|
||||
--grid-border: #30363d;
|
||||
--grid-border: #3d444d;
|
||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
--grid-bg: #161b22;
|
||||
--grid-header-bg: #1f2430;
|
||||
--grid-header-text: #e6edf3;
|
||||
--grid-border: #30363d;
|
||||
--grid-border: #3d444d;
|
||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
@@ -160,10 +160,28 @@
|
||||
--grid-bg: #ffffff;
|
||||
--grid-header-bg: #eaeef2;
|
||||
--grid-header-text: #1f2328;
|
||||
--grid-border: #d0d7de;
|
||||
--grid-border: #b6bec9;
|
||||
--grid-zebra: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
/* ===== THEME — HIGH-CONTRAST (generated mode, see themes.js) ===== */
|
||||
:root[data-theme="high-contrast"] {
|
||||
--grid-bg: #000000;
|
||||
--grid-header-bg: #141414;
|
||||
--grid-header-text: #ffffff;
|
||||
--grid-border: #6b6b6b;
|
||||
--grid-zebra: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
/* ===== THEME — SEPIA (generated mode, see themes.js) ===== */
|
||||
:root[data-theme="sepia"] {
|
||||
--grid-bg: #f5ecd5;
|
||||
--grid-header-bg: #e8dcc0;
|
||||
--grid-header-text: #3d2e1a;
|
||||
--grid-border: #b09a6d;
|
||||
--grid-zebra: rgba(61, 46, 26, 0.04);
|
||||
}
|
||||
|
||||
/* ===== BASE ===== */
|
||||
html {
|
||||
font-size: 16px;
|
||||
@@ -1491,10 +1509,12 @@ select {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Full-bleed viewers (PDF, images, Excalidraw) must use the whole width when
|
||||
the navigation sidebar is hidden instead of the centered reading column. */
|
||||
/* Full-bleed viewers (PDF, images, Excalidraw, spreadsheets) must use the
|
||||
whole width when the navigation sidebar is hidden instead of the centered
|
||||
reading column. */
|
||||
.sidebar.hidden ~ .content-wrapper .content-area:has(.pdf-viewer-container),
|
||||
.sidebar.hidden ~ .content-wrapper .content-area:has(.image-viewer-container),
|
||||
.sidebar.hidden ~ .content-wrapper .content-area:has(.xlsx-viewer),
|
||||
.sidebar.hidden ~ .content-wrapper .content-area:has(iframe[src*="excalidraw-editor.html"]) {
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
@@ -10998,24 +11018,33 @@ body.desktop-mode .editor-container {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border-light, var(--border));
|
||||
}
|
||||
.csv-table tr:hover td {
|
||||
background: var(--surface);
|
||||
}
|
||||
/* Google Sheets : le survol d'une ligne ne teinte rien (jamais de highlight
|
||||
de ligne entière au survol). */
|
||||
|
||||
/* ── XLSX Viewer ── */
|
||||
/* #154-A1 — the viewer shell is a grouped command bar: sheet tabs on the left,
|
||||
action groups (Formules · Insertion · Vue · Fichier) on the right, then a
|
||||
status row stating the viewer's limits (read-only, formulas not recalculated). */
|
||||
/* #154-A1 — the viewer shell is a grouped command bar (edition commands),
|
||||
then a sheet strip (tabs on the left, File menu + Save on the right), then
|
||||
a status row stating the viewer's limits (read-only). #180 pins the bar to
|
||||
the top edge of the page, full content width. */
|
||||
.xlsx-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 10px;
|
||||
position: sticky; /* #180 — stays on the top edge while the grid scrolls */
|
||||
/* La marge haute négative (pleine largeur) tirait la barre 40 px sous sa
|
||||
position statique : `top: 0` la replaquait et elle recouvrait le contenu
|
||||
(en-tête A–Z, ligne 1) de 32 px. La contrainte reprend la même expression
|
||||
que la marge : aucun déplacement au repos, épinglage intact au scroll. */
|
||||
top: calc(-1 * clamp(16px, 3vw, 40px));
|
||||
z-index: 30;
|
||||
margin: calc(-1 * clamp(16px, 3vw, 40px)) calc(-1 * clamp(16px, 4vw, 40px)) 8px;
|
||||
padding: 8px clamp(16px, 4vw, 40px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
border-radius: 0;
|
||||
background: var(--surface2);
|
||||
position: relative; /* anchors the A14 structure menu */
|
||||
}
|
||||
.xlsx-cmdbar {
|
||||
display: flex;
|
||||
@@ -11023,6 +11052,277 @@ body.desktop-mode .editor-container {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* #185 — the Google Sheets menu bar: a flat row of text buttons above the
|
||||
ribbon. No chrome, no borders: only the open menu gets a filled pill. */
|
||||
.xlsx-menubar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
margin: -2px 0 -2px;
|
||||
}
|
||||
/* No `position: relative` here on purpose: the dropdown is positioned by
|
||||
anchorUnder() (viewer.js) relative to the sticky `.xlsx-toolbar`, exactly like
|
||||
the Structure / Mise en forme / ribbon menus. Making the wrapper a containing
|
||||
block would shift every dropdown by the button's offset. */
|
||||
.xlsx-menuwrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
.xlsx-menubtn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xlsx-menubtn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-menubtn[aria-expanded="true"] {
|
||||
background: var(--accent-bg, var(--bg-hover));
|
||||
color: var(--accent-text, var(--accent, #4a90d9));
|
||||
}
|
||||
/* The Gemini entry keeps its own accent so it reads as the AI corner of the
|
||||
bar, like the themed icon in Google Sheets. */
|
||||
.xlsx-menuwrap-gemini .xlsx-menubtn {
|
||||
color: var(--accent, #4a90d9);
|
||||
font-weight: 600;
|
||||
}
|
||||
.xlsx-menudrop {
|
||||
min-width: 260px;
|
||||
max-height: min(70vh, 560px);
|
||||
overflow-y: auto;
|
||||
z-index: 40;
|
||||
}
|
||||
/* Accordion section (Nombre, Thème, Zoom, Alignement, …). */
|
||||
.xlsx-menu-sub {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-left: 18px;
|
||||
border-left: 2px solid var(--border);
|
||||
margin: 2px 0 4px 8px;
|
||||
}
|
||||
.xlsx-menu-sub[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.xlsx-menu-item {
|
||||
width: 100%;
|
||||
}
|
||||
.xlsx-menu-item-d2 {
|
||||
padding-left: 18px;
|
||||
}
|
||||
.xlsx-menu-caret {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.xlsx-menu-item[aria-expanded="true"] > .xlsx-menu-caret {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
/* An entry ObsiGate does not implement: dimmed but still clickable, so it
|
||||
answers instead of pretending. */
|
||||
.xlsx-menu-item-na {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.xlsx-menu-item-na:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.xlsx-menu-empty {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* #185 — Google Sheets' ribbon look: flat square icon buttons, no border and
|
||||
no resting background, grouped by thin vertical rules. The old rounded /
|
||||
bordered chrome fought with the theme of every one of the 15 themes. */
|
||||
.xlsx-toolbar .btn-action {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.xlsx-toolbar .btn-action:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.xlsx-toolbar .btn-action.active {
|
||||
background: var(--accent-bg, var(--bg-hover));
|
||||
color: var(--accent-text, var(--accent, #4a90d9));
|
||||
}
|
||||
.xlsx-toolbar .btn-action:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.xlsx-toolbar .xlsx-cmd-sep {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: var(--border);
|
||||
}
|
||||
.xlsx-toolbar .xlsx-select {
|
||||
height: 30px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.xlsx-toolbar .xlsx-select:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
/* Save stays the one solid pill: it is the only command with a consequence
|
||||
outside the browser. */
|
||||
.xlsx-toolbar .xlsx-save-primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-toolbar .xlsx-save-primary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.xlsx-formula-bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
/* Wrapped command groups: every control stays visible, no scrollbar. */
|
||||
.xlsx-cmdbar > .xlsx-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
}
|
||||
.xlsx-cmdbar .xlsx-cmd-group,
|
||||
.xlsx-cmdbar .xlsx-cmd-sep {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* Native selects (zoom, number format): no custom dropdown to dismiss. */
|
||||
.xlsx-select {
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
max-width: 130px;
|
||||
}
|
||||
.xlsx-select:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* B/I/S/U letter buttons + decimal shorthands. */
|
||||
.xlsx-fontstyle {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
min-width: 28px;
|
||||
}
|
||||
.xlsx-dec {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
.xlsx-fontsize {
|
||||
min-width: 26px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* Colour pickers: a letter/swatch label wrapping the native input. */
|
||||
.xlsx-colorpick {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.xlsx-colorpick input[type="color"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
/* #182 — colour swatch under the picker: mirrors the chosen colour so applying
|
||||
one to a selection is visible on the button itself. Empty (transparent)
|
||||
until a colour is actually picked, so the default ribbon stays clean. */
|
||||
.xlsx-color-swatch {
|
||||
width: 14px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.xlsx-colorletter {
|
||||
font-family: Georgia, serif;
|
||||
font-weight: 700;
|
||||
border-bottom: 3px solid var(--accent);
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
/* Ribbon dropdown menus share the structure-menu look. */
|
||||
.xlsx-ribbon-menu {
|
||||
min-width: 220px;
|
||||
}
|
||||
/* Floating contextual mini-bar (multi-cell selections). */
|
||||
.xlsx-minibar {
|
||||
position: fixed;
|
||||
z-index: 10060;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 24px var(--shadow, rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
.xlsx-minibar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.xlsx-minibar button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-minibar button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
.xlsx-minibar button svg,
|
||||
.xlsx-minibar button i[data-lucide] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.xlsx-minibar button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
.xlsx-toolbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
@@ -11082,6 +11382,31 @@ body.desktop-mode .editor-container {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
/* #180 — sheet strip: tabs on the left, File menu + Save pinned right. */
|
||||
.xlsx-sheetbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.xlsx-sheetbar .xlsx-tabs {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.xlsx-sheetbar-end {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.xlsx-file-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.xlsx-file-item {
|
||||
text-align: left;
|
||||
}
|
||||
.xlsx-status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -11162,28 +11487,45 @@ body.desktop-mode .editor-container {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.xlsx-table {
|
||||
background: var(--grid-bg);
|
||||
background: var(--grid-bg, var(--surface));
|
||||
}
|
||||
/* #185 — one uniform 1px grid line on EVERY side of every cell (header rows
|
||||
included), the way Sheets draws it. Previously only the bottom edge existed
|
||||
and its colour came from `--border`, which is far too close to the cell
|
||||
background on most themes: the grid read as a smear instead of a grid.
|
||||
`--grid-border` is now derived from the theme by themes.js (same token for
|
||||
every line, so the mesh stays perfectly regular). */
|
||||
.xlsx-table td,
|
||||
.xlsx-table th {
|
||||
border: 1px solid var(--grid-border, var(--border));
|
||||
}
|
||||
.xlsx-table td {
|
||||
border-bottom: 1px solid var(--grid-border);
|
||||
border-bottom: 1px solid var(--grid-border, var(--border));
|
||||
/* Cellules de données : une seule et même couleur (fond de grille). Pas de
|
||||
variante une-ligne-sur-deux ici : sa spécificité écraserait la teinte de
|
||||
sélection (une ligne sur deux non teintée). */
|
||||
background: var(--grid-bg, var(--surface));
|
||||
}
|
||||
/* #154-A1 — zebra + hover make rows scannable; headers use dedicated tokens so
|
||||
they are visually distinct from the cells. */
|
||||
.xlsx-table tbody tr:nth-child(even) td {
|
||||
background: var(--grid-zebra);
|
||||
}
|
||||
.xlsx-table tbody tr:hover td {
|
||||
background: var(--bg-hover);
|
||||
/* Cellules fusionnées : couleur d'en-tête, sauf en sélection (la teinte de
|
||||
sélection gagne, uniforme sur toutes les cellules). */
|
||||
.xlsx-table tbody td[rowspan]:not([rowspan="1"]):not(.xlsx-selected),
|
||||
.xlsx-table tbody td[colspan]:not([colspan="1"]):not(.xlsx-selected) {
|
||||
background: var(--grid-header-bg, var(--bg-hover));
|
||||
color: var(--grid-header-text, var(--text-primary));
|
||||
}
|
||||
/* Google Sheets : aucun survol teinté — ni la ligne entière, ni la cellule.
|
||||
Rien ne se met en couleur tant que l'utilisateur n'a pas sélectionné au
|
||||
moins 2 cellules (voir `.xlsx-range-multi`). */
|
||||
.xlsx-table th.xlsx-corner,
|
||||
.xlsx-table th.xlsx-rownum {
|
||||
background: var(--grid-header-bg);
|
||||
color: var(--grid-header-text);
|
||||
background: var(--grid-header-bg, var(--bg-hover));
|
||||
color: var(--grid-header-text, var(--text-primary));
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
border-right: 1px solid var(--grid-border);
|
||||
/* #185 — the same 1px line as the cells: the header is told apart by its
|
||||
fill, not by a heavier rule that broke the grid's regularity. */
|
||||
border: 1px solid var(--grid-border, var(--border));
|
||||
position: sticky;
|
||||
left: 0;
|
||||
/* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
|
||||
@@ -11194,7 +11536,9 @@ body.desktop-mode .editor-container {
|
||||
}
|
||||
.xlsx-table th.xlsx-corner {
|
||||
left: 0;
|
||||
top: 0;
|
||||
/* Suit `--xlsx-stick` comme l'en-tête : mesuré en JS sur le recouvrement
|
||||
réel de la toolbar sticky, sinon la ligne A–Z glisse dessous. */
|
||||
top: var(--xlsx-stick, 0px);
|
||||
z-index: 4;
|
||||
}
|
||||
/* #153 A8 — the column headers stay visible while the sheet scrolls down.
|
||||
@@ -11202,11 +11546,43 @@ body.desktop-mode .editor-container {
|
||||
order is intentional: thead (3) < row numbers (2) < corner (4). */
|
||||
.xlsx-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: var(--xlsx-stick, 0px);
|
||||
z-index: 3;
|
||||
background: var(--grid-header-bg);
|
||||
color: var(--grid-header-text);
|
||||
border-bottom: 2px solid var(--grid-border);
|
||||
background: var(--grid-header-bg, var(--bg-hover));
|
||||
color: var(--grid-header-text, var(--text-primary));
|
||||
border-bottom: 1px solid var(--grid-border, var(--border));
|
||||
}
|
||||
/* #185 — Affichage ▸ Afficher ▸ Grille / En-têtes, and Outils ▸ Accessibilité:
|
||||
display concerns only, never a change to the file. */
|
||||
.xlsx-viewer.xlsx-no-grid .xlsx-table td,
|
||||
.xlsx-viewer.xlsx-no-grid .xlsx-table th {
|
||||
border-width: 0;
|
||||
}
|
||||
.xlsx-viewer.xlsx-no-headers thead,
|
||||
.xlsx-viewer.xlsx-no-headers th.xlsx-rownum,
|
||||
.xlsx-viewer.xlsx-no-headers th.xlsx-corner {
|
||||
display: none;
|
||||
}
|
||||
.xlsx-viewer.xlsx-a11y .xlsx-table td {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.xlsx-viewer.xlsx-a11y {
|
||||
--grid-border: color-mix(in srgb, var(--text-primary, #888) 42%, var(--surface, #000));
|
||||
}
|
||||
/* #180 — while the pointer rides a header edge, the representative resize
|
||||
icon must win over the cell (`cell`) and header (`pointer`) cursors. The
|
||||
edge header itself is mirrored inline by the viewer (its inline
|
||||
`cursor:pointer` beats any stylesheet rule). */
|
||||
.xlsx-table.xlsx-resize-col,
|
||||
.xlsx-table.xlsx-resize-col th,
|
||||
.xlsx-table.xlsx-resize-col td {
|
||||
cursor: col-resize;
|
||||
}
|
||||
.xlsx-table.xlsx-resize-row,
|
||||
.xlsx-table.xlsx-resize-row th,
|
||||
.xlsx-table.xlsx-resize-row td {
|
||||
cursor: row-resize;
|
||||
}
|
||||
.xlsx-table td[contenteditable] {
|
||||
/* #155 — Excel-style cross cursor over the cells. */
|
||||
@@ -11218,6 +11594,57 @@ body.desktop-mode .editor-container {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* #181-A1 — SELECTION vs EDITION, like Google Sheets / Excel.
|
||||
A selected cell is outlined but must NOT show a text caret; the caret is
|
||||
only revealed in edit mode (double-click, F2 or the first keystroke).
|
||||
`caret-color: transparent` hides it without leaving `contenteditable`, so the
|
||||
click-to-select / type-to-replace flow stays native. */
|
||||
.xlsx-table td.xlsx-cell {
|
||||
caret-color: transparent;
|
||||
}
|
||||
.xlsx-table td.xlsx-editing {
|
||||
caret-color: auto;
|
||||
cursor: text;
|
||||
}
|
||||
/* The editing cell also reads as "open": a solid accent frame plus a raised
|
||||
shadow, so the state is visible even on a row that is part of a range. */
|
||||
.xlsx-table td.xlsx-editing {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* #181-A2 — range indicator: the four edges of the selected block trace it
|
||||
exactly (Excel/Sheets draw a rectangle, not a flat wash), and the covered
|
||||
row/column headers are highlighted too. `outline` on inner edges would be
|
||||
overdrawn by the next cell, so the four borders are declared separately and
|
||||
win over `.xlsx-selected`'s background. */
|
||||
.xlsx-table tbody td.xlsx-sel-top {
|
||||
border-top: 2px solid var(--accent, #4a90d9);
|
||||
}
|
||||
.xlsx-table tbody td.xlsx-sel-bottom {
|
||||
border-bottom: 2px solid var(--accent, #4a90d9);
|
||||
}
|
||||
.xlsx-table tbody td.xlsx-sel-left {
|
||||
border-left: 2px solid var(--accent, #4a90d9);
|
||||
}
|
||||
.xlsx-table tbody td.xlsx-sel-right {
|
||||
border-right: 2px solid var(--accent, #4a90d9);
|
||||
}
|
||||
/* A one-cell selection keeps its four accent edges (a framed box) but NO fill:
|
||||
`.xlsx-range-multi` is toggled on the viewer by applySelection() and gates
|
||||
the tint, which starts at 2 cells — Google Sheets behaviour. */
|
||||
.xlsx-viewer.xlsx-range-multi .xlsx-table tbody td.xlsx-selected {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
.xlsx-table thead th.xlsx-hdr-selected,
|
||||
.xlsx-table th.xlsx-rownum.xlsx-hdr-selected {
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-text, var(--accent, #4a90d9));
|
||||
font-weight: 700;
|
||||
}
|
||||
/* #153 A7 — the active cell keeps its outline even when focus moves to the
|
||||
formula bar, so the user never loses track of what the bar edits.
|
||||
#154-A1 — solid (not dashed) for a stronger active-cell affordance. */
|
||||
@@ -11226,25 +11653,47 @@ body.desktop-mode .editor-container {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */
|
||||
/* Barre d'édition de la cellule, juste au-dessus de la grille :
|
||||
[ position | champ d'édition ] + recherche. Bandeau distinct du fond pour
|
||||
rester identifiable dans tous les thèmes (le fond transparent la rendait
|
||||
invisible). Elle vit DANS la barre d'outils sticky (dernier rang) : la
|
||||
toolbar restait sinon peinte par-dessus au défilement. */
|
||||
.xlsx-formula-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--surface2, var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
/* Dernier rang de la toolbar sticky : l'écart est déjà géré par son `gap`. */
|
||||
.xlsx-toolbar .xlsx-formula-bar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Zone d'édition : position de la cellule | champ d'édition. */
|
||||
.xlsx-editbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* #156-A7 — the Name Box is editable (“Atteindre”): a plain input styled like
|
||||
the read-only badge it replaced. */
|
||||
.xlsx-active-cell {
|
||||
width: 84px;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--grid-border, var(--border));
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -11252,17 +11701,22 @@ body.desktop-mode .editor-container {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.xlsx-formula-sep {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
/* Pastille « fx » façon tableur : repère visuel du champ d'édition. */
|
||||
.xlsx-edit-fx {
|
||||
flex: 0 0 auto;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.xlsx-formula-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--grid-border, var(--border));
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
@@ -11334,7 +11788,7 @@ body.desktop-mode .editor-container {
|
||||
min-width: 200px;
|
||||
max-width: 86vw;
|
||||
padding: 4px;
|
||||
background: var(--bg, #fff);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
|
||||
@@ -11393,18 +11847,23 @@ body.desktop-mode .editor-container {
|
||||
.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 */
|
||||
/* Hauteur de l'en-tête + décalage toolbar sticky (`--xlsx-stick`). */
|
||||
top: calc(var(--xlsx-stick, 0px) + 33px); /* thead height — keeps the frozen row below the header */
|
||||
z-index: 2;
|
||||
background: var(--grid-header-bg);
|
||||
background: var(--grid-header-bg, var(--bg-hover));
|
||||
}
|
||||
.xlsx-table td.xlsx-frozen-col {
|
||||
position: sticky;
|
||||
left: 44px; /* the row-number column width */
|
||||
background: var(--grid-bg);
|
||||
background: var(--grid-bg, var(--surface));
|
||||
}
|
||||
|
||||
/* #153 A14 — structure menu (sheets / rows / columns) */
|
||||
.xlsx-structure-menu {
|
||||
/* #185 — the menu-bar dropdowns share the toolbar menus' look; the toolbar
|
||||
menus keep the `.xlsx-structure-menu` class to themselves so `.xlsx-structure-menu`
|
||||
queries (viewer, tests, E2E) only ever match the open toolbar menu. */
|
||||
.xlsx-structure-menu,
|
||||
.xlsx-menudrop {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
@@ -11412,7 +11871,8 @@ body.desktop-mode .editor-container {
|
||||
gap: 2px;
|
||||
min-width: 240px;
|
||||
max-width: min(320px, 92vw);
|
||||
margin-top: 4px;
|
||||
/* Positioned under its toggle by anchorUnder() (viewer.js), which already
|
||||
adds the 4px gap. */
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
@@ -11610,6 +12070,29 @@ body.desktop-mode .editor-container {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* Resize ghost: fixed guide line following the pointer while dragging a
|
||||
header edge (column widths / row heights). */
|
||||
.xlsx-resize-ghost {
|
||||
position: fixed;
|
||||
z-index: 10060;
|
||||
margin: 0;
|
||||
background: var(--accent, #4a90d9);
|
||||
pointer-events: none;
|
||||
}
|
||||
.xlsx-resize-ghost[data-kind="col"] { width: 2px; }
|
||||
.xlsx-resize-ghost[data-kind="row"] { height: 2px; }
|
||||
|
||||
/* Comment marker: content-free so copy/export/save text stays clean. */
|
||||
.xlsx-comment-dot::after {
|
||||
content: "\1F4AC";
|
||||
font-size: 0.68em;
|
||||
margin-left: 3px;
|
||||
vertical-align: super;
|
||||
cursor: help;
|
||||
}
|
||||
/* Computed formula links behave like spreadsheet links. */
|
||||
.xlsx-formula-link { color: var(--accent, #4a90d9); text-decoration: underline; }
|
||||
|
||||
/* #179 — shared menu-entry icon (grid context menu, structure, format, export). */
|
||||
.xlsx-menu-icon {
|
||||
width: 14px;
|
||||
@@ -11636,10 +12119,9 @@ body.desktop-mode .editor-container {
|
||||
outline: 2px solid var(--accent, #4a90d9);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* #155 — Excel-like range selection + right-click / long-press context menu. */
|
||||
.xlsx-table tbody td.xlsx-selected {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
/* #155 — Excel-like range selection + right-click / long-press context menu.
|
||||
Le remplissage de la sélection est déclaré plus haut et conditionné par
|
||||
`.xlsx-range-multi` : pas de fond teinté pour une sélection d'une cellule. */
|
||||
.xlsx-viewer.xlsx-selecting,
|
||||
.xlsx-viewer.xlsx-selecting td {
|
||||
user-select: none;
|
||||
@@ -11673,7 +12155,8 @@ body.desktop-mode .editor-container {
|
||||
.xlsx-context-menu {
|
||||
min-width: min(260px, 86vw);
|
||||
}
|
||||
.xlsx-structure-menu {
|
||||
.xlsx-structure-menu,
|
||||
.xlsx-menudrop {
|
||||
max-width: 92vw;
|
||||
}
|
||||
}
|
||||
@@ -11703,6 +12186,203 @@ body.desktop-mode .editor-container {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* #183 — Sheets-like colour panel (text + fill). The chrome follows the
|
||||
active theme (variables only); the swatches carry their own colour inline,
|
||||
which is data, not decoration. */
|
||||
.xlsx-colorpick-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.xlsx-color-native {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.xlsx-colour-panel {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 236px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 6px 18px var(--shadow, rgba(0, 0, 0, 0.22));
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.xlsx-colour-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xlsx-colour-reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-colour-reset:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.xlsx-colour-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* 8 columns x 10 rows = the 80 curated swatches. */
|
||||
.xlsx-colour-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.xlsx-colour-swatch-btn {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-width: 20px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.xlsx-colour-swatch-btn:hover {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
.xlsx-colour-swatch-btn.selected {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
.xlsx-colour-check {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.xlsx-colour-section {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.xlsx-colour-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.xlsx-colour-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xlsx-colour-row .xlsx-colour-swatch-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.xlsx-colour-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-colour-add:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.xlsx-colour-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-colour-edit:hover {
|
||||
color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.xlsx-colour-foot {
|
||||
margin-top: 10px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.xlsx-colour-foot-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 4px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-colour-foot-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
/* Mise en forme menu: the colour rows now carry a trigger + a live dot. */
|
||||
.xlsx-format-colour {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.xlsx-format-colour-btn {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.xlsx-format-colour-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.xlsx-format-colour-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-green, var(--accent)) 100%);
|
||||
}
|
||||
.xlsx-colour-panel {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* JSDOM shims for the tests that click anchors */
|
||||
mark {
|
||||
font: inherit;
|
||||
@@ -11785,7 +12465,7 @@ mark {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent, #4a90d9);
|
||||
border-radius: 4px;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.53.3",
|
||||
"version": "2.54.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
+200
-15
@@ -41,6 +41,8 @@ const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
||||
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
|
||||
// committed next to the other fixture; nothing in the suite writes to it.
|
||||
const LARGE = 'sample-xlsx-large.xlsx';
|
||||
const LARGE_PATH = path.resolve(process.cwd(), 'test_vault', LARGE);
|
||||
let originalLargeBytes = null;
|
||||
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||
|
||||
@@ -85,19 +87,17 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
|
||||
// the viewer rewrites the workbook and an openpyxl round-trip drops the cached
|
||||
// formula results (BUG-085), so the shadow line only exists on a pristine
|
||||
// fixture.
|
||||
test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => {
|
||||
test('calcule les formules à l’écran, garde le texte en barre (#153 A12)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>).
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>): the
|
||||
// local engine computes it, so the cell shows the value (not the shadow).
|
||||
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await expect(formulaCell).toContainText('=B1*2');
|
||||
|
||||
const cached = formulaCell.locator('.xlsx-cached');
|
||||
await expect(cached).toHaveCount(1);
|
||||
await expect(cached).toHaveText('200');
|
||||
// The tooltip is translated client-side, never hardcoded by the backend.
|
||||
await expect(cached).toHaveAttribute('title', /Excel/);
|
||||
await expect(formulaCell).toHaveText('200');
|
||||
await expect(formulaCell.locator('.xlsx-cached')).toHaveCount(0);
|
||||
await formulaCell.click();
|
||||
await expect(page.locator('#xlsx-formula-input')).toHaveValue('=B1*2');
|
||||
|
||||
// A plain value cell must not be duplicated with a shadow line.
|
||||
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
|
||||
@@ -286,11 +286,12 @@ test.describe('Excel viewer — presse-papiers, clavier et zone Nom (#156 P1)',
|
||||
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
|
||||
await cellOf('B12').click();
|
||||
await page.keyboard.press('Control+a');
|
||||
await expect(nameBox).toHaveValue('A1:C500');
|
||||
// #184 — la feuille rendue va jusqu'à Z (la grille est posée A → Z).
|
||||
await expect(nameBox).toHaveValue('A1:Z500');
|
||||
await page.keyboard.press('Control+Home');
|
||||
await expect(nameBox).toHaveValue('A1');
|
||||
await page.keyboard.press('Control+End');
|
||||
await expect(nameBox).toHaveValue('C500');
|
||||
await expect(nameBox).toHaveValue('Z500');
|
||||
|
||||
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
|
||||
// utilise le presse-papiers interne de la grille : aucune permission
|
||||
@@ -358,27 +359,160 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
await page.locator('#xlsx-structure-btn').click();
|
||||
const toggle = page.locator('#xlsx-structure-btn');
|
||||
await toggle.click();
|
||||
const menu = page.locator('.xlsx-structure-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.locator('.xlsx-structure-item i[data-lucide], .xlsx-structure-item svg').first()).toBeAttached();
|
||||
// The menu opens right under its button, inside the toolbar width.
|
||||
const [btnBox, menuBox, toolbarBox] = await Promise.all([
|
||||
toggle.boundingBox(), menu.boundingBox(),
|
||||
page.locator('#content-area .xlsx-toolbar').boundingBox(),
|
||||
]);
|
||||
expect(menuBox.y).toBeGreaterThanOrEqual(btnBox.y + btnBox.height - 2);
|
||||
expect(menuBox.y).toBeLessThanOrEqual(btnBox.y + btnBox.height + 24);
|
||||
// Le menu reste DANS la barre : près du bord droit il est décalé vers la
|
||||
// gauche (clamp de anchorUnder) au lieu de dépasser.
|
||||
expect(menuBox.x).toBeGreaterThanOrEqual(toolbarBox.x - 2);
|
||||
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 2);
|
||||
// Clic dans la grille = le focus quitte le menu → fermeture.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await expect(page.locator('.xlsx-structure-menu')).toHaveCount(0);
|
||||
await expect(page.locator('#xlsx-structure-btn')).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('le menu Export se ferme au clic extérieur', async ({ page }) => {
|
||||
test('le menu Fichier (Télécharger/CSV/Export) se ferme au clic extérieur', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
await page.locator('#xlsx-export-btn').click();
|
||||
const menu = page.locator('#xlsx-export-menu');
|
||||
await page.locator('#xlsx-file-btn').click();
|
||||
const menu = page.locator('#xlsx-file-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
// Les 5 sorties sont réunies dans ce seul menu (#185 : il est passé dans
|
||||
// la barre de menus, au-dessus du ruban), repliées dans la section
|
||||
// « Télécharger » — les sections du menu sont des accordéons.
|
||||
await menu.locator('.xlsx-menu-item', { hasText: 'Télécharger' }).first().click();
|
||||
await expect(menu.locator('#xlsx-download-btn')).toBeVisible();
|
||||
await expect(menu.locator('#xlsx-csv-btn')).toBeVisible();
|
||||
await expect(page.locator('#content-area .xlsx-sheetbar-end #xlsx-save-btn')).toBeVisible();
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('#185 : les 10 menus Google Sheets au-dessus du ruban, et leurs sections', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const bar = page.locator('#content-area .xlsx-menubar');
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(bar.locator('.xlsx-menubtn')).toHaveText([
|
||||
'Fichier', 'Modifier', 'Affichage', 'Insertion', 'Format',
|
||||
'Données', 'Outils', 'Gemini', 'Extensions', 'Aide',
|
||||
]);
|
||||
// Le ruban est bien SOUS les menus.
|
||||
const [barBox, ribbonBox] = await Promise.all([
|
||||
bar.boundingBox(),
|
||||
page.locator('#content-area .xlsx-cmdbar').boundingBox(),
|
||||
]);
|
||||
expect(ribbonBox.y).toBeGreaterThan(barBox.y);
|
||||
|
||||
// Une section s'ouvre en accordéon, comme dans Sheets.
|
||||
await page.locator('#content-area [data-xlsx-menu="format"]').click();
|
||||
const format = page.locator('#xlsx-menu-format');
|
||||
await expect(format).toBeVisible();
|
||||
const number = format.locator('.xlsx-menu-item', { hasText: 'Nombre' }).first();
|
||||
await number.click();
|
||||
await expect(number).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(format.locator('[data-xlsx-action="numfmt"]').first()).toBeVisible();
|
||||
|
||||
// Une option non implémentée le dit au lieu de ne rien faire.
|
||||
const ghost = format.locator('[data-xlsx-unavailable]').first();
|
||||
await ghost.click();
|
||||
await expect(page.locator('.toast').first()).toBeVisible();
|
||||
await expect(format).toBeHidden();
|
||||
});
|
||||
|
||||
test('#185 : la grille est unie (1px sur les quatre côtés) et se masque', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||
const sides = await cell.evaluate((td) => {
|
||||
const cs = getComputedStyle(td);
|
||||
return [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth];
|
||||
});
|
||||
expect(new Set(sides).size).toBe(1);
|
||||
expect(sides[0]).toBe('1px');
|
||||
|
||||
await page.locator('#content-area [data-xlsx-menu="view"]').click();
|
||||
const view = page.locator('#xlsx-menu-view');
|
||||
await expect(view).toBeVisible();
|
||||
// Les entrées d'affichage vivent dans la section « Afficher » (accordéon).
|
||||
await view.locator('.xlsx-menu-item', { hasText: 'Afficher' }).first().click();
|
||||
await page.locator('#xlsx-menu-view [data-xlsx-action="toggle-grid"]').click();
|
||||
await expect(page.locator('#content-area .xlsx-viewer')).toHaveClass(/xlsx-no-grid/);
|
||||
const hidden = await cell.evaluate((td) => getComputedStyle(td).borderTopWidth);
|
||||
expect(hidden).toBe('0px');
|
||||
});
|
||||
|
||||
// Mon seul test mutant : restaure la fixture comme le bloc lossy.
|
||||
test.beforeAll(() => {
|
||||
if (existsSync(LARGE_PATH)) originalLargeBytes = readFileSync(LARGE_PATH);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
if (originalLargeBytes) writeFileSync(LARGE_PATH, originalLargeBytes);
|
||||
});
|
||||
|
||||
test('insère une colonne à droite et une ligne en fin de feuille', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
// #184 — la grille posée va jusqu'à Z : le compte d'en-têtes ne peut plus
|
||||
// témoigner de l'insertion (un re-rendu re-complète à 26 colonnes). Le
|
||||
// toast est le signal de l'opération, la fixture restaurée sa preuve.
|
||||
const headers = () => page.locator('#content-area .xlsx-table thead th:not(.xlsx-corner)');
|
||||
await expect(headers()).toHaveCount(26);
|
||||
await headers().nth(2).click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'droite' }).click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
// Insérée localement (pas de re-rendu complet) : la colonne s'ajoute à
|
||||
// la grille posée, 26 → 27.
|
||||
await expect(headers()).toHaveCount(27);
|
||||
|
||||
// La ligne 501 dépasse le plafond de rendu : l'insertion réussit
|
||||
// (toast) même si la ligne reste hors champ.
|
||||
await page.locator('#content-area th.xlsx-rownum').nth(499).click({ button: 'right' });
|
||||
await page.locator('.xlsx-context-item', { hasText: 'dessous' }).click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('la barre façon Sheets expose ses contrôles et agit', async ({ page }) => {
|
||||
await login(page);
|
||||
await openXlsx(page, LARGE);
|
||||
|
||||
for (const id of ['#xs-search', '#xs-print', '#xs-paint', '#xs-zoom', '#xs-cur', '#xs-pct',
|
||||
'#xs-numfmt', '#xs-b', '#xs-i', '#xs-s', '#xs-u', '#xs-borders', '#xs-merge',
|
||||
'#xs-alignh', '#xs-alignv', '#xs-wrap', '#xs-rot', '#xs-more', '#xlsx-save-btn']) {
|
||||
await expect(page.locator(id)).toBeVisible();
|
||||
}
|
||||
// Zoom : la grille suit le sélecteur.
|
||||
await page.locator('#xs-zoom').selectOption('150');
|
||||
await expect.poll(async () => page.locator('#content-area .xlsx-panels').evaluate(
|
||||
(el) => el.style.zoom || '100%')).toBe('150%');
|
||||
await page.locator('#xs-zoom').selectOption('100');
|
||||
|
||||
// Gras via la barre : PUT + toast, sans dialogue.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await page.locator('#xs-b').click();
|
||||
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Mini-barre contextuelle sur une plage.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await page.locator('#content-area td[data-cell="B2"]').click({ modifiers: ['Shift'] });
|
||||
await expect(page.locator('#xlsx-minibar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
|
||||
await login(page);
|
||||
@@ -402,3 +536,54 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
expect(itemBox.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #186 — création d'un classeur depuis la modale « Créer un fichier » ──────
|
||||
|
||||
test.describe('Excel viewer — création depuis l\'UI (#186)', () => {
|
||||
// Le nom saisi dans la modale ne porte que le nom court : le dossier parent
|
||||
// (« IT ») est déjà choisi par le menu contextuel.
|
||||
const stem = `e2e-cree-${Date.now()}`;
|
||||
const CREATED = `IT/${stem}.xlsx`;
|
||||
|
||||
async function cleanup(request) {
|
||||
await request.fetch(
|
||||
`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(CREATED)}`,
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
test('la liste des types propose Excel et le classeur créé s\'ouvre dans la visionneuse', async ({ page, request }) => {
|
||||
await cleanup(request);
|
||||
await login(page);
|
||||
|
||||
// Menu contextuel du dossier « IT » → « Nouveau fichier ici ».
|
||||
await page.locator('.tree-item.vault-item[data-vault="TestVault"]').first().click();
|
||||
await page.locator('.tree-item[data-vault="TestVault"][data-path="IT"]').first()
|
||||
.click({ button: 'right' });
|
||||
const menu = page.locator('#context-menu.active');
|
||||
await expect(menu).toBeVisible({ timeout: 5000 });
|
||||
await menu.locator('.context-menu-item', { hasText: 'Nouveau fichier ici' }).click();
|
||||
|
||||
await page.locator('.obsigate-modal-overlay.active').waitFor({ state: 'visible', timeout: 5000 });
|
||||
const extSelect = page.locator('#file-ext-select');
|
||||
// L'option Excel existe, et son libellé vient des locales (jamais du dur).
|
||||
await expect(extSelect.locator('option[value=".xlsx"]')).toHaveText('Excel (.xlsx)');
|
||||
|
||||
await extSelect.selectOption('.xlsx');
|
||||
await page.fill('#file-name-input', stem);
|
||||
await page.click('#file-create-btn');
|
||||
|
||||
// Le classeur s'ouvre dans la visionneuse : une grille, pas une erreur.
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.locator('#content-area .xlsx-tab', { hasText: 'Feuille1' })).toBeVisible();
|
||||
|
||||
// Et c'est un vrai classeur : l'API le sert en tableur, avec sa feuille.
|
||||
const body = await request
|
||||
.get(`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(CREATED)}`)
|
||||
.then((r) => r.json());
|
||||
expect(body.is_xlsx).toBe(true);
|
||||
expect(body.xlsx_sheets.map((s) => s.name)).toEqual(['Feuille1']);
|
||||
|
||||
await cleanup(request);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — unit tests for the spreadsheet formula engine
|
||||
* (frontend/js/xlsx/formula.js). Pure logic, no DOM needed.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-formula.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
const modUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "formula.js")
|
||||
).href;
|
||||
const {
|
||||
isFormulaText, parseFormula, evaluate, createScope, displayValue,
|
||||
colToIndex, indexToCol, parseA1,
|
||||
} = await import(modUrl);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (e) {
|
||||
console.error(` ✗ ${name}`);
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate `text` against a fake sheet {ref: raw}. */
|
||||
function run(text, cells = {}, locale = "en") {
|
||||
const scope = createScope({
|
||||
readRaw: (_sheet, ref) => {
|
||||
const hit = Object.entries(cells).find(
|
||||
([k]) => k.toUpperCase() === String(ref).toUpperCase()
|
||||
);
|
||||
return hit ? hit[1] : null;
|
||||
},
|
||||
sheet: "F",
|
||||
locale,
|
||||
});
|
||||
return displayValue(evaluate(parseFormula(text), scope), locale);
|
||||
}
|
||||
|
||||
await test("detects formula text", () => {
|
||||
assert.ok(isFormulaText("=1+1"));
|
||||
assert.ok(isFormulaText(" @A1 "));
|
||||
assert.ok(!isFormulaText("hello"));
|
||||
assert.ok(!isFormulaText("="));
|
||||
});
|
||||
|
||||
await test("refs helpers", () => {
|
||||
assert.equal(colToIndex("A"), 1);
|
||||
assert.equal(colToIndex("Z"), 26);
|
||||
assert.equal(colToIndex("AA"), 27);
|
||||
assert.equal(indexToCol(28), "AB");
|
||||
assert.deepEqual(parseA1("$B$12"), { col: 2, row: 12 });
|
||||
assert.equal(parseA1("ZZZ"), null);
|
||||
});
|
||||
|
||||
await test("arithmetic and Excel precedence (-2^2 = 4)", () => {
|
||||
assert.equal(run("=1+2*3"), "7");
|
||||
assert.equal(run("=(1+2)*3"), "9");
|
||||
assert.equal(run("=2^3"), "8");
|
||||
assert.equal(run("=-2^2"), "4");
|
||||
assert.equal(run("=10/4"), "2.5");
|
||||
assert.equal(run("=50%"), "0.5");
|
||||
assert.equal(run("=5*10%"), "0.5");
|
||||
});
|
||||
|
||||
await test("comparisons and concatenation", () => {
|
||||
assert.equal(run("=1+1=2"), "TRUE");
|
||||
assert.equal(run('="a"&"b"'), "ab");
|
||||
assert.equal(run("=3>2"), "TRUE");
|
||||
assert.equal(run("=1<>1"), "FALSE");
|
||||
});
|
||||
|
||||
await test("cell refs and ranges", () => {
|
||||
const cells = { A1: 2, A2: 4, B1: "x" };
|
||||
assert.equal(run("=A1*3", cells), "6");
|
||||
assert.equal(run("=SOMME(A1:A2)", cells), "6");
|
||||
assert.equal(run("=SUM(A1:A2)", cells), "6");
|
||||
assert.equal(run("=MOYENNE(A1:A2)", cells), "3");
|
||||
assert.equal(run("=A1&B1", cells), "2x");
|
||||
});
|
||||
|
||||
await test("French decimal comma with ; separator", () => {
|
||||
assert.equal(run("=1,5+1", {}, "fr"), "2.5");
|
||||
assert.equal(run("=SOMME(1;2;3)"), "6");
|
||||
assert.equal(run("=SI(1>2;10;20)"), "20");
|
||||
});
|
||||
|
||||
await test("functions FR/EN", () => {
|
||||
assert.equal(run("=MAX(1;5;3)"), "5");
|
||||
assert.equal(run("=MIN(1;5;3)"), "1");
|
||||
assert.equal(run("=ABS(-4)"), "4");
|
||||
assert.equal(run("=ARRONDI(2.456;2)"), "2.46");
|
||||
assert.equal(run("=RACINE(9)"), "3");
|
||||
assert.equal(run("=PUISSANCE(2;10)"), "1024");
|
||||
assert.equal(run("=NB(1;\"x\";3)"), "2");
|
||||
assert.equal(run("=NBVAL(1;\"x\";3)"), "3");
|
||||
assert.equal(run("=NBCAR(\"héllo\")"), "5");
|
||||
assert.equal(run("=MAJUSCULES(\"abc\")"), "ABC");
|
||||
assert.equal(run("=GAUCHE(\"hello\";2)"), "he");
|
||||
assert.equal(run("=DROITE(\"hello\";2)"), "lo");
|
||||
assert.equal(run("=STXT(\"hello\";2;3)"), "ell");
|
||||
assert.equal(run("=ET(1;1)"), "TRUE");
|
||||
assert.equal(run("=OU(0;1)"), "TRUE");
|
||||
assert.equal(run("=NON(1)"), "FALSE");
|
||||
assert.equal(run('=SIERREUR(1/0;"raté")'), "raté");
|
||||
assert.equal(run("=MEDIANE(3;1;2)"), "2");
|
||||
assert.equal(run("=PRODUIT(2;3;4)"), "24");
|
||||
assert.equal(run("=MOD(7;3)"), "1");
|
||||
assert.equal(run("=ENT(2.9)"), "2");
|
||||
});
|
||||
|
||||
await test("nested formulas and chained refs", () => {
|
||||
const cells = { A1: "=B1*2", B1: 21 };
|
||||
assert.equal(run("=A1+1", cells), "43");
|
||||
});
|
||||
|
||||
await test("errors surface as localized codes", () => {
|
||||
assert.equal(run("=1/0"), "#DIV/0!");
|
||||
assert.equal(run("=1/0", {}, "fr"), "#DIV/0!");
|
||||
assert.equal(run("=NOSUCHFN(1)"), "#NAME?");
|
||||
assert.equal(run("=NOSUCHFN(1)", {}, "fr"), "#NOM?");
|
||||
assert.equal(run('="a"+1'), "#VALUE!");
|
||||
assert.equal(run('="a"+1', {}, "fr"), "#VALEUR!");
|
||||
assert.equal(run("=A99999999"), "#REF!");
|
||||
});
|
||||
|
||||
await test("cycles are detected", () => {
|
||||
const cells = { A1: "=B1", B1: "=A1" };
|
||||
assert.equal(run("=A1", cells), "#CIRC!");
|
||||
assert.equal(run("=A1", cells, "fr"), "#CYCLE!");
|
||||
});
|
||||
|
||||
await test("booleans localize", () => {
|
||||
assert.equal(run("=VRAI"), "TRUE");
|
||||
assert.equal(run("=VRAI", {}, "fr"), "VRAI");
|
||||
assert.equal(run("=TRUE"), "TRUE");
|
||||
assert.equal(run("=1=1", {}, "fr"), "VRAI");
|
||||
});
|
||||
|
||||
await test("HYPERLINK yields a link value", async () => {
|
||||
const scope = createScope({ readRaw: () => null, sheet: "F", locale: "en" });
|
||||
const v = evaluate(parseFormula('=LIEN("https://x.test";"ici")'), scope);
|
||||
assert.equal(v.t, "link");
|
||||
assert.equal(v.url, "https://x.test");
|
||||
assert.equal(v.label, "ici");
|
||||
assert.equal(displayValue(v), "ici");
|
||||
});
|
||||
|
||||
await test("garbage is rejected by the parser", () => {
|
||||
assert.throws(() => parseFormula("=1+"), Error);
|
||||
assert.throws(() => parseFormula("=SUM("), Error);
|
||||
assert.throws(() => parseFormula("=(1"), Error);
|
||||
});
|
||||
|
||||
await test("quoted and absolute refs", () => {
|
||||
const cells = { A1: 5 };
|
||||
assert.equal(run("=$A$1+1", cells), "6");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} passed`);
|
||||
process.exit(process.exitCode || 0);
|
||||
@@ -1,14 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the spreadsheet menus polish (ROADMAP #179).
|
||||
* ObsiGate — JSDOM tests for the spreadsheet menus (ROADMAP #179, #185).
|
||||
*
|
||||
* Covers frontend/js/xlsx/context-menu.js and frontend/js/xlsx/command-bar.js:
|
||||
* Covers frontend/js/xlsx/context-menu.js, command-bar.js and menus.js:
|
||||
* - every grid-menu entry renders its representative Lucide icon + safe label;
|
||||
* - the grid menu closes on Escape / outside click / focus loss / scroll /
|
||||
* resize, and offers arrow-key navigation with autofocus on open;
|
||||
* - trackDismissable() gives Structure / Mise en forme / Export the same
|
||||
* dismissal contract (toggle exempt, custom onClose);
|
||||
* - the ribbon carries the new icons (save, tab "+", export entries).
|
||||
* - the ribbon carries its icons (save, tab "+", gridlines);
|
||||
* - #185: the menu bar lists the ten Google Sheets menus, every entry is
|
||||
* actionable or honestly flagged unavailable, labels come from the catalog,
|
||||
* editing entries disappear on a read-only workbook, and the historical
|
||||
* Fichier ids (#xlsx-file-btn / -menu / #xlsx-download-btn / #xlsx-csv-btn)
|
||||
* survive the move from the sheet strip.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-menus.test.mjs
|
||||
*/
|
||||
@@ -43,6 +48,18 @@ globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
||||
|
||||
// #185 — the menu bar renders localized labels, so the real FR catalog has to
|
||||
// be reachable: serve /static/locales/*.json from disk and nothing else.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/static/locales/")) {
|
||||
const file = path.join(REPO_ROOT, "frontend", "locales", "fr.json");
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(readFileSync(file, "utf8")) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}), text: async () => "" };
|
||||
};
|
||||
|
||||
// ── Module under test ───────────────────────────────────────────────────────
|
||||
const ctxMenuUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
|
||||
@@ -50,9 +67,17 @@ const ctxMenuUrl = pathToFileURL(
|
||||
const cmdBarUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
|
||||
).href;
|
||||
const menusUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "menus.js")
|
||||
).href;
|
||||
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
|
||||
await import(ctxMenuUrl);
|
||||
const { buildCommandBar } = await import(cmdBarUrl);
|
||||
const { buildMenuBar, SHEETS_MENUS } = await import(menusUrl);
|
||||
const { initI18n } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
|
||||
);
|
||||
await initI18n();
|
||||
|
||||
// ── Mini runner ─────────────────────────────────────────────────────────────
|
||||
let testCount = 0;
|
||||
@@ -163,13 +188,32 @@ await test("focus leaving the menu closes it, focus inside keeps it", () => {
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
await test("scroll and resize close the menu", () => {
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(!isContextMenuOpen(), "scroll closes");
|
||||
await test("double close never throws and a late click still runs its action", () => {
|
||||
// Regression: in a real browser, remove() fires focusout synchronously,
|
||||
// which re-entered closeContextMenu — the second remove() threw
|
||||
// NotFoundError and aborted the item's onClick before it ran.
|
||||
let runs = 0;
|
||||
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "Copy", onClick: () => { runs++; } }]);
|
||||
const btn = menu.querySelector(".xlsx-context-item");
|
||||
closeContextMenu();
|
||||
closeContextMenu(); // idempotent: no-op, no throw
|
||||
assert.ok(!isContextMenuOpen());
|
||||
btn.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(runs, 1, "click on the detached entry still runs its action");
|
||||
});
|
||||
|
||||
await test("scroll and resize close the menu", async () => {
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
window.dispatchEvent(new w.Event("resize"));
|
||||
assert.ok(!isContextMenuOpen(), "resize closes");
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
// The smooth-scroll tail of the opening gesture is forgiven…
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(isContextMenuOpen(), "immediate scroll does not close");
|
||||
// …but a later scroll detaches the menu.
|
||||
await sleep(450);
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(!isContextMenuOpen(), "later scroll closes");
|
||||
});
|
||||
|
||||
// ── trackDismissable (Structure / Format / Export) ──────────────────────────
|
||||
@@ -226,26 +270,143 @@ await test("trackDismissable: Escape and focus loss dismiss", () => {
|
||||
toggle.remove();
|
||||
});
|
||||
|
||||
// ── Ribbon icons ────────────────────────────────────────────────────────────
|
||||
await test("the ribbon carries save, tab-plus and export icons", () => {
|
||||
const { actionsHtml, tabs } = buildCommandBar({
|
||||
// ── Ribbon icons (#185: the File menu moved to the menu bar) ────────────────
|
||||
await test("the ribbon carries save, tab-plus and gridline icons", () => {
|
||||
const { actionsHtml, tabs, fileHtml } = buildCommandBar({
|
||||
sheets: [{ name: "F1" }],
|
||||
isCsv: false,
|
||||
readOnly: false,
|
||||
editable: true,
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = tabs + actionsHtml;
|
||||
host.innerHTML = tabs + actionsHtml + fileHtml;
|
||||
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
|
||||
assert.ok(saveIcon, "save button has an icon");
|
||||
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
|
||||
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
|
||||
assert.ok(plusIcon, "tab + button has an icon");
|
||||
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
|
||||
const exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) =>
|
||||
i.getAttribute("data-lucide")
|
||||
const gridIcon = host.querySelector("#xs-grid i[data-lucide]");
|
||||
assert.ok(gridIcon, "gridline switch has an icon");
|
||||
assert.equal(gridIcon.getAttribute("data-lucide"), "grid-3x3");
|
||||
// #185 — the sheet strip keeps Save only; the file outputs live in the menu bar.
|
||||
assert.equal(host.querySelector("#xlsx-file-btn"), null, "no file dropdown left on the strip");
|
||||
});
|
||||
|
||||
// ── #185 — the menu bar itself ──────────────────────────────────────────────
|
||||
await test("buildMenuBar renders the ten Sheets menus with their labels", () => {
|
||||
assert.deepEqual(
|
||||
SHEETS_MENUS.map((m) => m.id),
|
||||
["file", "edit", "view", "insert", "format", "data", "tools", "gemini", "extensions", "help"],
|
||||
);
|
||||
assert.deepEqual(exportIcons, ["file-text", "code", "printer"]);
|
||||
const html = buildMenuBar({ editable: true, readOnly: false });
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
const menus = [...host.querySelectorAll(".xlsx-menubar > .xlsx-menuwrap")];
|
||||
assert.deepEqual(
|
||||
menus.map((w) => w.querySelector(".xlsx-menubtn").textContent.trim()),
|
||||
["Fichier", "Modifier", "Affichage", "Insertion", "Format", "Données", "Outils", "Gemini", "Extensions", "Aide"],
|
||||
);
|
||||
// Every button controls its own dropdown, hidden until opened.
|
||||
menus.forEach((w) => {
|
||||
const btn = w.querySelector(".xlsx-menubtn");
|
||||
const drop = host.querySelector(`#${btn.getAttribute("aria-controls")}`);
|
||||
assert.ok(drop, `${btn.dataset.xlsxMenu} has a dropdown`);
|
||||
assert.equal(drop.getAttribute("role"), "menu");
|
||||
assert.equal(drop.style.display, "none", "dropdown starts closed");
|
||||
assert.equal(btn.getAttribute("aria-expanded"), "false");
|
||||
});
|
||||
});
|
||||
|
||||
await test("the Fichier menu keeps the historical ids and export markers", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
assert.ok(host.querySelector("#xlsx-file-btn"), "file button id kept");
|
||||
assert.ok(host.querySelector("#xlsx-file-menu"), "file menu id kept");
|
||||
assert.ok(host.querySelector("#xlsx-download-btn"), "download entry kept its id");
|
||||
assert.ok(host.querySelector("#xlsx-csv-btn"), "csv entry kept its id");
|
||||
assert.deepEqual(
|
||||
[...host.querySelectorAll("#xlsx-file-menu [data-xlsx-export]")].map((n) => n.dataset.xlsxExport),
|
||||
["md", "html", "print"],
|
||||
);
|
||||
});
|
||||
|
||||
await test("every menu entry is either actionable or honestly flagged", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const rows = [...host.querySelectorAll(".xlsx-menu-item")];
|
||||
assert.ok(rows.length > 120, `all Sheets entries are listed (${rows.length})`);
|
||||
rows.forEach((row) => {
|
||||
const actionable = row.hasAttribute("data-xlsx-action") || row.hasAttribute("data-xlsx-export");
|
||||
const flagged = row.hasAttribute("data-xlsx-unavailable");
|
||||
assert.ok(actionable || flagged || row.hasAttribute("aria-haspopup"),
|
||||
`"${row.textContent.trim()}" is actionable, a section, or flagged unavailable`);
|
||||
});
|
||||
// A declared action always resolves against the viewer's registry (#185).
|
||||
const ACTIONS = [
|
||||
"share", "download", "export-csv", "rename", "trash", "history", "details", "settings", "print",
|
||||
"undo", "redo", "cut", "copy", "paste", "paste-values", "delete-rows", "delete-cols",
|
||||
"clear-contents", "select-all", "find-replace", "freeze", "freeze-row", "freeze-col",
|
||||
"unfreeze", "toggle-formula-bar", "toggle-grid", "toggle-headers", "dashboard", "zoom",
|
||||
"fullscreen", "insert-rows-above", "insert-rows-below", "insert-cols-left",
|
||||
"insert-cols-right", "insert-cells", "link", "comment", "function", "numfmt", "theme-menu",
|
||||
"bold", "italic", "underline", "strike", "fontsize", "align", "valign",
|
||||
"wrap-on", "wrap-off", "rotate", "clear-format", "banded", "sort-asc", "sort-desc",
|
||||
"sort-reset", "filter-column", "named-ranges", "cleanup-dupes", "cleanup-trim",
|
||||
"split-text", "autocomplete", "a11y", "gemini", "help", "whatsnew", "shortcuts", "report",
|
||||
];
|
||||
const declared = new Set(
|
||||
[...host.querySelectorAll("[data-xlsx-action]")].map((n) => n.dataset.xlsxAction),
|
||||
);
|
||||
ACTIONS.forEach((name) => assert.ok(declared.has(name), `menu declares "${name}"`));
|
||||
// "apply-theme" is deliberately absent: the theme list is built on demand.
|
||||
assert.equal(declared.has("apply-theme"), false, "themes are not hard-coded in the markup");
|
||||
});
|
||||
|
||||
await test("Format ▸ Thème is an empty section the viewer fills on demand (#185)", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const row = host.querySelector('[data-xlsx-menu="format"]')
|
||||
.closest(".xlsx-menuwrap")
|
||||
.querySelector('[data-xlsx-action="theme-menu"]');
|
||||
assert.ok(row, "the Thème section exists");
|
||||
const sub = row.nextElementSibling;
|
||||
assert.equal(sub.className, "xlsx-menu-sub", "it is a section, not a leaf");
|
||||
assert.equal(sub.hidden, true, "collapsed");
|
||||
assert.equal(sub.children.length, 0, "empty until the viewer fills it");
|
||||
assert.equal(row.getAttribute("aria-expanded"), "false");
|
||||
// The next entry is untouched by the empty section.
|
||||
assert.ok(host.querySelector('[data-xlsx-action="bold"]'), "Bold still listed");
|
||||
});
|
||||
|
||||
await test("editing entries disappear on a read-only workbook, view entries stay", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: false, readOnly: true });
|
||||
assert.equal(host.querySelector('[data-xlsx-action="bold"]'), null, "no Bold");
|
||||
assert.equal(host.querySelector('[data-xlsx-action="insert-cells"]'), null, "no Insert cells");
|
||||
assert.equal(host.querySelector('[data-xlsx-action="undo"]'), null, "no Undo");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="copy"]'), "Copy stays available");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="zoom"]'), "Zoom stays available");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay available");
|
||||
});
|
||||
|
||||
await test("menu labels come from the i18n catalog, never from raw text", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const labels = [...host.querySelectorAll(".xlsx-menu-label")].map((n) => n.textContent);
|
||||
assert.ok(labels.length > 100);
|
||||
assert.equal(labels.some((l) => /xlsx\./.test(l)), false, "no unresolved key is rendered");
|
||||
assert.ok(labels.includes("Nouveau"), "Google Sheets' own wording is kept");
|
||||
assert.ok(labels.includes("Couleurs alternées"));
|
||||
assert.ok(labels.includes("Diviser le texte en colonnes"));
|
||||
});
|
||||
|
||||
await test("a sheet name or cell value cannot leak HTML into a menu label", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
host.querySelectorAll(".xlsx-menu-label").forEach((n) => {
|
||||
assert.equal(n.querySelector("img, script"), null, "labels stay text nodes");
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} passed`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
# tests/test_api_main.py — Integration tests for main.py API endpoints
|
||||
# Uses the existing app_with_vault / client fixtures from conftest.py
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Health & Metadata
|
||||
@@ -200,6 +203,45 @@ class TestFileCRUD:
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
|
||||
def test_create_xlsx_empty_workbook(self, client, test_vault_dir):
|
||||
"""#186 - "Create a file" > Excel must produce a readable workbook.
|
||||
|
||||
An .xlsx is a ZIP archive, not a text file: writing the empty payload
|
||||
of the modal as text would leave a file the viewer cannot open.
|
||||
"""
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
resp = client.post("/api/file/TestVault", json={
|
||||
"path": "nouveau.xlsx",
|
||||
"content": "",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
# The archive is a real workbook: one empty sheet, loadable by openpyxl.
|
||||
wb = openpyxl.load_workbook(Path(test_vault_dir) / "nouveau.xlsx")
|
||||
assert wb.sheetnames == ["Feuille1"]
|
||||
|
||||
# And the viewer serves it as a spreadsheet.
|
||||
resp2 = client.get("/api/file/TestVault", params={"path": "nouveau.xlsx"})
|
||||
assert resp2.status_code == 200
|
||||
body = resp2.json()
|
||||
assert body["is_xlsx"] is True
|
||||
assert [s["name"] for s in body["xlsx_sheets"]] == ["Feuille1"]
|
||||
|
||||
def test_create_xlsx_in_new_subdir(self, client):
|
||||
"""#186 - same in a subdirectory that does not exist yet."""
|
||||
pytest.importorskip("openpyxl")
|
||||
|
||||
resp = client.post("/api/file/TestVault", json={
|
||||
"path": "Tableaux/annee.xlsx",
|
||||
"content": "",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
resp2 = client.get("/api/file/TestVault", params={"path": "Tableaux/annee.xlsx"})
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["is_xlsx"] is True
|
||||
|
||||
def test_save_file(self, client):
|
||||
"""Save (update) an existing file"""
|
||||
resp = client.put("/api/file/TestVault/save?path=note1.md", json={
|
||||
|
||||
@@ -129,6 +129,36 @@ class TestRowColOps:
|
||||
assert wb["Data"]["B1"].value == "Valeur"
|
||||
wb.close()
|
||||
|
||||
def test_insert_past_the_last_row_and_column_takes_effect(self, client, book):
|
||||
# openpyxl's sparse model silently ignores inserts past the used
|
||||
# extent: the service materialises the missing rows/columns first so
|
||||
# « below the last row » / « right of the last column » behave.
|
||||
wb = openpyxl.load_workbook(book)
|
||||
last_row, last_col = wb["Data"].max_row, wb["Data"].max_column
|
||||
wb.close()
|
||||
assert _put(client, actions=[
|
||||
{"op": "col_insert", "sheet": "Data", "at": last_col + 1},
|
||||
]).status_code == 200
|
||||
assert _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": last_row + 1},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
ws = wb["Data"]
|
||||
assert ws.max_column == last_col + 1, "one blank column appended"
|
||||
assert ws.max_row == last_row + 1, "one blank row appended"
|
||||
wb.close()
|
||||
|
||||
def test_insert_far_past_the_extent_extends_the_sheet(self, client, book):
|
||||
wb = openpyxl.load_workbook(book)
|
||||
last_row = wb["Data"].max_row
|
||||
wb.close()
|
||||
assert _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": last_row + 3},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"].max_row >= last_row + 3
|
||||
wb.close()
|
||||
|
||||
|
||||
class TestGuards:
|
||||
def test_unknown_sheet_is_400(self, client, book):
|
||||
|
||||
@@ -250,6 +250,115 @@ class TestStyleWriting:
|
||||
assert backups, "the rewrite is backed up first"
|
||||
|
||||
|
||||
class TestExtendedStyles:
|
||||
"""Nouvelles clés de style (barre façon Sheets) : taille, barré,
|
||||
alignements verticaux, retour ligne, rotation, bordures — et commentaires."""
|
||||
|
||||
def _style(self, client, path, ops, **extra):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/style",
|
||||
params={"path": path},
|
||||
json={"ops": ops, **extra},
|
||||
)
|
||||
|
||||
def test_font_size_strike_valign_wrap_rotation(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{
|
||||
"op": "cell", "sheet": "Data", "range": "A1",
|
||||
"style": {
|
||||
"font_size": 14, "strike": True, "valign": "middle",
|
||||
"wrap": True, "rotation": -45,
|
||||
},
|
||||
},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
cell = wb["Data"]["A1"]
|
||||
assert cell.font.size == 14
|
||||
assert cell.font.strike is True
|
||||
assert cell.alignment.vertical == "center"
|
||||
assert cell.alignment.wrap_text is True
|
||||
assert cell.alignment.textRotation == 135
|
||||
wb.close()
|
||||
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert "font-size:14pt" in sheet["styles"]["A1"]
|
||||
assert "line-through" in sheet["styles"]["A1"]
|
||||
assert "vertical-align:middle" in sheet["styles"]["A1"]
|
||||
assert "white-space:normal" in sheet["styles"]["A1"]
|
||||
# La diagonale (-45° → 135 OOXML) est relue en `rotate()` CSS.
|
||||
assert "transform:rotate(45deg)" in sheet["styles"]["A1"]
|
||||
|
||||
def test_borders_all_outer_none(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1:B2",
|
||||
"style": {"border": "all", "border_color": "#112233"}},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
side = wb["Data"]["A1"].border.left
|
||||
assert side.style == "thin" and side.color.rgb == "FF112233"
|
||||
wb.close()
|
||||
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1:B2", "style": {"border": "none"}},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
cleared = wb["Data"]["A1"].border.left
|
||||
assert cleared is None or cleared.style is None
|
||||
wb.close()
|
||||
|
||||
def test_comment_set_clear_and_readback(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": "À vérifier"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
assert wb["Data"]["B2"].comment.text == "À vérifier"
|
||||
wb.close()
|
||||
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
assert resp.json()["xlsx_sheets"][0]["comments"]["B2"] == "À vérifier"
|
||||
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_clear", "sheet": "Data", "ref": "B2"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
assert wb["Data"]["B2"].comment is None
|
||||
wb.close()
|
||||
|
||||
def test_formats_map_lists_number_formats(self, client, plain_book):
|
||||
self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "B2", "style": {"number_format": "0.00"}},
|
||||
])
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
assert resp.json()["xlsx_sheets"][0]["formats"]["B2"] == "0.00"
|
||||
|
||||
def test_extended_invalid_inputs_are_refused(self, client, plain_book):
|
||||
style = lambda s: self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": s},
|
||||
]).status_code
|
||||
assert style({"font_size": 200}) == 400
|
||||
assert style({"font_size": "grand"}) == 400
|
||||
assert style({"valign": "diagonal"}) == 400
|
||||
# 180° (texte vertical retourné, relu tel quel) est accepté — seules
|
||||
# les valeurs vraiment hors bornes sont refusées.
|
||||
assert style({"rotation": 180}) == 200
|
||||
assert style({"rotation": 181}) == 400
|
||||
assert style({"rotation": -91}) == 400
|
||||
assert style({"border": "dotted-circle"}) == 400
|
||||
assert style({"border_style": "wavy"}) == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": ""},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2:C3", "text": "x"},
|
||||
]).status_code == 400
|
||||
|
||||
|
||||
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"})
|
||||
|
||||
Reference in New Issue
Block a user