Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b4962efb | ||
|
|
e01e837a2a | ||
|
|
634ba8a272 |
+355
-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).
|
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
|
> **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.56.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,360 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.56.0] — 2026-10-08
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **#189 - Tableur : mise en page mobile (barres repliées, grille prioritaire)**
|
||||||
|
- Sur mobile (≤ 768 px), la **barre de menus** (Fichier, Modifier…), le
|
||||||
|
**ruban** de formatage et le **groupe Recherche** sont **repliés par
|
||||||
|
défaut** : la barre des feuilles et la **barre de formule (fx)** restent
|
||||||
|
toujours visibles, et la grille récupère la hauteur d'écran perdue
|
||||||
|
(l'ensemble dépassait 50 % de l'écran).
|
||||||
|
- Un **bouton unique** (`#xlsx-tools-toggle`, icône ☰) dans la barre des
|
||||||
|
feuilles déplie/replie le tout (`aria-expanded`, état actif) ; sur
|
||||||
|
desktop il reste masqué et rien ne bouge.
|
||||||
|
- **Ergonomie tactile** : cibles ≥ 44 px (boutons, menus, onglets), groupes
|
||||||
|
du ruban qui s'enroulent au lieu de déborder à cette taille, onglets de
|
||||||
|
feuilles sur une seule ligne qui **défile horizontalement**, champs de la
|
||||||
|
barre de formule en 16 px (pas de zoom iOS au focus), barre de formule qui
|
||||||
|
passe en 2 lignes quand les outils sont ouverts.
|
||||||
|
- Tests : JSDOM `xlsx-viewer.test.mjs` (bascule + règles CSS, 165/165) et
|
||||||
|
E2E mobile (repli par défaut, dépliage, ruban sans débordement, cibles
|
||||||
|
44 px).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.55.0] — 2026-10-08
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **#188 - Secrets masqués : couverture universelle + clic pour copier**
|
||||||
|
- **Détection élargie** (`backend/secret_redactor.py`) : les formats de
|
||||||
|
clés les plus répandus sont masqués d'office — OpenAI/Anthropic/
|
||||||
|
OpenRouter (`sk-`), Stripe (`sk_live_`, `whsec_`), GitLab (`glpat-`),
|
||||||
|
GitHub (`ghp_`, `github_pat_`), Google (`AIza…`, `ya29.`), AWS
|
||||||
|
(`AKIA`/`ASIA`), Slack (`xoxb-`), SendGrid, Hugging Face, npm, Docker,
|
||||||
|
Resend, Square, Atlassian, Discord, Telegram, jetons `Bearer …` — en
|
||||||
|
plus des JWT, clés privées et connection strings déjà couverts ; le
|
||||||
|
plancher des affectations `api_key=`/`token=`/`secret=` passe de 20 à
|
||||||
|
8 caractères.
|
||||||
|
- **Mots de passe masqués à leur tour** : `password=`, `"passwd": "…"`,
|
||||||
|
`db_password=`, `passphrase=`, `mot de passe=`… toute longueur et
|
||||||
|
toute quote, avec le libellé `[MOT DE PASSE MASQUÉ]` ; `PWD=` (dossier
|
||||||
|
de travail shell) reste épargné.
|
||||||
|
- **Clic sur un masque = copie de la valeur** : l'aperçu authentifié
|
||||||
|
sert les masques en `<span class="secret-mask" data-secret="…">` — la
|
||||||
|
valeur n'apparaît jamais en clair et un clic la copie dans le
|
||||||
|
presse-papiers (toast + infobulle FR/EN). Les masques passent par des
|
||||||
|
placeholders qui survivent à la conversion markdown (blocs de code
|
||||||
|
compris), où un `<span>` serait affiché en toutes lettres ; les blocs
|
||||||
|
de code masqués ne sont plus re-colorés (highlight.js perdrait le
|
||||||
|
badge).
|
||||||
|
- **Aucune fuite à l'extérieur** : partages publics, exports PDF et
|
||||||
|
contexte IA / MCP ne reçoivent que le libellé, jamais la valeur.
|
||||||
|
- Guide in-app FR/EN + `docs/GUIDES/AUTHENTIFICATION_SECURITE.md`
|
||||||
|
(nouvelle section « Secrets masqués dans les aperçus »), README FR/EN,
|
||||||
|
tests `TestSecretRedactor` (+33).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
## [2.53.3] — 2026-10-07
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ COPY VERSION ./VERSION
|
|||||||
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
|
# 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 \
|
RUN groupadd -g 1000 obsigate && useradd -u 1000 -g obsigate -d /app -s /sbin/nologin obsigate \
|
||||||
&& mkdir -p /app/data \
|
&& mkdir -p /app/data \
|
||||||
|
&& chmod 777 /app/data \
|
||||||
&& chown -R obsigate:obsigate /app
|
&& chown -R obsigate:obsigate /app
|
||||||
USER obsigate
|
USER obsigate
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
|||||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`
|
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`. Sur mobile (≤ 768 px), la barre de menus et le ruban sont **repliés par défaut** — un bouton ☰ les déplie — pour que la grille occupe toute la hauteur d'écran
|
||||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||||
@@ -802,7 +802,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
|
|||||||
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
|
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
|
||||||
- **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB)
|
- **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB)
|
||||||
- **Backup automatique** : chaque modification/suppression sauvegardée dans `.obsigate-backup/` avec timestamp
|
- **Backup automatique** : chaque modification/suppression sauvegardée dans `.obsigate-backup/` avec timestamp
|
||||||
- **Secret redaction** : masquage automatique des JWT, clés API, tokens dans les aperçus
|
- **Secret redaction** : masquage automatique des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens dans les aperçus — cliquez sur un masque pour copier la valeur
|
||||||
- **Utilisateur non-root** : conteneur Docker sous `obsigate` (UID 1000)
|
- **Utilisateur non-root** : conteneur Docker sous `obsigate` (UID 1000)
|
||||||
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
||||||
- **Secrets dans `.env`** : jamais dans `docker-compose.yml`
|
- **Secrets dans `.env`** : jamais dans `docker-compose.yml`
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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.56.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.53.3 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.56.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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
|||||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`
|
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`. On mobile (≤ 768 px), the menu bar and ribbon are **collapsed by default** — a single ☰ button expands them — so the grid gets the full screen height
|
||||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||||
@@ -964,7 +964,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
|
|||||||
- **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts)
|
- **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts)
|
||||||
- **Audit log** : All writes, deletions, and config changes are logged in `data/audit.log` (JSON lines, 10 MB rotation)
|
- **Audit log** : All writes, deletions, and config changes are logged in `data/audit.log` (JSON lines, 10 MB rotation)
|
||||||
- **Automatic backup** : Every file modification or deletion is saved in `.obsigate-backup/` with timestamp
|
- **Automatic backup** : Every file modification or deletion is saved in `.obsigate-backup/` with timestamp
|
||||||
- **Secret redaction** : Automatic masking of JWTs, API keys, tokens, and connection strings in previews
|
- **Secret redaction** : Automatic masking of JWTs, passwords, API keys (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens and connection strings in previews — click a mask to copy the value
|
||||||
- **Non-root user** : The Docker container runs under user `obsigate` (UID 1000)
|
- **Non-root user** : The Docker container runs under user `obsigate` (UID 1000)
|
||||||
- **Read-only volumes** : Vaults are mounted as `:ro` by default in docker-compose
|
- **Read-only volumes** : Vaults are mounted as `:ro` by default in docker-compose
|
||||||
- **Secrets in `.env`** : Passwords and tokens are never in `docker-compose.yml`
|
- **Secrets in `.env`** : Passwords and tokens are never in `docker-compose.yml`
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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.56.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.53.3 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.56.0 | Last updated: September 2026*
|
||||||
|
|||||||
+23
-4
@@ -21,7 +21,7 @@ import mistune
|
|||||||
|
|
||||||
from backend.image_processor import preprocess_images
|
from backend.image_processor import preprocess_images
|
||||||
from backend.indexer import find_file_in_index, get_vault_data
|
from backend.indexer import find_file_in_index, get_vault_data
|
||||||
from backend.secret_redactor import redact_file_content
|
from backend.secret_redactor import redact_with_placeholders, restore_masks
|
||||||
from backend.services.sanitizer import sanitize_html
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
|
||||||
|
|
||||||
@@ -166,7 +166,13 @@ def _normalize_line_breaks(text: str) -> str:
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
|
def _render_markdown(
|
||||||
|
raw_md: str,
|
||||||
|
vault_name: str,
|
||||||
|
current_file_path: Path | None = None,
|
||||||
|
*,
|
||||||
|
click_to_copy: bool = False,
|
||||||
|
) -> str:
|
||||||
"""Render a markdown string to HTML with wikilink and image support.
|
"""Render a markdown string to HTML with wikilink and image support.
|
||||||
|
|
||||||
Uses the cached singleton mistune renderer for performance.
|
Uses the cached singleton mistune renderer for performance.
|
||||||
@@ -175,6 +181,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
|||||||
raw_md: Raw markdown text (frontmatter already stripped).
|
raw_md: Raw markdown text (frontmatter already stripped).
|
||||||
vault_name: Current vault for wikilink resolution context.
|
vault_name: Current vault for wikilink resolution context.
|
||||||
current_file_path: Absolute path to the current markdown file.
|
current_file_path: Absolute path to the current markdown file.
|
||||||
|
click_to_copy: Restore masked secrets as clickable badges carrying
|
||||||
|
the real value (authenticated app preview, feature #188).
|
||||||
|
Public shares and PDF exports keep plain labels: the secret
|
||||||
|
never reaches their HTML.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HTML string.
|
HTML string.
|
||||||
@@ -184,8 +194,13 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
|||||||
vault_root = Path(vault_data["path"]) if vault_data else None
|
vault_root = Path(vault_data["path"]) if vault_data else None
|
||||||
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
||||||
|
|
||||||
# Redact secrets before rendering (P0 security)
|
# Redact secrets before rendering (P0 security). Placeholders survive
|
||||||
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
|
# the markdown conversion (fenced code blocks included) and are turned
|
||||||
|
# back into visible masks — clickable badges when click_to_copy — right
|
||||||
|
# after the HTML is produced (feature #188).
|
||||||
|
raw_md, secret_entries = redact_with_placeholders(
|
||||||
|
raw_md, str(current_file_path) if current_file_path else ""
|
||||||
|
)
|
||||||
|
|
||||||
# Preprocess images first
|
# Preprocess images first
|
||||||
if vault_root:
|
if vault_root:
|
||||||
@@ -201,6 +216,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
|||||||
# renderers HTML renvoient toujours `str` à l'exécution).
|
# renderers HTML renvoient toujours `str` à l'exécution).
|
||||||
rendered = cast(str, _markdown_renderer(converted))
|
rendered = cast(str, _markdown_renderer(converted))
|
||||||
|
|
||||||
|
# Restore secret masks (plain labels, or clickable badges carrying the
|
||||||
|
# real value on the authenticated app preview — feature #188).
|
||||||
|
rendered = restore_masks(rendered, secret_entries, click_to_copy=click_to_copy)
|
||||||
|
|
||||||
# Add heading IDs for TOC navigation
|
# Add heading IDs for TOC navigation
|
||||||
rendered = _add_heading_ids(rendered)
|
rendered = _add_heading_ids(rendered)
|
||||||
|
|
||||||
|
|||||||
@@ -648,7 +648,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
|||||||
tags = _extract_tags(post)
|
tags = _extract_tags(post)
|
||||||
|
|
||||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
||||||
html_content = _render_markdown(post.content, vault_name, file_path)
|
html_content = _render_markdown(post.content, vault_name, file_path, click_to_copy=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vault": vault_name,
|
"vault": vault_name,
|
||||||
|
|||||||
@@ -323,9 +323,13 @@ def api_file_xlsx_style(
|
|||||||
|
|
||||||
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
||||||
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
||||||
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
|
``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||||
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
``font_color``/``fill_color`` as ``#rrggbb``, ``align``
|
||||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
|
(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).
|
and ``freeze`` (``cell``, empty to release).
|
||||||
|
|
||||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||||
@@ -548,6 +552,10 @@ async def api_file_create(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
FileCreateResponse confirming creation.
|
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):
|
if not check_vault_access(vault_name, current_user):
|
||||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|||||||
+191
-38
@@ -1,48 +1,104 @@
|
|||||||
"""
|
"""
|
||||||
Secret redactor: masks sensitive patterns in rendered text.
|
Secret redactor: masks sensitive patterns in rendered text.
|
||||||
|
|
||||||
Scans for common secret patterns and replaces them with [MASQUÉ]
|
Scans for common secret patterns and replaces them with a French mask
|
||||||
before content is served to the frontend. Prevents accidental
|
label (``[CLÉ API MASQUÉE]``, ``[MOT DE PASSE MASQUÉ]``, …) before content
|
||||||
exposure of API keys, tokens, and passwords in previews.
|
is served to the frontend. Prevents accidental exposure of API keys,
|
||||||
|
tokens, and passwords in previews.
|
||||||
|
|
||||||
Patterns detected:
|
Patterns detected:
|
||||||
- Generic API keys (long alphanumeric strings with key/secret/token prefix)
|
- Generic API keys (``api_key=…``, ``token: …`` — values of 8+ chars)
|
||||||
|
- Passwords (``password=…``, ``"passwd": "…"`` — any length)
|
||||||
- JWT tokens (eyJ... base64url)
|
- JWT tokens (eyJ... base64url)
|
||||||
- AWS-style keys (AKIA..., sk-..., etc.)
|
- Provider key formats: OpenAI/Anthropic/OpenRouter (``sk-``), Stripe,
|
||||||
|
GitLab, Google (``AIza…`` / ``ya29.``), AWS, GitHub, Slack, SendGrid,
|
||||||
|
Hugging Face, npm, Docker, Resend, Square, Atlassian, Discord,
|
||||||
|
Telegram, ``Bearer …`` tokens
|
||||||
- Private key blocks (-----BEGIN ... PRIVATE KEY-----)
|
- Private key blocks (-----BEGIN ... PRIVATE KEY-----)
|
||||||
- Connection strings with passwords
|
- Connection strings with passwords
|
||||||
|
- Bare hex secrets next to a secret keyword (BUG-035)
|
||||||
|
|
||||||
|
Interactive masking (feature #188): :func:`redact_with_placeholders`
|
||||||
|
returns the text with every mask replaced by an opaque placeholder plus
|
||||||
|
the list of ``(label, secret)`` entries; :func:`restore_masks` turns the
|
||||||
|
placeholders back into plain labels (public shares, PDF exports, AI
|
||||||
|
context) or into clickable ``<span class="secret-mask" data-secret="…">``
|
||||||
|
badges (authenticated app preview) so a click copies the real value to
|
||||||
|
the clipboard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html as _html
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.redactor")
|
logger = logging.getLogger("obsigate.redactor")
|
||||||
|
|
||||||
# --- Patterns ---
|
# --- Patterns ---
|
||||||
# Order matters: more specific patterns first
|
# Each entry is ``(pattern, replacement, secret_group)``:
|
||||||
_PATTERNS = [
|
# * ``replacement``: a literal label, a ``\\1``-style template, or a
|
||||||
|
# callable receiving the match and returning the visible label;
|
||||||
|
# * ``secret_group``: index of the group holding the value that a click
|
||||||
|
# copies to the clipboard (feature #188).
|
||||||
|
_PATTERNS: list[tuple[re.Pattern[str], object, int]] = [
|
||||||
# Private key blocks
|
# Private key blocks
|
||||||
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]'),
|
(re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]', 0),
|
||||||
|
|
||||||
# JWT tokens (base64url encoded, starts with eyJ)
|
# JWT tokens (base64url encoded, starts with eyJ)
|
||||||
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]'),
|
(re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]', 0),
|
||||||
|
|
||||||
# Connection strings with passwords
|
# Connection strings with passwords
|
||||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'),
|
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]', 0),
|
||||||
|
|
||||||
# Generic API key patterns: key=... or token=... or secret=...
|
# Passwords — any length, bare or quoted (``password=…``,
|
||||||
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
|
# ``"passwd": "…"``). The left side may carry a qualifier
|
||||||
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
|
# (``db_password``, ``DATABASE.PASSWORD``, ``user_pwd``); a *bare*
|
||||||
|
# ``PWD=`` (shell working directory) must NOT match, hence ``pwd``
|
||||||
# Prefixed API keys (sk-..., pk-..., rk-...)
|
# only in its qualified branch.
|
||||||
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
|
(re.compile(
|
||||||
|
r'(?i)((?:[A-Za-z0-9_.-]*(?:password|passwd|passphrase|mot\s+de\s+passe)'
|
||||||
# AWS access keys
|
r'|[A-Za-z0-9_.-]+pwd)["\']?\s*[:=]\s*["\']?)([^\s"\',;]{4,})'),
|
||||||
(re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'),
|
r'\1[MOT DE PASSE MASQUÉ]', 2),
|
||||||
|
|
||||||
|
# Generic API key assignments: api_key=…, token=…, secret=… — values
|
||||||
|
# of 8+ characters (short enough to catch real keys, long enough to
|
||||||
|
# skip plain words).
|
||||||
|
(re.compile(r'(?i)([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|auth[_-]?token)["\']?\s*[:=]\s*["\']?)([^\s\'"]{8,})'),
|
||||||
|
lambda m: f'{m.group(1)}[MASQUÉ]' if ("=" in m.group(0) or ":" in m.group(0)) else '[MASQUÉ]', 2),
|
||||||
|
|
||||||
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
|
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
|
||||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
|
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]', 0),
|
||||||
|
|
||||||
|
# AWS access keys
|
||||||
|
(re.compile(r'(?:AKIA|ASIA)[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]', 0),
|
||||||
|
|
||||||
|
# Provider key formats (feature #188) — one alternation covering the
|
||||||
|
# large majority of token shapes in the wild.
|
||||||
|
(re.compile(
|
||||||
|
r'(?<![A-Za-z0-9])(?:'
|
||||||
|
r'sk-[A-Za-z0-9_\-]{16,}' # OpenAI / Anthropic / OpenRouter
|
||||||
|
r'|sk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe secret key
|
||||||
|
r'|pk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe publishable key
|
||||||
|
r'|whsec_[A-Za-z0-9]{16,}' # Stripe / Svix webhook secret
|
||||||
|
r'|glpat-[A-Za-z0-9_\-]{20,}' # GitLab personal access token
|
||||||
|
r'|github_pat_[A-Za-z0-9_]{22,}' # GitHub fine-grained PAT
|
||||||
|
r'|npm_[A-Za-z0-9]{36}' # npm automation token
|
||||||
|
r'|dckr_pat_[A-Za-z0-9_\-]{20,}' # Docker Hub token
|
||||||
|
r'|hf_[A-Za-z0-9]{30,}' # Hugging Face token
|
||||||
|
r'|AIza[0-9A-Za-z_\-]{35}' # Google API key
|
||||||
|
r'|ya29\.[0-9A-Za-z_\-]{20,}' # Google OAuth access token
|
||||||
|
r'|xox[baprs]-[0-9A-Za-z\-]{10,}' # Slack token
|
||||||
|
r'|SG\.[A-Za-z0-9_\-]{16,}' # SendGrid API key
|
||||||
|
r'|re_[A-Za-z0-9]{40}' # Resend API key
|
||||||
|
r'|sq0[a-z]{3}-[A-Za-z0-9_\-]{16,}' # Square access token
|
||||||
|
r'|ATATT[A-Za-z0-9_\-]{20,}' # Atlassian access token
|
||||||
|
r'|[NOP][A-Za-z0-9_\-]{23,28}\.[A-Za-z0-9_\-]{6}\.[A-Za-z0-9_\-]{27,}' # Discord bot token
|
||||||
|
r'|\d{8,10}:[A-Za-z0-9_\-]{35}' # Telegram bot token
|
||||||
|
r'|Bearer\s+[A-Za-z0-9._~+/=\-]{20,}' # Authorization: Bearer …
|
||||||
|
r')'),
|
||||||
|
'[CLÉ API MASQUÉE]', 0),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# BUG-035: bare 40–64 char hex strings used to be redacted unconditionally,
|
# BUG-035: bare 40–64 char hex strings used to be redacted unconditionally,
|
||||||
@@ -61,14 +117,23 @@ _HASH_CONTEXT_RE = re.compile(
|
|||||||
#: How far before the hex string a keyword may appear to count as context.
|
#: How far before the hex string a keyword may appear to count as context.
|
||||||
_HEX_CONTEXT_WINDOW = 60
|
_HEX_CONTEXT_WINDOW = 60
|
||||||
|
|
||||||
|
# --- Interactive masking (feature #188) ---
|
||||||
|
# Private-use-area sentinels: they survive markdown rendering (mistune
|
||||||
|
# treats them as plain text, fenced code blocks included) and are
|
||||||
|
# stripped by ``backend.render._heading_slugify``.
|
||||||
|
_PLACEHOLDER_OPEN = "\uE000"
|
||||||
|
_PLACEHOLDER_CLOSE = "\uE001"
|
||||||
|
_PLACEHOLDER_RE = re.compile("\uE000(\\d+)\uE001")
|
||||||
|
|
||||||
def _redact_bare_hex_secrets(text: str) -> tuple:
|
|
||||||
|
def _redact_bare_hex_secrets(text: str, mask) -> tuple[str, int]:
|
||||||
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
|
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
|
||||||
|
|
||||||
Git/SHA/checksum contexts are left untouched (BUG-035).
|
Git/SHA/checksum contexts are left untouched (BUG-035).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to scan.
|
text: Text to scan.
|
||||||
|
mask: ``mask(original, label) -> str`` replacement builder.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(redacted_text, redaction_count) tuple.
|
(redacted_text, redaction_count) tuple.
|
||||||
@@ -82,12 +147,38 @@ def _redact_bare_hex_secrets(text: str) -> tuple:
|
|||||||
return match.group(0)
|
return match.group(0)
|
||||||
if _SECRET_CONTEXT_RE.search(window):
|
if _SECRET_CONTEXT_RE.search(window):
|
||||||
count += 1
|
count += 1
|
||||||
return '[HEX_KEY MASQUÉ]'
|
return mask(match.group(0), '[HEX_KEY MASQUÉ]')
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
|
|
||||||
return _HEX_RE.sub(_replace, text), count
|
return _HEX_RE.sub(_replace, text), count
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text: str, mask) -> tuple[str, int]:
|
||||||
|
"""Apply every pattern; ``mask(original, label) -> str`` builds the
|
||||||
|
replacement (plain label, or placeholder for the interactive mode)."""
|
||||||
|
count = 0
|
||||||
|
result = text
|
||||||
|
for pattern, replacement, secret_group in _PATTERNS:
|
||||||
|
|
||||||
|
def _sub(match: re.Match, replacement=replacement, secret_group=secret_group) -> str:
|
||||||
|
if callable(replacement):
|
||||||
|
label = replacement(match)
|
||||||
|
else:
|
||||||
|
label = match.expand(str(replacement))
|
||||||
|
return mask(match.group(secret_group), label)
|
||||||
|
|
||||||
|
new_result, n = pattern.subn(_sub, result)
|
||||||
|
count += n
|
||||||
|
result = new_result
|
||||||
|
result, hex_count = _redact_bare_hex_secrets(result, mask)
|
||||||
|
return result, count + hex_count
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_mask(original: str, label: str) -> str:
|
||||||
|
"""Plain masking: only the visible label survives."""
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
def redact(text: str) -> tuple:
|
def redact(text: str) -> tuple:
|
||||||
"""Redact sensitive patterns from text.
|
"""Redact sensitive patterns from text.
|
||||||
|
|
||||||
@@ -97,22 +188,84 @@ def redact(text: str) -> tuple:
|
|||||||
Returns:
|
Returns:
|
||||||
(redacted_text, redaction_count) tuple.
|
(redacted_text, redaction_count) tuple.
|
||||||
"""
|
"""
|
||||||
count = 0
|
result, count = _redact(text, _plain_mask)
|
||||||
result = text
|
|
||||||
for pattern, replacement in _PATTERNS:
|
|
||||||
if callable(replacement):
|
|
||||||
new_result, n = pattern.subn(replacement, result)
|
|
||||||
else:
|
|
||||||
new_result, n = pattern.subn(str(replacement), result)
|
|
||||||
count += n
|
|
||||||
result = new_result
|
|
||||||
result, hex_count = _redact_bare_hex_secrets(result)
|
|
||||||
count += hex_count
|
|
||||||
if count > 0:
|
if count > 0:
|
||||||
logger.info(f"Redacted {count} secret(s) from content")
|
logger.info(f"Redacted {count} secret(s) from content")
|
||||||
return result, count
|
return result, count
|
||||||
|
|
||||||
|
|
||||||
|
def redact_with_placeholders(text: str, file_path: str = "") -> tuple[str, list[tuple[str, str]]]:
|
||||||
|
"""Redact *text*, replacing every mask with an opaque placeholder.
|
||||||
|
|
||||||
|
Used by the markdown rendering pipeline: placeholders survive the
|
||||||
|
markdown → HTML conversion (fenced code blocks included, where a
|
||||||
|
literal ``<span>`` would be shown as text), then
|
||||||
|
:func:`restore_masks` turns them back into labels or clickable badges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The raw text content to scan.
|
||||||
|
file_path: Optional file path for logging context.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(text_with_placeholders, entries) where *entries* is the list of
|
||||||
|
``(label, secret)`` tuples referenced by the placeholders, in
|
||||||
|
order of appearance.
|
||||||
|
"""
|
||||||
|
entries: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def mask(original: str, label: str) -> str:
|
||||||
|
entries.append((label, original))
|
||||||
|
return f"{_PLACEHOLDER_OPEN}{len(entries) - 1}{_PLACEHOLDER_CLOSE}"
|
||||||
|
|
||||||
|
result, count = _redact(text, mask)
|
||||||
|
if count > 0:
|
||||||
|
logger.warning(f"Redacted {count} potential secret(s) from {file_path or '<unknown>'}")
|
||||||
|
return result, entries
|
||||||
|
|
||||||
|
|
||||||
|
def restore_masks(
|
||||||
|
text: str,
|
||||||
|
entries: list[tuple[str, str]],
|
||||||
|
*,
|
||||||
|
click_to_copy: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Turn placeholders produced by :func:`redact_with_placeholders` back
|
||||||
|
into visible masks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Rendered HTML still containing placeholders.
|
||||||
|
entries: The ``(label, secret)`` list returned alongside.
|
||||||
|
click_to_copy: When True (authenticated app preview), each mask
|
||||||
|
becomes ``<span class="secret-mask" data-secret="…">label</span>``
|
||||||
|
so a click copies the real value. When False (public shares,
|
||||||
|
PDF exports), only the plain label is restored — the secret
|
||||||
|
never reaches the page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The text with every placeholder replaced.
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _sub(match: re.Match) -> str:
|
||||||
|
idx = int(match.group(1))
|
||||||
|
if idx >= len(entries):
|
||||||
|
return ""
|
||||||
|
label, original = entries[idx]
|
||||||
|
label_esc = _html.escape(str(label), quote=False)
|
||||||
|
if not click_to_copy:
|
||||||
|
return label_esc
|
||||||
|
return (
|
||||||
|
'<span class="secret-mask" data-secret="'
|
||||||
|
+ _html.escape(str(original), quote=True)
|
||||||
|
+ '">'
|
||||||
|
+ label_esc
|
||||||
|
+ "</span>"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _PLACEHOLDER_RE.sub(_sub, text)
|
||||||
|
|
||||||
|
|
||||||
def redact_file_content(content: str, file_path: str = "") -> str:
|
def redact_file_content(content: str, file_path: str = "") -> str:
|
||||||
"""Redact a file's content for preview rendering.
|
"""Redact a file's content for preview rendering.
|
||||||
|
|
||||||
|
|||||||
+220
-15
@@ -41,6 +41,28 @@ _EXCALIDRAW_SKELETON = (
|
|||||||
'"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
|
'"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:
|
def _rel(root: Path, path: Path) -> str:
|
||||||
"""Return *path* relative to *root* as a POSIX-style string."""
|
"""Return *path* relative to *root* as a POSIX-style string."""
|
||||||
@@ -98,13 +120,14 @@ def create_file(
|
|||||||
*,
|
*,
|
||||||
overwrite: bool = False,
|
overwrite: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a text file in a vault.
|
"""Create a file in a vault.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
vault_name: Name of the vault.
|
vault_name: Name of the vault.
|
||||||
path: Vault-relative path of the new file.
|
path: Vault-relative path of the new file.
|
||||||
content: Initial content (an Excalidraw skeleton is injected for empty
|
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
|
overwrite: When True, replace an existing file (with a backup) instead
|
||||||
of raising ``already_exists``.
|
of raising ``already_exists``.
|
||||||
|
|
||||||
@@ -130,17 +153,25 @@ def create_file(
|
|||||||
)
|
)
|
||||||
create_backup(file_path, vault_name, _rel(root, file_path))
|
create_backup(file_path, vault_name, _rel(root, file_path))
|
||||||
|
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
try:
|
try:
|
||||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if file_path.suffix.lower() == ".excalidraw" and not content.strip():
|
if suffix == ".xlsx":
|
||||||
content = _EXCALIDRAW_SKELETON
|
# Binary OPC archive: a text write would leave an unopenable file
|
||||||
file_path.write_text(content, encoding="utf-8")
|
# (#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:
|
except PermissionError as e:
|
||||||
raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e
|
raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e
|
||||||
|
|
||||||
rel_path = _rel(root, file_path)
|
rel_path = _rel(root, file_path)
|
||||||
logger.info(f"File created: {vault_name}/{rel_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]:
|
def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]:
|
||||||
@@ -673,11 +704,42 @@ def mutate_xlsx_structure(
|
|||||||
"Position 'at' / 'count' invalides", code="invalid", status=400
|
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||||
)
|
)
|
||||||
if op == "row_insert":
|
if op == "row_insert":
|
||||||
ws.insert_rows(at, count)
|
# Le modèle sparse d'openpyxl ignore les inserts
|
||||||
|
# au-delà de la dernière ligne utilisée (rien à
|
||||||
|
# décaler), et l'écrivain abandonne les cellules
|
||||||
|
# vides (None) au cycle suivant : on matérialise
|
||||||
|
# avec " " (un blanc qui persiste, relu tel quel,
|
||||||
|
# affiché vide, ignoré par la recherche) pour
|
||||||
|
# qu'une insertion « sous la dernière ligne »
|
||||||
|
# crée bien des lignes blanches, comme Excel.
|
||||||
|
if at > ws.max_row:
|
||||||
|
if at - ws.max_row > _INSERT_GAP_LIMIT:
|
||||||
|
raise ServiceError(
|
||||||
|
"Position trop éloignée de la fin de la feuille",
|
||||||
|
code="invalid", status=400,
|
||||||
|
)
|
||||||
|
for r in range(at, at + count):
|
||||||
|
cell = ws.cell(row=r, column=1)
|
||||||
|
if cell.value is None:
|
||||||
|
cell.value = " "
|
||||||
|
else:
|
||||||
|
ws.insert_rows(at, count)
|
||||||
elif op == "row_delete":
|
elif op == "row_delete":
|
||||||
ws.delete_rows(at, count)
|
ws.delete_rows(at, count)
|
||||||
elif op == "col_insert":
|
elif op == "col_insert":
|
||||||
ws.insert_cols(at, count)
|
# Idem côté colonnes : « à droite de la dernière ».
|
||||||
|
if at > ws.max_column:
|
||||||
|
if at - ws.max_column > _INSERT_GAP_LIMIT:
|
||||||
|
raise ServiceError(
|
||||||
|
"Position trop éloignée de la fin de la feuille",
|
||||||
|
code="invalid", status=400,
|
||||||
|
)
|
||||||
|
for c in range(at, at + count):
|
||||||
|
cell = ws.cell(row=1, column=c)
|
||||||
|
if cell.value is None:
|
||||||
|
cell.value = " "
|
||||||
|
else:
|
||||||
|
ws.insert_cols(at, count)
|
||||||
else:
|
else:
|
||||||
ws.delete_cols(at, count)
|
ws.delete_cols(at, count)
|
||||||
applied.append(f"{op}:{sheet}@{at}x{count}")
|
applied.append(f"{op}:{sheet}@{at}x{count}")
|
||||||
@@ -725,16 +787,34 @@ def mutate_xlsx_structure(
|
|||||||
# #156-A8 — write-side formatting. Every value is data-driven (the colours
|
# #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
|
# 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.
|
# 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 = {
|
_STYLE_KEYS = {
|
||||||
"bold",
|
"bold",
|
||||||
"italic",
|
"italic",
|
||||||
"underline",
|
"underline",
|
||||||
|
"strike",
|
||||||
|
"font_size",
|
||||||
"font_color",
|
"font_color",
|
||||||
"fill_color",
|
"fill_color",
|
||||||
"align",
|
"align",
|
||||||
|
"valign",
|
||||||
|
"wrap",
|
||||||
|
"rotation",
|
||||||
|
"border",
|
||||||
|
"border_style",
|
||||||
|
"border_color",
|
||||||
"number_format",
|
"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
|
MAX_STYLE_CELLS = 10_000
|
||||||
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
|
_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
|
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
|
unknown = set(style) - _STYLE_KEYS
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ServiceError(
|
raise ServiceError(
|
||||||
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
|
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)
|
font = copy_mod.copy(cell.font)
|
||||||
if "bold" in style:
|
if "bold" in style:
|
||||||
font.bold = bool(style["bold"])
|
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"])
|
font.italic = bool(style["italic"])
|
||||||
if "underline" in style:
|
if "underline" in style:
|
||||||
font.underline = "single" if style["underline"] else None
|
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:
|
if "font_color" in style:
|
||||||
rgb = _style_color(style["font_color"])
|
rgb = _style_color(style["font_color"])
|
||||||
font.color = Color(rgb=rgb) if rgb else None
|
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,
|
horizontal=align,
|
||||||
vertical=getattr(current, "vertical", None),
|
vertical=getattr(current, "vertical", None),
|
||||||
wrap_text=getattr(current, "wrap_text", 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:
|
if "number_format" in style:
|
||||||
fmt = style["number_format"] or "General"
|
fmt = style["number_format"] or "General"
|
||||||
if not isinstance(fmt, str) or len(fmt) > 120:
|
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:
|
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
|
||||||
|
|
||||||
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
|
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
|
||||||
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
|
``bold``, ``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||||
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
|
``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`` ;
|
``number_format`` ;
|
||||||
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
|
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
|
||||||
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
|
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
|
||||||
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
|
* ``{"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).
|
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
|
||||||
|
|
||||||
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
||||||
@@ -960,7 +1123,17 @@ def mutate_xlsx_style(
|
|||||||
raise ServiceError(
|
raise ServiceError(
|
||||||
"Largeur invalide (0 à 255)", code="invalid", status=400
|
"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}")
|
applied.append(f"col_width:{ws.title}!{col}={width}")
|
||||||
elif op == "row_height":
|
elif op == "row_height":
|
||||||
ws = _sheet(wb, action)
|
ws = _sheet(wb, action)
|
||||||
@@ -972,7 +1145,14 @@ def mutate_xlsx_style(
|
|||||||
raise ServiceError(
|
raise ServiceError(
|
||||||
"Hauteur invalide (0 à 409)", code="invalid", status=400
|
"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}")
|
applied.append(f"row_height:{ws.title}!{row}={height}")
|
||||||
elif op == "freeze":
|
elif op == "freeze":
|
||||||
ws = _sheet(wb, action)
|
ws = _sheet(wb, action)
|
||||||
@@ -981,6 +1161,31 @@ def mutate_xlsx_style(
|
|||||||
_range(cell_ref)
|
_range(cell_ref)
|
||||||
ws.freeze_panes = cell_ref.upper() or None
|
ws.freeze_panes = cell_ref.upper() or None
|
||||||
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
|
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:
|
else:
|
||||||
raise ServiceError(
|
raise ServiceError(
|
||||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
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")
|
fragments.append("font-style:italic")
|
||||||
# #156-A8 — underline is written from the viewer too, so it is read back
|
# #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).
|
# (the toggle in the formatting menu needs to see its own effect).
|
||||||
|
decorations = []
|
||||||
if font and font.underline:
|
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
|
fmt = cell.number_format
|
||||||
if fmt and fmt not in ("General", "@"):
|
if fmt and fmt not in ("General", "@"):
|
||||||
# A custom number format is signalled typographically (mono font)
|
# 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")
|
fragments.append("font-family:'JetBrains Mono',monospace")
|
||||||
alignment = cell.alignment
|
alignment = cell.alignment
|
||||||
align = alignment.horizontal if alignment else None
|
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]]:
|
def _sheet_style_maps(
|
||||||
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
|
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
|
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.
|
verbatim to ``td.style``; a plain cell is simply absent from the map.
|
||||||
``left`` is the table default and never included. Bounded by
|
``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] = {}
|
styles: dict[str, str] = {}
|
||||||
aligns: 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 row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
|
||||||
for cell in row:
|
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
|
continue
|
||||||
fragments, align = _cell_fragments(cell)
|
fragments, align = _cell_fragments(cell)
|
||||||
if fragments:
|
if fragments:
|
||||||
styles[cell.coordinate] = ";".join(fragments)
|
styles[cell.coordinate] = ";".join(fragments)
|
||||||
if align and align != "left":
|
if align and align != "left":
|
||||||
aligns[cell.coordinate] = align
|
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]]:
|
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]]:
|
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
|
One normal (non-streaming) load serves the three A15 metadata maps: the
|
||||||
fragments are the workbook's own values, a failure yields ``{}`` per sheet
|
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]] = {}
|
out: dict[str, dict[str, Any]] = {}
|
||||||
try:
|
try:
|
||||||
for ws in wb.worksheets:
|
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)
|
merged = getattr(ws, "merged_cells", None)
|
||||||
ranges = getattr(merged, "ranges", None) or []
|
ranges = getattr(merged, "ranges", None) or []
|
||||||
out[ws.title] = {
|
out[ws.title] = {
|
||||||
"styles": styles,
|
"styles": styles,
|
||||||
"aligns": aligns,
|
"aligns": aligns,
|
||||||
|
"comments": comments,
|
||||||
|
"formats": formats,
|
||||||
|
"colwidths": colwidths,
|
||||||
|
"rowheights": rowheights,
|
||||||
"merges": [str(r) for r in ranges],
|
"merges": [str(r) for r in ranges],
|
||||||
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
|
"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:
|
except Exception:
|
||||||
logger.debug("xlsx meta unavailable", exc_info=True)
|
logger.debug("xlsx meta unavailable", exc_info=True)
|
||||||
for t in wb.sheetnames:
|
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
|
return out
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
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,
|
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||||
"styles": sheet_meta.get("styles", {}),
|
"styles": sheet_meta.get("styles", {}),
|
||||||
"aligns": sheet_meta.get("aligns", {}),
|
"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", []),
|
"merges": sheet_meta.get("merges", []),
|
||||||
"freeze": sheet_meta.get("freeze", ""),
|
"freeze": sheet_meta.get("freeze", ""),
|
||||||
}
|
}
|
||||||
@@ -629,6 +754,10 @@ def read_sheet_window(
|
|||||||
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
|
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
|
||||||
"styles": meta.get("styles", {}),
|
"styles": meta.get("styles", {}),
|
||||||
"aligns": meta.get("aligns", {}),
|
"aligns": meta.get("aligns", {}),
|
||||||
|
"comments": meta.get("comments", {}),
|
||||||
|
"formats": meta.get("formats", {}),
|
||||||
|
"colwidths": meta.get("colwidths", {}),
|
||||||
|
"rowheights": meta.get("rowheights", {}),
|
||||||
"merges": meta.get("merges", []),
|
"merges": meta.get("merges", []),
|
||||||
"freeze": meta.get("freeze", ""),
|
"freeze": meta.get("freeze", ""),
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.53.3"
|
version = "2.56.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.53.3"
|
version = "2.56.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.53.3",
|
"version": "2.56.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
image: obsigate:latest
|
image: obsigate:latest
|
||||||
container_name: obsigate-test
|
container_name: obsigate-test
|
||||||
user: "1000:1000"
|
user: "197609:197609"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "2021:8080"
|
- "2021:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- /tmp/obsigate-test-vault:/vaults/TestVault
|
- ./test_vault:/vaults/TestVault
|
||||||
- /tmp/obsigate-test-dir:/vaults/TestDir
|
- ./test_dir:/vaults/TestDir
|
||||||
- /tmp/obsigate-test-data:/app/data
|
- obsigate_data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- VAULT_1_NAME=TestVault
|
- VAULT_1_NAME=TestVault
|
||||||
- VAULT_1_PATH=/vaults/TestVault
|
- VAULT_1_PATH=/vaults/TestVault
|
||||||
@@ -51,3 +51,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
ollama_data:
|
ollama_data:
|
||||||
|
obsigate_data:
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
|
|||||||
| **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery |
|
| **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery |
|
||||||
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
||||||
| **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` |
|
| **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` |
|
||||||
| **Redaction** | Masquage des JWT, clés API, tokens dans les aperçus et retours d'outils |
|
| **Redaction** | Masquage des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens et connection strings dans les aperçus markdown et les retours d'outils — clic sur un masque = copie de la valeur |
|
||||||
| **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints |
|
| **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints |
|
||||||
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
||||||
| **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) |
|
| **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) |
|
||||||
@@ -187,6 +187,30 @@ et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md).
|
|||||||
Une politique minimale est validée à la création d'un compte. Choisissez des mots
|
Une politique minimale est validée à la création d'un compte. Choisissez des mots
|
||||||
de passe longs et uniques ; activez le MFA pour les comptes admin.
|
de passe longs et uniques ; activez le MFA pour les comptes admin.
|
||||||
|
|
||||||
|
### Secrets masqués dans les aperçus
|
||||||
|
|
||||||
|
Quand une note contient un secret, l'aperçu markdown le remplace par un masque
|
||||||
|
— `[CLÉ API MASQUÉE]`, `[MOT DE PASSE MASQUÉ]`, `[JWT MASQUÉ]`,
|
||||||
|
`[CONNECTION_STRING MASQUÉE]` — au lieu de la valeur :
|
||||||
|
|
||||||
|
- **Détectés automatiquement** : mots de passe (`password=`, `"passwd": "…"`,
|
||||||
|
`db_password=…`, toute longueur), affectations `api_key=` / `token=` /
|
||||||
|
`secret=`, JWT, clés privées, connection strings, hex en contexte secret,
|
||||||
|
et les formats de clés les plus répandus — OpenAI/Anthropic/OpenRouter
|
||||||
|
(`sk-`), GitHub (`ghp_`, `github_pat_`), Google (`AIza…`, `ya29.`), AWS
|
||||||
|
(`AKIA…`/`ASIA…`), Slack (`xoxb-`), Stripe (`sk_live_`, `whsec_`), GitLab
|
||||||
|
(`glpat-`), Hugging Face (`hf_`), npm, Docker, SendGrid, Resend, Square,
|
||||||
|
Atlassian, Discord, Telegram, jetons `Bearer …`.
|
||||||
|
- **Clic = copie** : dans l'application (aperçu authentifié), cliquer sur un
|
||||||
|
masque copie la valeur réelle dans le presse-papiers (infobulle « Cliquer
|
||||||
|
pour copier la valeur »). La valeur n'apparaît jamais en clair à l'écran.
|
||||||
|
- **Jamais exposé à l'extérieur** : partages publics (`/s/{token}`), exports
|
||||||
|
PDF et contexte envoyé à l'IA / au serveur MCP ne reçoivent que le libellé,
|
||||||
|
jamais la valeur derrière le masque.
|
||||||
|
- **Hors périmètre** : la vue « source » (fichier brut) et les aperçus de
|
||||||
|
fichiers non-markdown ne sont pas masqués — c'est le fichier lui-même qui
|
||||||
|
est affiché.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Variables d'environnement
|
## 7. Variables d'environnement
|
||||||
|
|||||||
@@ -134,24 +134,87 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
|||||||
|
|
||||||
## 6. Tableurs Excel (XLSX)
|
## 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
|
### Affichage et édition
|
||||||
|
|
||||||
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
|
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 à
|
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
|
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 ·
|
commandes reprend l'ordre de Google Sheets (recherche, annuler/rétablir,
|
||||||
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
|
impression, peinture, zoom, formats de nombre, police, couleurs, bordures,
|
||||||
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
|
fusion, alignements, retour ligne, rotation, options) et le bouton **⋮**
|
||||||
**Enregistrer** envoie les cellules modifiées à
|
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.
|
||||||
|
|
||||||
|
**Mise en page mobile** — sur un écran étroit (≤ 768 px), la barre de menus et
|
||||||
|
le ruban de formatage sont **repliés par défaut** afin que la grille occupe
|
||||||
|
toute la hauteur disponible : seuls les onglets de feuilles, **Enregistrer** et
|
||||||
|
la **barre de formule (fx)** restent affichés. Le bouton **☰** (à gauche des
|
||||||
|
onglets) déplie/replie l'ensemble ; les commandes tactiles font au moins
|
||||||
|
44 px, les onglets défilent horizontalement et les champs de saisie restent en
|
||||||
|
16 px pour éviter le zoom automatique d'iOS.
|
||||||
|
|
||||||
|
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
|
`PUT /api/file/{vault}/xlsx/save` : une sauvegarde par feuille, avec
|
||||||
**backup automatique** du fichier avant écriture, et une écriture
|
**backup automatique** du fichier avant écriture, et une écriture
|
||||||
**atomique** (le classeur n'est jamais laissé à moitié écrit).
|
**atomique** (le classeur n'est jamais laissé à moitié écrit).
|
||||||
|
|
||||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
|
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Une
|
||||||
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
|
pastille **« Lecture seule »** rappelle la seule limite de la vue : les
|
||||||
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
|
formats `.xls`/`.ods`. ObsiGate calcule par ailleurs les formules courantes
|
||||||
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
|
à l'écran (arithmétique, `SOMME`, `MOYENNE`, `SI`…) ; les fonctions non
|
||||||
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
|
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
|
### Avertissement avant enregistrement
|
||||||
|
|
||||||
@@ -262,18 +325,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
|
indique le nombre de feuilles concernées et passer sur une correspondance
|
||||||
**active l'onglet** qui la contient.
|
**active l'onglet** qui la contient.
|
||||||
|
|
||||||
La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais
|
La sortie est rassemblée dans un seul menu **Fichier**, placé à droite des
|
||||||
sur les valeurs calculées en cache) :
|
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
|
- **Télécharger le fichier** — le classeur d'origine, tel qu'il est sur le
|
||||||
cellules est active (le nom du fichier reprend la plage, ex.
|
disque ;
|
||||||
`Fruits-A1B2.csv`), sinon la feuille entière ;
|
- **CSV** — exporte la **sélection** quand une plage de plusieurs cellules est
|
||||||
- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document
|
active (le nom du fichier reprend la plage, ex. `Fruits-A1B2.csv`), sinon la
|
||||||
HTML autonome, mêmes règles de sélection ;
|
feuille entière ;
|
||||||
- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule,
|
- **Markdown** et **HTML** — tableau markdown ou document HTML autonome,
|
||||||
sans le ruban ni les panneaux de l'application.
|
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,
|
**Structure** — le menu **Structure** de la barre d'outils ajoute,
|
||||||
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
||||||
@@ -281,12 +350,23 @@ colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
|||||||
automatique et confirmation, comme pour l'édition des cellules).
|
automatique et confirmation, comme pour l'édition des cellules).
|
||||||
|
|
||||||
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
|
**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 ;
|
- **caractère** — gras, italique, souligné, barré, taille de police,
|
||||||
- **alignement** — gauche, centré, droite ;
|
effacer la mise en forme ;
|
||||||
- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune
|
- **alignement** — gauche, centré, droite, justifié (+ vertical, retour à la
|
||||||
palette imposée) ;
|
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 ;
|
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
|
||||||
- **structure** — fusionner / défusionner les cellules, figer / libérer les
|
- **structure** — fusionner / défusionner les cellules, figer / libérer les
|
||||||
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
|
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
|
||||||
@@ -300,8 +380,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).
|
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
|
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.
|
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
|
Les **commentaires** se lisent (pastille) et s'éditent (mini-barre ou menu ⋮) ;
|
||||||
conditionnelle et bordures restent hors périmètre.
|
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
|
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||||
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
||||||
@@ -354,11 +436,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
|
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
|
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
|
||||||
plafond.
|
plafond.
|
||||||
- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles
|
- **Moteur de formule local** : ObsiGate calcule à l'écran l'arithmétique, les
|
||||||
qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie
|
références, les plages et une trentaine de fonctions en français et en
|
||||||
commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE)
|
anglais (`SOMME`, `MOYENNE`, `SI`, `LIEN`…). Une saisie commençant par `=`
|
||||||
tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le
|
ou `@` reste stockée comme **texte** (garde anti-DDE) tant que le bouton
|
||||||
signale par un message. Excel reste la référence pour les valeurs calculées.
|
`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
|
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
|
||||||
actions de structure ; en revanche une **suppression** (feuille, ligne,
|
actions de structure ; en revanche une **suppression** (feuille, ligne,
|
||||||
colonne) n'est pas annulable, faute d'inverse.
|
colonne) n'est pas annulable, faute d'inverse.
|
||||||
|
|||||||
+291
-16
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.53.3 | **Dernière mise à jour :** 2026-10-07
|
> **Version :** 2.56.0 | **Dernière mise à jour :** 2026-10-08
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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)
|
### 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)
|
### 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
|
### 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
|
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
|
||||||
|
|
||||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
- **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)
|
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
|
||||||
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
|
- **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
|
(#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
|
## ✅ Terminé — Assistant IA
|
||||||
|
|
||||||
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
|
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
|
||||||
|
|
||||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
- **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
|
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
|
||||||
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
|
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
|
quick actions `agent: true` basculaient déjà le mode en silence. Un
|
||||||
profit d'un agent toujours actif : toute requête texte part sur
|
utilisateur pouvait donc voir l'assistant agir sans jamais avoir activé le
|
||||||
`/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les mutations
|
bouton. Retiré au profit d'un agent toujours actif : toute requête texte
|
||||||
gardent la confirmation two-step) ; les images seules restent sur
|
part sur `/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les
|
||||||
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant textuelle).
|
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 :**
|
- **Sous-tâches :**
|
||||||
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
|
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
|
||||||
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
|
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
|
||||||
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
|
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
|
||||||
- [x] **A2** Optimisations requêtes AI : `/agent` résout le provider comme
|
- [x] **A2** Optimisations requêtes AI : `/agent` résout désormais le
|
||||||
`/chat` (avant : `req.provider` brut transmis au provider adapter →
|
provider comme `/chat` (avant : `req.provider` brut transmis au
|
||||||
moteur réel ≠ étiquette SSE) ; schémas d'outils mis en cache (~27 ms de
|
provider adapter → moteur réel ≠ étiquette SSE) ; schémas d'outils
|
||||||
pydantic économisés par requête agent/MCP)
|
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
|
- [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) ;
|
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
|
||||||
ruff/mypy 0 erreur ; build + instance Docker locale à jour
|
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
|
## ✅ Terminé — Navigation
|
||||||
|
|
||||||
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
||||||
@@ -291,6 +494,68 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ✅ Terminé — Secrets masqués
|
||||||
|
|
||||||
|
### 188. Secrets masqués — couverture universelle des clés API & mots de passe + clic pour copier
|
||||||
|
|
||||||
|
- **Effort :** 1 jour | **Impact :** 🟡
|
||||||
|
- **Statut :** ✅ **livré le 2026-10-08** — ouvert le 2026-10-08
|
||||||
|
- **Fiche :** [features/secret-mask-188.md](./features/secret-mask-188.md)
|
||||||
|
- **Description :** le redacteur (`secret_redactor.py`) ne couvrait que les
|
||||||
|
JWT, clés `sk-`/`AKIA`/`ghp_`, connection strings et affectations
|
||||||
|
`key=value` de 20+ caractères — la plupart des clés API réelles et **tous
|
||||||
|
les mots de passe courts** passaient en clair dans l'aperçu markdown, et
|
||||||
|
un masque affiché n'était pas récupérable sans ouvrir la source. #188
|
||||||
|
couvre la majorité des formats de clés, masque les mots de passe quelle
|
||||||
|
que soit leur longueur et rend chaque masque **cliquable** dans l'aperçu
|
||||||
|
authentifié (copie dans le presse-papiers) sans jamais exposer la valeur
|
||||||
|
à l'extérieur (partages, PDF, IA/MCP).
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] **A1** Détection universelle : table de formats fournisseur (OpenAI,
|
||||||
|
Stripe, GitLab, GitHub, Google, AWS, Slack, SendGrid, Hugging Face,
|
||||||
|
npm, Docker, Resend, Square, Atlassian, Discord, Telegram,
|
||||||
|
`Bearer …`) + plancher des affectations génériques 20 → 8 caractères
|
||||||
|
- [x] **A2** Mots de passe : `[MOT DE PASSE MASQUÉ]` toute longueur /
|
||||||
|
quote (`password=`, `"passwd":`, `db_password=`, `mot de passe=`) ;
|
||||||
|
`PWD=` shell épargné
|
||||||
|
- [x] **A3** Clic → copie : placeholders markdown-safe (blocs de code
|
||||||
|
compris) réécrits en `<span class="secret-mask" data-secret>`
|
||||||
|
côté aperçu authentifié (listener délégué, toast + infobulle
|
||||||
|
i18n FR/EN) ; libellé seul pour partages / PDF / IA / MCP ;
|
||||||
|
code masqué non re-coloré (highlight.js perdrait le badge)
|
||||||
|
- [x] **A4** Guide in-app FR/EN, `GUIDES/AUTHENTIFICATION_SECURITE.md`,
|
||||||
|
README FR/EN, tests `TestSecretRedactor` (+33)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Terminé — Mise en page mobile du tableur
|
||||||
|
|
||||||
|
### 189. Tableur — mise en page mobile (barres repliées, grille prioritaire)
|
||||||
|
|
||||||
|
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Framework :** CSS vanilla + `frontend/js/viewer.js`
|
||||||
|
- **Statut :** ✅ **livré le 2026-10-08** — ouvert le 2026-10-08
|
||||||
|
- **Fiche :** [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md)
|
||||||
|
- **Description :** sur mobile, la barre de menus + le ruban + la barre de
|
||||||
|
formule s'empilaient et occupaient plus de 50 % de la hauteur d'écran.
|
||||||
|
Sous 768 px, `.xlsx-menubar`, `.xlsx-cmdbar` et le groupe Recherche sont
|
||||||
|
**repliés par défaut** (la barre de formule fx et les onglets restent
|
||||||
|
visibles) et un **bouton unique** `#xlsx-tools-toggle` dans la barre des
|
||||||
|
feuilles déplie/replie l'ensemble (`aria-expanded`, état actif) — desktop
|
||||||
|
inchangé (bouton masqué).
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] **A1** Repli CSS par défaut ≤ 768 px + révélation via
|
||||||
|
`.xlsx-toolbar.xlsx-tools-open` (menubar, ruban, Recherche)
|
||||||
|
- [x] **A2** Bascule `#xlsx-tools-toggle` dans `.xlsx-sheetbar`
|
||||||
|
(`aria-expanded` + classe `active` + i18n `xlsx.tools_toggle` FR/EN)
|
||||||
|
- [x] **A3** Tactile : cibles ≥ 44 px, onglets en défilement horizontal,
|
||||||
|
champs de la barre de formule en 16 px (anti-zoom iOS), barre de
|
||||||
|
formule en 2 lignes quand les outils sont ouverts
|
||||||
|
- [x] **A4** Tests : JSDOM `xlsx-viewer.test.mjs` (+2, 165/165) et E2E
|
||||||
|
mobile (repli par défaut, dépliage, no-overflow ruban, 44 px) ;
|
||||||
|
`openXlsx()` déplie automatiquement ≤ 768 px pour les tests desktop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⚪ Backlog — Priorité 4 (P4)
|
## ⚪ Backlog — Priorité 4 (P4)
|
||||||
|
|
||||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||||
@@ -475,6 +740,16 @@
|
|||||||
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
|
| 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) |
|
||||||
|
| 189 | Tableur — mise en page mobile : barres repliées par défaut, bascule unique, cibles tactiles 44 px | Unreleased | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.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) |
|
| 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) |
|
| 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) |
|
| 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,57 @@
|
|||||||
|
# Fiche — #188 · Secrets masqués : couverture universelle + clic pour copier
|
||||||
|
|
||||||
|
- **Statut** : ✅ livré le 2026-10-08 (ouvert le 2026-10-08)
|
||||||
|
- **Effort** : ~1 jour · **Impact** : 🟡
|
||||||
|
- **Zones** : `backend/secret_redactor.py`, `backend/render.py`,
|
||||||
|
`backend/routers/files_read.py`, `frontend/js/viewer.js`,
|
||||||
|
`frontend/style.css`, `frontend/locales/{fr,en}.json`
|
||||||
|
|
||||||
|
## Problème
|
||||||
|
|
||||||
|
Le redacteur (`secret_redactor.py`) ne masquait que les JWT, les clés
|
||||||
|
`sk-`/`pk-`/`rk-`, `AKIA…`, `ghp_…`, les connection strings et les
|
||||||
|
affectations `api_key=`/`token=`/`secret=` de **20 caractères ou plus**.
|
||||||
|
Résultat : la majorité des clés API réelles (Google, Slack, Stripe, GitLab,
|
||||||
|
Hugging Face, npm…) et **tous les mots de passe courts** passaient en clair
|
||||||
|
dans l'aperçu markdown, et un masque affiché (`[CLÉ API MASQUÉE]`) n'était
|
||||||
|
pas récupérable : il fallait ouvrir la source pour relire la valeur.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
1. **Détection universelle** — une alternation unique de formats
|
||||||
|
fournisseur (OpenAI/Anthropic `sk-`, Stripe `sk_live_`/`whsec_`, GitLab
|
||||||
|
`glpat-`, GitHub `ghp_`/`github_pat_`, Google `AIza…`/`ya29.`, AWS
|
||||||
|
`AKIA`/`ASIA`, Slack `xoxb-`, SendGrid `SG.`, Hugging Face `hf_`, npm,
|
||||||
|
Docker, Resend, Square, Atlassian, Discord, Telegram, `Bearer …`) en
|
||||||
|
plus des motifs déjà présents ; le plancher des affectations génériques
|
||||||
|
passe de 20 à 8 caractères.
|
||||||
|
2. **Mots de passe** — motif dédié (`[MOT DE PASSE MASQUÉ]`) valable toute
|
||||||
|
longueur et toute quote (`password=`, `"passwd": "…"`, `db_password=`,
|
||||||
|
`passphrase=`, `mot de passe=`), qualificateur inclus. `PWD=` (dossier de
|
||||||
|
travail shell) reste épargné : `pwd` n'est reconnu que qualifié.
|
||||||
|
3. **Clic → copie** — le pipeline de rendu passe par des
|
||||||
|
**placeholders** (`\uE000n\uE001`) qui survivent à la conversion markdown
|
||||||
|
(un `<span>` littéral dans un bloc de code serait affiché tel quel), puis
|
||||||
|
`restore_masks()` les réécrit après le rendu :
|
||||||
|
- **aperçu authentifié** (`GET /api/file/…`, `click_to_copy=True`) :
|
||||||
|
`<span class="secret-mask" data-secret="valeur">[CLÉ API MASQUÉE]</span>` —
|
||||||
|
la valeur n'est jamais affichée, un clic la copie (listener délégué
|
||||||
|
unique sur la zone de contenu, toast i18n FR/EN, infobulle) ;
|
||||||
|
- **partages publics, exports PDF, contexte IA / MCP** : libellé seul,
|
||||||
|
la valeur ne quitte pas le serveur.
|
||||||
|
Les blocs `<pre><code>` portant un masque ne sont pas re-colorés
|
||||||
|
(highlight.js reconstruirait le markup et perdrait le badge).
|
||||||
|
|
||||||
|
## Hors périmètre (volontaire)
|
||||||
|
|
||||||
|
- La vue « source » (fichier brut) et les aperçus de fichiers
|
||||||
|
**non-markdown** ne sont pas masqués : c'est le fichier lui-même qui est
|
||||||
|
affiché, le redacteur ne s'y appliquait jamais.
|
||||||
|
- Le serveur MCP / l'assistant IA continuent de recevoir les libellés seuls
|
||||||
|
(inchangé).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_api_main.py::TestSecretRedactor` : formats fournisseurs
|
||||||
|
(paramétrés), mots de passe (paramétrés), `PWD=` non masqué, round-trip
|
||||||
|
placeholder → span cliquable, rendu markdown cliquable vs libellé seul.
|
||||||
@@ -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,81 @@
|
|||||||
|
# #189 — Tableur : mise en page mobile (barres repliées, grille prioritaire)
|
||||||
|
|
||||||
|
> **Statut :** ✅ livré (2026-10-08) | **Effort :** 0,5-1 jour | **Impact :** 🟡
|
||||||
|
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||||
|
> [#180](./xlsx-sheetbar-180.md) · [#185](./xlsx-menus-185.md)
|
||||||
|
|
||||||
|
## 1. Constat
|
||||||
|
|
||||||
|
Sur mobile, l'empilement `.xlsx-menubar` (10 menus) + `.xlsx-cmdbar`
|
||||||
|
(~40 boutons qui s'enroulent sur 4-5 rangées) + `.xlsx-sheetbar` +
|
||||||
|
`.xlsx-formula-bar` occupait **plus de 50 % de la hauteur d'écran** : la
|
||||||
|
grille, objet premier de l'éditeur, passait après la chrome.
|
||||||
|
|
||||||
|
## 2. Livré
|
||||||
|
|
||||||
|
### A1 — Repli par défaut (CSS, ≤ 768 px)
|
||||||
|
|
||||||
|
Bloc `#189` dans `frontend/style.css` (après les règles `pointer: coarse`) :
|
||||||
|
|
||||||
|
- `.xlsx-menubar`, `.xlsx-cmdbar`, `.xlsx-find-group` → `display: none` par
|
||||||
|
défaut sous 768 px ;
|
||||||
|
- `.xlsx-toolbar.xlsx-tools-open …` les réaffiche (état de la classe portée
|
||||||
|
par la **barre entière**, donc menubar + ruban + Recherche restent cohérents) ;
|
||||||
|
- `.xlsx-formula-bar` reste **toujours visible** (zone Nom + `fx` + champ) et
|
||||||
|
passe en `flex-wrap` quand les outils sont ouverts (le groupe Recherche
|
||||||
|
réapparaît sur une 2ᵉ ligne) ;
|
||||||
|
- `.xlsx-tools-toggle { display: none }` sur desktop : **zéro changement
|
||||||
|
visuel hors mobile**.
|
||||||
|
|
||||||
|
### A2 — Bascule unique (JS)
|
||||||
|
|
||||||
|
`frontend/js/viewer.js` (`renderXlsxViewer`) :
|
||||||
|
|
||||||
|
- bouton `#xlsx-tools-toggle` (icône Lucide `menu`, `aria-expanded`,
|
||||||
|
`aria-label` i18n `xlsx.tools_toggle`) inséré **en tête de
|
||||||
|
`.xlsx-sheetbar`** — donc toujours visible, entre les onglets et Enregistrer ;
|
||||||
|
- listener : bascule `.xlsx-tools-open` sur `.xlsx-toolbar`, met à jour
|
||||||
|
`aria-expanded` + classe `active`, `safeCreateIcons()` pour l'icône.
|
||||||
|
|
||||||
|
### A3 — Ergonomie tactile (CSS)
|
||||||
|
|
||||||
|
- cibles ≥ **44 px** : `.xlsx-toolbar .btn-action`, `.xlsx-menubtn`,
|
||||||
|
`.xlsx-tab` / `.xlsx-tab-add` ; à cette taille un groupe du ruban
|
||||||
|
(police, nombre…) déborde d'un téléphone : `.xlsx-cmd-group` passe en
|
||||||
|
`flex: 0 1 auto; min-width: 0; flex-wrap: wrap` pour s'enrouler au lieu
|
||||||
|
de sortir de l'écran (contre-preuve E2E : `scrollWidth - clientWidth ≤ 1`) ;
|
||||||
|
- onglets de feuilles : une seule ligne, **défilement horizontal**
|
||||||
|
(`overflow-x: auto`, scrollbars masquées) au lieu d'enrouler ;
|
||||||
|
- champs de la barre de formule en **16 px** : en dessous, iOS zoome
|
||||||
|
automatiquement la page au focus (la « classique » debout de l'UI) ;
|
||||||
|
- groupe Recherche masqué avec le ruban pour ne pas écraser le champ de saisie.
|
||||||
|
|
||||||
|
### A4 — i18n
|
||||||
|
|
||||||
|
Clé `xlsx.tools_toggle` : FR « Afficher / masquer les barres d'outils » /
|
||||||
|
EN « Show / hide toolbars » (parité vérifiée par `tests/test_i18n_parity.py`).
|
||||||
|
|
||||||
|
## 3. Tests
|
||||||
|
|
||||||
|
- `tests/frontend/xlsx-viewer.test.mjs` (+2, **165/165**) :
|
||||||
|
1. la bascule vit dans la barre des feuilles et flippe correctement
|
||||||
|
classe / `aria-expanded` / `active` (aller-retour) ;
|
||||||
|
2. `style.css` masque menubar/ruban/Recherche par défaut sous 768 px et les
|
||||||
|
réaffiche via `.xlsx-tools-open` (44 px, onglets scrollables, inputs
|
||||||
|
16 px présents dans le bloc).
|
||||||
|
- `tests/e2e/xlsx-viewer.spec.js` — test mobile réécrit : repli par défaut
|
||||||
|
(menubar/ruban cachés, formule + onglets visibles) → dépliage → ruban sans
|
||||||
|
débordement horizontal → cibles ≥ 44 px → repli ; `openXlsx()` déplie
|
||||||
|
automatiquement sous 768 px pour que les tests « desktop » restent valables
|
||||||
|
sur les deux viewports.
|
||||||
|
|
||||||
|
## 4. Limites assumées
|
||||||
|
|
||||||
|
- Le repli est piloté par la **largeur** (`max-width: 768px`), pas par
|
||||||
|
`pointer: coarse` : une tablette large en mode tactile garde la chrome
|
||||||
|
complète (elle a la hauteur pour).
|
||||||
|
- Le groupe Recherche n'est accessible sur mobile qu'avec les outils ouverts
|
||||||
|
; le reset de tri reste atteignable par la mini-barre de sélection et le
|
||||||
|
menu Données (vérifié).
|
||||||
|
- Pas de drawer latéral ni de FAB : un seul bouton rétractable suffit à
|
||||||
|
l'usage — ajouter un panneau glissant si le retour utilisateur le réclame.
|
||||||
@@ -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.
|
||||||
+6
-2
@@ -5302,8 +5302,12 @@ curl -X POST https://votre-serveur.com/webhook \
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Secret redactor</strong><span data-i18n="help.desc_9846fc07"> : Masque
|
<strong>Secret redactor</strong><span data-i18n="help.desc_9846fc07"> : Masque
|
||||||
JWT, clés API, tokens GitHub dans les
|
JWT, mots de passe et clés API (OpenAI,
|
||||||
aperçus</span></li>
|
Anthropic, GitHub, Google, AWS, Slack,
|
||||||
|
Stripe, GitLab, Hugging Face…) dans les
|
||||||
|
aperçus markdown ; cliquez sur un masque
|
||||||
|
pour copier la valeur dans le
|
||||||
|
presse-papiers</span></li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Path traversal</strong> : Validation
|
<strong>Path traversal</strong> : Validation
|
||||||
des chemins contre les attaques
|
des chemins contre les attaques
|
||||||
|
|||||||
@@ -587,6 +587,26 @@ function applyTheme(themeKey, mode) {
|
|||||||
Object.keys(vars).forEach(function(key) {
|
Object.keys(vars).forEach(function(key) {
|
||||||
root.style.setProperty(key, vars[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
|
// Store resolved vars on the theme for export/preview
|
||||||
if (!theme.modes[mode]) theme.modes[mode] = vars;
|
if (!theme.modes[mode]) theme.modes[mode] = vars;
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -795,15 +795,16 @@ const FileOperations = {
|
|||||||
<div class="modal-form-group">
|
<div class="modal-form-group">
|
||||||
<label class="modal-label">${t('modal.file_type')}</label>
|
<label class="modal-label">${t('modal.file_type')}</label>
|
||||||
<select class="modal-select" id="file-ext-select">
|
<select class="modal-select" id="file-ext-select">
|
||||||
<option value=".md">Markdown (.md)</option>
|
<option value=".md">${t('modal.ext_md')}</option>
|
||||||
<option value=".txt">Texte (.txt)</option>
|
<option value=".txt">${t('modal.ext_txt')}</option>
|
||||||
<option value=".py">Python (.py)</option>
|
<option value=".py">${t('modal.ext_py')}</option>
|
||||||
<option value=".js">JavaScript (.js)</option>
|
<option value=".js">${t('modal.ext_js')}</option>
|
||||||
<option value=".json">JSON (.json)</option>
|
<option value=".json">${t('modal.ext_json')}</option>
|
||||||
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
|
<option value=".xlsx">${t('modal.ext_xlsx')}</option>
|
||||||
<option value=".yaml">YAML (.yaml)</option>
|
<option value=".excalidraw">${t('modal.ext_excalidraw')}</option>
|
||||||
<option value=".sh">Shell (.sh)</option>
|
<option value=".yaml">${t('modal.ext_yaml')}</option>
|
||||||
<option value=".ps1">PowerShell (.ps1)</option>
|
<option value=".sh">${t('modal.ext_sh')}</option>
|
||||||
|
<option value=".ps1">${t('modal.ext_ps1')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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.
|
// Not editing: refresh the read view when it shows the written document.
|
||||||
if (state.currentVault === vault && state.currentPath === path) {
|
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
|
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
|
||||||
// refresh) and discard the in-editor scene. Its own save already persisted
|
// refresh) and discard the in-editor scene. Its own save already persisted
|
||||||
// the file, so there is nothing to reload here.
|
// the file, so there is nothing to reload here.
|
||||||
|
|||||||
+3672
-187
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
|
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
|
||||||
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
|
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
|
||||||
* is testable and the viewer only wires behaviour.
|
* 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 { t } from '../i18n.js';
|
||||||
import { escapeHtml } from '../utils.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 {object} ctx
|
||||||
* @param {Array<{name: string}>} ctx.sheets
|
* @param {Array<{name: string}>} ctx.sheets
|
||||||
* @param {boolean} ctx.isCsv
|
* @param {boolean} ctx.isCsv
|
||||||
* @param {boolean} ctx.readOnly
|
* @param {boolean} ctx.readOnly
|
||||||
* @param {boolean} ctx.editable - not a CSV and not read-only
|
* @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 }) {
|
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||||
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
// 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>`
|
? `<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>`;
|
: ""}</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 = [
|
const statusPills = [
|
||||||
readOnly
|
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>`
|
? `<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("");
|
].filter(Boolean).join("");
|
||||||
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
|
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 = [];
|
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) {
|
if (!readOnly) {
|
||||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
|
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>
|
<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>
|
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||||
</button>
|
</button>
|
||||||
</span>`);
|
</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">
|
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
<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>
|
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>`);
|
</span>`);
|
||||||
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></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">
|
// #185 — the ribbon now reads like Google Sheets': flat icon buttons, no
|
||||||
<button class="btn-action" id="xlsx-download-btn">
|
// chrome, grouped by thin vertical rules. The gridline switch belongs to it
|
||||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
// (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>
|
||||||
<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>`);
|
</span>`);
|
||||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></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 _menu = null;
|
||||||
let _openedAt = 0;
|
let _openedAt = 0;
|
||||||
|
// Only a touch long-press can emit a click right after the menu opened.
|
||||||
|
let _openedByTouch = false;
|
||||||
let _dismissBound = false;
|
let _dismissBound = false;
|
||||||
|
|
||||||
export function closeContextMenu() {
|
export function closeContextMenu() {
|
||||||
// BUG-108: drop the reference BEFORE touching the DOM — removing a menu
|
// Drop the reference BEFORE touching the DOM: removing a menu that holds
|
||||||
// that holds the focus fires blur/focusout synchronously, which re-enters
|
// the focus fires blur/focusout synchronously, which re-enters this very
|
||||||
// this very function; the second remove() on the mid-flight node throws
|
// function — a second remove() on the mid-flight node throws NotFoundError
|
||||||
// NotFoundError and aborts the caller's click handler before its action
|
// and aborts the caller's click handler before its action runs.
|
||||||
// runs (contextual « Coller » silently dead, e2e xlsx-viewer A5).
|
|
||||||
const menu = _menu;
|
const menu = _menu;
|
||||||
_menu = null;
|
_menu = null;
|
||||||
if (menu) menu.remove();
|
if (menu) menu.remove();
|
||||||
@@ -36,9 +37,11 @@ function bindDismiss() {
|
|||||||
_dismissBound = true;
|
_dismissBound = true;
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
if (!_menu) return;
|
if (!_menu) return;
|
||||||
// Ignore the very gesture that opened the menu (touch long-press can emit
|
// Ignore the very gesture that opened the menu — but only for a touch
|
||||||
// a click right after).
|
// long-press, which is the one that emits a trailing click. A mouse
|
||||||
if (Date.now() - _openedAt < 250) return;
|
// 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;
|
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
});
|
});
|
||||||
@@ -56,8 +59,12 @@ function bindDismiss() {
|
|||||||
});
|
});
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.addEventListener("blur", closeContextMenu);
|
window.addEventListener("blur", closeContextMenu);
|
||||||
// A scroll or resize detaches the viewport-anchored menu from its cell.
|
// A scroll detaches the viewport-anchored menu from its cell — except the
|
||||||
window.addEventListener("scroll", closeContextMenu, true);
|
// 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);
|
window.addEventListener("resize", closeContextMenu);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,25 +118,50 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
|
|||||||
if (onClose) onClose();
|
if (onClose) onClose();
|
||||||
else menuEl.remove();
|
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) => {
|
const onDocClick = (e) => {
|
||||||
if (done) return;
|
if (done || dropIfDetached()) return;
|
||||||
if (!menuEl.isConnected) { done = true; unbind(); return; }
|
|
||||||
const t = e.target;
|
const t = e.target;
|
||||||
if (menuEl.contains(t)) return;
|
if (menuEl.contains(t)) return;
|
||||||
if (toggle && (toggle === t || toggle.contains(t))) return;
|
if (toggle && (toggle === t || toggle.contains(t))) return;
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
if (e.key === "Escape" && menuEl.isConnected) dismiss();
|
if (dropIfDetached()) return;
|
||||||
|
if (e.key === "Escape") dismiss();
|
||||||
};
|
};
|
||||||
const onFocusOut = (e) => {
|
const onFocusOut = (e) => {
|
||||||
if (done || !menuEl.isConnected) return;
|
if (done || dropIfDetached()) return;
|
||||||
const next = e.relatedTarget;
|
const next = e.relatedTarget;
|
||||||
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
||||||
dismiss();
|
dismiss();
|
||||||
};
|
};
|
||||||
const onScroll = () => {
|
// Toolbar-anchored menus travel WITH their button: a scroll inside the
|
||||||
if (menuEl.isConnected) dismiss();
|
// 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("click", onDocClick);
|
||||||
document.addEventListener("keydown", onKey);
|
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
|
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||||
* @returns {HTMLElement} the menu element
|
* @returns {HTMLElement} the menu element
|
||||||
*/
|
*/
|
||||||
export function openContextMenu(x, y, items) {
|
export function openContextMenu(x, y, items, { byTouch = false } = {}) {
|
||||||
bindDismiss();
|
bindDismiss();
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
const menu = document.createElement("div");
|
const menu = document.createElement("div");
|
||||||
@@ -198,6 +230,7 @@ export function openContextMenu(x, y, items) {
|
|||||||
|
|
||||||
_menu = menu;
|
_menu = menu;
|
||||||
_openedAt = Date.now();
|
_openedAt = Date.now();
|
||||||
|
_openedByTouch = byTouch;
|
||||||
// Keyboard users land on the first entry right away; mouse users are
|
// Keyboard users land on the first entry right away; mouse users are
|
||||||
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
|
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
|
||||||
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
|
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;
|
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). */
|
/** Find a rendered cell inside a sheet panel by (row, col). */
|
||||||
export function findTd(panel, 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. */
|
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
|
||||||
|
|||||||
+247
-5
@@ -1004,7 +1004,7 @@
|
|||||||
"help.desc_8f0bffe3": "JWT tokens",
|
"help.desc_8f0bffe3": "JWT tokens",
|
||||||
"help.desc_9186a3a3": "Explorez l'arborescence : Naviguez dans les dossiers",
|
"help.desc_9186a3a3": "Explorez l'arborescence : Naviguez dans les dossiers",
|
||||||
"help.desc_93902d18": "Utilisez les tags : Filtrez par tags pour affiner",
|
"help.desc_93902d18": "Utilisez les tags : Filtrez par tags pour affiner",
|
||||||
"help.desc_9846fc07": "In the",
|
"help.desc_9846fc07": ": Masks JWTs, passwords and API keys (OpenAI, Anthropic, GitHub, Google, AWS, Slack, Stripe, GitLab, Hugging Face…) in markdown previews — click a mask to copy the value to the clipboard",
|
||||||
"help.desc_985d759b": "Token unique : 64 caractères hexadécimaux, impossible à deviner",
|
"help.desc_985d759b": "Token unique : 64 caractères hexadécimaux, impossible à deviner",
|
||||||
"help.desc_991307d6": " — Generate content from the selection:",
|
"help.desc_991307d6": " — Generate content from the selection:",
|
||||||
"help.desc_995a711f": ": Opens the CodeMirror editor",
|
"help.desc_995a711f": ": Opens the CodeMirror editor",
|
||||||
@@ -1461,6 +1461,16 @@
|
|||||||
"modal.delete_permanently": "Delete permanently",
|
"modal.delete_permanently": "Delete permanently",
|
||||||
"modal.delete_warning": "Warning!",
|
"modal.delete_warning": "Warning!",
|
||||||
"modal.dir_to_delete": "Folder to delete:",
|
"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_name": "File name",
|
||||||
"modal.file_to_delete": "File to delete:",
|
"modal.file_to_delete": "File to delete:",
|
||||||
"modal.file_type": "File type",
|
"modal.file_type": "File type",
|
||||||
@@ -1847,8 +1857,8 @@
|
|||||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||||
"xlsx.readonly_badge": "Read-only",
|
"xlsx.readonly_badge": "Read-only",
|
||||||
"xlsx.readonly_hint": "This format (.xls / .ods) cannot be edited in ObsiGate — convert it to .xlsx to edit.",
|
"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": "Formulas computed locally",
|
||||||
"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_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_title": "Truncated sheet",
|
||||||
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
||||||
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
||||||
@@ -1876,8 +1886,31 @@
|
|||||||
"xlsx.find_count": "{index}/{count}",
|
"xlsx.find_count": "{index}/{count}",
|
||||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
|
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
|
||||||
"xlsx.csv_export": "Export the sheet or the selection as CSV",
|
"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_btn": "Export",
|
||||||
|
"xlsx.export_csv": "Export as CSV",
|
||||||
"xlsx.export_title": "Export the selection or the sheet",
|
"xlsx.export_title": "Export the selection or the sheet",
|
||||||
|
"xlsx.file_menu": "File",
|
||||||
"xlsx.export_md": "Export as Markdown",
|
"xlsx.export_md": "Export as Markdown",
|
||||||
"xlsx.export_html": "Export as HTML",
|
"xlsx.export_html": "Export as HTML",
|
||||||
"xlsx.print": "Print",
|
"xlsx.print": "Print",
|
||||||
@@ -1885,6 +1918,47 @@
|
|||||||
"xlsx.export_scope_selection": "the selection",
|
"xlsx.export_scope_selection": "the selection",
|
||||||
"xlsx.export_done": "Exported {scope}",
|
"xlsx.export_done": "Exported {scope}",
|
||||||
"xlsx.format_btn": "Formatting",
|
"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_bold": "Bold",
|
||||||
"xlsx.format_italic": "Italic",
|
"xlsx.format_italic": "Italic",
|
||||||
"xlsx.format_underline": "Underline",
|
"xlsx.format_underline": "Underline",
|
||||||
@@ -1906,9 +1980,13 @@
|
|||||||
"xlsx.format_freeze": "Freeze panes here",
|
"xlsx.format_freeze": "Freeze panes here",
|
||||||
"xlsx.format_unfreeze": "Unfreeze panes",
|
"xlsx.format_unfreeze": "Unfreeze panes",
|
||||||
"xlsx.format_col_width": "Column width…",
|
"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_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_applied": "Formatting saved",
|
||||||
"xlsx.format_error": "Formatting failed",
|
"xlsx.format_error": "Formatting failed",
|
||||||
"xlsx.sort_asc": "Sort column A→Z",
|
"xlsx.sort_asc": "Sort column A→Z",
|
||||||
@@ -1969,6 +2047,167 @@
|
|||||||
"xlsx.feature_signature": "digital signature",
|
"xlsx.feature_signature": "digital signature",
|
||||||
"xlsx.feature_rich_comments": "rich comments",
|
"xlsx.feature_rich_comments": "rich comments",
|
||||||
"xlsx.feature_macros": "macros",
|
"xlsx.feature_macros": "macros",
|
||||||
|
|
||||||
|
"xlsx.mbar_label": "Spreadsheet menus",
|
||||||
|
"xlsx.cmdbar_label": "Spreadsheet command bar",
|
||||||
|
"xlsx.tools_toggle": "Show / hide toolbars",
|
||||||
|
"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_md": "Download as .md",
|
||||||
"viewer.download_file": "Download file",
|
"viewer.download_file": "Download file",
|
||||||
"viewer.pretty": "Pretty",
|
"viewer.pretty": "Pretty",
|
||||||
@@ -1983,6 +2222,9 @@
|
|||||||
"viewer.export_html": "Export as HTML",
|
"viewer.export_html": "Export as HTML",
|
||||||
"viewer.export_md_bundle": "Export as Markdown bundle (.zip)",
|
"viewer.export_md_bundle": "Export as Markdown bundle (.zip)",
|
||||||
"viewer.export_start": "Exporting...",
|
"viewer.export_start": "Exporting...",
|
||||||
|
"viewer.secret_copy_hint": "Click to copy the value",
|
||||||
|
"viewer.secret_copied": "Value copied to the clipboard",
|
||||||
|
"viewer.secret_copy_failed": "Copy failed",
|
||||||
"viewer.export_title": "Export document",
|
"viewer.export_title": "Export document",
|
||||||
"viewer.forge_brand": "Forge",
|
"viewer.forge_brand": "Forge",
|
||||||
"viewer.forge_title": "Forge (new editor)",
|
"viewer.forge_title": "Forge (new editor)",
|
||||||
|
|||||||
+247
-5
@@ -1004,7 +1004,7 @@
|
|||||||
"help.desc_8f0bffe3": ": Tokens JWT\n avec mots de passe hachés Argon2id",
|
"help.desc_8f0bffe3": ": Tokens JWT\n avec mots de passe hachés Argon2id",
|
||||||
"help.desc_9186a3a3": "Explorez l'arborescence :\n Naviguez dans les dossiers",
|
"help.desc_9186a3a3": "Explorez l'arborescence :\n Naviguez dans les dossiers",
|
||||||
"help.desc_93902d18": "Utilisez les tags : Filtrez\n par tags pour affiner",
|
"help.desc_93902d18": "Utilisez les tags : Filtrez\n par tags pour affiner",
|
||||||
"help.desc_9846fc07": ": Masque\n JWT, clés API, tokens GitHub dans les\n aperçus",
|
"help.desc_9846fc07": ": Masque JWT, mots de passe et clés API (OpenAI, Anthropic, GitHub, Google, AWS, Slack, Stripe, GitLab, Hugging Face…) dans les aperçus markdown ; cliquez sur un masque pour copier la valeur dans le presse-papiers",
|
||||||
"help.desc_985d759b": "Token unique : 64\n caractères hexadécimaux, impossible à\n deviner",
|
"help.desc_985d759b": "Token unique : 64\n caractères hexadécimaux, impossible à\n deviner",
|
||||||
"help.desc_991307d6": "— Génère du\n contenu à partir de la sélection :",
|
"help.desc_991307d6": "— Génère du\n contenu à partir de la sélection :",
|
||||||
"help.desc_995a711f": ": Ouvre l'éditeur\n CodeMirror",
|
"help.desc_995a711f": ": Ouvre l'éditeur\n CodeMirror",
|
||||||
@@ -1461,6 +1461,16 @@
|
|||||||
"modal.delete_permanently": "Supprimer définitivement",
|
"modal.delete_permanently": "Supprimer définitivement",
|
||||||
"modal.delete_warning": "Attention !",
|
"modal.delete_warning": "Attention !",
|
||||||
"modal.dir_to_delete": "Dossier à supprimer:",
|
"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_name": "Nom du fichier",
|
||||||
"modal.file_to_delete": "Fichier à supprimer:",
|
"modal.file_to_delete": "Fichier à supprimer:",
|
||||||
"modal.file_type": "Type de fichier",
|
"modal.file_type": "Type de fichier",
|
||||||
@@ -1847,8 +1857,8 @@
|
|||||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||||
"xlsx.readonly_badge": "Lecture seule",
|
"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.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": "Formules calculées localement",
|
||||||
"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_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_title": "Feuille tronquée",
|
||||||
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
||||||
"xlsx.truncated_cols": "{shown} colonnes 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": "{index}/{count}",
|
||||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
|
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
|
||||||
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
|
"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_btn": "Exporter",
|
||||||
|
"xlsx.export_csv": "Exporter en CSV",
|
||||||
"xlsx.export_title": "Exporter la sélection ou la feuille",
|
"xlsx.export_title": "Exporter la sélection ou la feuille",
|
||||||
|
"xlsx.file_menu": "Fichier",
|
||||||
"xlsx.export_md": "Exporter en Markdown",
|
"xlsx.export_md": "Exporter en Markdown",
|
||||||
"xlsx.export_html": "Exporter en HTML",
|
"xlsx.export_html": "Exporter en HTML",
|
||||||
"xlsx.print": "Imprimer",
|
"xlsx.print": "Imprimer",
|
||||||
@@ -1885,6 +1918,47 @@
|
|||||||
"xlsx.export_scope_selection": "la sélection",
|
"xlsx.export_scope_selection": "la sélection",
|
||||||
"xlsx.export_done": "Export de {scope} terminé",
|
"xlsx.export_done": "Export de {scope} terminé",
|
||||||
"xlsx.format_btn": "Mise en forme",
|
"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_bold": "Gras",
|
||||||
"xlsx.format_italic": "Italique",
|
"xlsx.format_italic": "Italique",
|
||||||
"xlsx.format_underline": "Souligné",
|
"xlsx.format_underline": "Souligné",
|
||||||
@@ -1906,9 +1980,13 @@
|
|||||||
"xlsx.format_freeze": "Figer les volets ici",
|
"xlsx.format_freeze": "Figer les volets ici",
|
||||||
"xlsx.format_unfreeze": "Libérer les volets",
|
"xlsx.format_unfreeze": "Libérer les volets",
|
||||||
"xlsx.format_col_width": "Largeur de colonne…",
|
"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_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_applied": "Mise en forme enregistrée",
|
||||||
"xlsx.format_error": "Échec de la mise en forme",
|
"xlsx.format_error": "Échec de la mise en forme",
|
||||||
"xlsx.sort_asc": "Trier la colonne A→Z",
|
"xlsx.sort_asc": "Trier la colonne A→Z",
|
||||||
@@ -1969,6 +2047,167 @@
|
|||||||
"xlsx.feature_signature": "signature numérique",
|
"xlsx.feature_signature": "signature numérique",
|
||||||
"xlsx.feature_rich_comments": "commentaires enrichis",
|
"xlsx.feature_rich_comments": "commentaires enrichis",
|
||||||
"xlsx.feature_macros": "macros",
|
"xlsx.feature_macros": "macros",
|
||||||
|
|
||||||
|
"xlsx.mbar_label": "Menus du tableur",
|
||||||
|
"xlsx.cmdbar_label": "Barre de commandes du tableur",
|
||||||
|
"xlsx.tools_toggle": "Afficher / masquer les barres d'outils",
|
||||||
|
"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_md": "Télécharger en .md",
|
||||||
"viewer.download_file": "Télécharger le fichier",
|
"viewer.download_file": "Télécharger le fichier",
|
||||||
"viewer.pretty": "Pretty",
|
"viewer.pretty": "Pretty",
|
||||||
@@ -1983,6 +2222,9 @@
|
|||||||
"viewer.export_html": "Exporter en HTML",
|
"viewer.export_html": "Exporter en HTML",
|
||||||
"viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)",
|
"viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)",
|
||||||
"viewer.export_start": "Export en cours...",
|
"viewer.export_start": "Export en cours...",
|
||||||
|
"viewer.secret_copy_hint": "Cliquer pour copier la valeur",
|
||||||
|
"viewer.secret_copied": "Valeur copiée dans le presse-papiers",
|
||||||
|
"viewer.secret_copy_failed": "Échec de la copie",
|
||||||
"viewer.export_title": "Exporter le document",
|
"viewer.export_title": "Exporter le document",
|
||||||
"viewer.forge_brand": "Forge",
|
"viewer.forge_brand": "Forge",
|
||||||
"viewer.forge_title": "Forge (nouvel éditeur)",
|
"viewer.forge_title": "Forge (nouvel éditeur)",
|
||||||
|
|||||||
+823
-52
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.53.3",
|
"version": "2.56.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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
+236
-17
@@ -41,6 +41,8 @@ const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
|||||||
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
|
// 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.
|
// committed next to the other fixture; nothing in the suite writes to it.
|
||||||
const LARGE = 'sample-xlsx-large.xlsx';
|
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).
|
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||||
|
|
||||||
@@ -70,6 +72,14 @@ async function openXlsx(page, file) {
|
|||||||
}
|
}
|
||||||
await treeItem.dblclick({ timeout: 5000 });
|
await treeItem.dblclick({ timeout: 5000 });
|
||||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||||
|
// #189 — sous 768px la barre de menus et le ruban sont repliés par défaut.
|
||||||
|
// Les tests « desktop » qui les manipulent les déplient d'abord ; le test
|
||||||
|
// mobile dédié ouvre lui-même la feuille pour vérifier l'état replié.
|
||||||
|
const toggle = page.locator('#xlsx-tools-toggle');
|
||||||
|
if (await toggle.isVisible().catch(() => false)) {
|
||||||
|
await toggle.click();
|
||||||
|
await expect(page.locator('#content-area .xlsx-menubar')).toBeVisible();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => {
|
test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => {
|
||||||
@@ -85,19 +95,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
|
// 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
|
// formula results (BUG-085), so the shadow line only exists on a pristine
|
||||||
// fixture.
|
// 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 login(page);
|
||||||
await openFixture(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"]');
|
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
|
||||||
await expect(formulaCell).toContainText('=B1*2');
|
await expect(formulaCell).toHaveText('200');
|
||||||
|
await expect(formulaCell.locator('.xlsx-cached')).toHaveCount(0);
|
||||||
const cached = formulaCell.locator('.xlsx-cached');
|
await formulaCell.click();
|
||||||
await expect(cached).toHaveCount(1);
|
await expect(page.locator('#xlsx-formula-input')).toHaveValue('=B1*2');
|
||||||
await expect(cached).toHaveText('200');
|
|
||||||
// The tooltip is translated client-side, never hardcoded by the backend.
|
|
||||||
await expect(cached).toHaveAttribute('title', /Excel/);
|
|
||||||
|
|
||||||
// A plain value cell must not be duplicated with a shadow line.
|
// 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);
|
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
|
||||||
@@ -286,11 +294,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.
|
// — A6 : Ctrl+A sélectionne la feuille rendue, Ctrl+Home/End ses coins.
|
||||||
await cellOf('B12').click();
|
await cellOf('B12').click();
|
||||||
await page.keyboard.press('Control+a');
|
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 page.keyboard.press('Control+Home');
|
||||||
await expect(nameBox).toHaveValue('A1');
|
await expect(nameBox).toHaveValue('A1');
|
||||||
await page.keyboard.press('Control+End');
|
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
|
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
|
||||||
// utilise le presse-papiers interne de la grille : aucune permission
|
// utilise le presse-papiers interne de la grille : aucune permission
|
||||||
@@ -358,33 +367,181 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
|||||||
await login(page);
|
await login(page);
|
||||||
await openFixture(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');
|
const menu = page.locator('.xlsx-structure-menu');
|
||||||
await expect(menu).toBeVisible();
|
await expect(menu).toBeVisible();
|
||||||
await expect(menu.locator('.xlsx-structure-item i[data-lucide], .xlsx-structure-item svg').first()).toBeAttached();
|
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.
|
// Clic dans la grille = le focus quitte le menu → fermeture.
|
||||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
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-menu')).toHaveCount(0);
|
||||||
await expect(page.locator('#xlsx-structure-btn')).toHaveAttribute('aria-expanded', 'false');
|
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 login(page);
|
||||||
await openFixture(page);
|
await openFixture(page);
|
||||||
|
|
||||||
await page.locator('#xlsx-export-btn').click();
|
await page.locator('#xlsx-file-btn').click();
|
||||||
const menu = page.locator('#xlsx-export-menu');
|
const menu = page.locator('#xlsx-file-menu');
|
||||||
await expect(menu).toBeVisible();
|
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 page.locator('#content-area td[data-cell="A1"]').click();
|
||||||
await expect(menu).toBeHidden();
|
await expect(menu).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
|
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 : barres repliées par défaut, ruban sans débordement et cibles 44px (#189)', async ({ page, viewport }) => {
|
||||||
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
|
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.evaluate(() => window.TabManager.open('TestVault', FIXTURE));
|
await page.evaluate((f) => window.TabManager.open('TestVault', f), FIXTURE);
|
||||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// #189 — par défaut la barre de menus et le ruban sont repliés : la grille
|
||||||
|
// garde la hauteur d'écran. La barre de formule, elle, reste visible.
|
||||||
|
await expect(page.locator('#content-area .xlsx-menubar')).toBeHidden();
|
||||||
|
await expect(page.locator('#content-area .xlsx-cmdbar')).toBeHidden();
|
||||||
|
await expect(page.locator('#content-area .xlsx-formula-bar')).toBeVisible();
|
||||||
|
await expect(page.locator('#content-area .xlsx-sheetbar')).toBeVisible();
|
||||||
|
const toggle = page.locator('#xlsx-tools-toggle');
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
|
||||||
|
// Un clic déplie menus + ruban (état annoncé par aria-expanded).
|
||||||
|
await toggle.click();
|
||||||
|
await expect(page.locator('#content-area .xlsx-menubar')).toBeVisible();
|
||||||
|
await expect(page.locator('#content-area .xlsx-cmdbar')).toBeVisible();
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
// Le ruban s'enroule au lieu de déborder horizontalement.
|
// Le ruban s'enroule au lieu de déborder horizontalement.
|
||||||
const overflow = await page.evaluate(() => {
|
const overflow = await page.evaluate(() => {
|
||||||
const bar = document.querySelector('#content-area .xlsx-cmdbar');
|
const bar = document.querySelector('#content-area .xlsx-cmdbar');
|
||||||
@@ -392,6 +549,17 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
|||||||
});
|
});
|
||||||
expect(overflow).toBeLessThanOrEqual(1);
|
expect(overflow).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// Cibles tactiles : le basculeur et chaque commande du ruban font 44px.
|
||||||
|
const toggleBox = await toggle.boundingBox();
|
||||||
|
expect(toggleBox.height).toBeGreaterThanOrEqual(44);
|
||||||
|
const ribbonBtnBox = await page.locator('#xs-b').boundingBox();
|
||||||
|
expect(ribbonBtnBox.height).toBeGreaterThanOrEqual(44);
|
||||||
|
|
||||||
|
// Repli : la grille retrouve toute sa hauteur.
|
||||||
|
await toggle.click();
|
||||||
|
await expect(page.locator('#content-area .xlsx-menubar')).toBeHidden();
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
// Menu contextuel (clic droit émulé) : tient dans le viewport, entrées ≥ 44px.
|
// Menu contextuel (clic droit émulé) : tient dans le viewport, entrées ≥ 44px.
|
||||||
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
|
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
|
||||||
const menu = page.locator('.xlsx-context-menu');
|
const menu = page.locator('.xlsx-context-menu');
|
||||||
@@ -402,3 +570,54 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
|||||||
expect(itemBox.height).toBeGreaterThanOrEqual(44);
|
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
|
#!/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;
|
* - every grid-menu entry renders its representative Lucide icon + safe label;
|
||||||
* - the grid menu closes on Escape / outside click / focus loss / scroll /
|
* - the grid menu closes on Escape / outside click / focus loss / scroll /
|
||||||
* resize, and offers arrow-key navigation with autofocus on open;
|
* resize, and offers arrow-key navigation with autofocus on open;
|
||||||
* - trackDismissable() gives Structure / Mise en forme / Export the same
|
* - trackDismissable() gives Structure / Mise en forme / Export the same
|
||||||
* dismissal contract (toggle exempt, custom onClose);
|
* 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
|
* Usage: node tests/frontend/xlsx-menus.test.mjs
|
||||||
*/
|
*/
|
||||||
@@ -43,6 +48,18 @@ globalThis.localStorage = w.localStorage;
|
|||||||
globalThis.sessionStorage = w.sessionStorage;
|
globalThis.sessionStorage = w.sessionStorage;
|
||||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
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 ───────────────────────────────────────────────────────
|
// ── Module under test ───────────────────────────────────────────────────────
|
||||||
const ctxMenuUrl = pathToFileURL(
|
const ctxMenuUrl = pathToFileURL(
|
||||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
|
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
|
||||||
@@ -50,9 +67,17 @@ const ctxMenuUrl = pathToFileURL(
|
|||||||
const cmdBarUrl = pathToFileURL(
|
const cmdBarUrl = pathToFileURL(
|
||||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
|
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
|
||||||
).href;
|
).href;
|
||||||
|
const menusUrl = pathToFileURL(
|
||||||
|
path.join(REPO_ROOT, "frontend", "js", "xlsx", "menus.js")
|
||||||
|
).href;
|
||||||
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
|
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
|
||||||
await import(ctxMenuUrl);
|
await import(ctxMenuUrl);
|
||||||
const { buildCommandBar } = await import(cmdBarUrl);
|
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 ─────────────────────────────────────────────────────────────
|
// ── Mini runner ─────────────────────────────────────────────────────────────
|
||||||
let testCount = 0;
|
let testCount = 0;
|
||||||
@@ -163,13 +188,32 @@ await test("focus leaving the menu closes it, focus inside keeps it", () => {
|
|||||||
outside.remove();
|
outside.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test("scroll and resize close the menu", () => {
|
await test("double close never throws and a late click still runs its action", () => {
|
||||||
openContextMenu(10, 10, itemsOf());
|
// Regression: in a real browser, remove() fires focusout synchronously,
|
||||||
window.dispatchEvent(new w.Event("scroll"));
|
// which re-entered closeContextMenu — the second remove() threw
|
||||||
assert.ok(!isContextMenuOpen(), "scroll closes");
|
// 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());
|
openContextMenu(10, 10, itemsOf());
|
||||||
window.dispatchEvent(new w.Event("resize"));
|
window.dispatchEvent(new w.Event("resize"));
|
||||||
assert.ok(!isContextMenuOpen(), "resize closes");
|
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) ──────────────────────────
|
// ── trackDismissable (Structure / Format / Export) ──────────────────────────
|
||||||
@@ -226,26 +270,143 @@ await test("trackDismissable: Escape and focus loss dismiss", () => {
|
|||||||
toggle.remove();
|
toggle.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Ribbon icons ────────────────────────────────────────────────────────────
|
// ── Ribbon icons (#185: the File menu moved to the menu bar) ────────────────
|
||||||
await test("the ribbon carries save, tab-plus and export icons", () => {
|
await test("the ribbon carries save, tab-plus and gridline icons", () => {
|
||||||
const { actionsHtml, tabs } = buildCommandBar({
|
const { actionsHtml, tabs, fileHtml } = buildCommandBar({
|
||||||
sheets: [{ name: "F1" }],
|
sheets: [{ name: "F1" }],
|
||||||
isCsv: false,
|
isCsv: false,
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
editable: true,
|
editable: true,
|
||||||
});
|
});
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
host.innerHTML = tabs + actionsHtml;
|
host.innerHTML = tabs + actionsHtml + fileHtml;
|
||||||
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
|
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
|
||||||
assert.ok(saveIcon, "save button has an icon");
|
assert.ok(saveIcon, "save button has an icon");
|
||||||
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
|
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
|
||||||
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
|
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
|
||||||
assert.ok(plusIcon, "tab + button has an icon");
|
assert.ok(plusIcon, "tab + button has an icon");
|
||||||
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
|
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
|
||||||
const exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) =>
|
const gridIcon = host.querySelector("#xs-grid i[data-lucide]");
|
||||||
i.getAttribute("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`);
|
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
|
# tests/test_api_main.py — Integration tests for main.py API endpoints
|
||||||
# Uses the existing app_with_vault / client fixtures from conftest.py
|
# Uses the existing app_with_vault / client fixtures from conftest.py
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# Health & Metadata
|
# Health & Metadata
|
||||||
@@ -200,6 +203,45 @@ class TestFileCRUD:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is True
|
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):
|
def test_save_file(self, client):
|
||||||
"""Save (update) an existing file"""
|
"""Save (update) an existing file"""
|
||||||
resp = client.put("/api/file/TestVault/save?path=note1.md", json={
|
resp = client.put("/api/file/TestVault/save?path=note1.md", json={
|
||||||
@@ -803,6 +845,95 @@ class TestSecretRedactor:
|
|||||||
text = f"value {blob} end"
|
text = f"value {blob} end"
|
||||||
assert redact_file_content(text) == text
|
assert redact_file_content(text) == text
|
||||||
|
|
||||||
|
# ── Feature #188 : couverture universelle + clic → copie ──────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("secret", [
|
||||||
|
"sk-" + "a" * 30, # OpenAI / OpenRouter
|
||||||
|
"sk_live_" + "b" * 24, # Stripe
|
||||||
|
"whsec_" + "c" * 24, # Stripe / Svix
|
||||||
|
"glpat-" + "d" * 24, # GitLab
|
||||||
|
"github_pat_" + "e" * 30, # GitHub fine-grained
|
||||||
|
"npm_" + "f" * 36, # npm
|
||||||
|
"dckr_pat_" + "g" * 24, # Docker Hub
|
||||||
|
"hf_" + "h" * 32, # Hugging Face
|
||||||
|
"AIza" + "I" * 35, # Google API key
|
||||||
|
"ya29." + "J" * 25, # Google OAuth
|
||||||
|
"xoxb-" + "K" * 24, # Slack
|
||||||
|
"SG." + "L" * 24, # SendGrid
|
||||||
|
"re_" + "M" * 40, # Resend
|
||||||
|
"sq0atp-" + "N" * 24, # Square
|
||||||
|
"ATATT" + "O" * 24, # Atlassian
|
||||||
|
"AKIA" + "P" * 16, # AWS
|
||||||
|
"ASIA" + "Q" * 16, # AWS STS
|
||||||
|
"ghp_" + "R" * 36, # GitHub legacy
|
||||||
|
"123456789:AA" + "S" * 33, # Telegram bot (35 chars after ":")
|
||||||
|
"Bearer " + "T" * 32, # Authorization header
|
||||||
|
])
|
||||||
|
def test_universal_provider_keys_masked(self, secret):
|
||||||
|
"""#188 — the large majority of token formats end up masked."""
|
||||||
|
from backend.secret_redactor import redact_file_content
|
||||||
|
result = redact_file_content(f"here: {secret}")
|
||||||
|
assert secret not in result
|
||||||
|
assert "MASQUÉ" in result
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text", [
|
||||||
|
"password=hunter2",
|
||||||
|
"PASSWORD=short",
|
||||||
|
"db_password=abc12345",
|
||||||
|
'password: "quoted1"',
|
||||||
|
'{"passwd": "jsonpass"}',
|
||||||
|
"passphrase=longenough",
|
||||||
|
"mot de passe=chezmoi",
|
||||||
|
"user_pwd=s3cret",
|
||||||
|
"token=abcdef12", # 8+ char generic values are masked too
|
||||||
|
])
|
||||||
|
def test_passwords_masked_any_length(self, text):
|
||||||
|
"""#188 — passwords are masked whatever their length / quoting."""
|
||||||
|
from backend.secret_redactor import redact_file_content
|
||||||
|
result = redact_file_content(text)
|
||||||
|
assert "MASQUÉ" in result
|
||||||
|
# No raw value left behind
|
||||||
|
assert text.split("=", 1)[-1].split(":", 1)[-1].strip('"\' ') not in result
|
||||||
|
|
||||||
|
def test_pwd_env_var_not_masked(self):
|
||||||
|
"""``PWD=`` is a shell working directory, not a password."""
|
||||||
|
from backend.secret_redactor import redact_file_content
|
||||||
|
text = "PWD=/home/bruno/notes"
|
||||||
|
assert redact_file_content(text) == text
|
||||||
|
|
||||||
|
def test_placeholders_restore_clickable_mask(self):
|
||||||
|
"""#188 — placeholders become spans carrying the real value."""
|
||||||
|
from backend.secret_redactor import redact_with_placeholders, restore_masks
|
||||||
|
secret = "sk-" + "a" * 30
|
||||||
|
text, entries = redact_with_placeholders(f"key: {secret}")
|
||||||
|
assert secret not in text and entries
|
||||||
|
html = restore_masks(text, entries, click_to_copy=True)
|
||||||
|
assert f'data-secret="{secret}"' in html
|
||||||
|
assert 'class="secret-mask"' in html
|
||||||
|
# Plain restore never leaks the value
|
||||||
|
plain = restore_masks(text, entries)
|
||||||
|
assert secret not in plain and "data-secret" not in plain
|
||||||
|
|
||||||
|
def test_render_markdown_masks_clickable_in_app(self):
|
||||||
|
"""#188 — the authenticated preview serves clickable masks."""
|
||||||
|
from backend.render import _render_markdown
|
||||||
|
secret = "sk-" + "a" * 30
|
||||||
|
md = f"```bash\nexport KEY={secret}\n```\n"
|
||||||
|
html = _render_markdown(md, "test_vault", None, click_to_copy=True)
|
||||||
|
# The value only ever appears inside the data-secret attribute
|
||||||
|
assert 'class="secret-mask"' in html
|
||||||
|
assert f'data-secret="{secret}"' in html
|
||||||
|
assert f">{secret}" not in html # never as visible text
|
||||||
|
|
||||||
|
def test_render_markdown_plain_without_click(self):
|
||||||
|
"""Public shares / PDF exports get the plain label only."""
|
||||||
|
from backend.render import _render_markdown
|
||||||
|
secret = "sk-" + "a" * 30
|
||||||
|
html = _render_markdown(f"Cle: {secret}\n", "test_vault", None)
|
||||||
|
assert secret not in html
|
||||||
|
assert "[CLÉ API MASQUÉE]" in html
|
||||||
|
assert "data-secret" not in html
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# Static / PWA caching (Cloudflare / mobile freshness)
|
# Static / PWA caching (Cloudflare / mobile freshness)
|
||||||
|
|||||||
@@ -129,6 +129,36 @@ class TestRowColOps:
|
|||||||
assert wb["Data"]["B1"].value == "Valeur"
|
assert wb["Data"]["B1"].value == "Valeur"
|
||||||
wb.close()
|
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:
|
class TestGuards:
|
||||||
def test_unknown_sheet_is_400(self, client, book):
|
def test_unknown_sheet_is_400(self, client, book):
|
||||||
|
|||||||
@@ -250,6 +250,115 @@ class TestStyleWriting:
|
|||||||
assert backups, "the rewrite is backed up first"
|
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:
|
class TestEndpointWiring:
|
||||||
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
|
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
|
||||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
||||||
|
|||||||
Reference in New Issue
Block a user