Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd192df036 | ||
|
|
8662d23ec8 | ||
|
|
10453d8dfe | ||
|
|
8235d632b8 | ||
|
|
f4c8504c8d | ||
|
|
4fb7c43e06 | ||
|
|
54caa48d1f | ||
|
|
f00a8bea8f | ||
|
|
55a9fcc0f4 | ||
|
|
4cdea956d7 | ||
|
|
ae06436f91 | ||
|
|
d0e10d4cc6 | ||
|
|
c94f065f80 | ||
|
|
36b4962efb | ||
|
|
e01e837a2a | ||
|
|
634ba8a272 |
@@ -72,6 +72,7 @@ jobs:
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -93,6 +94,7 @@ jobs:
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,6 +44,7 @@ test_dir/video/
|
||||
test_vault/images/
|
||||
test_vault/markdown/
|
||||
test_vault/budget.xlsx
|
||||
test_home/
|
||||
|
||||
# Tauri updater signing keys (private key — never commit)
|
||||
desktop/*.key
|
||||
|
||||
+606
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.53.3**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.65.2**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,611 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.65.2] — 2026-10-10
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-111 — Admin : icône de fermeture + navigation du tableau de bord**
|
||||
- La modale « Administration — Utilisateurs » ouverte depuis la page `/admin` n'affichait pas l'icône du bouton fermer (lucide absent de `admin.html`) — même version du script UMD que `index.html` ajoutée.
|
||||
- Le tableau de bord admin affiche désormais **une seule section** à la fois (état initial : hash de l'URL ou « Statistiques temps réel ») au lieu des 4 sections empilées.
|
||||
|
||||
---
|
||||
|
||||
## [2.65.1] — 2026-10-10
|
||||
|
||||
---
|
||||
|
||||
## [2.65.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#194 (UI) — Dossier personnel visible dans l'arborescence**
|
||||
- Les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de
|
||||
la barre latérale (tri stable appliqué à la source, `frontend/js/sidebar.js`,
|
||||
donc aussi les sélecteurs de vault).
|
||||
- Icône **maison** lucide (`data-lucide="home"`, même style/`stroke:
|
||||
currentColor` que les autres icônes) via `getVaultIcon()` —
|
||||
`frontend/js/viewer.js`.
|
||||
- `docker-compose.test-win.yml` : montage `test_home:/vaults/Home` +
|
||||
`OBSIGATE_HOME_ROOT=/vaults/Home` pour tester la feature en local
|
||||
(dossier `test_home/` ignoré par git).
|
||||
- Tests : `sidebar-filters.test.mjs` +2 (icône maison pour `home-*`,
|
||||
gemme violette inchangée ailleurs).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#194 — Un dossier perso n'est plus visible par les autres comptes**
|
||||
- `check_vault_access` (`backend/auth/middleware.py`) refuse désormais tout
|
||||
vault `home-*` qui n'est pas **explicitement** octroyé au compte : le
|
||||
`vaults: ["*"]` de l'admin ne couvre plus les dossiers persos (avant,
|
||||
l'admin voyait `home-<user>` de chaque utilisateur dans sa barre latérale).
|
||||
- Les filtres `« * »` écrits en dur dans les routes ont été remplacés par
|
||||
cet appel unique — dashboard, conflits, liens retour, favoris,
|
||||
abonnements push : partout la même règle.
|
||||
- `/api/search` filtre les résultats par vault accessible **avant**
|
||||
pagination (`search_vaults(is_allowed=…)`), sinon les notes personnelles
|
||||
d'un autre pouvait apparaître dans les résultats.
|
||||
- `_grant` octroie désormais le home aussi aux comptes `« * »` : l'admin
|
||||
voit **son** `home-admin`, réparé au démarrage pour les comptes existants.
|
||||
- Tests : `test_user_home.py` +2 (`« * »` sans homes, filtrage de la
|
||||
recherche), assertion API inversée dans `test_auth_api.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.64.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#194 — Dossier personnel par utilisateur**
|
||||
- Chaque utilisateur créé reçoit `<OBSIGATE_HOME_ROOT>/<username>` (ex.
|
||||
`/NFS/OBSIDIAN_DOC/Home/<user>` côté hôte) monté comme un vault propre
|
||||
`home-<user>` : l'isolation utilise l'**ACL par vault existante**
|
||||
(`check_vault_access`), sans nouvelle ACL par chemin.
|
||||
- `backend/user_home.py` : `ensure_user_home()` idempotent (mkdir + registre
|
||||
vault + watcher + octroi dans `user.vaults`), appelé à la création d'un
|
||||
compte (`POST /api/auth/admin/users`) **et** au démarrage
|
||||
(`ensure_all_user_homes()` : migration des users existants + réparation
|
||||
d'un dossier ou d'un registre perdu).
|
||||
- **Registre persistant des vaults dynamiques** `data/vaults.json`
|
||||
(`load_vault_config()` le fusionne avec les env `VAULT_N_*`) : auparavant,
|
||||
tout vault ajouté à la volée (`/api/vaults/add`) disparaissait au premier
|
||||
reindex complet et au redémarrage.
|
||||
- Suppression d'un compte → vault refermé (index/watcher/registre) mais
|
||||
**dossier conservé** (aucune perte de données).
|
||||
- Correctif : `reload_single_vault` remet la config du vault après
|
||||
`remove_vault_from_index` (sinon le vault disparaissait de
|
||||
`vault_config` jusqu'au rechargement complet).
|
||||
- Déploiement : montage unique `/NFS/OBSIDIAN_DOC/Home:/vaults/Home` +
|
||||
variable `OBSIGATE_HOME_ROOT` (fonctionnalité inactive sans cette
|
||||
variable — dev, tests, desktop inchangés).
|
||||
- Tests : `tests/test_user_home.py` (10) + 2 tests API dans
|
||||
`test_auth_api.py` (provisionnement, isolation, retrait).
|
||||
|
||||
---
|
||||
|
||||
## [2.63.0] — 2026-10-09
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#193 — Chat : un post s'affiche comme un document markdown**
|
||||
- Le rendu vient du **serveur** : `backend/file_chat.py` ajoute un champ
|
||||
`html` à chaque message en réutilisant le pipeline des documents
|
||||
(`backend/render.py::_render_markdown` → mistune + wikilinks + masquage des
|
||||
secrets + **sanitizer BUG-021**). Pas de second moteur markdown côté client,
|
||||
donc pas de nouvelle surface XSS.
|
||||
- Titres, listes, tableaux, citations, notes, listes de tâches et **blocs de
|
||||
code colorés** : le front injecte le HTML dans un `.md-content` puis passe
|
||||
`safeHighlight()` (highlight.js + alias de langages) sur chaque `pre code`,
|
||||
exactement comme la visionneuse de documents.
|
||||
- Le `html` est calculé **à la lecture** (et à l'ajout, pour l'écho SSE) :
|
||||
rien n'est persisté dans `data/chats/*.json`, un message reste du texte.
|
||||
- Repli conservé : sans `html`, le texte brut + liens cliquables s'affichent
|
||||
comme avant ; un échec de rendu n'emporte jamais le message.
|
||||
- CSS : `.file-chat-md` annule le `pre-wrap` de la bulle (sinon les retours à
|
||||
la ligne du HTML source devenaient des lignes vides) et compacte
|
||||
`.md-content` (tailles, `pre`/`table` défilants) pour une bulle de chat.
|
||||
- Tests : pytest `TestMarkdownRendering` +7 (54 → **61** dans
|
||||
`test_file_chat.py`), JSDOM `filechat.test.mjs` +2 (25 → **27**).
|
||||
|
||||
---
|
||||
|
||||
## [2.62.0] — 2026-10-09
|
||||
|
||||
### Modifié
|
||||
|
||||
- **BUG-110 — Chat : refonte visuelle des posts et des indicateurs**
|
||||
- **Pièces jointes image** : boutons **icônes seules** (24 px, `aria-label`)
|
||||
rangés **sous l'image**, alignés à droite — télécharger, ouvrir.
|
||||
- **Clic sur une image = onglet de l'app** : le clic (ou le bouton « ouvrir »)
|
||||
affiche la pièce jointe dans un **onglet**, avec le même visualiseur que les
|
||||
images de vault (zoom/pan/plein écran/lightbox) — plus d'ouverture dans un
|
||||
onglet du navigateur. `TabManager.openChatImage()` + branche `chatImage` de
|
||||
`activate()` ; `renderImageViewer()` accepte une `url` directe (une pièce
|
||||
jointe de chat ne vit pas dans un vault).
|
||||
- **Indicateur de non-lus** : point de couleur pulsant sur l'icône « Chat » de
|
||||
la sidebar tant qu'il reste des messages non lus, en plus du badge chiffré.
|
||||
- **Notification OS** : quand l'app n'est pas au premier plan (`document.hidden`
|
||||
ou hors focus), un message entrant déclenche une notification navigateur
|
||||
(titre « Nouveau message de {user} », aperçu du texte, clic → retour sur
|
||||
l'app). Permission demandée au premier clic d'ouverture du chat.
|
||||
- **Suppression réellement annulable** : le `DELETE` est **différé de 10 s** ;
|
||||
la ligne passe à « Ce message a été supprimé » avec un bouton « Annuler » qui
|
||||
réarme le message, et un échec réseau le rend. (L'ancien « Annuler »
|
||||
restaurait une copie DOM d'un message déjà détruit côté serveur.)
|
||||
- i18n FR/EN : `chat.deleted_message`, `chat.undo`, `chat.download_image`,
|
||||
`chat.view_image` — clés mortes retirées (`chat.undo_success`,
|
||||
`chat.undo_error`, `chat.new_messages_indicator`).
|
||||
- Tests : `tests/frontend/filechat.test.mjs` 19 → **25** (6 tests #110).
|
||||
|
||||
---
|
||||
|
||||
## [2.61.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#192 - Chat : boîte de saisie auto-agrandissante + accusé de réception**
|
||||
- **Saisie auto-agrandissante** : la boîte d'envoi (panneau document +
|
||||
sidebar) est un `textarea` dont la hauteur suit le contenu (plafond
|
||||
160 px puis défilement) — on relit son message entier avant l'envoi.
|
||||
`Entrée` envoie, `Maj+Entrée` saute une ligne, la hauteur repasse à une
|
||||
ligne après l'envoi.
|
||||
- **Accusé de réception** : chaque conversation stocke
|
||||
`{utilisateur: dernier_ts_de_lecture}` ; l'expéditeur voit **✓ envoyé**
|
||||
puis **✓✓ lu** sur ses propres messages (un message est lu dès qu'un
|
||||
*autre* participant ouvre la conversation). Le `GET` d'historique renvoie
|
||||
la carte de lecture, `POST /api/chat/read` la met à jour et la diffuse
|
||||
en SSE (`chat_read`) — les indicateurs basculent en direct.
|
||||
- **Notification** : quand un message passe à ✓✓, l'expéditeur reçoit le
|
||||
toast « {user} a lu votre message » (FR/EN) ; un message reçu pendant
|
||||
l'affichage du canal accuse réception aussitôt.
|
||||
- Verrou global sur le read-modify-write du store de chat (une lecture ne
|
||||
peut plus faire disparaître un message).
|
||||
- Tests : pytest `TestReadReceipts` (7) + JSDOM `filechat.test.mjs` +3
|
||||
(19).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-109 - Tuile de lien : l'image de la carte ne s'affichait jamais** —
|
||||
la CSP (`img-src 'self' data: blob:`) bloque toute `og:image` distante et
|
||||
les images relatives étaient résolues contre l'origine d'ObsiGate (404).
|
||||
La vignette est désormais téléversée à l'envoi dans `chat_uploads`
|
||||
(`/api/chat/attachment/<uuid>`, même origine, garde SSRF + plafond 2 Mo +
|
||||
allow-list d'extensions) ; échec du téléchargement → carte conservée sans
|
||||
vignette. Les cartes déjà publiées gardent leur ancien lien (elles
|
||||
étaient déjà muettes).
|
||||
|
||||
---
|
||||
|
||||
## [2.60.0] — 2026-10-08
|
||||
|
||||
---
|
||||
|
||||
## [2.59.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#191 - Chat : suppression de post, messages privés, boîte compacte, link preview**
|
||||
- **Suppression d'un post** : bouton 🗑 sur ses propres messages (et sur
|
||||
tout le monde pour un admin), confirmation, `DELETE /api/chat/{id}` et
|
||||
`/api/chat/dm/{user}/{id}`, retrait en direct chez tous les clients
|
||||
(SSE `chat_deleted`).
|
||||
- **Messages privés 2 utilisateurs** : rangée de canaux au-dessus du fil
|
||||
(« Général » + un bouton par utilisateur), conversations isolées
|
||||
(vault `__dm__`, chemin `alice|bob` trié), placeholder dédié,
|
||||
compteurs de non-lus par canal, `GET /api/chat/users` pour la liste
|
||||
des destinataires (sans hash/mot de passe, sans soi-même).
|
||||
- **Boîte d'édition compacte** : bouton d'envoi réduit à une icône
|
||||
(panneau document + sidebar), la saisie garde toute la largeur.
|
||||
- **Link preview** : métadonnées OpenGraph récupérées à l'envoi
|
||||
(titre, description, image, site), carte cliquable esthétique,
|
||||
garde SSRF réutilisée (host privé/loopback rejeté), cache borné
|
||||
200 entrées, échec silencieux (URL morte → le message passe sans carte).
|
||||
- Tests : pytest +13 (45 au total file_chat), JSDOM `filechat.test.mjs`
|
||||
+5 (16).
|
||||
|
||||
---
|
||||
|
||||
## [2.58.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#190 - Chat : suivi du document, chat général en onglet sidebar**
|
||||
- **Suivi du document** : le panneau du chat affiche le document ciblé
|
||||
(titre + chemin au survol) et **suit automatiquement** le document
|
||||
rendu — changer de fichier re-cible le panneau ouvert.
|
||||
- **Onglet « Chat »** (dernier, après Historique IA) dans la sidebar :
|
||||
conversation générale non liée à un fichier, **indicateur de messages
|
||||
non lus** (pastille rouge sur l'onglet), **pièces jointes image/vidéo**
|
||||
(upload avec allow-list d'extensions, 25 MB max, nom UUID), **URL
|
||||
cliquables** dans les messages, date/heure d'envoi.
|
||||
- **Ordre des onglets** : la barre de filtre passe **sous** les onglets
|
||||
et sert de **recherche dans le chat** quand l'onglet Chat est actif
|
||||
(texte + auteur).
|
||||
- Backend : `GET/POST /api/chat`, `POST /api/chat/upload`,
|
||||
`GET /api/chat/attachment/{name}` (même store #169 via les sentinelles
|
||||
`__global__/general`), broadcast SSE `chat_message` réutilisé.
|
||||
- Tests : pytest +12 (27 au total), JSDOM `filechat.test.mjs` +5 (11).
|
||||
|
||||
---
|
||||
|
||||
## [2.57.1] — 2026-10-08
|
||||
|
||||
---
|
||||
|
||||
## [2.57.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#169 - Chat intégré par fichier**
|
||||
- Bouton **💬 Chat** dans la barre d'actions du document : ouvre un panneau
|
||||
latéral avec l'historique de la discussion et la saisie d'un message.
|
||||
- Persistance par fichier (`data/chats/`, plafond 500 messages, texte
|
||||
tronqué à 4000 caractères) ; routes `GET/POST /api/file/{vault}/chat`
|
||||
protégées (auth + accès vault + contrôle de traversal).
|
||||
- Temps réel via le transport SSE existant (événement `chat_message`) :
|
||||
le panneau ouvert s'incrémente en direct, **toast de notification**
|
||||
« Nouveau message de … » si le panneau est fermé ou affiche un autre
|
||||
fichier (pas de doublon côté émetteur).
|
||||
- Mobile : panneau plein écran ; i18n FR/EN.
|
||||
- Tests : pytest `test_file_chat.py` (15) + JSDOM `filechat.test.mjs` (6),
|
||||
ajoutés au pipeline CI.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Corrigé
|
||||
|
||||
@@ -41,6 +41,7 @@ COPY VERSION ./VERSION
|
||||
# Using explicit UID/GID 1000 to match common host user and docker-compose settings
|
||||
RUN groupadd -g 1000 obsigate && useradd -u 1000 -g obsigate -d /app -s /sbin/nologin obsigate \
|
||||
&& mkdir -p /app/data \
|
||||
&& chmod 777 /app/data \
|
||||
&& chown -R obsigate:obsigate /app
|
||||
USER obsigate
|
||||
|
||||
|
||||
+6
-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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` 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
|
||||
- **🌓 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
|
||||
@@ -222,6 +222,7 @@ Les vaults sont configurées par paires `VAULT_N_NAME` / `VAULT_N_PATH` (N = 1,
|
||||
| `VAULT_1_PATH` | Chemin dans le conteneur | `/vaults/Obsidian-RECETTES` |
|
||||
| `VAULT_1_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `06_Boite_a_Outils/6.2_Attachments` |
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Scan d'images au démarrage (défaut : true) | `true` |
|
||||
| `OBSIGATE_HOME_ROOT` | Racine des dossiers personnels (un vault `home-<user>` par compte). Absente = fonctionnalité désactivée. | `/vaults/Home` |
|
||||
|
||||
**Règles de nommage :** lettres, chiffres et tirets uniquement ; pas d'espaces ; le nom doit correspondre au chemin dans le conteneur.
|
||||
|
||||
@@ -802,7 +803,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)
|
||||
- **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
|
||||
- **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)
|
||||
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
||||
- **Secrets dans `.env`** : jamais dans `docker-compose.yml`
|
||||
@@ -976,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.53.3).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.65.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.53.3 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.65.2 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` 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
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -238,6 +238,7 @@ Vaults are configured using pairs of `VAULT_N_NAME` / `VAULT_N_PATH` variables (
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Enable image scanning on startup (optional, default: true) | `true` |
|
||||
| `VAULT_2_NAME` | Display name of the vault | `IT` |
|
||||
| `VAULT_2_PATH` | Path inside the container | `/vaults/Obsidian_IT` |
|
||||
| `OBSIGATE_HOME_ROOT` | Root folder for per-user home directories (one `home-<user>` vault per account). Unset = feature disabled. | `/vaults/Home` |
|
||||
|
||||
**Naming rules:**
|
||||
- Use only letters, numbers, and hyphens
|
||||
@@ -964,7 +965,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)
|
||||
- **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
|
||||
- **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)
|
||||
- **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`
|
||||
@@ -1151,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.53.3).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.65.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.53.3 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.65.2 | Last updated: September 2026*
|
||||
|
||||
@@ -142,6 +142,11 @@ def require_admin(current_user=Depends(require_auth)):
|
||||
return current_user
|
||||
|
||||
|
||||
def is_home_vault(vault_name: str) -> bool:
|
||||
"""Un dossier personnel (#194) : vault « home-<user> »."""
|
||||
return vault_name.startswith("home-")
|
||||
|
||||
|
||||
def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
"""Check if a user has access to a specific vault.
|
||||
|
||||
@@ -149,8 +154,13 @@ def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
- vaults == ["*"] → full access (admin default)
|
||||
- vault_name in vaults → access granted
|
||||
- otherwise → denied
|
||||
|
||||
#194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon
|
||||
un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur.
|
||||
"""
|
||||
vaults = user.get("_token_vaults") or user.get("vaults", [])
|
||||
if is_home_vault(vault_name):
|
||||
return vault_name in vaults
|
||||
if "*" in vaults:
|
||||
return True
|
||||
return vault_name in vaults
|
||||
|
||||
+17
-2
@@ -946,9 +946,20 @@ async def create_user_endpoint(
|
||||
user = create_user(
|
||||
req.username, req.password, req.role, req.vaults, req.display_name
|
||||
)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
# #194 : dossier perso — best effort, réparé au démarrage si le disque
|
||||
# (NFS) est indisponible (ensure_user_home journalise l'erreur).
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
await ensure_user_home(req.username)
|
||||
# La réponse doit refléter l'octroi du vault perso (#194) : create_user a
|
||||
# renvoyé un instantané construit avant l'octroi.
|
||||
fresh = get_user(req.username)
|
||||
if fresh:
|
||||
user["vaults"] = fresh.get("vaults", [])
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/admin/users/{username}")
|
||||
@@ -975,9 +986,13 @@ async def delete_user_endpoint(
|
||||
raise HTTPException(400, "Impossible de supprimer son propre compte")
|
||||
try:
|
||||
delete_user(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
# #194 : fermer le vault du dossier perso (le dossier est conservé).
|
||||
from backend.user_home import release_user_home
|
||||
|
||||
await release_user_home(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
|
||||
|
||||
# ── API / MCP tokens (feature #107) ──────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# backend/file_chat.py — historique de discussion (#169, #190)
|
||||
"""Chat history persisted under ``data/chats/``.
|
||||
|
||||
One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so
|
||||
the filename never carries user-controlled path separators. Writes are
|
||||
atomic (tmp + move) and the message list is capped at
|
||||
:data:`MAX_MESSAGES` to bound growth.
|
||||
|
||||
#190 adds the **general chat**: the same store addressed with the reserved
|
||||
sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second
|
||||
implementation. Messages may carry an ``attachment`` (image/video/url)
|
||||
uploaded under ``data/chat_uploads/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.render import _render_markdown
|
||||
|
||||
logger = logging.getLogger("obsigate.file_chat")
|
||||
|
||||
CHAT_DIR = Path("data/chats")
|
||||
MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first)
|
||||
MAX_TEXT = 4000 # characters per message
|
||||
|
||||
# ponytail: global lock over read-modify-write — chat writes are HTTP-only and
|
||||
# serialized anyway, this just makes losing a message to a future thread (or a
|
||||
# watcher hook) impossible; per-file locks if it ever becomes contended.
|
||||
_LOCK = threading.RLock()
|
||||
|
||||
# #190 — general (non file-bound) conversation, stored like any other one.
|
||||
GLOBAL_VAULT = "__global__"
|
||||
GLOBAL_PATH = "general"
|
||||
|
||||
# #191 — private (2 users) conversations reuse the same store: the vault is
|
||||
# the reserved sentinel and the path is the sorted username pair, so the
|
||||
# storage key never depends on who asks.
|
||||
DM_VAULT = "__dm__"
|
||||
|
||||
# #190 — attachments (image/video) live outside the vaults.
|
||||
UPLOAD_DIR = Path("data/chat_uploads")
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment
|
||||
ALLOWED_ATTACH_EXT = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
|
||||
".mp4", ".webm", ".ogg", ".mov", ".m4v",
|
||||
}
|
||||
|
||||
# #191 — link preview fetch budget
|
||||
PREVIEW_TIMEOUT = 5.0 # seconds
|
||||
PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed
|
||||
PREVIEW_IMAGE_MAX = 2 * 1024 * 1024 # ponytail: 2 MB ceiling on a thumbnail
|
||||
|
||||
|
||||
def _chat_file(vault: str, path: str) -> Path:
|
||||
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
|
||||
CHAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32]
|
||||
return CHAT_DIR / f"{key}.json"
|
||||
|
||||
|
||||
def _read(vault: str, path: str) -> dict[str, Any]:
|
||||
"""Load the raw chat document (empty structure when missing/corrupt)."""
|
||||
file = _chat_file(vault, path)
|
||||
if not file.exists():
|
||||
return {"vault": vault, "path": path, "messages": []}
|
||||
try:
|
||||
doc = json.loads(file.read_text(encoding="utf-8"))
|
||||
if not isinstance(doc.get("messages"), list):
|
||||
raise TypeError("messages is not a list") # caught by the handler below
|
||||
return doc
|
||||
except Exception as e:
|
||||
logger.error("Failed to read chat for %s/%s: %s", vault, path, e)
|
||||
return {"vault": vault, "path": path, "messages": []}
|
||||
|
||||
|
||||
def _write(file: Path, doc: dict[str, Any]) -> None:
|
||||
"""Atomically persist *doc* (tmp file + rename)."""
|
||||
try:
|
||||
tmp = file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
shutil.move(str(tmp), str(file))
|
||||
except Exception as e:
|
||||
logger.error("Failed to write chat %s: %s", file.name, e)
|
||||
|
||||
|
||||
def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
|
||||
"""Return the chat history for *(vault, path)* (chronological).
|
||||
|
||||
Every message carries its rendered ``html`` (#193): same markdown pipeline
|
||||
as a document (mistune + sanitizer), computed on read so a template change
|
||||
applies to the whole history without rewriting the JSON store.
|
||||
"""
|
||||
return [_decorate(m, vault) for m in _read(vault, path).get("messages", [])]
|
||||
|
||||
|
||||
def _decorate(msg: dict[str, Any], vault: str) -> dict[str, Any]:
|
||||
"""Return a copy of *msg* with its sanitized markdown ``html`` (#193).
|
||||
|
||||
The stored message is left untouched (``html`` is never persisted). A
|
||||
rendering failure must never break the chat: the message goes out with an
|
||||
empty ``html`` and the client falls back to plain text.
|
||||
"""
|
||||
try:
|
||||
html = _render_markdown(msg.get("text", ""), vault)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning("chat markdown rendering failed: %s", e)
|
||||
html = ""
|
||||
return {**msg, "html": html}
|
||||
|
||||
|
||||
def add_message(
|
||||
vault: str,
|
||||
path: str,
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message and persist it. Returns the stored message.
|
||||
|
||||
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first).
|
||||
*attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url;
|
||||
*preview* (#191) is the OpenGraph card of the first URL in *text*.
|
||||
"""
|
||||
text = (text or "").strip()[:MAX_TEXT]
|
||||
msg: dict[str, Any] = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"user": user or "anonyme",
|
||||
"text": text,
|
||||
"ts": time.time(),
|
||||
}
|
||||
if attachment:
|
||||
msg["attachment"] = attachment
|
||||
if preview:
|
||||
msg["preview"] = preview
|
||||
return _append(vault, path, msg)
|
||||
|
||||
|
||||
def _append(
|
||||
vault: str,
|
||||
path: str,
|
||||
msg: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Cap, persist and return *msg* (shared by file, global and DM chats).
|
||||
|
||||
The read-modify-write of the whole document happens under ``_LOCK`` so a
|
||||
concurrent writer can never drop a message (same class of bug as
|
||||
BUG-029 on ``users.json``).
|
||||
"""
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
messages = list(doc.get("messages", []))
|
||||
messages.append(msg)
|
||||
if len(messages) > MAX_MESSAGES:
|
||||
messages = messages[-MAX_MESSAGES:]
|
||||
doc["messages"] = messages
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return _decorate(msg, vault) # #193 — le html part avec l'écho SSE
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
def get_read(vault: str, path: str) -> dict[str, float]:
|
||||
"""Return ``{username: last_read_ts}`` for a conversation (#192)."""
|
||||
return {str(u): float(ts) for u, ts in (_read(vault, path).get("read") or {}).items()}
|
||||
|
||||
|
||||
def mark_read(vault: str, path: str, user: str) -> dict[str, float]:
|
||||
"""Record that *user* has seen the conversation (#192).
|
||||
|
||||
Returns the whole read map so the caller can broadcast it on SSE: a
|
||||
sender learns their messages were received as soon as the recipient
|
||||
displays the conversation.
|
||||
"""
|
||||
if not user:
|
||||
return get_read(vault, path)
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
read = {str(u): float(ts) for u, ts in (doc.get("read") or {}).items()}
|
||||
read[user] = time.time()
|
||||
doc["read"] = read
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return read
|
||||
|
||||
|
||||
# --- #191 : messages privés (2 utilisateurs) -------------------------------
|
||||
|
||||
def dm_path(user_a: str, user_b: str) -> str:
|
||||
"""Storage path for the private conversation between two users.
|
||||
|
||||
The pair is sorted so both participants address the same document.
|
||||
"""
|
||||
return "|".join(sorted([user_a, user_b]))
|
||||
|
||||
|
||||
def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]:
|
||||
"""Return the private history between two users (chronological)."""
|
||||
return get_messages(DM_VAULT, dm_path(user_a, user_b))
|
||||
|
||||
|
||||
def add_dm_message(
|
||||
user_a: str,
|
||||
user_b: str,
|
||||
author: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a private message. Returns the stored message."""
|
||||
return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview)
|
||||
|
||||
|
||||
# --- #191 : suppression -----------------------------------------------------
|
||||
|
||||
def delete_message(vault: str, path: str, message_id: str) -> bool:
|
||||
"""Remove one message from a conversation. True when it existed."""
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
messages = list(doc.get("messages", []))
|
||||
kept = [m for m in messages if m.get("id") != message_id]
|
||||
if len(kept) == len(messages):
|
||||
return False
|
||||
doc["messages"] = kept
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return True
|
||||
|
||||
|
||||
# --- #190 : chat général (conversation centrale, hors fichier) -------------
|
||||
|
||||
def get_global_messages() -> list[dict[str, Any]]:
|
||||
"""Return the general-chat history (chronological)."""
|
||||
return get_messages(GLOBAL_VAULT, GLOBAL_PATH)
|
||||
|
||||
|
||||
def add_global_message(
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message to the general chat. Returns the stored message."""
|
||||
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview)
|
||||
|
||||
|
||||
def save_attachment(filename: str, data: bytes) -> dict[str, Any]:
|
||||
"""Persist an uploaded attachment under :data:`UPLOAD_DIR`.
|
||||
|
||||
Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID
|
||||
(never the client name), the extension must be in
|
||||
:data:`ALLOWED_ATTACH_EXT` and the size is capped at
|
||||
:data:`MAX_UPLOAD_BYTES`.
|
||||
|
||||
Raises:
|
||||
ValueError: extension refused, empty file or size exceeded.
|
||||
"""
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in ALLOWED_ATTACH_EXT:
|
||||
raise ValueError(f"extension refusée : {ext or '(aucune)'}")
|
||||
if not data:
|
||||
raise ValueError("fichier vide")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)")
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOAD_DIR / name).write_bytes(data)
|
||||
kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image"
|
||||
return {
|
||||
"name": name,
|
||||
"url": f"/api/chat/attachment/{name}",
|
||||
"mime": _MIME_BY_EXT.get(ext, "application/octet-stream"),
|
||||
"kind": kind,
|
||||
}
|
||||
|
||||
|
||||
# --- #191 : link preview ----------------------------------------------------
|
||||
|
||||
_URL_RE = re.compile(r"https?://[^\s<>\"']+")
|
||||
_PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {}
|
||||
PREVIEW_CACHE_MAX = 200
|
||||
|
||||
|
||||
def _og(content: str, prop: str) -> str:
|
||||
"""Extract one OpenGraph/``<title>`` value from an HTML head (regex)."""
|
||||
for pattern in (
|
||||
rf'<meta[^>]+(?:property|name)="{prop}"[^>]+content="([^"]*)"',
|
||||
rf'<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="{prop}"',
|
||||
):
|
||||
m = re.search(pattern, content, re.IGNORECASE)
|
||||
if m:
|
||||
return html.unescape(m.group(1)).strip()[:300]
|
||||
if prop == "og:title":
|
||||
m = re.search(r"<title[^>]*>([^<]*)</title>", content, re.IGNORECASE)
|
||||
if m:
|
||||
return html.unescape(m.group(1)).strip()[:300]
|
||||
return ""
|
||||
|
||||
|
||||
# BUG-109 — content-type → extension (the attachment allow-list decides).
|
||||
_IMG_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
def _proxy_image(img_url: str, page_url: str) -> str:
|
||||
"""Download *img_url* into ``chat_uploads`` and return a same-origin URL.
|
||||
|
||||
The response CSP is ``img-src 'self' data: blob:``: a remote ``og:image``
|
||||
would be blocked by the browser (BUG-109). Relative and protocol-relative
|
||||
values are resolved against *page_url* first. Raises ``ValueError`` /
|
||||
``SSRFError`` on any failure — the caller keeps the card and drops only
|
||||
the thumbnail.
|
||||
"""
|
||||
from backend.tools.web import USER_AGENT, _assert_public_http_url
|
||||
|
||||
full = urljoin(page_url, img_url)
|
||||
_assert_public_http_url(full)
|
||||
resp = httpx.get(
|
||||
full,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"HTTP {resp.status_code}")
|
||||
data = resp.content
|
||||
if not data or len(data) > PREVIEW_IMAGE_MAX:
|
||||
raise ValueError("image vide ou trop lourde")
|
||||
mime = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||
ext = _IMG_EXT_BY_MIME.get(mime) or Path(urlparse(full).path).suffix.lower()
|
||||
# save_attachment(): allow-list d'extensions + nom UUID (jamais le nom distant)
|
||||
return str(save_attachment(f"preview{ext}", data)["url"])
|
||||
|
||||
|
||||
def build_preview(text: str) -> dict[str, Any] | None:
|
||||
"""Fetch OpenGraph metadata for the first URL in *text* (#191).
|
||||
|
||||
SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a
|
||||
bounded dict. Returns ``{url, title, description, image, site}`` or
|
||||
``None`` when there is no URL / the fetch fails (never raises: a dead
|
||||
link must not block the message). ``image`` is a **same-origin**
|
||||
``/api/chat/attachment/...`` URL (BUG-109), empty when the thumbnail
|
||||
could not be fetched.
|
||||
"""
|
||||
m = _URL_RE.search(text or "")
|
||||
if not m:
|
||||
return None
|
||||
url = m.group(0).rstrip(".,;:!?)")
|
||||
if url in _PREVIEW_CACHE:
|
||||
cached = _PREVIEW_CACHE[url]
|
||||
return dict(cached) if cached else None
|
||||
try:
|
||||
from backend.tools.web import USER_AGENT, _assert_public_http_url
|
||||
|
||||
_assert_public_http_url(url)
|
||||
resp = httpx.get(
|
||||
url,
|
||||
headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"},
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"HTTP {resp.status_code}")
|
||||
body = resp.text[:PREVIEW_MAX_BYTES]
|
||||
# BUG-109 : vignette téléchargée côté serveur — une image distante
|
||||
# échouerait à la CSP. Échec isolé = carte sans vignette.
|
||||
image = _og(body, "og:image")
|
||||
try:
|
||||
image = _proxy_image(image, url) if image else ""
|
||||
except Exception as ie:
|
||||
logger.debug("preview image failed for %s: %s", url, ie)
|
||||
image = ""
|
||||
preview = {
|
||||
"url": url,
|
||||
"title": _og(body, "og:title") or _og(body, "og:site_name"),
|
||||
"description": _og(body, "og:description"),
|
||||
"image": image,
|
||||
"site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url),
|
||||
}
|
||||
if not preview["title"]:
|
||||
raise ValueError("pas de titre")
|
||||
except Exception as e:
|
||||
logger.debug("link preview failed for %s: %s", url, e)
|
||||
preview = None
|
||||
if len(_PREVIEW_CACHE) >= PREVIEW_CACHE_MAX:
|
||||
_PREVIEW_CACHE.pop(next(iter(_PREVIEW_CACHE))) # oldest first (dict order)
|
||||
_PREVIEW_CACHE[url] = preview
|
||||
return dict(preview) if preview else None
|
||||
|
||||
|
||||
def attachment_path(name: str) -> Path | None:
|
||||
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
|
||||
p = Path(name)
|
||||
if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT:
|
||||
return None
|
||||
file = UPLOAD_DIR / p.name
|
||||
return file if file.exists() else None
|
||||
|
||||
|
||||
# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few).
|
||||
_MIME_BY_EXT = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogg": "video/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/x-m4v",
|
||||
}
|
||||
+68
-1
@@ -37,6 +37,12 @@ _last_full_index_ts: str = ""
|
||||
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
|
||||
_on_index_change: Callable[..., None] | None = None
|
||||
|
||||
# Registre des vaults ajoutés à la volée (#194) : les env VAULT_N_*/DIR_N_*
|
||||
# ne couvrent que le déploiement, tout ce qui est créé à runtime
|
||||
# (/api/vaults/add, dossiers perso) vivrait uniquement en mémoire sinon et
|
||||
# disparaîtrait au prochain rebuild ou redémarrage.
|
||||
DYNAMIC_VAULTS_FILE = Path("data/vaults.json")
|
||||
|
||||
|
||||
def set_index_change_hook(hook):
|
||||
"""Register a callback for incremental inverted index updates.
|
||||
@@ -134,7 +140,64 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
}
|
||||
n += 1
|
||||
|
||||
return vaults
|
||||
# Registre dynamique (#194) : les vaults créés à runtime (dossiers
|
||||
# persos, /api/vaults/add) sont chargés en premier, les env gagnent en
|
||||
# cas de collision de nom (vérité du déploiement).
|
||||
merged = _load_dynamic_vaults()
|
||||
merged.update(vaults)
|
||||
return merged
|
||||
|
||||
|
||||
def _load_dynamic_vaults() -> dict[str, dict[str, Any]]:
|
||||
"""Read vaults registered at runtime from ``data/vaults.json`` (#194)."""
|
||||
if not DYNAMIC_VAULTS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
|
||||
vaults = data.get("vaults", {})
|
||||
return vaults if isinstance(vaults, dict) else {}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error(f"Failed to read {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_dynamic_vaults(vaults: dict[str, dict[str, Any]]) -> None:
|
||||
"""Atomic write of the dynamic vault registry (tmp + rename, as users.json)."""
|
||||
try:
|
||||
DYNAMIC_VAULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = DYNAMIC_VAULTS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"version": 1, "vaults": vaults}, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, DYNAMIC_VAULTS_FILE)
|
||||
except OSError as e:
|
||||
# Le registre est un filet : vault_config mémoire reste valable
|
||||
# jusqu'au prochain rebuild, qui se reparera du dossier manquant.
|
||||
logger.error(f"Failed to write {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
|
||||
|
||||
def persist_vault(vault_name: str) -> None:
|
||||
"""Persist *vault_name* so it survives rebuild/restart (#194).
|
||||
|
||||
Idempotent. Env-declared vaults are re-added by :func:`load_vault_config`
|
||||
anyway; persisting them too is harmless (single source after merge).
|
||||
"""
|
||||
cfg = vault_config.get(vault_name)
|
||||
if not cfg:
|
||||
return
|
||||
vaults = _load_dynamic_vaults()
|
||||
vaults[vault_name] = cfg
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
def unpersist_vault(vault_name: str) -> None:
|
||||
"""Drop *vault_name* from the dynamic registry (#194). No-op if absent."""
|
||||
vaults = _load_dynamic_vaults()
|
||||
if vault_name not in vaults:
|
||||
return
|
||||
vaults.pop(vault_name, None)
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
|
||||
@@ -867,6 +930,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
|
||||
# Remove old vault data from index structures
|
||||
await remove_vault_from_index(vault_name)
|
||||
# remove_vault_from_index a poppé la config : la remettre, sinon le vault
|
||||
# disparaît de vault_config jusqu'au prochain reload complet (#194 — les
|
||||
# vaults dynamiques n'y reviennent que par data/vaults.json).
|
||||
vault_config[vault_name] = config
|
||||
|
||||
# Re-add the vault with updated configuration
|
||||
import functools
|
||||
|
||||
@@ -364,6 +364,12 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
logger.info("File watcher disabled by configuration.")
|
||||
|
||||
# #194 : dossier personnel par utilisateur — réparation/migration
|
||||
# (mkdir manquant, registre perdu, user créé hors API). Après le
|
||||
# watcher pour que les vaults ajoutés soient surveillés.
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
await ensure_all_user_homes()
|
||||
|
||||
logger.info("Background startup complete.")
|
||||
|
||||
asyncio.create_task(_background_startup())
|
||||
@@ -509,6 +515,7 @@ from backend.routers.config import _load_config
|
||||
from backend.routers.config import router as config_router
|
||||
from backend.routers.conflicts import router as conflicts_router
|
||||
from backend.routers.duplicates import router as duplicates_router
|
||||
from backend.routers.file_chat import router as file_chat_router
|
||||
from backend.routers.files_media import router as files_media_router
|
||||
from backend.routers.files_read import router as files_read_router
|
||||
from backend.routers.files_write import router as files_write_router
|
||||
@@ -534,6 +541,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
||||
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
|
||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
||||
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.middleware import require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
|
||||
logger = logging.getLogger("obsigate.push")
|
||||
|
||||
@@ -167,9 +167,9 @@ async def subscribe_push(
|
||||
"""Subscribe to push notifications for a vault."""
|
||||
username = current_user.get("username", "unknown")
|
||||
|
||||
# Check if user has access to this vault
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
if "*" not in user_vaults and request.vault not in user_vaults:
|
||||
# Check if user has access to this vault (#194 : "*" n'inclut pas les
|
||||
# dossiers persos, check_vault_access est la source unique).
|
||||
if not check_vault_access(request.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="No access to this vault")
|
||||
|
||||
# Check if subscription already exists
|
||||
|
||||
+23
-4
@@ -21,7 +21,7 @@ import mistune
|
||||
|
||||
from backend.image_processor import preprocess_images
|
||||
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
|
||||
|
||||
|
||||
@@ -166,7 +166,13 @@ def _normalize_line_breaks(text: str) -> str:
|
||||
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.
|
||||
|
||||
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).
|
||||
vault_name: Current vault for wikilink resolution context.
|
||||
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:
|
||||
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
|
||||
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
||||
|
||||
# Redact secrets before rendering (P0 security)
|
||||
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
|
||||
# Redact secrets before rendering (P0 security). Placeholders survive
|
||||
# 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
|
||||
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).
|
||||
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
|
||||
rendered = _add_heading_ids(rendered)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import index
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
from backend.schemas import (
|
||||
@@ -495,14 +495,13 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||
async def api_dashboard(current_user=Depends(require_auth)):
|
||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
vault_stats = []
|
||||
total_files = 0
|
||||
total_tags = set()
|
||||
total_size = 0
|
||||
total_images = 0
|
||||
for vname, vdata in index.items():
|
||||
if "*" not in user_vaults and vname not in user_vaults:
|
||||
if not check_vault_access(vname, current_user):
|
||||
continue
|
||||
files = vdata.get("files", [])
|
||||
fc = len(files)
|
||||
|
||||
@@ -31,10 +31,9 @@ router = APIRouter(tags=["conflicts"])
|
||||
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
||||
async def api_conflicts(current_user=Depends(require_auth)):
|
||||
"""List sync-conflict files across accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
all_conflicts = get_conflicts()
|
||||
if "*" not in user_vaults:
|
||||
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
|
||||
# #194 : filtrage via check_vault_access ("*" n'inclut pas les homes).
|
||||
all_conflicts = [c for c in all_conflicts if check_vault_access(c["vault"], current_user)]
|
||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# backend/routers/file_chat.py — chat (#169, #190)
|
||||
"""Chat endpoints: history read + message post with SSE fan-out.
|
||||
|
||||
- ``GET/POST /api/file/{vault_name}/chat`` — per-file chat: auth + vault
|
||||
access + path traversal check (``resolve_safe_path`` raises
|
||||
``ServiceError`` mapped by the app-level handler).
|
||||
- ``GET/POST /api/chat`` — the **general chat** (#190), not bound to a file.
|
||||
- ``POST /api/chat/upload`` / ``GET /api/chat/attachment/{name}`` (#190):
|
||||
image/video attachments (extension allow-list, size cap, UUID name).
|
||||
|
||||
Every post is broadcast on the existing SSE channel (``chat_message``) so
|
||||
all connected clients update live without a second WebSocket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend import file_chat as _store
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.auth.user_store import get_all_users, get_user
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
|
||||
router = APIRouter() # tags dérivés de `tag_for_path` → « Files »
|
||||
|
||||
|
||||
def _check(vault_name: str, path: str, current_user: dict[str, Any]) -> None:
|
||||
"""Authz + traversal guard shared by both verbs."""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
resolve_safe_path(Path(vault_data["path"]), path) # ServiceError → 403/500
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/chat", response_model=ChatHistoryResponse)
|
||||
async def api_file_chat_history(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Return the chat history for a file (chronological)."""
|
||||
_check(vault_name, path, current_user)
|
||||
return {"messages": _store.get_messages(vault_name, path), "read": _store.get_read(vault_name, path)}
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
|
||||
async def api_file_chat_post(
|
||||
vault_name: str,
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post a chat message and broadcast it on SSE (``chat_message``)."""
|
||||
path = str(body.get("path") or "")
|
||||
text = str(body.get("text") or "")
|
||||
if not path:
|
||||
raise HTTPException(400, "path is required")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
_check(vault_name, path, current_user)
|
||||
msg = _store.add_message(vault_name, path, current_user.get("username", ""), text)
|
||||
await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg})
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
# --- #190 : chat général ----------------------------------------------------
|
||||
|
||||
def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate the optional ``attachment`` object sent by the client."""
|
||||
raw = body.get("attachment")
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
name = str(raw.get("name") or "")
|
||||
# Only an already-uploaded file (or an http(s) URL) may travel along.
|
||||
if not _store.attachment_path(name) and not str(raw.get("url", "")).startswith(("http://", "https://", "/api/")):
|
||||
raise HTTPException(400, "attachment inconnu")
|
||||
return {
|
||||
"name": name,
|
||||
"url": str(raw.get("url") or ""),
|
||||
"mime": str(raw.get("mime") or ""),
|
||||
"kind": str(raw.get("kind") or "file"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/chat", response_model=ChatHistoryResponse)
|
||||
async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Return the general chat history (#190, chronological)."""
|
||||
return {
|
||||
"messages": _store.get_global_messages(),
|
||||
"read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH),
|
||||
}
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None:
|
||||
"""Authorization for marking a conversation read (#192).
|
||||
|
||||
General chat is open to any member, a DM only to the two participants,
|
||||
a file chat follows the vault ACL.
|
||||
"""
|
||||
if vault == _store.GLOBAL_VAULT:
|
||||
return
|
||||
if vault == _store.DM_VAULT:
|
||||
if current_user.get("username") not in str(path).split("|"):
|
||||
raise HTTPException(403, "Accès refusé à cette conversation privée")
|
||||
return
|
||||
_check(vault, path, current_user)
|
||||
|
||||
|
||||
@router.post("/api/chat/read", response_model=ChatReadResponse)
|
||||
async def api_chat_read(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Record that the caller has seen a conversation (#192).
|
||||
|
||||
Broadcast on SSE (``chat_read``) so the sender's own messages flip to
|
||||
✓✓ live on every connected client.
|
||||
"""
|
||||
vault = str(body.get("vault") or "")
|
||||
path = str(body.get("path") or "")
|
||||
if not vault or not path:
|
||||
raise HTTPException(400, "vault and path are required")
|
||||
_check_read(vault, path, current_user)
|
||||
username = current_user.get("username", "")
|
||||
read = _store.mark_read(vault, path, username)
|
||||
await sse_manager.broadcast(
|
||||
"chat_read",
|
||||
{"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read},
|
||||
)
|
||||
return {"read": read, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat", response_model=ChatMessageResponse)
|
||||
async def api_chat_post(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post to the general chat and broadcast it on SSE (``chat_message``)."""
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
# #191 — best-effort link preview: a dead/slow URL never blocks the post.
|
||||
msg = _store.add_global_message(
|
||||
current_user.get("username", ""), text, _attachment(body),
|
||||
_store.build_preview(text),
|
||||
)
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat/upload")
|
||||
async def api_chat_upload(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Store an image/video attachment (#190). Returns ``{attachment}``."""
|
||||
data = await file.read()
|
||||
try:
|
||||
info = _store.save_attachment(file.filename or "", data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"attachment": info}
|
||||
|
||||
|
||||
@router.get("/api/chat/attachment/{name}")
|
||||
async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Serve an uploaded attachment (name validated against the allow-list)."""
|
||||
path = _store.attachment_path(name)
|
||||
if not path:
|
||||
raise HTTPException(404, "Attachment not found")
|
||||
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
|
||||
return FileResponse(str(path), media_type=info)
|
||||
|
||||
|
||||
# --- #191 : suppression + messages privés -----------------------------------
|
||||
|
||||
def _owner_or_admin(msg_user: str, current_user: dict[str, Any]) -> None:
|
||||
"""A post may be deleted by its author or by an admin."""
|
||||
if current_user.get("role") != "admin" and current_user.get("username") != msg_user:
|
||||
raise HTTPException(403, "Seul l'auteur ou un administrateur peut supprimer ce message")
|
||||
|
||||
|
||||
def _find_and_authorize(vault: str, path: str, message_id: str, current_user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Locate *message_id* in the conversation and check the delete right."""
|
||||
for m in _store.get_messages(vault, path):
|
||||
if m.get("id") == message_id:
|
||||
_owner_or_admin(m.get("user", ""), current_user)
|
||||
return m
|
||||
raise HTTPException(404, "Message not found")
|
||||
|
||||
|
||||
@router.delete("/api/chat/{message_id}", response_model=StatusResponse)
|
||||
async def api_chat_delete(message_id: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Delete a message from the general chat (author or admin, #191)."""
|
||||
_find_and_authorize(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id, current_user)
|
||||
if not _store.delete_message(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id):
|
||||
raise HTTPException(404, "Message not found")
|
||||
await sse_manager.broadcast(
|
||||
"chat_deleted",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "id": message_id},
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/api/chat/users", response_model=list[dict[str, Any]])
|
||||
async def api_chat_users(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Usernames available for a private conversation (chat DM picker, #191).
|
||||
|
||||
Every authenticated member may see who else is around — this is a
|
||||
self-hosted portal, not a directory that needs hiding.
|
||||
"""
|
||||
me = current_user.get("username", "")
|
||||
return [
|
||||
{"username": u.get("username", ""), "display_name": u.get("display_name") or u.get("username", "")}
|
||||
for u in get_all_users()
|
||||
if u.get("username") and u.get("username") != me
|
||||
]
|
||||
|
||||
|
||||
def _dm_peer(username: str, current_user: dict[str, Any]) -> str:
|
||||
"""Validate the DM peer exists and is not ourselves."""
|
||||
if not username or username == current_user.get("username"):
|
||||
raise HTTPException(400, "Destinataire invalide")
|
||||
if not get_user(username):
|
||||
raise HTTPException(404, "Utilisateur inconnu")
|
||||
return username
|
||||
|
||||
|
||||
@router.get("/api/chat/dm/{username}", response_model=ChatHistoryResponse)
|
||||
async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Private history with *username* (#191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
return {
|
||||
"messages": _store.get_dm_messages(current_user["username"], peer),
|
||||
"read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse)
|
||||
async def api_chat_dm_post(
|
||||
username: str,
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post a private message and broadcast it to both participants (#191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
msg = _store.add_dm_message(
|
||||
current_user["username"], peer, current_user.get("username", ""), text,
|
||||
_attachment(body), _store.build_preview(text),
|
||||
)
|
||||
# Same shape as the general chat so the client routes on vault/path.
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.DM_VAULT, "path": _store.dm_path(current_user["username"], peer), "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/chat/dm/{username}/{message_id}", response_model=StatusResponse)
|
||||
async def api_chat_dm_delete(
|
||||
username: str,
|
||||
message_id: str,
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Delete one private message (author or admin, #191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
vault, path = _store.DM_VAULT, _store.dm_path(current_user["username"], peer)
|
||||
_find_and_authorize(vault, path, message_id, current_user)
|
||||
if not _store.delete_message(vault, path, message_id):
|
||||
raise HTTPException(404, "Message not found")
|
||||
await sse_manager.broadcast(
|
||||
"chat_deleted", {"vault": vault, "path": path, "id": message_id}
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
@@ -166,12 +166,11 @@ async def api_file_backlinks(
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
backlinks = get_backlinks(vault_name, path)
|
||||
|
||||
# Filter by user-accessible vaults
|
||||
if "*" not in user_vaults:
|
||||
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
|
||||
# Filter by user-accessible vaults (#194 : check_vault_access, "*" sans
|
||||
# les dossiers persos).
|
||||
backlinks = [b for b in backlinks if check_vault_access(b["vault"], current_user)]
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
@@ -648,7 +647,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
tags = _extract_tags(post)
|
||||
|
||||
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 {
|
||||
"vault": vault_name,
|
||||
|
||||
@@ -323,9 +323,13 @@ def api_file_xlsx_style(
|
||||
|
||||
``ops`` is an ordered list applied in one locked, atomic rewrite:
|
||||
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
|
||||
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
|
||||
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
|
||||
``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||
``font_color``/``fill_color`` as ``#rrggbb``, ``align``
|
||||
(left/center/right/justify), ``valign`` (top/middle/bottom), ``wrap``,
|
||||
``rotation`` (-90..90), ``border`` (all/outer/none) + ``border_style`` /
|
||||
``border_color`` and ``number_format``), ``merge``/``unmerge`` (``range``),
|
||||
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``),
|
||||
``comment_set`` (``ref``, ``text``) / ``comment_clear`` (``ref``)
|
||||
and ``freeze`` (``cell``, empty to release).
|
||||
|
||||
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
|
||||
@@ -548,6 +552,10 @@ async def api_file_create(
|
||||
|
||||
Returns:
|
||||
FileCreateResponse confirming creation.
|
||||
|
||||
Note:
|
||||
A ``.xlsx`` path creates an empty workbook (one ``Feuille1`` sheet) built
|
||||
with openpyxl: the payload is binary, so ``content`` is ignored (#186).
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
@@ -59,7 +59,6 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
|
||||
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
if not username:
|
||||
return {"files": []}
|
||||
@@ -68,7 +67,8 @@ async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(re
|
||||
files_resp = []
|
||||
for item in history:
|
||||
v_name = item["vault"]
|
||||
if "*" not in user_vaults and v_name not in user_vaults:
|
||||
# #194 : check_vault_access — "*" n'inclut pas les dossiers persos.
|
||||
if not check_vault_access(v_name, current_user):
|
||||
continue
|
||||
|
||||
# Find in index to get metadata
|
||||
|
||||
@@ -82,7 +82,12 @@ async def api_search(
|
||||
# paginate in the shared service so routes and tools share the same logic.
|
||||
return await loop.run_in_executor(
|
||||
get_search_executor(),
|
||||
partial(search_vaults, q, vault, tag, limit, offset),
|
||||
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
||||
# dossiers persos) — sinon un user voyait les notes des autres.
|
||||
partial(
|
||||
search_vaults, q, vault, tag, limit, offset,
|
||||
is_allowed=lambda v: check_vault_access(v, current_user),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm
|
||||
|
||||
stats = await add_vault_to_index(name, vault_path)
|
||||
|
||||
# #194 : persister, sinon ce vault disparaît au prochain rebuild/redémarrage.
|
||||
from backend.indexer import persist_vault
|
||||
|
||||
persist_vault(name)
|
||||
|
||||
# Start watching the new vault
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
@@ -80,6 +85,10 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin))
|
||||
await watcher.remove_vault(vault_name)
|
||||
|
||||
await remove_vault_from_index(vault_name)
|
||||
# #194 : plus de trace au redémarrage (les vaults d'env, eux, reviennent).
|
||||
from backend.indexer import unpersist_vault
|
||||
|
||||
unpersist_vault(vault_name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
|
||||
return {"status": "ok", "vault": vault_name}
|
||||
|
||||
|
||||
@@ -151,6 +151,54 @@ class BacklinksResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class ChatMessageItem(BaseModel):
|
||||
"""One chat message (``GET/POST /api/file/{vault}/chat`` + ``/api/chat``)."""
|
||||
|
||||
id: str = Field(description="Message id")
|
||||
user: str = Field(description="Author username")
|
||||
text: str = Field(description="Message body")
|
||||
ts: float = Field(description="Unix timestamp")
|
||||
attachment: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Optional image/video/url attachment {name, url, mime, kind}",
|
||||
)
|
||||
preview: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Link preview card {url, title, description, image, site} (#191)",
|
||||
)
|
||||
html: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Rendered (mistune + sanitized) HTML of ``text``, like a document "
|
||||
"preview — tables, lists, fenced code blocks (#193)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatHistoryResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/chat``."""
|
||||
|
||||
messages: list[ChatMessageItem] = Field(description="Messages, chronological")
|
||||
read: dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="{username: last read unix ts} per participant (#192)",
|
||||
)
|
||||
|
||||
|
||||
class ChatReadResponse(BaseModel):
|
||||
"""Response for ``POST /api/chat/read`` (#192 read receipts)."""
|
||||
|
||||
read: dict[str, float] = Field(description="{username: last read unix ts}")
|
||||
status: str = Field(description="'ok'")
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
"""Response for ``POST /api/file/{vault}/chat``."""
|
||||
|
||||
message: ChatMessageItem
|
||||
status: str = Field(description="'ok'")
|
||||
|
||||
|
||||
class BackupsListResponse(BaseModel):
|
||||
"""Response for ``GET /api/backups``."""
|
||||
|
||||
|
||||
+191
-38
@@ -1,48 +1,104 @@
|
||||
"""
|
||||
Secret redactor: masks sensitive patterns in rendered text.
|
||||
|
||||
Scans for common secret patterns and replaces them with [MASQUÉ]
|
||||
before content is served to the frontend. Prevents accidental
|
||||
exposure of API keys, tokens, and passwords in previews.
|
||||
Scans for common secret patterns and replaces them with a French mask
|
||||
label (``[CLÉ API MASQUÉE]``, ``[MOT DE PASSE MASQUÉ]``, …) before content
|
||||
is served to the frontend. Prevents accidental exposure of API keys,
|
||||
tokens, and passwords in previews.
|
||||
|
||||
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)
|
||||
- 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-----)
|
||||
- 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 re
|
||||
|
||||
logger = logging.getLogger("obsigate.redactor")
|
||||
|
||||
# --- Patterns ---
|
||||
# Order matters: more specific patterns first
|
||||
_PATTERNS = [
|
||||
# Each entry is ``(pattern, replacement, secret_group)``:
|
||||
# * ``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
|
||||
(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)
|
||||
(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
|
||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'),
|
||||
|
||||
# Generic API key patterns: key=... or token=... or secret=...
|
||||
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
|
||||
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
|
||||
|
||||
# Prefixed API keys (sk-..., pk-..., rk-...)
|
||||
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
|
||||
|
||||
# AWS access keys
|
||||
(re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'),
|
||||
|
||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]', 0),
|
||||
|
||||
# Passwords — any length, bare or quoted (``password=…``,
|
||||
# ``"passwd": "…"``). The left side may carry a qualifier
|
||||
# (``db_password``, ``DATABASE.PASSWORD``, ``user_pwd``); a *bare*
|
||||
# ``PWD=`` (shell working directory) must NOT match, hence ``pwd``
|
||||
# only in its qualified branch.
|
||||
(re.compile(
|
||||
r'(?i)((?:[A-Za-z0-9_.-]*(?:password|passwd|passphrase|mot\s+de\s+passe)'
|
||||
r'|[A-Za-z0-9_.-]+pwd)["\']?\s*[:=]\s*["\']?)([^\s"\',;]{4,})'),
|
||||
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_)
|
||||
(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,
|
||||
@@ -61,14 +117,23 @@ _HASH_CONTEXT_RE = re.compile(
|
||||
#: How far before the hex string a keyword may appear to count as context.
|
||||
_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.
|
||||
|
||||
Git/SHA/checksum contexts are left untouched (BUG-035).
|
||||
|
||||
Args:
|
||||
text: Text to scan.
|
||||
mask: ``mask(original, label) -> str`` replacement builder.
|
||||
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
@@ -82,12 +147,38 @@ def _redact_bare_hex_secrets(text: str) -> tuple:
|
||||
return match.group(0)
|
||||
if _SECRET_CONTEXT_RE.search(window):
|
||||
count += 1
|
||||
return '[HEX_KEY MASQUÉ]'
|
||||
return mask(match.group(0), '[HEX_KEY MASQUÉ]')
|
||||
return match.group(0)
|
||||
|
||||
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:
|
||||
"""Redact sensitive patterns from text.
|
||||
|
||||
@@ -97,22 +188,84 @@ def redact(text: str) -> tuple:
|
||||
Returns:
|
||||
(redacted_text, redaction_count) tuple.
|
||||
"""
|
||||
count = 0
|
||||
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
|
||||
result, count = _redact(text, _plain_mask)
|
||||
if count > 0:
|
||||
logger.info(f"Redacted {count} secret(s) from content")
|
||||
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:
|
||||
"""Redact a file's content for preview rendering.
|
||||
|
||||
|
||||
+220
-15
@@ -41,6 +41,28 @@ _EXCALIDRAW_SKELETON = (
|
||||
'"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
|
||||
)
|
||||
|
||||
# Title of the single sheet of a workbook created from scratch (#186).
|
||||
_XLSX_DEFAULT_SHEET = "Feuille1"
|
||||
|
||||
|
||||
def _empty_xlsx_bytes() -> bytes:
|
||||
"""Return the bytes of a valid, empty single-sheet workbook.
|
||||
|
||||
An ``.xlsx`` is a ZIP archive (OPC), not a text file: writing ``""`` to it
|
||||
produces a file no reader (and no spreadsheet application) can open. The
|
||||
"Create a file" modal posts an empty text payload, so the archive itself is
|
||||
built here with openpyxl.
|
||||
"""
|
||||
import io
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
wb = Workbook()
|
||||
wb.active.title = _XLSX_DEFAULT_SHEET
|
||||
buffer = io.BytesIO()
|
||||
wb.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _rel(root: Path, path: Path) -> str:
|
||||
"""Return *path* relative to *root* as a POSIX-style string."""
|
||||
@@ -98,13 +120,14 @@ def create_file(
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a text file in a vault.
|
||||
"""Create a file in a vault.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
path: Vault-relative path of the new file.
|
||||
content: Initial content (an Excalidraw skeleton is injected for empty
|
||||
``.excalidraw`` files).
|
||||
``.excalidraw`` files). ``.xlsx`` is binary: the workbook is always
|
||||
built from scratch and *content* is ignored for that extension.
|
||||
overwrite: When True, replace an existing file (with a backup) instead
|
||||
of raising ``already_exists``.
|
||||
|
||||
@@ -130,17 +153,25 @@ def create_file(
|
||||
)
|
||||
create_backup(file_path, vault_name, _rel(root, file_path))
|
||||
|
||||
suffix = file_path.suffix.lower()
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if file_path.suffix.lower() == ".excalidraw" and not content.strip():
|
||||
content = _EXCALIDRAW_SKELETON
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
if suffix == ".xlsx":
|
||||
# Binary OPC archive: a text write would leave an unopenable file
|
||||
# (#186). Written as bytes, so the size below is the byte count.
|
||||
payload = _empty_xlsx_bytes()
|
||||
file_path.write_bytes(payload)
|
||||
else:
|
||||
if suffix == ".excalidraw" and not content.strip():
|
||||
content = _EXCALIDRAW_SKELETON
|
||||
payload = content.encode("utf-8")
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
except PermissionError as e:
|
||||
raise ServiceError("Permission denied: cannot create file", code="permission_denied", status=403) from e
|
||||
|
||||
rel_path = _rel(root, file_path)
|
||||
logger.info(f"File created: {vault_name}/{rel_path}")
|
||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
|
||||
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(payload)}
|
||||
|
||||
|
||||
def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> dict[str, Any]:
|
||||
@@ -673,11 +704,42 @@ def mutate_xlsx_structure(
|
||||
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||
)
|
||||
if op == "row_insert":
|
||||
ws.insert_rows(at, count)
|
||||
# Le modèle sparse d'openpyxl ignore les inserts
|
||||
# au-delà de la dernière ligne utilisée (rien à
|
||||
# décaler), et l'écrivain abandonne les cellules
|
||||
# vides (None) au cycle suivant : on matérialise
|
||||
# avec " " (un blanc qui persiste, relu tel quel,
|
||||
# affiché vide, ignoré par la recherche) pour
|
||||
# qu'une insertion « sous la dernière ligne »
|
||||
# crée bien des lignes blanches, comme Excel.
|
||||
if at > ws.max_row:
|
||||
if at - ws.max_row > _INSERT_GAP_LIMIT:
|
||||
raise ServiceError(
|
||||
"Position trop éloignée de la fin de la feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
for r in range(at, at + count):
|
||||
cell = ws.cell(row=r, column=1)
|
||||
if cell.value is None:
|
||||
cell.value = " "
|
||||
else:
|
||||
ws.insert_rows(at, count)
|
||||
elif op == "row_delete":
|
||||
ws.delete_rows(at, count)
|
||||
elif op == "col_insert":
|
||||
ws.insert_cols(at, count)
|
||||
# Idem côté colonnes : « à droite de la dernière ».
|
||||
if at > ws.max_column:
|
||||
if at - ws.max_column > _INSERT_GAP_LIMIT:
|
||||
raise ServiceError(
|
||||
"Position trop éloignée de la fin de la feuille",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
for c in range(at, at + count):
|
||||
cell = ws.cell(row=1, column=c)
|
||||
if cell.value is None:
|
||||
cell.value = " "
|
||||
else:
|
||||
ws.insert_cols(at, count)
|
||||
else:
|
||||
ws.delete_cols(at, count)
|
||||
applied.append(f"{op}:{sheet}@{at}x{count}")
|
||||
@@ -725,16 +787,34 @@ def mutate_xlsx_structure(
|
||||
# #156-A8 — write-side formatting. Every value is data-driven (the colours
|
||||
# come from the caller, never from a hardcoded palette) and the whole batch
|
||||
# rides the same lock, backup and atomic swap as the cell edits.
|
||||
# Cap anti-DoS : une insertion au-delà de la fin matérialise des cellules,
|
||||
# jamais un gouffre de lignes/colonnes.
|
||||
_INSERT_GAP_LIMIT = 1000
|
||||
_STYLE_KEYS = {
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strike",
|
||||
"font_size",
|
||||
"font_color",
|
||||
"fill_color",
|
||||
"align",
|
||||
"valign",
|
||||
"wrap",
|
||||
"rotation",
|
||||
"border",
|
||||
"border_style",
|
||||
"border_color",
|
||||
"number_format",
|
||||
}
|
||||
_STYLE_ALIGNS = {"left", "center", "right"}
|
||||
_STYLE_ALIGNS = {"left", "center", "right", "justify"}
|
||||
_STYLE_VALIGNS = {"top": "top", "middle": "center", "bottom": "bottom"}
|
||||
_STYLE_BORDERS = {"all", "outer", "none"}
|
||||
_STYLE_BORDER_STYLES = {
|
||||
"thin", "medium", "thick", "dashed", "dotted", "double", "hair",
|
||||
"mediumDashed", "dashDot", "mediumDashDot", "dashDotDot",
|
||||
"mediumDashDotDot", "slantDashDot",
|
||||
}
|
||||
MAX_STYLE_CELLS = 10_000
|
||||
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-fA-F]{6}$")
|
||||
|
||||
@@ -760,14 +840,14 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
"""
|
||||
import copy as copy_mod
|
||||
|
||||
from openpyxl.styles import Alignment, Color, PatternFill
|
||||
from openpyxl.styles import Alignment, Border, Color, PatternFill, Side
|
||||
|
||||
unknown = set(style) - _STYLE_KEYS
|
||||
if unknown:
|
||||
raise ServiceError(
|
||||
f"Style inconnu: {sorted(unknown)}", code="invalid", status=400
|
||||
)
|
||||
if {"bold", "italic", "underline", "font_color"} & set(style):
|
||||
if {"bold", "italic", "underline", "strike", "font_size", "font_color"} & set(style):
|
||||
font = copy_mod.copy(cell.font)
|
||||
if "bold" in style:
|
||||
font.bold = bool(style["bold"])
|
||||
@@ -775,6 +855,22 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
font.italic = bool(style["italic"])
|
||||
if "underline" in style:
|
||||
font.underline = "single" if style["underline"] else None
|
||||
if "strike" in style:
|
||||
font.strike = bool(style["strike"])
|
||||
if "font_size" in style:
|
||||
try:
|
||||
size = float(style["font_size"])
|
||||
except (TypeError, ValueError):
|
||||
raise ServiceError(
|
||||
f"Taille de police invalide: {style['font_size']!r}",
|
||||
code="invalid", status=400,
|
||||
) from None
|
||||
if not 6 <= size <= 72:
|
||||
raise ServiceError(
|
||||
f"Taille de police hors bornes (6-72): {style['font_size']!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
font.size = size
|
||||
if "font_color" in style:
|
||||
rgb = _style_color(style["font_color"])
|
||||
font.color = Color(rgb=rgb) if rgb else None
|
||||
@@ -797,7 +893,69 @@ def _apply_cell_style(cell: Any, style: dict[str, Any]) -> None:
|
||||
horizontal=align,
|
||||
vertical=getattr(current, "vertical", None),
|
||||
wrap_text=getattr(current, "wrap_text", None),
|
||||
text_rotation=getattr(current, "text_rotation", None),
|
||||
)
|
||||
if "valign" in style or "wrap" in style or "rotation" in style:
|
||||
current = cell.alignment
|
||||
vertical = getattr(current, "vertical", None)
|
||||
wrap_text = getattr(current, "wrap_text", None)
|
||||
text_rotation = getattr(current, "text_rotation", None) or 0
|
||||
if "valign" in style:
|
||||
valign = style["valign"]
|
||||
if valign not in _STYLE_VALIGNS:
|
||||
raise ServiceError(
|
||||
f"Alignement vertical invalide: {valign!r}", code="invalid", status=400
|
||||
)
|
||||
vertical = _STYLE_VALIGNS[valign]
|
||||
if "wrap" in style:
|
||||
wrap_text = bool(style["wrap"])
|
||||
if "rotation" in style:
|
||||
try:
|
||||
rotation = int(style["rotation"])
|
||||
except (TypeError, ValueError):
|
||||
raise ServiceError(
|
||||
f"Rotation invalide: {style['rotation']!r}", code="invalid", status=400
|
||||
) from None
|
||||
if not (-90 <= rotation <= 90 or rotation == 180):
|
||||
raise ServiceError(
|
||||
f"Rotation hors bornes (-90..90, 180): {rotation!r}", code="invalid", status=400
|
||||
)
|
||||
# OOXML stocke les angles horaires sur 91..180 (-45° → 135).
|
||||
# 180 = texte vertical retourné (le lecteur le relit tel quel).
|
||||
text_rotation = rotation if rotation >= 0 else 180 + rotation
|
||||
cell.alignment = Alignment(
|
||||
horizontal=getattr(current, "horizontal", None),
|
||||
vertical=vertical,
|
||||
wrap_text=wrap_text,
|
||||
text_rotation=text_rotation,
|
||||
)
|
||||
if {"border", "border_style", "border_color"} & set(style):
|
||||
which = style.get("border", "all")
|
||||
if which not in _STYLE_BORDERS:
|
||||
raise ServiceError(
|
||||
f"Bordure invalide: {which!r}", code="invalid", status=400
|
||||
)
|
||||
bstyle = style.get("border_style", "thin")
|
||||
if bstyle not in _STYLE_BORDER_STYLES:
|
||||
raise ServiceError(
|
||||
f"Style de bordure invalide: {bstyle!r}", code="invalid", status=400
|
||||
)
|
||||
raw_color = style.get("border_color", "#000000")
|
||||
if raw_color in (None, ""):
|
||||
raw_color = "FF000000"
|
||||
rgb = _style_color(raw_color)
|
||||
if rgb is None:
|
||||
raise ServiceError(
|
||||
f"Couleur de bordure invalide: {style.get('border_color')!r}",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
if which == "none":
|
||||
cell.border = Border()
|
||||
else:
|
||||
# `all` et `outer` se rendent pareil cellule par cellule : les
|
||||
# bords partagés coïncident, le contour de la plage reste net.
|
||||
side = Side(style=bstyle, color=rgb)
|
||||
cell.border = Border(left=side, right=side, top=side, bottom=side)
|
||||
if "number_format" in style:
|
||||
fmt = style["number_format"] or "General"
|
||||
if not isinstance(fmt, str) or len(fmt) > 120:
|
||||
@@ -821,12 +979,17 @@ def mutate_xlsx_style(
|
||||
``ops`` is an ordered list (1 to 50) applied in one locked, atomic rewrite:
|
||||
|
||||
* ``{"op": "cell", "sheet": "X", "range": "A1:B2", "style": {…}}`` with
|
||||
``bold``, ``italic``, ``underline``, ``font_color`` / ``fill_color``
|
||||
(``#rrggbb``, ``""`` clears), ``align`` (left/center/right) and
|
||||
``bold``, ``italic``, ``underline``, ``strike``, ``font_size`` (6-72),
|
||||
``font_color`` / ``fill_color`` (``#rrggbb``, ``""`` clears),
|
||||
``align`` (left/center/right/justify), ``valign`` (top/middle/bottom),
|
||||
``wrap`` (bool), ``rotation`` (-90..90), ``border``
|
||||
(all/outer/none) + ``border_style`` / ``border_color`` and
|
||||
``number_format`` ;
|
||||
* ``{"op": "merge"|"unmerge", "sheet": "X", "range": "A1:B2"}`` ;
|
||||
* ``{"op": "col_width", "sheet": "X", "col": "A", "width": 24}`` ;
|
||||
* ``{"op": "row_height", "sheet": "X", "row": 3, "height": 30}`` ;
|
||||
* ``{"op": "comment_set", "sheet": "X", "ref": "B2", "text": "…"}``
|
||||
(``author`` optional) / ``{"op": "comment_clear", ...}`` ;
|
||||
* ``{"op": "freeze", "sheet": "X", "cell": "B2"}`` (``""`` releases).
|
||||
|
||||
Same guards as the cell edits: per-file lock, ``.bak`` backup,
|
||||
@@ -960,7 +1123,17 @@ def mutate_xlsx_style(
|
||||
raise ServiceError(
|
||||
"Largeur invalide (0 à 255)", code="invalid", status=400
|
||||
)
|
||||
ws.column_dimensions[col.strip().upper()].width = float(width)
|
||||
width = float(width)
|
||||
if width == 0:
|
||||
# 0 = largeur par défaut (équivalent « Default
|
||||
# Width » d'Excel / « Reset » de Sheets) : on
|
||||
# retire la mesure explicite au lieu d'écrire une
|
||||
# largeur de 0, qu'Excel lirait comme une colonne
|
||||
# quasi masquée pendant que l'affichage reste en
|
||||
# largeur automatique.
|
||||
ws.column_dimensions.pop(col.strip().upper(), None)
|
||||
else:
|
||||
ws.column_dimensions[col.strip().upper()].width = width
|
||||
applied.append(f"col_width:{ws.title}!{col}={width}")
|
||||
elif op == "row_height":
|
||||
ws = _sheet(wb, action)
|
||||
@@ -972,7 +1145,14 @@ def mutate_xlsx_style(
|
||||
raise ServiceError(
|
||||
"Hauteur invalide (0 à 409)", code="invalid", status=400
|
||||
)
|
||||
ws.row_dimensions[row].height = float(height)
|
||||
height = float(height)
|
||||
if height == 0:
|
||||
# 0 = hauteur par défaut (même logique que la
|
||||
# largeur ci-dessus) : la mesure explicite est
|
||||
# retirée, la ligne redevient automatique.
|
||||
ws.row_dimensions.pop(row, None)
|
||||
else:
|
||||
ws.row_dimensions[row].height = height
|
||||
applied.append(f"row_height:{ws.title}!{row}={height}")
|
||||
elif op == "freeze":
|
||||
ws = _sheet(wb, action)
|
||||
@@ -981,6 +1161,31 @@ def mutate_xlsx_style(
|
||||
_range(cell_ref)
|
||||
ws.freeze_panes = cell_ref.upper() or None
|
||||
applied.append(f"freeze:{ws.title}!{cell_ref or '-'}")
|
||||
elif op in ("comment_set", "comment_clear"):
|
||||
from openpyxl.comments import Comment
|
||||
|
||||
ws = _sheet(wb, action)
|
||||
ref = str(action.get("ref") or action.get("cell") or "")
|
||||
min_col, min_row, max_col, max_row = _range(ref)
|
||||
if (min_col, min_row) != (max_col, max_row):
|
||||
raise ServiceError(
|
||||
"Un commentaire vise une cellule (A1), pas une plage",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
if op == "comment_clear":
|
||||
ws.cell(row=min_row, column=min_col).comment = None
|
||||
else:
|
||||
text = str(action.get("text") or "")
|
||||
if not text.strip() or len(text) > 2000:
|
||||
raise ServiceError(
|
||||
"Texte de commentaire invalide (1 à 2000 caractères)",
|
||||
code="invalid", status=400,
|
||||
)
|
||||
author = str(action.get("author") or "ObsiGate")[:64]
|
||||
ws.cell(row=min_row, column=min_col).comment = Comment(
|
||||
text, author
|
||||
)
|
||||
applied.append(f"{op}:{ws.title}!{ref}")
|
||||
else:
|
||||
raise ServiceError(
|
||||
f"Action inconnue: {op!r}", code="invalid", status=400
|
||||
|
||||
@@ -12,6 +12,7 @@ import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from backend.auth.middleware import is_home_vault
|
||||
from backend.history import get_recent_opened, is_bookmarked
|
||||
from backend.indexer import find_file_in_index, index
|
||||
|
||||
@@ -31,6 +32,10 @@ def humanize_mtime(mtime: float) -> str:
|
||||
|
||||
|
||||
def _can_access(vault: str, user_vaults: list[str]) -> bool:
|
||||
# #194 : un dossier perso ne bénéficie jamais de "*" (même règle que
|
||||
# backend.auth.middleware.check_vault_access).
|
||||
if is_home_vault(vault):
|
||||
return vault in user_vaults
|
||||
return "*" in user_vaults or vault in user_vaults
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -11,15 +12,19 @@ def search_vaults(
|
||||
tag: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
is_allowed: Callable[[str], bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
No permission filtering is applied here: callers that need it (the tool
|
||||
layer) filter the ``results`` list themselves.
|
||||
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||
restricted vault set neither distorts ``total`` nor the returned page.
|
||||
The tool layer filters on its own and leaves it as ``None``.
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
if is_allowed is not None:
|
||||
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Dossier personnel par utilisateur (#194).
|
||||
|
||||
Chaque utilisateur reçoit ``<OBSIGATE_HOME_ROOT>/<username>`` monté comme un
|
||||
vault propre ``home-<username>`` : l'isolation profite de l'ACL par vault
|
||||
déjà en place (``check_vault_access``), aucune ACL par chemin à inventer.
|
||||
|
||||
Tout est idempotent (``ensure_user_home``) pour être rappelé à la création
|
||||
d'un compte ET au démarrage : un dossier supprimé, un registre perdu ou un
|
||||
user créé hors API se réparent au boot.
|
||||
|
||||
La fonctionnalité est inactive tant que ``OBSIGATE_HOME_ROOT`` n'est pas
|
||||
défini (dev, tests, desktop) — comportement inchangé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.user_home")
|
||||
|
||||
# Même grammaire que CreateUserRequest.username_valid (backend/auth/router.py) :
|
||||
# segment de chemin sûr, déjà passé par la validation de l'API. Re-vérifié ici
|
||||
# car le nom sert à construire un chemin (défense en profondeur).
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,32}$")
|
||||
|
||||
|
||||
def home_root() -> Path | None:
|
||||
"""Racine des dossiers persos, ou ``None`` si la fonctionnalité est inactive."""
|
||||
root = os.environ.get("OBSIGATE_HOME_ROOT", "").strip()
|
||||
return Path(root) if root else None
|
||||
|
||||
|
||||
def home_vault_name(username: str) -> str:
|
||||
"""Nom de vault du dossier perso — pas de ``/`` ni ``::`` (segment d'URL, clé d'index)."""
|
||||
return f"home-{username}"
|
||||
|
||||
|
||||
async def ensure_user_home(username: str) -> str | None:
|
||||
"""Crée (si besoin) le dossier perso de *username*, son vault et son octroi.
|
||||
|
||||
Retourne le nom de vault, ou ``None`` si désactivé / nom invalide /
|
||||
erreur disque (journalisée, réparée au prochain démarrage).
|
||||
"""
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return None
|
||||
if not _USERNAME_RE.match(username):
|
||||
logger.warning(f"Home folder skipped: invalid username {username!r}")
|
||||
return None
|
||||
|
||||
home = root / username
|
||||
try:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
logger.exception(f"Cannot create home folder {home} for user '{username}'")
|
||||
return None
|
||||
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import add_vault_to_index, index, persist_vault, vault_config
|
||||
from backend.sse import sse_manager
|
||||
|
||||
vault_path = str(home)
|
||||
if name not in index:
|
||||
await add_vault_to_index(name, vault_path)
|
||||
persist_vault(name)
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.add_vault(name, vault_path)
|
||||
await sse_manager.broadcast("vault_added", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' registered at {vault_path}")
|
||||
elif name not in vault_config:
|
||||
vault_config[name] = {"path": vault_path, "attachmentsPath": None,
|
||||
"scanAttachmentsOnStartup": True}
|
||||
except Exception:
|
||||
logger.exception(f"Cannot register home vault '{name}' for user '{username}'")
|
||||
return None
|
||||
|
||||
_grant(username, name)
|
||||
return name
|
||||
|
||||
|
||||
async def release_user_home(username: str) -> None:
|
||||
"""Retire le vault du dossier perso à la suppression du compte (#194).
|
||||
|
||||
Le dossier sur disque est **conservé** (décision produit : pas de perte
|
||||
de données) ; seul l'index, le watcher et le registre le referment.
|
||||
"""
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import index, remove_vault_from_index, unpersist_vault
|
||||
|
||||
unpersist_vault(name)
|
||||
if name not in index:
|
||||
return
|
||||
await remove_vault_from_index(name)
|
||||
from backend.sse import sse_manager
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.remove_vault(name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' released (folder kept)")
|
||||
except Exception:
|
||||
logger.exception(f"Cannot release home vault '{name}'")
|
||||
|
||||
|
||||
def _grant(username: str, vault_name: str) -> None:
|
||||
"""Ajoute le vault à ``user.vaults`` s'il n'y est pas déjà (#194).
|
||||
|
||||
L'octroi est explicite même pour un admin (``vaults: ["*"]``) : ``*`` ne
|
||||
couvre jamais un dossier perso (voir ``check_vault_access``).
|
||||
"""
|
||||
from backend.auth.user_store import get_user, update_user
|
||||
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
return # créé hors API (bootstrap avant users.json) → réparé au boot suivant
|
||||
vaults = user.get("vaults") or []
|
||||
if vault_name in vaults:
|
||||
return
|
||||
update_user(username, {"vaults": [*vaults, vault_name]})
|
||||
|
||||
|
||||
async def ensure_all_user_homes() -> int:
|
||||
"""Passe de réparation/migration au démarrage : un home par user existant.
|
||||
|
||||
Balaye aussi les vaults orphelins (compte supprimé hors route, ex.
|
||||
``create_admin.py delete``) : ils sont refermés, dossier conservé.
|
||||
"""
|
||||
from backend.auth.user_store import get_all_users
|
||||
from backend.indexer import index, vault_config
|
||||
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return 0
|
||||
users = get_all_users()
|
||||
usernames = {u.get("username") for u in users}
|
||||
created = 0
|
||||
for user in users:
|
||||
username = user.get("username")
|
||||
if not username:
|
||||
continue
|
||||
if await ensure_user_home(username):
|
||||
created += 1
|
||||
if created:
|
||||
logger.info(f"User home folders ensured for {created} user(s)")
|
||||
|
||||
# Orphelins : vault home-<x> toujours indexé mais <x> n'existe plus.
|
||||
# Path.parent == root → on ne touche qu'aux dossiers sous la racine Home,
|
||||
# jamais à un vault admin nommé « home-… » par ailleurs.
|
||||
for name in list(index):
|
||||
if not name.startswith("home-"):
|
||||
continue
|
||||
owner = name[len("home-"):]
|
||||
if owner in usernames:
|
||||
continue
|
||||
cfg_path = Path((vault_config.get(name) or {}).get("path", ""))
|
||||
if cfg_path.parent != root:
|
||||
continue
|
||||
logger.warning(f"Orphan home vault '{name}' released (user deleted?)")
|
||||
await release_user_home(owner)
|
||||
return created
|
||||
+139
-10
@@ -129,8 +129,16 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-style:italic")
|
||||
# #156-A8 — underline is written from the viewer too, so it is read back
|
||||
# (the toggle in the formatting menu needs to see its own effect).
|
||||
decorations = []
|
||||
if font and font.underline:
|
||||
fragments.append("text-decoration:underline")
|
||||
decorations.append("underline")
|
||||
if font and getattr(font, "strike", False):
|
||||
decorations.append("line-through")
|
||||
if decorations:
|
||||
fragments.append("text-decoration:" + " ".join(decorations))
|
||||
size = getattr(font, "size", None) if font else None
|
||||
if isinstance(size, (int, float)) and size != 11:
|
||||
fragments.append(f"font-size:{size:g}pt")
|
||||
fmt = cell.number_format
|
||||
if fmt and fmt not in ("General", "@"):
|
||||
# A custom number format is signalled typographically (mono font)
|
||||
@@ -140,29 +148,129 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||
fragments.append("font-family:'JetBrains Mono',monospace")
|
||||
alignment = cell.alignment
|
||||
align = alignment.horizontal if alignment else None
|
||||
return fragments, (align if align in ("left", "right", "center") else None)
|
||||
if alignment is not None:
|
||||
vertical = getattr(alignment, "vertical", None)
|
||||
if vertical == "center":
|
||||
fragments.append("vertical-align:middle")
|
||||
elif vertical in ("top", "bottom"):
|
||||
fragments.append(f"vertical-align:{vertical}")
|
||||
if getattr(alignment, "wrap_text", None) is True:
|
||||
fragments.append("white-space:normal")
|
||||
elif getattr(alignment, "wrap_text", None) is False:
|
||||
fragments.append("white-space:nowrap")
|
||||
rotation = getattr(alignment, "text_rotation", None) or 0
|
||||
if rotation == 90 or rotation == 255:
|
||||
fragments.append("writing-mode:vertical-rl")
|
||||
elif rotation == 180:
|
||||
fragments.append("writing-mode:vertical-rl;transform:scale(-1)")
|
||||
else:
|
||||
# Angles diagonaux (ex. ±45° du menu Format › Rotation) : OOXML
|
||||
# stocke l'anti-horaire sur 0..90 et l'horaire sur 91..180 (-45°
|
||||
# → 135, cf. mutations). Le CSS tourne en sens horaire : signe
|
||||
# inversé pour un rendu fidèle.
|
||||
excel = rotation if rotation <= 90 else -(180 - rotation)
|
||||
if excel:
|
||||
fragments.append(f"transform:rotate({-excel}deg)")
|
||||
border = getattr(cell, "border", None)
|
||||
if border is not None:
|
||||
sides = []
|
||||
for side_name in ("left", "right", "top", "bottom"):
|
||||
side = getattr(border, side_name, None)
|
||||
style = getattr(side, "style", None)
|
||||
if not style:
|
||||
continue
|
||||
color = getattr(getattr(side, "color", None), "rgb", None)
|
||||
hexpart = ""
|
||||
if isinstance(color, str) and len(color) == 8:
|
||||
hexpart = f" #{color[2:].lower()}"
|
||||
width = {"medium": "2px", "thick": "3px"}.get(style, "1px")
|
||||
css_style = {"dashed": "dashed", "dotted": "dotted", "double": "double"}.get(
|
||||
style, "solid"
|
||||
)
|
||||
sides.append(f"border-{side_name}:{width} {css_style}{hexpart}")
|
||||
fragments.extend(sides)
|
||||
return fragments, (align if align in ("left", "right", "center", "justify") else None)
|
||||
|
||||
|
||||
def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
|
||||
def _sheet_style_maps(
|
||||
ws: Any,
|
||||
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
|
||||
"""Flat maps of one worksheet: css, align, comment text, number format.
|
||||
|
||||
The flat string is what the API serves and what the viewer applies
|
||||
verbatim to ``td.style``; a plain cell is simply absent from the map.
|
||||
``left`` is the table default and never included. Bounded by
|
||||
``MAX_ROWS x MAX_COLS`` like the render itself.
|
||||
``MAX_ROWS x MAX_COLS`` like the render itself. Comment texts are capped
|
||||
(tooltip use) and formats only list non-General number formats.
|
||||
"""
|
||||
styles: dict[str, str] = {}
|
||||
aligns: dict[str, str] = {}
|
||||
comments: dict[str, str] = {}
|
||||
formats: dict[str, str] = {}
|
||||
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
|
||||
for cell in row:
|
||||
if cell.value is None and cell.number_format == "General":
|
||||
comment = getattr(cell, "comment", None)
|
||||
comment_text = getattr(comment, "text", None) if comment is not None else None
|
||||
# Une cellule vide mais stylée (gras, diagonale, commentaire…)
|
||||
# doit survivre à la relecture : `has_style` (et le commentaire)
|
||||
# sont les seuls tests rapides qui la distinguent d'une cellule
|
||||
# vraiment vierge — sinon le style semble « ne pas fonctionner »
|
||||
# dès qu'on recharge le classeur.
|
||||
if cell.value is None and cell.number_format == "General" and not cell.has_style:
|
||||
if comment_text:
|
||||
comments[cell.coordinate] = str(comment_text)[:500]
|
||||
continue
|
||||
fragments, align = _cell_fragments(cell)
|
||||
if fragments:
|
||||
styles[cell.coordinate] = ";".join(fragments)
|
||||
if align and align != "left":
|
||||
aligns[cell.coordinate] = align
|
||||
return styles, aligns
|
||||
if comment_text:
|
||||
comments[cell.coordinate] = str(comment_text)[:500]
|
||||
fmt = getattr(cell, "number_format", None)
|
||||
if fmt and fmt not in ("General", "@", None):
|
||||
formats[cell.coordinate] = str(fmt)
|
||||
return styles, aligns, comments, formats
|
||||
|
||||
|
||||
def _sheet_dim_maps(ws: Any) -> tuple[dict[str, float], dict[str, float]]:
|
||||
"""Explicit column widths (units) and row heights (pt), by letter/number.
|
||||
|
||||
Only dimensions the file sets explicitly are listed (defaults are a
|
||||
rendering concern of the client). Bounded like the render itself.
|
||||
"""
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
colwidths: dict[str, float] = {}
|
||||
try:
|
||||
for key, dim in (getattr(ws, "column_dimensions", {}) or {}).items():
|
||||
width = getattr(dim, "width", None)
|
||||
if not isinstance(width, (int, float)):
|
||||
continue
|
||||
letters = str(key).split(":")
|
||||
try:
|
||||
cols = [column_index_from_string(a.strip()) for a in letters]
|
||||
except ValueError:
|
||||
continue
|
||||
lo, hi = min(cols), max(cols)
|
||||
for c in range(lo, min(hi, MAX_COLS) + 1):
|
||||
if c >= 1:
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
colwidths[get_column_letter(c)] = float(width)
|
||||
except Exception:
|
||||
logger.debug("xlsx column widths unavailable", exc_info=True)
|
||||
rowheights: dict[str, float] = {}
|
||||
try:
|
||||
for r, dim in (getattr(ws, "row_dimensions", {}) or {}).items():
|
||||
if not isinstance(r, int) or not 1 <= r <= MAX_ROWS:
|
||||
continue
|
||||
height = getattr(dim, "height", None)
|
||||
if isinstance(height, (int, float)):
|
||||
rowheights[str(r)] = float(height)
|
||||
except Exception:
|
||||
logger.debug("xlsx row heights unavailable", exc_info=True)
|
||||
return colwidths, rowheights
|
||||
|
||||
|
||||
def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]:
|
||||
@@ -445,7 +553,8 @@ def invalidate_workbook_meta(file_path: Path | str) -> None:
|
||||
|
||||
|
||||
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||
"""Return ``{sheet: {styles, aligns, comments, formats, colwidths,
|
||||
rowheights, merges, freeze}}``.
|
||||
|
||||
One normal (non-streaming) load serves the three A15 metadata maps: the
|
||||
fragments are the workbook's own values, a failure yields ``{}`` per sheet
|
||||
@@ -483,12 +592,17 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
try:
|
||||
for ws in wb.worksheets:
|
||||
styles, aligns = _sheet_style_maps(ws)
|
||||
styles, aligns, comments, formats = _sheet_style_maps(ws)
|
||||
colwidths, rowheights = _sheet_dim_maps(ws)
|
||||
merged = getattr(ws, "merged_cells", None)
|
||||
ranges = getattr(merged, "ranges", None) or []
|
||||
out[ws.title] = {
|
||||
"styles": styles,
|
||||
"aligns": aligns,
|
||||
"comments": comments,
|
||||
"formats": formats,
|
||||
"colwidths": colwidths,
|
||||
"rowheights": rowheights,
|
||||
"merges": [str(r) for r in ranges],
|
||||
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
|
||||
}
|
||||
@@ -496,7 +610,14 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||
except Exception:
|
||||
logger.debug("xlsx meta unavailable", exc_info=True)
|
||||
for t in wb.sheetnames:
|
||||
out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""})
|
||||
out.setdefault(
|
||||
t,
|
||||
{
|
||||
"styles": {}, "aligns": {}, "comments": {}, "formats": {},
|
||||
"colwidths": {}, "rowheights": {},
|
||||
"merges": [], "freeze": "",
|
||||
},
|
||||
)
|
||||
return out
|
||||
finally:
|
||||
wb.close()
|
||||
@@ -565,6 +686,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||
"styles": sheet_meta.get("styles", {}),
|
||||
"aligns": sheet_meta.get("aligns", {}),
|
||||
"comments": sheet_meta.get("comments", {}),
|
||||
"formats": sheet_meta.get("formats", {}),
|
||||
"colwidths": sheet_meta.get("colwidths", {}),
|
||||
"rowheights": sheet_meta.get("rowheights", {}),
|
||||
"merges": sheet_meta.get("merges", []),
|
||||
"freeze": sheet_meta.get("freeze", ""),
|
||||
}
|
||||
@@ -629,6 +754,10 @@ def read_sheet_window(
|
||||
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
|
||||
"styles": meta.get("styles", {}),
|
||||
"aligns": meta.get("aligns", {}),
|
||||
"comments": meta.get("comments", {}),
|
||||
"formats": meta.get("formats", {}),
|
||||
"colwidths": meta.get("colwidths", {}),
|
||||
"rowheights": meta.get("rowheights", {}),
|
||||
"merges": meta.get("merges", []),
|
||||
"freeze": meta.get("freeze", ""),
|
||||
}
|
||||
|
||||
@@ -223,4 +223,4 @@ $COMPOSE_CMD -f "$COMPOSE_FILE" ps
|
||||
|
||||
echo ""
|
||||
info "Logs récents (Ctrl+C pour quitter) :"
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20 -f
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.53.3"
|
||||
version = "2.65.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.53.3"
|
||||
version = "2.65.2"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.53.3",
|
||||
"version": "2.65.2",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -13,14 +13,14 @@ services:
|
||||
context: .
|
||||
image: obsigate:latest
|
||||
container_name: obsigate-test
|
||||
user: "1000:1000"
|
||||
user: "197609:197609"
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2021:8080"
|
||||
volumes:
|
||||
- /tmp/obsigate-test-vault:/vaults/TestVault
|
||||
- /tmp/obsigate-test-dir:/vaults/TestDir
|
||||
- /tmp/obsigate-test-data:/app/data
|
||||
- ./test_vault:/vaults/TestVault
|
||||
- ./test_dir:/vaults/TestDir
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
@@ -51,3 +51,4 @@ services:
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
obsigate_data:
|
||||
|
||||
@@ -21,12 +21,17 @@ services:
|
||||
volumes:
|
||||
- /c/dev/git/python/ObsiGate/test_vault:/vaults/TestVault
|
||||
- /c/dev/git/python/ObsiGate/test_dir:/vaults/TestDir
|
||||
# #194 : racine des dossiers persos — UN seul montage pour tous les
|
||||
# utilisateurs (dossier hôte : mkdir test_home à côté du dépôt).
|
||||
- /c/dev/git/python/ObsiGate/test_home:/vaults/Home
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
- DIR_1_NAME=TestDir
|
||||
- DIR_1_PATH=/vaults/TestDir
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
||||
|
||||
@@ -36,6 +36,9 @@ services:
|
||||
- /NFS/OBSIDIAN_DOC/Obsidian_WORKOUT:/vaults/Obsidian_WORKOUT
|
||||
- /NFS/OBSIDIAN_DOC/SessionsManager:/vaults/SessionsManager
|
||||
- /home/bruno:/vaults/bruno
|
||||
# #194 : racine des dossiers perso — UN seul montage pour tous les
|
||||
# utilisateurs (un montage par user demanderait un redémarrage).
|
||||
- /NFS/OBSIDIAN_DOC/Home:/vaults/Home
|
||||
- /DOCKER_CONFIG/ObsiGate/data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
@@ -50,6 +53,8 @@ services:
|
||||
- VAULT_5_PATH=/vaults/SessionsManager
|
||||
- DIR_1_NAME=Bruno
|
||||
- DIR_1_PATH=/vaults/bruno
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
# Assistant V2 — ObsiGate comme second cerveau & assistant personnel
|
||||
|
||||
> **Statut :** 📐 Spécification de conception (non implémentée)
|
||||
> **Dernière mise à jour :** 2026-10-08
|
||||
> **IDs proposés :** ObsiGate **#191 → #201** (liste « Assistant V2 »)
|
||||
> **Portée :** évolution de l'assistant IA existant en assistant à mémoire long terme,
|
||||
> outillé (connecteurs externes), planifié (rappels, briefing) et proactif (insights).
|
||||
> **Hors portée :** toute intégration avec l'infrastructure externe du homelab
|
||||
> (Honcho, Hermes, Ollama partagé, ntfy homelab, cron système). ObsiGate est **autonome** :
|
||||
> un seul conteneur, ses propres stores, ses propres clés.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
Transformer ObsiGate d'un portail de notes avec assistant vers un **second cerveau
|
||||
conversationnel et autonome** :
|
||||
|
||||
1. **Il se souvient** — mémoire long terme structurée (faits, préférences, décisions,
|
||||
entités), consolidée chaque nuit, **visible et éditable en markdown dans le vault**
|
||||
(pas de boîte noire).
|
||||
2. **Il agit** — lit/écrit le vault, mais aussi les services externes connectés :
|
||||
Gmail, Google Agenda, Teams, Discord, Notion, Drive… (cadre de connecteurs unifié).
|
||||
3. **Il anticipe** — rappels conversationnels, briefing quotidien généré par l'agent,
|
||||
détection de boucles ouvertes et d'engagements non tenus.
|
||||
4. **Il centralise** — ObsiGate devient le point d'entrée unique prise de notes +
|
||||
assistant ; tout le reste (desktop Tauri, MCP externe, PWA mobile) n'est qu'un
|
||||
accès à *ce même* cerveau.
|
||||
|
||||
Le modèle économique de la conception : **Muse/Snow/ChatGPT-Memory rendent l'effet
|
||||
« l'assistant me connaît » ; ici la mémoire est auditable (markdown dans le vault,
|
||||
audit log, dédupli, réversibilité) et les données ne quittent jamais le stack ObsiGate
|
||||
sauf appel explicite aux API des services que Bruno a connectés lui-même.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principes directeurs (non négociables)
|
||||
|
||||
| # | Principe | Conséquence technique |
|
||||
|---|---|---|
|
||||
| P1 | **Autonomie du stack** | Tout vit dans le conteneur `obsigate` + son volume `data/`. Aucun nouveau conteneur runtime (Ollama est retiré — voir §9). |
|
||||
| P2 | **Zéro framework d'agent externe** | Décision déjà prise en #92 : boucle maison `backend/agent/loop.py`. L'Assistant V2 l'étend, ne la remplace pas. |
|
||||
| P3 | **Un outil = un `@tool`** | Toute nouvelle capacité passe par le registre typé (`backend/tools/registry.py`) : risque, scope, rate-limit, confirmation, audit, labels + i18n. |
|
||||
| P4 | **Mémoire = markdown d'abord** | Les artefacts durables (profil, insights, briefings, rappels) sont des notes du vault ; la base SQLite n'est que l'index reconstructible. |
|
||||
| P5 | **Humain dans la boucle** | `WRITE`/`DANGEROUS` exigent confirmation (#91/#92) ; un fait de confiance faible reste `pending` tant que l'utilisateur ne le valide pas. |
|
||||
| P6 | **Secrets jamais committés** | Pattern existant : `data/api_keys.json` / `data/connector_secrets.json` (0600) ou variables `OBSIGATE_*` dans `.env` (`env_file`, jamais dans le compose). |
|
||||
| P7 | **i18n FR/EN systématique** | Toute clé d'interface (`ai.step.*`, config connecteurs, cartes mémoire) existe dans les deux locales. |
|
||||
| P8 | **Le desktop Tauri reste fonctionnel** | Les connecteurs se dégradent silencieusement hors ligne (capability = absente si pas de token) ; aucune dépendance à un service réseau au démarrage. |
|
||||
| P9 | **Sécurité existante réutilisée** | `_resolve_safe_path()`, `require_auth`/`require_vault_access`, SSRF guards (`validate_webhook_url`, `is_safe_target`), `secret_redactor` avant tout appel LLM. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Inventaire de l'existant (socle sur lequel on bâtit)
|
||||
|
||||
Constat clé : **l'ossature « agent » existe déjà**. Ce qui manque, c'est la mémoire
|
||||
long terme, les connecteurs, la couche proactive et l'ordonnancement de haut niveau.
|
||||
|
||||
| Brique | Fichier(s) | État | Rôle dans la V2 |
|
||||
|---|---|---|---|
|
||||
| Boucle agent multi-étapes + tool calling natif | `backend/agent/loop.py` (`run_agent`), `backend/ai_chat.py` (payload `tools`/`tool_choice`, conversion Gemini, fallback si refus) | ✅ livré (#91/#92) | Moteur de l'assistant V2 — inchangé |
|
||||
| Registre d'outils typé | `backend/tools/registry.py` (`@tool`, `ToolRisk` READ/WRITE/DANGEROUS, `ToolScope` IN_APP/MCP, confirmation auto si non-READ, rate-limit, audit, schéma OpenAI) | ✅ | Cadre d'insertion de **tous** les nouveaux outils |
|
||||
| Outils vault (~26) | `backend/tools/service.py`, `backend/services/*` (files, mutations, search, vaults, duplicates…) | ✅ | Outils natifs de l'assistant |
|
||||
| Recherche TF-IDF + sémantique embeddings (RRF) | `backend/search.py`, `backend/semantic_search.py`, `requirements-semantic.txt` (sentence-transformers **local, optionnel**, FAISS) | ✅ (#70) | Retrieval de la mémoire épisodique ; remplace le besoin Ollama embeddings |
|
||||
| Historique de conversation persistant | `backend/ai_history.py` (`data/sessions`, par utilisateur) | ✅ (#95) | **Mémoire épisodique** brute — source de la consolidation |
|
||||
| Serveur MCP externe | `backend/mcp/server.py` (Streamable HTTP `/mcp`, propose/apply) | ✅ (#79) | Même registre exposé — les outils V2 en profitent gratis |
|
||||
| Scheduler type cron | `backend/scheduler.py` (tick asyncio 60 s, `data/scheduled_tasks.json`, `interval_hours`/`daily_time`/`once_at`, actions `create_file`/`append_to_file`/`notify`) + outils `create/list/delete/run_scheduled_task` (`backend/tools/scheduled.py`) | ✅ (#170) | Socle des rappels & briefing — à étendre (§8) |
|
||||
| Notifications externes | `backend/notify.py` (canaux **Discord webhook, Telegram, SMTP, webhook générique**, triggers, secrets 0600) + outil `notify_external` | ✅ (#168) | Bras de sortie des rappels/briefing |
|
||||
| Web Push PWA | `backend/push.py` (VAPID) + `frontend/sw.js` | ✅ (#67) | Deuxième canal de rappel, sans dépendance externe |
|
||||
| Sources connectées (premier précédent) | `backend/tools/connected.py` (Gitea/GitHub, READ), page Configurations #103, `backend/tools/secrets.py` | ✅ (#92/#103) | Patron à généraliser en cadre connecteurs (§7) |
|
||||
| Web : recherche + fetch + crawl | `backend/tools/web.py` (Tavily/Brave/SerpAPI/Exa/SearXNG, `fetch_url`), `backend/tools/crawler.py` (`crawl_site`) | ✅ | Outils « connaissances hors vault » |
|
||||
| Production de documents | `backend/tools/documents.py` (`create_xlsx/docx/csv/pdf`) | ✅ | Actions de l'assistant |
|
||||
| Doublons | `backend/tools/duplicates.py` (`find_duplicates`, `merge_duplicate_notes` DANGEROUS) | ✅ (#166) | Hygiène de la mémoire |
|
||||
| Skills / slash-commandes | `backend/skills.py` (`/cmd`, `data/skills.json`) | ✅ | Vecteur naturel des prompts de consolidation & briefing |
|
||||
| Cache SQLite | `backend/tools/webcache.py` (précédent `sqlite3` stdlib dans `data/`) | ✅ | Patron du store mémoire (§5) |
|
||||
| Fournisseurs LLM | `backend/ai.py` (`PROVIDERS` : deepseek, openrouter, gemini, nvidia, qwencloud, xiaomi, mistral, ollama), `reload_ai_config()`, clés `data/api_keys.json` + fallback env | ✅ | Garder le multi-provider ; **retirer le conteneur ollama** (§9) |
|
||||
| Audit + redaction | `backend/audit.py`, `backend/tools/audit.py`, `backend/secret_redactor.py` | ✅ | Traçabilité mémoire/connecteurs |
|
||||
|
||||
**Gaps** (→ §5–§8) : mémoire sémantique (faits) · miroir markdown · consolidation ·
|
||||
cadre connecteurs générique + OAuth2 · Gmail/Calendar/Teams/Discord(in)/Notion/Drive ·
|
||||
rappels conversationnels · agent programmé (briefing) · insights proactifs.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture cible
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ FRONTEND (SPA + PWA + Tauri) │
|
||||
│ Assistant panel · Cartes mémoire · Config │
|
||||
│ « Sources connectées » · Briefings │
|
||||
└───────────────┬────────────────────────────┘
|
||||
│ REST + SSE (existant)
|
||||
┌──────────────────────────────────────▼─────────────────────────────────────┐
|
||||
│ BACKEND obsigate (1 conteneur) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ agent/loop.py│◄──│ tools/registry.py│◄──│ TOUS les outils V2 (@tool) │ │
|
||||
│ │ (run_agent) │ │ risque/scope/ │ │ vault · web · docs (exist.)│ │
|
||||
│ └──────┬───────┘ │ audit/limit │ │ memory.* (nouveau §5) │ │
|
||||
│ │ └──────────────────┘ │ connector.* (§7) │ │
|
||||
│ ┌──────┴────────┐ │ reminder.* (§8) │ │
|
||||
│ │ ai_chat.py │ │ scheduled.* (étendu) │ │
|
||||
│ │ providers API │ └────────────────────────────┘ │
|
||||
│ └──────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼───────────┐ ┌────────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ scheduler.py │ │ memory/ │ │ connectors/ │ │
|
||||
│ │ tick 60 s │ │ store SQLite │ │ base OAuth2+PKCE │ │
|
||||
│ │ + agent_prompt │─►│ (faits) │ │ gmail/gcal/teams/ │ │
|
||||
│ │ (nouv. action) │ │ consolidator │ │ discord/notion/drive │ │
|
||||
│ └──────┬───────────┘ └─────────┬──────────┘ └───────────┬───────────┘ │
|
||||
│ │ notify.broadcast │ miroir markdown │ HTTPS API │
|
||||
│ │ + Web Push ▼ ▼ │
|
||||
│ ┌──────▼──────────┐ ┌────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Vault Obsidian │ │ Vault Obsidian │ │ Services externes │ │
|
||||
│ │ (5 volumes NFS) │ │ 99_Assistant/*.md │ │ (Google/MS/Discord) │ │
|
||||
│ └─────────────────┘ └────────────────────┘ └─────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Nouveaux modules backend (alignés sur l'organisation existante) :
|
||||
|
||||
```
|
||||
backend/
|
||||
memory/
|
||||
store.py # SQLite data/memory.sqlite3 — CRUD faits + runs
|
||||
recall.py # fusion recall: facts + sémantique (#70) + épisodes (ai_history)
|
||||
consolidator.py # job « dreaming » nocturne (prompt + parsing + dédup)
|
||||
mirror.py # rendu markdown 99_Assistant/ + parse des edits humains
|
||||
connectors/
|
||||
base.py # ConnectorInterface: id, scopes, authorize_url, exchange,
|
||||
# refresh, capabilities(), call() (retry + rate-limit + audit)
|
||||
oauth.py # flux code + PKCE, redirect /api/connectors/{id}/callback
|
||||
registry.py # registre des connecteurs installés (comme tools/registry)
|
||||
gmail.py gcal.py teams.py discord.py notion.py drive.py
|
||||
routers/
|
||||
connectors.py # CRUD admin: installation, statut, test de connexion
|
||||
memory.py # API faits (liste/édit/suppression/validation pending)
|
||||
tools/
|
||||
memory.py # @tool memory.* (§5.4)
|
||||
reminders.py # @tool reminder.* (§8.1)
|
||||
connectors.py # @tool par capability connecteur (§7.4)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Mémoire long terme — #192 (socle), #193 (miroir), #194 (consolidation)
|
||||
|
||||
### 5.1 Modèle de données (SQLite, `data/memory.sqlite3`)
|
||||
|
||||
Précédent : `webcache.py`. Une seule table applicative + une table d'audit des runs.
|
||||
|
||||
```sql
|
||||
CREATE TABLE facts (
|
||||
id TEXT PRIMARY KEY, -- f-<uuid4>
|
||||
user_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL, -- ex: "santé", "Projet FlowDeck", "bruno"
|
||||
predicate TEXT NOT NULL, -- ex: "rdv", "préférence", "décision"
|
||||
value TEXT NOT NULL, -- contenu FR court (< 500 car.)
|
||||
kind TEXT NOT NULL CHECK (kind IN
|
||||
('preference','decision','entity','event','habit','goal','open_loop')),
|
||||
confidence REAL NOT NULL DEFAULT 0.5, -- 0..1 ; < 0.7 → status pending
|
||||
status TEXT NOT NULL DEFAULT 'confirmed'
|
||||
CHECK (status IN ('pending','confirmed','rejected','expired')),
|
||||
source TEXT NOT NULL, -- 'consolidation' | 'user' | 'chat'
|
||||
source_ref TEXT, -- session id ou chemin de note
|
||||
supersedes TEXT, -- id du fait remplacé (dédup temporel)
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL, -- reinforcement par usage
|
||||
expires_at TEXT -- nullable (ex: rdv passé)
|
||||
);
|
||||
CREATE INDEX idx_facts_subject ON facts(user_id, subject);
|
||||
CREATE INDEX idx_facts_status ON facts(user_id, status);
|
||||
|
||||
CREATE TABLE consolidation_runs (
|
||||
id TEXT PRIMARY KEY, started_at TEXT, finished_at TEXT,
|
||||
sessions_consumed INTEGER, notes_changed INTEGER,
|
||||
facts_added INTEGER, facts_updated INTEGER, error TEXT
|
||||
);
|
||||
```
|
||||
|
||||
Règles :
|
||||
- **Dédup temporel** : un nouveau fait `(subject, predicate)` avec valeur différente
|
||||
crée une ligne et marque l'ancienne `superseded` (via `supersedes`, status `expired`).
|
||||
Historique conservé, recall = dernier `confirmed` non expiré.
|
||||
- **Renforcement** : `last_seen_at` bump quand le fait est réaffirmé en chat ;
|
||||
les facts non revus depuis N jours (180) et non `decision` deviennent `expired`.
|
||||
- **PII santé/famille** : `consolidator` force `status='pending'` (validation humaine
|
||||
via cartes) si la note source appartient aux dossiers sensibles configurables
|
||||
(`OBSIGATE_MEMORY_REVIEW_FOLDERS`, ex. `Santé,Famille`).
|
||||
- Backup : `data/` étant déjà dans le cycle de backups existant, rien de neuf.
|
||||
|
||||
### 5.2 Miroir markdown dans le vault — #193
|
||||
|
||||
Dossier **`99_Assistant/`** (créé à la première activation, dans le vault principal
|
||||
configurable `OBSIGATE_MEMORY_VAULT=Main`, dossier racine configurable) :
|
||||
|
||||
| Fichier | Contenu | Écrit par | Éditable par Bruno |
|
||||
|---|---|---|---|
|
||||
| `Profil.md` | Préférences/habitus/goals confirmés, groupés par sujet (H2) | mirror (consolidation) | ✅ — les edits sont respectés (§5.3 règles de merge) |
|
||||
| `Insights.md` | Détections proactes #201 (boucles ouvertes, engagements, patterns), horodatées | mirror | ✅ (suppression = `rejected` en base) |
|
||||
| `Rappels.md` | Table des rappels actifs (liens vers tâches planifiées) | mirror scheduler | lecture seule (UI dédiée) |
|
||||
| `Briefings/YYYY-MM-DD.md` | Briefing quotidien #200 | agent programmé | ✅ (archivage, jamais régénéré) |
|
||||
| `Journal.md` | Dernières consolidations (nb faits ajoutés/validés) | mirror | lecture seule |
|
||||
|
||||
Le miroir utilise des **balises HTML de section** :
|
||||
|
||||
```markdown
|
||||
<!-- obsigate:managed:preferences -->
|
||||
…bloc régénéré…
|
||||
<!-- /obsigate:managed:preferences -->
|
||||
```
|
||||
|
||||
Règle de merge : à chaque écriture, le mirror compare le contenu précédent généré
|
||||
(hash stocké en base) avec le contenu actuel du fichier. Si l'utilisateur a modifié
|
||||
l'intérieur d'une section gérée → la modification est **re-parse en faits `source='user'`**
|
||||
(confiance 1.0, status confirmed) puis la section est régénérée à partir de la base.
|
||||
Tout ce qui est hors balises est laissé intact. C'est le mécanisme qui rend la mémoire
|
||||
*auditable et contestable* — l'anti-boîte-noire.
|
||||
|
||||
### 5.3 Consolidation nocturne (« dreaming ») — #194
|
||||
|
||||
Job planifié via le scheduler existant (§8.3), action `agent_prompt` dédiée :
|
||||
|
||||
1. **Collecte incrémentale** : sessions `ai_history` créées/modifiées depuis le dernier
|
||||
run + fichiers `.md` modifiés depuis le dernier run (via l'index/watcher mtime) —
|
||||
plafonds : 200 fichiers × 30 Ko (comme bookslm), contenu `redact_file_content()` avant LLM.
|
||||
2. **Extraction** (1 appel LLM, provider par défaut = cheap model) : prompt système
|
||||
d'extraction → JSON `[{subject, predicate, value, kind, confidence}]` validé Pydantic ;
|
||||
rejet des valeurs non sourcées.
|
||||
3. **Réconciliation** (déterministe, stdlib) : dédup/supersede par `(subject,predicate)`,
|
||||
plancher de confiance, dossiers sensibles → pending.
|
||||
4. **Écriture** : `facts` + `mirror.py` → sections gérées de `Profil.md`.
|
||||
5. **Cartes de validation** : les faits `pending` deviennent des cartes dans le panneau
|
||||
Assistant (« Valider / Modifier / Oublier »), persistées jusqu'à action.
|
||||
|
||||
Coût : 1–3 appels API/nuit (deepseek-chat ≈ quelques centimes). Budget max :
|
||||
`OBSIGATE_CONSOLIDATION_MAX_TOKENS` (défaut 30 000/jour) pour bornes dures.
|
||||
|
||||
### 5.4 Outils mémoire (registés `@tool`) — #192
|
||||
|
||||
| Outil | Risque | Description (schéma) |
|
||||
|---|---|---|
|
||||
| `memory_recall` | READ | `{query, subject?, kind?}` → faits confirmed pertinents + fusion RRF avec sémantique #70 sur le vault. Injecté **automatiquement** dans le prompt système de chaque chat (voir §5.5). |
|
||||
| `memory_save` | WRITE | `{subject, predicate, value, kind, confidence=0.9}` — le modèle propose, la carte de confirmation affiche le fait ; Bruno valide → `source='chat'`. |
|
||||
| `memory_update` | WRITE | `{fact_id, value | status}` — correction/revalidation. |
|
||||
| `memory_forget` | DANGEROUS | `{fact_id}` — status `rejected`, supprimé du miroir, tracé à l'audit. |
|
||||
| `profile_get` | READ | `{section?}` → contenu courant de `Profil.md` (plus riche que le recall brut). |
|
||||
|
||||
### 5.5 Injection dans le contexte (le « ça me connaît » quotidien)
|
||||
|
||||
Au démarrage de chaque session agent (`bookslm_routes` → futur endpoint `/agent`),
|
||||
construction d'un bloc mémoire coiffant le prompt système, budget ≤ 2 500 tokens :
|
||||
|
||||
```
|
||||
<memoried-context>
|
||||
Profil (résumé géré) : préférences, habitudes, objectifs actifs…
|
||||
Faits saillants liés au sujet : <memory_recall(query=requête utilisateur)>
|
||||
Rappels actifs : <n lignes de Rappels.md>
|
||||
Dernier briefing : <3 puces>
|
||||
</memoried-context>
|
||||
```
|
||||
|
||||
Ce bloc est **visible** dans la section « étapes » du panneau (label « Mémoire »),
|
||||
comme les autres étapes d'outils — pas de magie cachée.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rattachement MCP / Desktop / Skills
|
||||
|
||||
- Tous les nouveaux outils `@tool` sont automatiquement exposés au **serveur MCP**
|
||||
(`/mcp`, scopes IN_APP+MCP par défaut, propose/apply déjà gérés) → Claude Desktop /
|
||||
Cursor bénéficient gratis de `memory.*`, `reminder.*`, `connector.*`.
|
||||
- **Skills `/`** : nouvelles built-in `/profil` (montre Profil.md), `/rappel <texte>`
|
||||
(crée un rappel), `/brief` (régénère le briefing du jour), `/consolider`
|
||||
(déclenche le run de consolidation à la demande), `/connecteurs` (état des sources).
|
||||
- **Desktop Tauri** : les connecteurs sans token enregistré n'apparaissent pas dans le
|
||||
registre de schémas envoyés au LLM (`list_tools` filtré par capabilities actives) ;
|
||||
zéro appel réseau au démarrage.
|
||||
|
||||
---
|
||||
|
||||
## 7. Connecteurs externes — #195 (cadre), #196–#198 (implémentations)
|
||||
|
||||
### 7.1 Cadre (`backend/connectors/`)
|
||||
|
||||
Décision : **remplacer la logique ad hoc de `connected.py` (Gitea/GitHub par variables
|
||||
d'env) par un cadre général** — tout en gardant ces deux sources fonctionnelles
|
||||
(migration interne, même UX « Sources connectées » #103 étendue).
|
||||
|
||||
Concepts :
|
||||
|
||||
- **Connector** = classe (id, label, docs URL, scopes OAuth, capabilities nommées).
|
||||
- **Installation** = entrée `data/connectors.json` `{id, type, status, config (public),
|
||||
created_at}` ; secrets dans `data/connector_secrets.json` (0600) ou
|
||||
`OBSIGATE_CONNECTOR_SECRET_<ID>` — **même pattern strict que #168/#103**.
|
||||
- **Auth** : soit *token statique* (Discord webhook, Notion key, GitHub), soit
|
||||
**OAuth2 authorization-code + PKCE** (Google, Microsoft) :
|
||||
`GET /api/connectors/{id}/authorize` → redirection → `GET /api/connectors/{id}/callback`
|
||||
(échange + stockage `refresh_token` chiffré avec `data/secret.key` existant) →
|
||||
`POST /api/connectors/{id}/test` (validation par un appel READ bon marché).
|
||||
- **Refresh** transparent dans `call()` (anticipation du expiry, 1 retry sur 401).
|
||||
- **Capabilities → tools** : chaque capability expose un `@tool` (dynamiquement
|
||||
enregistré si le connecteur est installé), préfixé :
|
||||
`gmail.search_messages`, `gcal.list_events`, `teams.list_chats`…
|
||||
Risque par capability (lecture READ / écriture WRITE / envoi DANGEROUS).
|
||||
- **Budget & limites** : rate-limit par connecteur via `tools/ratelimit.py`,
|
||||
timeout httpx 15 s, retry backoff (pattern #92), cache SQLite des réponses GET
|
||||
bornées (pattern `webcache.py`, TTL 5 min pour le contexte de chat).
|
||||
- **Audit** : chaque `call()` journalisé (connecteur, capability, args tronqués,
|
||||
latence, statut) via `tools/audit.py`.
|
||||
|
||||
### 7.2 Matrice des connecteurs cibles
|
||||
|
||||
| Connecteur | Auth | Capabilités (→ outil) | Risque | Phase |
|
||||
|---|---|---|---|---|
|
||||
| **Google — Calendar** | OAuth2 PKCE | `gcal.list_events` (période, requête), `gcal.get_event`, `gcal.create_event`, `gcal.update_event` | READ, READ, WRITE, WRITE | #196 |
|
||||
| **Google — Gmail** | OAuth2 PKCE | `gmail.search_messages` (query `is:unread…`), `gmail.read_message`, `gmail.create_draft`, ~~`gmail.send`~~ | READ, READ, WRITE, 🔴 non exposé v1 | #196 |
|
||||
| **Google — Drive** | OAuth2 PKCE | `drive.search`, `drive.read_file` (import md/docx → vault) | READ | #198 (rattache backlog #167) |
|
||||
| **Microsoft — Teams / Graph** | Entra ID client, OAuth2 | `teams.list_chats`, `teams.read_messages`, `teams.send_message` | READ, READ, WRITE | #197 |
|
||||
| **Microsoft — Outlook** | idem Graph | `outlook.search_messages`, `outlook.list_events` (calendriers persos MS) | READ | #197 (optionnel, même jeton) |
|
||||
| **Discord** | webhook (sortie, ✅ #168) + **bot token lecture** (entrée, scopes `read`) | `discord.send` (existant via notify), `discord.list_messages`, `discord.search` (REST, pas de gateway websocket en v1) | WRITE, READ, READ | #198 |
|
||||
| **Notion** | API key (user token) | `notion.search`, `notion.read_page`, `notion.append_blocks` | READ, READ, WRITE | #198 |
|
||||
| **Slack** | webhook sortie + bot token lecture REST | `slack.send`, `slack.search_messages` | WRITE, READ | option future |
|
||||
| **Gitea / GitHub** | token (existant) | migrés sous le cadre | READ | #195 (migration) |
|
||||
| **Todoist / tasks externes** | token | — | — | hors v1 ; les tâches restent **dans le vault** (checklists) |
|
||||
|
||||
Règle transversale : **la prise de notes reste le vault**. Les connecteurs *nourrissent*
|
||||
l'assistant (contexte) et reçoivent des *actions* (événement, brouillon, message) ; ils
|
||||
ne remplacent jamais la note. C'est l'inverse des apps chat généralistes.
|
||||
|
||||
### 7.3 Utilisation des connecteurs par l'assistant
|
||||
|
||||
- L'agent choisit les outils selon les demandes (tool-calling existant, `tool_choice=auto`).
|
||||
- **Contexte briefing** (§8.2) : agrège `gcal.list_events(jour)` + rappels + mails
|
||||
non lus (si Gmail connecté) + notes modifiées → note de briefing.
|
||||
- **Demande type** : « est-ce que j'ai une réponse du dentiste ? » →
|
||||
`gmail.search_messages(from:...)` → cite le mail → propose la note.
|
||||
- **Confidentialité** : le contenu des connecteurs entre dans la conversation mais
|
||||
**jamais** dans la consolidation mémoire par défaut (seuls les faits *explicitement*
|
||||
confirmés par l'utilisateur via `memory_save` entrent en base).
|
||||
|
||||
### 7.4 UI « Sources connectées » (#103 étendue)
|
||||
|
||||
Page Configurations : carte par connecteur (statut, scopes, bouton Connect/Test/
|
||||
Déconnecter), journal des derniers appels (audit filtré), budget tokens. Textes FR/EN.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rappels, briefing, agent programmé — #199 (rappels), #200 (agent+briefing)
|
||||
|
||||
### 8.1 Rappels conversationnels — #199
|
||||
|
||||
Le scheduler #170 sait déjà planifier `notify`/`create_file`. Les rappels = **action
|
||||
typée de haut niveau**, pour que l'assistant puisse les gérer naturellement :
|
||||
|
||||
- Nouvelle action scheduler `reminder` : `{user_id, text, deliver: [channels…],
|
||||
recurring: none|daily|weekly|cron}` → au due : `notify.broadcast` + Web Push +
|
||||
append à `99_Assistant/Rappels.md` (historique `Rappels.md` → section « tenus »).
|
||||
- Outils : `reminder_create` (WRITE, `{texte, quand (ISO ou relatif « in 3 weeks »
|
||||
résolu côté serveur par la LLM→date), récurrence, canaux}`), `reminder_list` (READ),
|
||||
`reminder_snooze` (WRITE, +`{task_id, delta}` — le snooze bump `last_snoozed_count`,
|
||||
signal pour #201), `reminder_cancel` (WRITE).
|
||||
- UX : panneau Assistant « Cloche » + section latérale dédiée ; les rappels dus sont
|
||||
injectés en contexte mémoire (§5.5) pour que l'assistant puisse en parler de lui-même.
|
||||
|
||||
### 8.2 Agent programmé — nouveau type d'action `agent_prompt` — #200
|
||||
|
||||
Brique générique manquante : le scheduler doit pouvoir **lancer le run_agent** avec un
|
||||
prompt, pas seulement des actions primitives.
|
||||
|
||||
- Sécurité de la tâche non interactive (pas de confirmation humaine possible) :
|
||||
**whitelist de capacités** — READ sans limite ; WRITE restreint aux chemins
|
||||
`99_Assistant/**` (le prompt d'un scheduled-agent ne peut écrire ailleurs) ;
|
||||
DANGEROUS et `connectors.*` en écriture **interdits**. Paramètre
|
||||
`allowed_extra_tools: []` pour élargir explicitement à la création.
|
||||
- Plafonds : max 8 itérations d'outils, budget tokens par tâche, `last_error` +
|
||||
broadcast `schedule_failure` (déjà en place), `last_run_at` persisté.
|
||||
- Le **briefing du matin** = built-in : skill `/brief` + tâche `daily_time 07:00`
|
||||
(fuseau `OBSIGATE_TZ`) → prompt agrégateur (§7.3) → écrit `Briefings/YYYY-MM-DD.md`
|
||||
+ notification (canaux de l'utilisateur + Web Push).
|
||||
- Autres prompts programmatifs naturels : « résumé hebdo des notes modifiées »,
|
||||
« veille » (crawl d'une page + diff dans une note) — mais chacun reste une tâche
|
||||
créée par l'utilisateur ou l'assistant, pas de magie par défaut.
|
||||
|
||||
### 8.3 Le job de consolidation passe par `agent_prompt`
|
||||
|
||||
#194 s'implémente comme tâche built-in `daily_time 23:30` + whitelist
|
||||
`memory.*`/`profile_get` — l'infrastructure d'ordonnancement de #200 sert la mémoire.
|
||||
(Ordre de livraison : #200 avant #194, cf. §10.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Retrait d'Ollama — #191
|
||||
|
||||
Objectif utilisateur : ObsiGate ne doit pas hériter de l'infra (ni Ollama, ni Honcho) ;
|
||||
et le conteneur Ollama (1,4 Go+ de poids, 2 modèles, RAM) ne sert plus.
|
||||
|
||||
1. `docker-compose.yml` : supprimer le service `ollama`, le volume `ollama_data`,
|
||||
les vars `OLLAMA_BASE_URL`/`OLLAMA_MODEL`, et la mention « port 11434 » de l'en-tête.
|
||||
Idem `docker-compose.test-linux.yml` / `docker-compose.test-win.yml`.
|
||||
2. `backend/ai.py` / `model_capabilities.py` : **conserver** le provider `ollama`
|
||||
(option de configuration, utile au desktop hors-ligne qui pointe vers un ollama
|
||||
local de l'utilisateur) mais retirer tout défaut pointant vers le réseau interne.
|
||||
Les tests d'isolation : `OBSIGATE_*` ne référence plus `http://ollama:11434`.
|
||||
3. Embeddings : par **sentence-transformers local optionnel** (déjà #70) — le mode
|
||||
par défaut de l'Assistant V2 fonctionne **sans embeddings** (TF-IDF + RRF + faits
|
||||
SQLite), les embeddings étant un raffinement auto-détecté (`SemanticIndex` existe,
|
||||
avec fallback API provider si configurée).
|
||||
4. Migration : `docker compose down && up -d` (règle .env connue P17/P29) ; supprimer
|
||||
le volume `ollama_data` (procédure documentée dans le CHANGELOG + ROADMAP de la tache #191).
|
||||
|
||||
---
|
||||
|
||||
## 10. Phasage & IDs
|
||||
|
||||
> Chaque tranche est livrable indépendamment, CI verte + DoD `DELIVERY_WORKFLOW.md`,
|
||||
> i18n FR/EN, tests, guide utilisateur `docs/GUIDES/`.
|
||||
|
||||
| ID | Tranche | Contenu | Effort | Dépend | Critères d'acceptation |
|
||||
|---|---|---|---|---|---|
|
||||
| #191 | **Purge Ollama** | compose ×3, docs, notes migration | 0,5 j | — | plus aucun conteneur ollama ; AI editor + agent OK sur deepseek/openrouter/gemini |
|
||||
| #192 | **Mémoire socle** | `memory/store.py`, `recall.py`, 5 outils `@tool`, tests | 3–4 j | #191 | recall/save/update/forget fonctionnent en chat ; audit + confirmation OK |
|
||||
| #193 | **Miroir markdown** | `mirror.py`, `99_Assistant/`, balises gérées, re-parse edits | 2–3 j | #192 | édition manuelle de Profil.md → faits `source='user'` ; réécritures idempotentes |
|
||||
| #200 | **Agent programmé + briefing** | action `agent_prompt` (whitelist), skill `/brief`, tâches built-in | 3–4 j | #192 | briefing 07:00 → note + notif ; WRITE limité `99_Assistant/**` prouvé par tests |
|
||||
| #194 | **Consolidation nocturne** | `consolidator.py`, incrémental, pending/cartes, budget tokens | 3–4 j | #200, #193 | 1 nuit test → ≥1 fait utile extrait ; aucun secret extrait (redaction test) ; budget respecté |
|
||||
| #199 | **Rappels** | action `reminder`, 4 outils, UI cloche, snooze-count | 2–3 j | #200 | « rappelle-moi dans 3 semaines » → rappel réel ; snooze ≥3 → signal visible #201 |
|
||||
| #195 | **Cadre connecteurs** | `connectors/` base+oauth+registry, UI Sources, migration Gitea/GitHub | 4–5 j | — | OAuth2 PKCE de bout en bout avec un provider de test ; secrets 0600 |
|
||||
| #196 | **Google Calendar + Gmail** | capabilities §7.2, context briefing étendu | 3–4 j | #195 | agenda du jour cité dans briefing ; recherche mail via chat avec confirmation WRITE nulle |
|
||||
| #197 | **Microsoft Teams/Outlook** | Graph client min, capabilities READ + send | 3–4 j | #195 | lire un chat Teams et y répondre via carte de confirmation |
|
||||
| #198 | **Discord read + Notion + Drive** | capacités restantes §7.2 | 3–5 j | #195 | import page Notion → note vault ; `drive.read_file` OK |
|
||||
| #201 | **Proactivité / insights** | détecteurs stdlib (open loops, snoozes, pourrissement TODO, récurrences) → `Insights.md` + suggestions en contexte | 3–4 j | #194, #199 | un scenario « note à faire > 21 jours » et « snooze ×3 » génèrent des insights réels et acceptables/refusables |
|
||||
|
||||
Ordre recommandé : `#191 → #192 → #193 → #200 → #194 → #199` (la mémoire et le
|
||||
briefing rendent l'assistant utile **sans** connecteur) puis connecteurs `#195 → #196…`
|
||||
et enfin `#201` (le coaching a besoin de la mémoire garnie).
|
||||
|
||||
---
|
||||
|
||||
## 11. Sécurité & confidentialité (rappel explicite)
|
||||
|
||||
- **Secrets** : uniquement `data/connector_secrets.json` (0600, chiffrés `secret.key`)
|
||||
ou env `OBSIGATE_CONNECTOR_SECRET_*` — jamais le compose, jamais git (#168/#103).
|
||||
- **SSRF** : toutes les URLs configurées par l'utilisateur passent
|
||||
`validate_webhook_url`/`is_safe_target` ; les endpoints des providers OAuth sont
|
||||
une allowlist codée (accounts.google.com, login.microsoftonline.com…).
|
||||
- **Chiffrement au repos** optionnel : même pattern que #172 (backlog existant) —
|
||||
si activé, s'applique à `connector_secrets.json` + `memory.sqlite3`.
|
||||
- **Redaction** : `redact_file_content()` systématique avant LLM pour toute note
|
||||
consolidée ; jamais le contenu brut des connecteurs dans les prompts hors
|
||||
appel d'outil explicite.
|
||||
- **Consentement par tâche planifiée** : les tâches `agent_prompt` listent leurs
|
||||
capacités autorisées dans l'UI, avec journal `last_run` dépliable.
|
||||
- **Effacement** : `memory_forget` + purge des sessions #95 + export de la base
|
||||
(`GET /api/memory/export` JSON) → réversibilité complète RGPD-like.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risques & arbitrages assumés
|
||||
|
||||
| Risque | Mitigation |
|
||||
|---|---|
|
||||
| Qualité variable de l'extraction de faits → mémoire « bruitée » | seuil confiance + pending/cartes ; dossiers sensibles → validation manuelle ; `last_seen` decay ; effacement facile |
|
||||
| Coût LLM (consolidation + briefing quotidiens) | cheap model dédié par var `OBSIGATE_AGENT_BACKGROUND_MODEL` ; budgets durs en tokens ; aggregation déterministe (stdlib) avant LLM |
|
||||
| Dérive du scheduler qui exécute des prompts non surveillés | whitelist capacités + budget itérations + plafond chemins ; journal d'audit par tâche |
|
||||
| Élargissement du scope backend (main.py déjà volumineux) | nouveaux modules dédiés (`memory/`, `connectors/`) ; aucune logique dans `main.py` ; routers séparés |
|
||||
| OAuth app Google/Microsoft à créer manuellement (client id) | guide `docs/GUIDES/CONNECTEURS_GOOGLE_MS.md` pas-à-pas FR/EN ; possible de rester sur tokens statiques Notion/GitHub sans projet cloud |
|
||||
| Éditions humaines du miroir non re-parsables (markdown libre) | balises `managed` strictes ; sections hors-balises intouchables ; re-parse = faits, pas diff sémantique |
|
||||
| Tauri desktop + connecteurs = surface réseau | capabilities absentes du registre tant que non installées ; offline-first global inchangé |
|
||||
|
||||
---
|
||||
|
||||
## 13. Définition de terminé (par tranche, conforme DoD dépôt)
|
||||
|
||||
Pour chaque ID : tests unitaires backend (+tests de non-régression pour tout bug),
|
||||
tests JSDOM si UI, E2E Playwright si flux UI touché, ruff/mypy 0 erreur, i18n FR/EN,
|
||||
labels `ai.step.*` pour tout nouvel outil, mise à jour `CHANGELOG.md` `[Unreleased]`,
|
||||
`docs/ROADMAP.md` (statut + fiche `docs/features/assistant-v2-<slug>.md` par tranche),
|
||||
guide utilisateur `docs/GUIDES/` si exposé à l'utilisateur, CI verte, commit conventionnel
|
||||
référençant l'ID.
|
||||
|
||||
---
|
||||
|
||||
## Annexe A — Variables d'environnement nouvelles
|
||||
|
||||
```
|
||||
# Mémoire
|
||||
OBSIGATE_MEMORY_ENABLED=true
|
||||
OBSIGATE_MEMORY_VAULT=Main # vault où vit 99_Assistant/
|
||||
OBSIGATE_MEMORY_REVIEW_FOLDERS=Santé,Famille # forcent status=pending
|
||||
OBSIGATE_CONSOLIDATION_MODEL= # cheap model, défaut = provider courant
|
||||
OBSIGATE_CONSOLIDATION_MAX_TOKENS=30000
|
||||
# Briefing / agent programmé
|
||||
OBSIGATE_TZ=America/Toronto
|
||||
OBSIGATE_BRIEFING_TIME=07:00
|
||||
# Connecteurs (ou UI Sources connectées — stockage prioritaire)
|
||||
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET # app Google Cloud (consent screen)
|
||||
AZURE_CLIENT_ID / AZURE_CLIENT_SECRET # app Entra ID
|
||||
OBSIGATE_NOTION_API_KEY=
|
||||
OBSIGATE_DISCORD_BOT_TOKEN= # lecture REST uniquement
|
||||
```
|
||||
|
||||
## Annexe B — Prompt de consolidation (extrait)
|
||||
|
||||
```
|
||||
Tu extrais des faits durables à partir de notes et conversations récentes d'un
|
||||
journal personnel. Pour chaque fait : subject, predicate, value (une phrase,
|
||||
français), kind ∈ preference|decision|entity|event|habit|goal|open_loop,
|
||||
confidence 0..1. Règles :
|
||||
- Uniquement ce qui est explicitement affirmé ou fortement implicite ;
|
||||
- Rien de sensible sans appui textuel direct (santé, finances, familles) ;
|
||||
- Jamais de mot de passe, token, numéro personnel (signalés comme refusés) ;
|
||||
- Si un fait contredit un fait existant, produit le nouveau avec note de supersede.
|
||||
Sortie : JSON strict {facts: [...]} — aucun commentaire.
|
||||
```
|
||||
|
||||
## Annexe C — Prompt du briefing (extrait)
|
||||
|
||||
```
|
||||
Contexte fourni : agenda du jour (gcal), rappels dus, mails non lus importants
|
||||
(gmail si connecté), notes modifiées hier, faits pending à valider.
|
||||
Produis une note markdown Briefings/YYYY-MM-DD.md :
|
||||
- Aujourd'hui : événements + rappels (liens vault si applicable)
|
||||
- À trancher : 2–4 questions ouvertes détectées dans les notes récentes
|
||||
- Mémoire : 1–3 faits nouveaux à valider (citez la source note)
|
||||
Pas d'invention. Chaque affirmation cite sa source (chemin ou sujet connecteur).
|
||||
```
|
||||
@@ -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 |
|
||||
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
||||
| **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 |
|
||||
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
||||
| **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
|
||||
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
|
||||
|
||||
@@ -178,6 +178,31 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
|
||||
> Selon le mode, la lecture et l'édition se remplacent : `Editer` et `Forge`
|
||||
> prennent la place de la vue lecture ; revenez avec `✓` / `×` ou `Échap`.
|
||||
|
||||
### Discuter d'un fichier
|
||||
|
||||
- **Bouton « Chat »** (💬, barre d'actions du document) : ouvre un panneau
|
||||
latéral avec l'historique de la discussion et la saisie d'un message.
|
||||
- Les nouveaux messages s'affichent en direct ; si le panneau est fermé, une
|
||||
notification annonce l'envoi. L'historique est conservé par fichier.
|
||||
- Le panneau **suit le document** : changer de fichier re-cible la discussion.
|
||||
|
||||
### Chat général
|
||||
|
||||
- Onglet **💬 Chat** (dernier de la sidebar, sous les onglets) : conversation
|
||||
centrale, valable pour toute l'application, avec **pastille rouge** quand
|
||||
de nouveaux messages arrivent.
|
||||
- **📎** attache une image ou une vidéo (25 MB max) ; les URL sont cliquables.
|
||||
- La **barre de filtre** sert à chercher dans le chat (texte ou auteur) quand
|
||||
cet onglet est actif.
|
||||
- **🗑** (survol d'un message) : supprime votre message — un administrateur
|
||||
peut supprimer celui de n'importe qui. Confirmation demandée, le retrait
|
||||
est immédiat pour tout le monde.
|
||||
- **Messages privés** : la rangée de pastilles au-dessus du fil propose
|
||||
**Général** puis chaque utilisateur ; choisissez un destinataire pour une
|
||||
conversation à deux, avec son propre compteur de non-lus.
|
||||
- **Link preview** : coller une URL suffit — titre, description, image et
|
||||
site s'affichent en carte cliquable sous le message (si le site répond).
|
||||
|
||||
---
|
||||
|
||||
## 7. Rechercher
|
||||
|
||||
@@ -134,24 +134,87 @@ curl "http://localhost:2020/api/file/Recettes/pdf/info?path=menu.pdf"
|
||||
|
||||
## 6. Tableurs Excel (XLSX)
|
||||
|
||||
### Créer un classeur
|
||||
|
||||
Pour démarrer un tableur, clic droit sur un vault ou un dossier →
|
||||
**Nouveau fichier** (ou **Nouveau fichier ici**), choisissez le type **Excel
|
||||
(.xlsx)** et saisissez un nom : le classeur est créé avec une feuille
|
||||
**Feuille1** et s'ouvre directement dans l'éditeur, prêt à être saisi. La même
|
||||
liste de types est accessible depuis la palette de commandes (`Ctrl+Alt+Space`).
|
||||
|
||||
### Affichage et édition
|
||||
|
||||
Un fichier `.xlsx` s'ouvre dans une visionneuse dédiée : un tableau par
|
||||
feuille, des onglets pour naviguer entre elles (toujours visibles, même à
|
||||
une seule feuille), les en-têtes A1/B1 et les numéros de ligne. La barre de
|
||||
commandes regroupe les actions en sections (Formules · Insertion · Vue ·
|
||||
Fichier) autour d'un bouton **Enregistrer** principal. Chaque cellule est
|
||||
modifiable directement (clic), `Entrée` valide, `Échap` annule la saisie.
|
||||
**Enregistrer** envoie les cellules modifiées à
|
||||
commandes reprend l'ordre de Google Sheets (recherche, annuler/rétablir,
|
||||
impression, peinture, zoom, formats de nombre, police, couleurs, bordures,
|
||||
fusion, alignements, retour ligne, rotation, options) et le bouton **⋮**
|
||||
regroupe figeage, tri, filtre, dimensions, lien, commentaire ; les menus
|
||||
**Mise en forme** / **Structure** restent disponibles. Elle est **épinglée en
|
||||
haut** de la page et l'éditeur occupe toute la largeur du contenu, pour que
|
||||
les commandes restent atteignables pendant le défilement d'un grand tableau.
|
||||
|
||||
**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
|
||||
**backup automatique** du fichier avant écriture, et une écriture
|
||||
**atomique** (le classeur n'est jamais laissé à moitié écrit).
|
||||
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Deux
|
||||
pastilles d'état rappellent les limites de la vue : **« Lecture seule »**
|
||||
pour les formats `.xls`/`.ods`, et **« Formules non recalculées »** — ObsiGate
|
||||
affiche la formule telle qu'elle est enregistrée, Excel la recalcule à
|
||||
l'ouverture et les cellules dépendantes ne se rafraîchissent pas à l'écran.
|
||||
Le bouton **« + »** à côté des onglets ajoute une nouvelle feuille. Une
|
||||
pastille **« Lecture seule »** rappelle la seule limite de la vue : les
|
||||
formats `.xls`/`.ods`. ObsiGate calcule par ailleurs les formules courantes
|
||||
à l'écran (arithmétique, `SOMME`, `MOYENNE`, `SI`…) ; les fonctions non
|
||||
prises en charge affichent la formule telle qu'enregistrée, Excel la
|
||||
recalcule à l'ouverture.
|
||||
|
||||
Le tableau suit le **thème de l'application** : fond, en-têtes, bordures et
|
||||
lignes alternées sont dérivés du thème actif (tous les thèmes, y compris
|
||||
contrasté élevé et sépia).
|
||||
|
||||
La grille montre toujours les colonnes **A à Z** et **1000 lignes**, comme un
|
||||
tableur : les lignes et colonnes inutilisées restent affichées et leurs
|
||||
cellules vides sont **éditables** comme les autres. Seule la feuille affichée
|
||||
est étendue, à son ouverture.
|
||||
|
||||
### Avertissement avant enregistrement
|
||||
|
||||
@@ -262,18 +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
|
||||
**active l'onglet** qui la contient.
|
||||
|
||||
La sortie propose quatre formats, toujours sur le **contenu affiché** (et jamais
|
||||
sur les valeurs calculées en cache) :
|
||||
La sortie est rassemblée dans un seul menu **Fichier**, placé à droite des
|
||||
onglets de feuilles, juste avant le bouton **Enregistrer**. Il propose cinq
|
||||
actions, toujours sur le **contenu affiché** (et jamais sur les valeurs
|
||||
calculées en cache) :
|
||||
|
||||
- **CSV** (bouton `CSV`) — exporte la **sélection** quand une plage de plusieurs
|
||||
cellules est active (le nom du fichier reprend la plage, ex.
|
||||
`Fruits-A1B2.csv`), sinon la feuille entière ;
|
||||
- **Markdown** et **HTML** (menu **Exporter**) — tableau markdown ou document
|
||||
HTML autonome, mêmes règles de sélection ;
|
||||
- **Imprimer** (menu **Exporter**) — imprime la feuille ou la sélection seule,
|
||||
sans le ruban ni les panneaux de l'application.
|
||||
- **Télécharger le fichier** — le classeur d'origine, tel qu'il est sur le
|
||||
disque ;
|
||||
- **CSV** — exporte la **sélection** quand une plage de plusieurs cellules est
|
||||
active (le nom du fichier reprend la plage, ex. `Fruits-A1B2.csv`), sinon la
|
||||
feuille entière ;
|
||||
- **Markdown** et **HTML** — tableau markdown ou document HTML autonome,
|
||||
mêmes règles de sélection ;
|
||||
- **Imprimer** — imprime la feuille ou la sélection seule, sans le ruban ni
|
||||
les panneaux de l'application.
|
||||
|
||||
Rien de tout cela ne modifie le classeur.
|
||||
Le menu se ferme au clic extérieur, à la perte de focus, avec `Échap` ou au
|
||||
défilement, et se parcourt aux flèches. Rien de tout cela ne modifie le
|
||||
classeur.
|
||||
|
||||
**Structure** — le menu **Structure** de la barre d'outils ajoute,
|
||||
renomme, duplique ou supprime une feuille, et insère/supprime des lignes ou
|
||||
@@ -281,12 +350,23 @@ colonnes autour de la cellule active (`PUT …/xlsx/structure`, backup
|
||||
automatique et confirmation, comme pour l'édition des cellules).
|
||||
|
||||
**Mise en forme** — le bouton **Mise en forme** ouvre un menu qui agit sur la
|
||||
**sélection courante** (une cellule ou une plage) :
|
||||
**sélection courante** (une cellule ou une plage), repris aussi dans la barre
|
||||
d'outils (gras, italique, barré, souligné, taille, couleurs, bordures,
|
||||
alignements, retour ligne, rotation) :
|
||||
|
||||
- **caractère** — gras, italique, souligné, effacer la mise en forme ;
|
||||
- **alignement** — gauche, centré, droite ;
|
||||
- **couleurs** — couleur de police et couleur de fond (sélecteur natif, aucune
|
||||
palette imposée) ;
|
||||
- **caractère** — gras, italique, souligné, barré, taille de police,
|
||||
effacer la mise en forme ;
|
||||
- **alignement** — gauche, centré, droite, justifié (+ vertical, retour à la
|
||||
ligne, rotation) ;
|
||||
- **bordures** — toutes, extérieures, aucune ;
|
||||
- **couleurs** — couleur de police et couleur de fond, via un **panneau façon
|
||||
Google Sheets** : *Réinitialiser*, une palette de 80 pastilles (une ligne de
|
||||
gris du noir au blanc, puis huit teintes du clair au foncé), une section
|
||||
**Standard** de huit couleurs prédéfinies et une section **Personnalisé**.
|
||||
Un clic sur une pastille referme le panneau et applique la couleur à la
|
||||
cellule ou à la plage sélectionnée. Le bouton **+** ouvre le sélecteur du
|
||||
système pour une teinte libre. « Mise en forme conditionnelle » et
|
||||
« Couleurs en alternance » sont annoncées mais pas encore disponibles ;
|
||||
- **format de nombre** — général, nombre, pourcentage, devise, date, texte ;
|
||||
- **structure** — fusionner / défusionner les cellules, figer / libérer les
|
||||
volets, largeur de colonne, hauteur de ligne (fusionner exige une vraie
|
||||
@@ -300,8 +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).
|
||||
La lecture restitue par ailleurs les couleurs, polices, cellules fusionnées et
|
||||
volets figés du fichier ; l'ancrage de la zone figée est conservé au défilement.
|
||||
Les **commentaires**, liens hypertexte, validation de données, mise en forme
|
||||
conditionnelle et bordures restent hors périmètre.
|
||||
Les **commentaires** se lisent (pastille) et s'éditent (mini-barre ou menu ⋮) ;
|
||||
les liens s'insèrent comme formules `=LIEN(url;libellé)` (ouvr. `Ctrl+clic`).
|
||||
Restent hors périmètre : validation de données, mise en forme conditionnelle,
|
||||
graphiques et tableaux croisés dynamiques.
|
||||
|
||||
**Formats de fichiers** — `.xlsm` s'édite comme un `.xlsx` et ses
|
||||
**macros sont préservées** à l'enregistrement (y compris le chargement des
|
||||
@@ -354,11 +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
|
||||
la source de vérité : utilisez **Charger la suite** pour exporter au-delà du
|
||||
plafond.
|
||||
- **Aucun moteur de formule** : ObsiGate lit et écrit les formules telles
|
||||
qu'Excel les a enregistrées, sans jamais les recalculer. Une saisie
|
||||
commençant par `=` ou `@` est stockée comme **texte** (garde anti-DDE)
|
||||
tant que le bouton `f(x)` n'est pas activé ; l'enregistrement vous le
|
||||
signale par un message. Excel reste la référence pour les valeurs calculées.
|
||||
- **Moteur de formule local** : ObsiGate calcule à l'écran l'arithmétique, les
|
||||
références, les plages et une trentaine de fonctions en français et en
|
||||
anglais (`SOMME`, `MOYENNE`, `SI`, `LIEN`…). Une saisie commençant par `=`
|
||||
ou `@` reste stockée comme **texte** (garde anti-DDE) tant que le bouton
|
||||
`f(x)` n'est pas activé ; l'enregistrement vous le signale par un message.
|
||||
Les fonctions non prises en charge affichent la valeur mise en cache par
|
||||
Excel quand elle existe. Excel reste la référence pour les valeurs calculées.
|
||||
- L'**annulation** couvre l'édition, l'effacement, le tri/filtre et les
|
||||
actions de structure ; en revanche une **suppression** (feuille, ligne,
|
||||
colonne) n'est pas annulable, faute d'inverse.
|
||||
|
||||
+19
-1
@@ -216,6 +216,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 |
|
||||
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
|
||||
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
|
||||
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
|
||||
| *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles |
|
||||
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -293,7 +296,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-17 | BUG-065, #78 (complément) | Correction + feature | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs`, `docs/features/excalidraw.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-065** : l'auto-save Excalidraw (débounce 2 s) déclenchait `PUT save` → SSE `index_updated` → `reloadExternalWrite` → `openFile` → recréation de l'iframe = refresh visible pendant le dessin. Auto-save retirée (`excalidraw-viewer.js` : plus de `requestSave`/`saveTimer`), sauvegarde explicite (bouton 💾 / Ctrl+S) ; `reloadExternalWrite` (utils.js) court-circuite le re-rendu si un iframe Excalidraw est ouvert sur ce fichier (attributs `data-excalidraw-vault`/`data-excalidraw-path`) ; le badge « Modified » suit désormais une signature des éléments (`id:versionNonce`) au lieu de tout `onChange` — resize/zoom/plein écran ne marquent plus le fichier modifié. **#78 (complément)** : bouton **plein écran** `#btn-fullscreen` dans la barre d'outils de l'éditeur (`requestFullscreen` sur le document de l'iframe) + iframe créée avec `allow="fullscreen" allowfullscreen`. Vérifié Playwright : bascule plein écran OK (`document.fullscreenElement` true→false), badge non modifié après bascule ; tests statiques `excalidraw-viewer.test.mjs` 12/12, validate-imports 38 modules, unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
|
||||
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
@@ -328,6 +331,21 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-02 | BUG-106 | Correction | `backend/main.py`, `tests/test_csp_nonce.py` | **BUG-106 — page blanche sur /docs** : la CSP `#87 T5c` (`script-src` sans `unsafe-inline`, nonce frais par réponse) s'applique aussi aux pages générées par FastAPI (`/docs`, `/redoc`) dont le balisage n'est jamais passé par `inject_csp_nonce` → le navigateur refusait le script inline `SwaggerUIBundle(…)`, la div restait vide. `SecurityHeadersMiddleware` injecte désormais le nonce dans ce HTML avec le helper existant ; le corps est décompressé/recompressé car `SSESafeGZipMiddleware` est plus proche de la route (les réponses arrivent gunzippées). `/redoc` n'a aucun script inline (bundle jsdelivr seul, couvert par `script-src`) — vérifié tel quel. Vérifié : `test_docs_inline_script_nonced`, `test_redoc_served_with_csp`, `test_docs_external_bundle_not_nonced` + 45 passed sur les 3 suites sécurité, ruff/mypy 0. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
|
||||
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
|
||||
| 2026-10-08 | #189 | Fonctionnalité | `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `docs/ROADMAP.md`, `docs/features/xlsx-mobile-layout-189.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md`, `CHANGELOG.md` | **#189 — mise en page mobile du tableur** : sur ≤ 768 px les barres menus/ruban/Recherche sont repliées par défaut (la barre des feuilles + barre formule fx restent visibles, la grille récupère la hauteur perdue), bascule unique `#xlsx-tools-toggle` (☰, `aria-expanded`), cibles tactiles ≥ 44 px, groupes du ruban qui s'enroulent, onglets de feuilles défilants, inputs 16 px (anti-zoom iOS). E2E mobile ajouté + helper `openXlsx()` (déplie le ruban). 10 échecs E2E mobile **préexistants** prouvés par `git stash` (hors périmètre, CI ne lance que desktop). Vérifié : xlsx-viewer.test.mjs 165/165, E2E desktop 19 passed/1 skipped, suite 1628 passed, ruff/mypy 0, CI run #1937 success | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #191 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#191 — chat : suppression de post, messages privés, boîte compacte, link preview** : (A) `DELETE /api/chat/{id}` + `/api/chat/dm/{peer}/{id}` (auteur ou admin, 403 sinon) avec diffusion SSE `chat_deleted` et bouton 🗑 au survol côté client (`confirm()` natif) ; (B) DM 2 utilisateurs : vault `__dm__` + `dm_path(a,b)` trié, `GET /api/chat/users` (destinataires sans soi-même, aucun hash exposé), routes `GET/POST /api/chat/dm/{user}` (404 inconnu, 400 DM à soi), rangée de canaux `.chat-channels` dans la sidebar, non-lus par canal (compteurs localStorage, anciens timestamps/compteurs mélangés unifiés) ; (C) bouton d'envoi réduit à une icône dans les deux formulaires ; (D) `build_preview(text)` : 1ʳᵉ URL → garde SSRF `_assert_public_http_url` → `httpx.get` 5 s/512 Ko → OG (titre/desc/image/site) en carte cliquable, best-effort (échec → message sans carte), cache 200 entrées. Bug trouvé en cours de route : `max_redirects` n'existe pas sur `httpx.get` → TypeError avalé par le `try` = previews jamais générées en prod, invisible des tests (ils mockaient `build_preview`) ; arg retiré + `test_build_preview_happy_path_parses_og` (httpx mocké) ajouté. Tests : pytest 45 file_chat (44→45, +13 sur #191), JSDOM filechat 16 (+5) |
|
||||
| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-10 | BUG-111 | Correction UI admin | `frontend/admin.html`, `frontend/js/admin.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-111** : (1) la modale « Administration — Utilisateurs » ouverte depuis `/admin` n'affichait pas l'icône du bouton fermer — `safeCreateIcons()` retourne immédiatement car le global `lucide` n'existe pas sur `admin.html` (script UMD absent — seul index.html le chargeait) ; ajout du même `<script defer>` lucide@0.344.0. (2) Le tableau de bord admin affichait les 4 sections d'un coup (scrollspy) — `_wireSectionNav` masque désormais toutes les sections sauf celle cliquée (état initial : hash de l'URL ou section Stats). Vérifié : 1705 passed, suites frontend vertes, instance docker locale 2020. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité | `backend/user_home.py` (nouveau), `backend/indexer.py`, `backend/auth/router.py`, `backend/routers/vaults.py`, `backend/main.py`, `docker-compose.yml`, `tests/test_user_home.py` (nouveau), `tests/test_auth_api.py`, `docs/features/user-home-194.md` (nouveau), `docs/ROADMAP.md`, `CHANGELOG.md`, `README.md`, `README.fr.md` | **#194 — dossier personnel par utilisateur** : chaque compte reçoit `<OBSIGATE_HOME_ROOT>/<user>` monté comme vault propre `home-<user>` (option A validée) — l'isolation s'appuie sur l'ACL par vault **existante** (`check_vault_access`), aucune ACL par chemin inventée. `backend/user_home.py` : `ensure_user_home()` idempotent (mkdir → `add_vault_to_index` → `persist_vault` → watcher → SSE → octroi dans `user.vaults`) appelé à `POST /api/auth/admin/users` **et** au démarrage (`ensure_all_user_homes()` : migration des users existants, réparation des dossiers/registres manquants, sweep des vaults `home-*` orphelins dont l'utilisateur a disparu hors route — garde `Path.parent == racine` pour ne jamais toucher à un vault admin homonyme) ; `release_user_home()` à la suppression (index/watcher/registre refermés, **dossier conservé**). **Blocage levé** : registre persistant `data/vaults.json` fusionné par `load_vault_config()` — avant, tout vault ajouté à la volée (`/api/vaults/add`) disparaissait au premier reindex complet et au redémarrage. Correctif au passage : `reload_single_vault()` remet `vault_config[name]` après `remove_vault_from_index()`. Réponse du POST = vaults réactualisés (l'instantané de `create_user` était antérieur à l'octroi). Déploiement : montage unique `/NFS/OBSIDIAN_DOC/Home:/vaults/Home` + `OBSIGATE_HOME_ROOT` (absente = feature inactive, dev/tests/desktop inchangés). Vérifié : `test_user_home.py` 12/12 (création, idempotence, username invalide, persistance après `load_vault_config`, retrait, isolation `check_vault_access`, orphelins), `test_auth_api.py::TestUserHome` 2/2, suite **1703 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13 | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres `« * »` écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes `« * »` → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+324
-38
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.53.3 | **Dernière mise à jour :** 2026-10-07
|
||||
> **Version :** 2.65.2 | **Dernière mise à jour :** 2026-10-10
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -30,6 +30,33 @@
|
||||
|
||||
## 🔵 En cours
|
||||
|
||||
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours
|
||||
- **Cadrage :** chaque utilisateur créé reçoit un dossier personnel monté en vault propre
|
||||
(`home-<user>`), isolé par l'ACL par vault existante — pas d'ACL par chemin à inventer.
|
||||
- **Décisions validées :** option A (un vault par utilisateur), nom `home-<user>`,
|
||||
dossier **conservé** à la suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home`
|
||||
(montée `/vaults/Home`, exposée par `OBSIGATE_HOME_ROOT`).
|
||||
- **Sous-tâches :**
|
||||
- [x] ID stable créé (avant codage, AGENTS.md)
|
||||
- [x] Registre persistant des vaults dynamiques `data/vaults.json` (les vaults ajoutés à la
|
||||
volée sont aujourd'hui perdus au redémarrage / reindex — `load_vault_config` ne lit
|
||||
que les env `VAULT_N_*`)
|
||||
- [x] Service `ensure_user_home()` idempotent : mkdir + vault + octroi, appelé à la création
|
||||
d'un user et au démarrage (réparation/migration)
|
||||
- [x] Retrait au suppression de compte (index/watcher/registre) sans supprimer le dossier
|
||||
- [x] Montage Docker + `OBSIGATE_HOME_ROOT` (prod **et** compose de test
|
||||
`docker-compose.test-win.yml` → `test_home:/vaults/Home`)
|
||||
- [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison
|
||||
lucide (`getVaultIcon`), vérifié live sur l'instance 2020
|
||||
- [x] Isolation : « * » (admin) ne couvre **plus** les dossiers persos —
|
||||
`check_vault_access` + filtres des routes + `/api/search` (avant
|
||||
pagination) ; octroi du home aussi aux comptes « * »
|
||||
- [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs
|
||||
|
||||
---
|
||||
|
||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Framework :** Tauri v2 (Rust + Webview)
|
||||
@@ -40,29 +67,9 @@
|
||||
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
|
||||
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
|
||||
|
||||
### 159. Gestion desktop des vaults & dossiers — retrait par menu contextuel + ajout en configuration
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** commandes Rust existantes (`add/remove/list_vaults|dirs`) + `ContextMenuManager`
|
||||
- **Statut :** ✅ livré le 2026-10-02 (v2.50.0) — ouvert le 2026-10-02 (prérequis : BUG-104/BUG-105 livrés)
|
||||
- **Reste à faire :**
|
||||
- [x] Retrait d'un vault **ou dossier racine** par menu contextuel (entrée « Retirer de l'application », desktop uniquement, confirmation, déregistration seule)
|
||||
- [x] Section Configuration : liste des vaults/dossiers desktop + ajout (bouton vault existant `pickAndAddVault` + nouveau sélecteur `pick_folder` sans effet de bord pour les dossiers)
|
||||
- [x] Rafraîchissement de la jump list après chaque ajout/retrait
|
||||
- [x] i18n FR/EN, tests (garde-fou ACL + unit), E2E, fiche features, release
|
||||
|
||||
### 160. Premier lancement professionnel + section Configuration harmonisée
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** config Rust + CSS classes existantes
|
||||
- **Statut :** ✅ livré le 2026-10-02 (v2.51.0) — ouvert le 2026-10-02
|
||||
- **Reste à faire :**
|
||||
- [x] Premier lancement : monter `%USERPROFILE%\ObsiGate` comme vault par défaut (remplace `voute_obsidian`), `vault_path` dessus, nom de dossier racine home dérivé du chemin (fin du hardcode « bruno »)
|
||||
- [x] Document `Prise en main.md` (contenu embarqué `include_str!`) écrit dans ce dossier si absent — jamais d'écrasement
|
||||
- [x] Refonte visuelle de la section « 🖥️ Vaults & dossiers (Desktop) » : classes CSS (motif `webauthn-key-item`), boutons `.config-btn-sm`, zéro style inline, override mobile 44px
|
||||
- [x] Tests Rust (config par défaut + doc embarqué), suites frontend, E2E, docs, release
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Visionneuse & édition Excel (P0/P1/P2)
|
||||
## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2)
|
||||
|
||||
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
|
||||
|
||||
@@ -112,7 +119,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Refonte UI/UX tableur (P2)
|
||||
## ✅ Terminé — Refonte UI/UX tableur (P2)
|
||||
|
||||
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
|
||||
|
||||
@@ -139,7 +146,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Ergonomie tableur
|
||||
## ✅ Terminé — Ergonomie tableur
|
||||
|
||||
### 155. Ergonomie tableur — menu contextuel & sélection type Excel
|
||||
|
||||
@@ -157,12 +164,12 @@
|
||||
|
||||
---
|
||||
|
||||
## 🔵 En cours — Polish menus tableur
|
||||
## ✅ Terminé — Polish menus tableur
|
||||
|
||||
### 179. Éditeur tableur — icônes des menus & boutons, fermeture au focus, polish mobile
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** 🔵 en cours depuis 2026-10-04
|
||||
- **Statut :** ✅ **livré le 2026-10-04** (v2.53.0) — ouvert le 2026-10-04
|
||||
- **Fiche :** [features/xlsx-menus-179.md](./features/xlsx-menus-179.md)
|
||||
- **Description :** la grille (#155), les menus Structure/Mise en forme/Export
|
||||
(#156-A8/A11/A14) et les boutons du ruban (#154) sont textuels et restent
|
||||
@@ -181,33 +188,236 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Ergonomie & grille du tableur
|
||||
|
||||
### 186. Création d'un fichier Excel depuis « Créer un fichier »
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
- **Statut :** ✅ **livré le 2026-10-05** - ouvert le 2026-10-05
|
||||
- **Fiche :** [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md)
|
||||
- **Description :** l'éditeur Excel étant disponible, la modale « Créer un fichier » ne
|
||||
proposait que des formats texte (`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`,
|
||||
`.ps1`) + Excalidraw : impossible de démarrer un classeur depuis l'UI. Il fallait
|
||||
passer par l'assistant IA (`create_xlsx`) ou déposer un fichier existant.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Option **Excel (.xlsx)** dans la liste des types, et les 10 libellés du
|
||||
`<select>` passés en i18n FR/EN (`modal.ext_*`) — ils étaient codés en dur
|
||||
- [x] **A2** `create_file()` backend : un `.xlsx` est une archive OPC, pas du texte —
|
||||
le classeur est **construit avec openpyxl** (une feuille `Feuille1`) au lieu
|
||||
d'écrire la charge utile vide, sinon le fichier est illisible par la visionneuse
|
||||
- [x] **A3** Tests : non-régression pytest (`test_api_main.py` — classeur relu par
|
||||
openpyxl et servi en `is_xlsx`), E2E `xlsx-viewer.spec.js` (option présente,
|
||||
création depuis le menu contextuel, ouverture dans la grille)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Assistant IA
|
||||
|
||||
### 187. Assistant IA — mode agent toujours actif, retrait du bouton toggle
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
- **Statut :** ✅ **livré le 2026-10-06** — ouvert le 2026-10-06
|
||||
- **Statut :** ✅ **livré le 2026-10-07** (v2.53.2) — ouvert le 2026-10-06
|
||||
- **Description :** le bouton « mode agent » du panneau (toggle persisté en
|
||||
localStorage, défaut OFF) n'était plus protecteur : Deep Research et les
|
||||
quick actions `agent: true` basculaient déjà le mode en silence. Retiré au
|
||||
profit d'un agent toujours actif : toute requête texte part sur
|
||||
`/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les mutations
|
||||
gardent la confirmation two-step) ; les images seules restent sur
|
||||
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant textuelle).
|
||||
quick actions `agent: true` basculaient déjà le mode en silence. Un
|
||||
utilisateur pouvait donc voir l'assistant agir sans jamais avoir activé le
|
||||
bouton. Retiré au profit d'un agent toujours actif : toute requête texte
|
||||
part sur `/api/ai/bookslm/agent` (outils lire/lister/chercher/modifier ; les
|
||||
mutations gardent la confirmation two-step) ; les images seules restent sur
|
||||
`/api/ai/bookslm/chat` (endpoint multimodal, la boucle agent étant
|
||||
textuelle).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Frontend : bouton header, `_agentMode`, toggle/persistance et les
|
||||
auto-bascules (Deep Research, quick actions) supprimés ; `_postChat`
|
||||
route texte→`/agent`, images→`/chat` ; aide et i18n FR/EN mises à jour
|
||||
- [x] **A2** Optimisations requêtes AI : `/agent` résout le provider comme
|
||||
`/chat` (avant : `req.provider` brut transmis au provider adapter →
|
||||
moteur réel ≠ étiquette SSE) ; schémas d'outils mis en cache (~27 ms de
|
||||
pydantic économisés par requête agent/MCP)
|
||||
- [x] **A2** Optimisations requêtes AI : `/agent` résout désormais le
|
||||
provider comme `/chat` (avant : `req.provider` brut transmis au
|
||||
provider adapter → moteur réel ≠ étiquette SSE) ; schémas d'outils
|
||||
mis en cache (~27 ms de pydantic économisés par requête agent/MCP)
|
||||
- [x] **A3** Tests : `ai.test.mjs` adapté (100/100) ; non-régression pytest
|
||||
(`test_bookslm.py` provider résolu, `test_tools.py` cache sûr) ;
|
||||
ruff/mypy 0 erreur ; build + instance Docker locale à jour
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Barre de menus & grille du tableur
|
||||
|
||||
### 185. Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-menus-185.md](./features/xlsx-menus-185.md)
|
||||
- **Description :** l'éditeur n'avait **aucune barre de menus** (ni Fichier, ni
|
||||
Modifier, ni Affichage, ni Insertion, ni Format, ni Données, ni Outils, ni
|
||||
Gemini, ni Extensions, ni Aide) : beaucoup de fonctions n'étaient atteignables
|
||||
que par clic droit. Le ruban gardait un chromer bordersé qui ne ressemblait à
|
||||
rien et la grille n'était bordée qu'en bas avec une couleur quasi identique au
|
||||
fond des cellules — le quadrillage se lisait comme une tache.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** `frontend/js/xlsx/menus.js` : les **dix menus de Google Sheets**,
|
||||
dans l'ordre et avec les libellés exacts (161 entrées) ; sections en
|
||||
accordéon ; le menu **Fichier** quitte la barre d'onglets pour la barre de
|
||||
menus en **conservant ses identifiants**, seul *Enregistrer* reste
|
||||
épinglé
|
||||
- [x] **A2** **Majorité des fonctions câblées** : Partager, Renommer,
|
||||
Corbeille, Historique, Détails ; Annuler/Rétablir/Couper/Copier/Coller,
|
||||
**Collage spécial (valeurs)**, Supprimer, Tout sélectionner,
|
||||
Rechercher-et-remplacer ; Figer, Afficher (barre de formule / grille /
|
||||
en-têtes / tableau de bord), Zoom, Plein écran ; Lignes / Colonnes /
|
||||
Cellules, Lien, Commentaire, Fonction ; Nombre, Thème, B/I/U/S, Taille,
|
||||
Alignement, Retour ligne, Rotation, **Couleurs alternées**, Effacer ;
|
||||
Trier, **Filtre**, Plages nommées, **Nettoyage (doublons / espaces)**,
|
||||
**Diviser le texte en colonnes** ; Autocomplétion, Accessibilité ;
|
||||
Gemini ; Aide, Mises à jour, Raccourcis, Signalement. Les 36 options
|
||||
absentes d'ObsiGate **sont listées et le disent** au clic
|
||||
- [x] **A3** **Ruban façon Sheets** : boutons plats 30 × 30 sans chromer,
|
||||
survol discret, groupes par filets ; nouveau bouton grille piloté par le
|
||||
menu Affichage
|
||||
- [x] **A4** **Grille unie et visible** : 1 px **sur les quatre côtés** de
|
||||
chaque cellule (en-têtes compris) et un seul `--grid-border` dérivé du
|
||||
thème (`color-mix` texte → surface) au lieu de `--border` invisible
|
||||
- [x] Tests JSDOM (`xlsx-menus` + `xlsx-viewer`), E2E `xlsx-viewer.spec.js`,
|
||||
i18n FR/EN (134 clés `xlsx.m*` / `xlsx.mi_*`)
|
||||
|
||||
---
|
||||
|
||||
### 184. Tableur — peinture de format, multi-lignes/colonnes, grille A→Z × 1000
|
||||
|
||||
- **Effort :** 1 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§8)
|
||||
- **Description :** « Peindre le format » échouait quand la source n'avait pas de
|
||||
format propre ; il manquait la sélection de plusieurs lignes/colonnes depuis
|
||||
les marges ; la grille s'arrêtait aux dimensions réelles du fichier au lieu
|
||||
d'aller jusqu'à A → Z et 1000 lignes.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Peinture de format **complète** (toutes propriétés) + source sans
|
||||
format ⇒ **réinitialisation** de la cible ; fuite du handler annulé
|
||||
- [x] **A2** Sélection multi-lignes / multi-colonnes par glissement sur les
|
||||
marges et `Maj`+clic ; le geste de redimensionnement garde la priorité
|
||||
- [x] **A3** Grille étendue à **A → Z × 1000 lignes**, cellules vides
|
||||
éditables, padding à l'activation de l'onglet, pas de doublons
|
||||
- [x] **A4** Performance : index de cellules `findTd`, court-circuits
|
||||
formule/styles, marqueur `data-wired`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Panneau de couleurs du tableur
|
||||
|
||||
### 183. Panneau de sélection de couleurs façon Google Sheets (texte et fond)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§7)
|
||||
- **Description :** le sélecteur natif `<input type="color">` du ruban et du menu
|
||||
Mise en forme est remplacé par un panneau conforme aux maquettes : *Réinitialiser*,
|
||||
palette 8 × 10, STANDARD, PERSONNALISÉ, pied de page. Un clic sur une pastille
|
||||
referme le panneau et applique la couleur.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Module `frontend/js/xlsx/color-picker.js` : palette 8 × 10
|
||||
(8 gris puis 8 teintes × 9 nuances), **sortie en `#rrggbb`**
|
||||
- [x] **A2** Coche de sélection blanche/noire selon la luminance
|
||||
- [x] **A3** Câblage ruban + menu Mise en forme, fermeture au clic, application
|
||||
sur cellule **ou plage**, réinitialisation, « + » / crayons vers le
|
||||
sélecteur natif
|
||||
- [x] **A4** Chrome du panneau thémé (variables CSS), i18n FR/EN (21 clés),
|
||||
pied de page honnête (message explicite pour les fonctions absentes)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Correctifs éditeur tableur
|
||||
|
||||
### 182. Tableur — désélection fiable & couleurs sur une plage
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) (§6)
|
||||
- **Description :** suite de #181 — la sélection multiple se dessinait mais ne
|
||||
s'effaçait plus, et l'application d'une couleur de texte / de fond sur une
|
||||
plage paraissait inerte.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Le clic simple efface toujours la plage, sans dépendre de
|
||||
l'événement `focus` (cause : `Maj`+clic + `preventDefault()` figeait le
|
||||
focus, donc aucun `focus` ne partait au clic suivant)
|
||||
- [x] **A2** Après `Maj`+clic, le focus suit la cellule cliquée ; la zone Nom
|
||||
affiche la plage après un glissement
|
||||
- [x] **A3** Couleurs de texte et de fond vérifiées sur les 3 chemins
|
||||
(menu Mise en forme, nuanciers du ruban, `Maj`+flèches et glissement)
|
||||
- [x] **A4** Pastille de couleur sur les nuanciers du ruban (retour visuel)
|
||||
- [x] **A5** **Contours de plage qui s'empilaient** : le sélecteur de
|
||||
nettoyage oubliait le `.` des classes (`querySelectorAll("xlsx-sel-top")`
|
||||
cherche une balise) — rien n'était effacé, les encadrés restaient et
|
||||
s'ajoutaient à chaque changement de cellule. Même famille de faute sur
|
||||
les en-têtes reflétés
|
||||
- [x] **A6** Pendant la saisie, les flèches **valident et déplacent** la
|
||||
sélection (au lieu de déplacer le curseur), comme Google Sheets ;
|
||||
`Alt`+flèche garde le saut de ligne
|
||||
- [x] Tests JSDOM `xlsx-viewer` **152/152** (+6), suites frontend vertes
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Polish éditeur tableur (mise en page & thèmes)
|
||||
|
||||
### 181. Éditeur tableur — modèle de saisie Google Sheets (sélection ≠ édition) & écarts fonctionnels
|
||||
|
||||
- **Effort :** 1 jour (A1-A3) + inventaire des écarts (A4) | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md)
|
||||
- **Description :** l'éditeur ne distingue pas **sélectionner** une cellule et
|
||||
**l'éditer** : le curseur de texte est visible en permanence, `Entrée` valide
|
||||
puis perd le focus, et l'indicateur de plage est discret. Aligner le modèle de
|
||||
saisie sur Google Sheets / Excel (A1-A3) et **inventorier** précisément ce qui
|
||||
manque encore face à un tableur de référence (A4) — le rapprochement exhaustif
|
||||
n'est pas réalisable en une tranche.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Deux états distincts : *sélection* (clic / flèches, pas de
|
||||
curseur via `caret-color: transparent`, contour visible) et *édition*
|
||||
(double-clic en place, première frappe qui **remplace**, `F2`,
|
||||
`Alt`+flèche pour un saut de ligne, `Échap` qui restaure)
|
||||
- [x] **A2** Sélection multiple : glisser, `Maj`+clic, `Maj`+flèches, avec
|
||||
contour de plage (`.xlsx-sel-*` sur les 4 bords réels) + en-têtes
|
||||
`A`/`B`… et `1`/`2`… reflétés
|
||||
- [x] **A3** `Entrée` contextuelle : en édition → valide et passe **à la
|
||||
cellule du dessous** ; en sélection → bascule en **mode édition**
|
||||
- [x] **A4** Inventaire des écarts vs Google Sheets / Excel et priorisation
|
||||
(P1 poignée de recopie / remplacer / validation de données / filtre
|
||||
auto / tri multi-critères ; P2 mise en forme conditionnelle / création
|
||||
de graphiques / TCD éditables / protection ; P3 analyse de données,
|
||||
historique, macros) — voir [fiche](./features/xlsx-sheet-input-181.md)
|
||||
- [x] Tests JSDOM (`xlsx-viewer` **146/146**, +10 cas #181), suites frontend
|
||||
vertes, build + déploiement de l'instance Docker locale
|
||||
|
||||
---
|
||||
|
||||
### 180. Éditeur tableur — redimensionnement au curseur, grille thémée, menu Fichier & barre épinglée
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡
|
||||
- **Statut :** ✅ **livré le 2026-10-08** (v2.54.0) — ouvert le 2026-10-05
|
||||
- **Fiche :** [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md)
|
||||
- **Description :** retours utilisateur après #179 — (1) le redimensionnement des
|
||||
colonnes/lignes par les marges (`A, B, C…` / `1, 2, 3…`) doit afficher un
|
||||
curseur iconique (`col-resize` / `row-resize`) quand le pointeur est sur le
|
||||
bord ; (2) la grille doit suivre le thème de l'utilisateur (15 thèmes ×
|
||||
4 modes + importés : tokens dérivés, plus de fond figé) ; (3) regrouper
|
||||
Télécharger / CSV / Exporter dans un **menu Fichier** en haut à droite, au
|
||||
niveau des onglets de feuilles, avec **Enregistrer** juste à côté ;
|
||||
(4) retirer l'indicateur « Formules calculées localement » ; (5) épingler la
|
||||
barre de boutons en haut, au bord de la marge, et étendre l'éditeur à la
|
||||
largeur de la page.
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Curseur de redimensionnement sur les marges d'en-têtes
|
||||
(en-tête de bord miroité en ligne + classe de repaint `.xlsx-resize-col/row`)
|
||||
- [x] **A2** Grille compatible tous thèmes (`--grid-*` dérivés dans
|
||||
`themes.js`, replis CSS, modes contraste élevé / sépia, menu Export sur `--surface`)
|
||||
- [x] **A3** Menu Fichier + Enregistrer au niveau des onglets (`.xlsx-sheetbar`,
|
||||
identifiants des contrôles conservés)
|
||||
- [x] **A4** Retrait de l'indicateur de formules + barre épinglée pleine largeur
|
||||
(`position: sticky`, `:has(.xlsx-viewer)` en plein cadre)
|
||||
- [x] Tests JSDOM (`xlsx-viewer` 136/136, `xlsx-menus` 12/12), E2E `xlsx-viewer.spec.js`,
|
||||
i18n FR/EN, 8 tests réalignés sur l'application locale des écritures
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Navigation
|
||||
|
||||
### 158. Navigation — vue répertoire en onglet, filtres & tris, retour Home complet
|
||||
@@ -291,6 +501,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)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -373,7 +645,7 @@
|
||||
| 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 |
|
||||
| 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 |
|
||||
| 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 |
|
||||
| 175 — Chat intégré | Non livré (transport WebSocket/rooms de #62 réutilisable) | ⚪ #169 |
|
||||
| 175 — Chat intégré | **Livré le 2026-10-08** (SSE, panneau latéral, historique, toast) | ✅ #169 |
|
||||
| 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 |
|
||||
| 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 |
|
||||
| 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ |
|
||||
@@ -444,7 +716,7 @@
|
||||
| 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) |
|
||||
| 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant |
|
||||
| 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) | 3-4 j | 🟢 | transport WebSocket/rooms de la collab #62 |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) — ✅ **livré le 2026-10-08** ([fiche](./features/file-chat-169.md) ; transport SSE #62, pas de 2ᵉ WebSocket) | 3-4 j | 🟢 | transport SSE #62 |
|
||||
| 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches |
|
||||
| 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) |
|
||||
| 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) |
|
||||
@@ -475,6 +747,20 @@
|
||||
| 154 | Tableur — Refonte UI/UX (ruban groupé, onglets permanents, badges d'état, inspecteur droit, undo/redo) | 2.40.0→2.43.1 | [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) |
|
||||
| 155 | Tableur — Menu contextuel (clic droit / appui long) & sélection type Excel | 2.44.0 | [features/xlsx-context-menu.md](./features/xlsx-context-menu.md) |
|
||||
| 156 | Tableur — Complétude éditeur : presse-papiers/clavier (A5-A7), mise en forme (A8), décision calcul (A9), undo unifié (A10), export + recherche multi-feuilles (A11), concurrence optimiste (A12), cache méta (A13), outils IA `.xlsm`/`.csv` (A14) + BUG-096 → BUG-099 | 2.45.0 | [features/xlsx-editor-completeness.md](./features/xlsx-editor-completeness.md) |
|
||||
| 179 | Tableur — icônes des menus & boutons, fermeture au focus, polish mobile | 2.53.0 | [features/xlsx-menus-179.md](./features/xlsx-menus-179.md) |
|
||||
| 180 | Tableur — redimensionnement au curseur, grille thémée, menu Fichier, barre épinglée | 2.54.0 | [features/xlsx-sheetbar-180.md](./features/xlsx-sheetbar-180.md) |
|
||||
| 181 | Tableur — modèle de saisie Google Sheets (sélection ≠ édition) & inventaire des écarts | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 182 | Tableur — désélection fiable, contours non empilés, couleurs sur une plage | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 183 | Tableur — panneau de couleurs façon Google Sheets (texte et fond) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 184 | Tableur — peinture de format complète, multi-lignes/colonnes, grille A → Z × 1000 (croissance par blocs) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) |
|
||||
| 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.md) |
|
||||
| 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) |
|
||||
| 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 | 2.56.0 | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md) |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | 2.57.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | 2.58.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 191 | Chat — suppression de post, messages privés 2 utilisateurs (canaux), boîte d'édition compacte, link preview OG | 2.60.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 192 | Chat — boîte de saisie auto-agrandissante + accusé de réception ✓✓ (retour live SSE, notification de lecture) · BUG-109 (image de tuile de lien) | 2.61.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# #186 — Création d'un fichier Excel depuis « Créer un fichier »
|
||||
|
||||
> **Statut :** ✅ livré le 2026-10-05 | **Effort :** 0,5 jour | **Impact :** 🟢
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [Guide Recherche, PDF, Excel & Excalidraw](../GUIDES/RECHERCHE_PDF_EXCALIDRAW.md) ·
|
||||
> [#153 — Visionneuse XLSX](./xlsx-viewer.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
ObsiGate sait **lire et éditer** les classeurs depuis #152/#153, et l'assistant IA sait
|
||||
en **produire** un (`create_xlsx`, #92). Mais la modale « Créer un fichier » — celle
|
||||
du menu contextuel d'un vault / d'un dossier et de la palette de commandes —
|
||||
ne proposait que des formats **texte** :
|
||||
|
||||
`.md`, `.txt`, `.py`, `.js`, `.json`, `.yaml`, `.sh`, `.ps1` (+ Excalidraw).
|
||||
|
||||
Démarrer un classeur depuis l'UI imposait donc de passer par l'IA (avec
|
||||
confirmation) ou de déposer un fichier existant dans le vault. Le point de départ
|
||||
naturel manquait alors que l'éditeur complet était disponible.
|
||||
|
||||
## 2. Livré (A1-A3)
|
||||
|
||||
### A1 — Option Excel dans la liste des types
|
||||
|
||||
`frontend/js/ui.js`, `FileOperations.showCreateFileModal()` : ajout de
|
||||
`<option value=".xlsx">` dans le `<select id="file-ext-select">`, après `.json`
|
||||
(ordre : formats texte, puis formats enrichis, comme `.excalidraw`).
|
||||
|
||||
Au passage, les **dix libellés** du sélecteur passent en i18n
|
||||
(`modal.ext_md`, `modal.ext_txt`, …, `modal.ext_xlsx`) : ils étaient des chaînes
|
||||
françaises codées en dur, ce qui exposait un texte non traduit en mode EN et
|
||||
violait la règle « tout texte d'interface existe en FR et en EN ». Les deux
|
||||
locales sont alignées (`tests/test_i18n_parity.py`).
|
||||
|
||||
Aucun changement pour les autres formats : le nom saisi sans extension reçoit
|
||||
celle choisie (`name.endsWith(ext) ? name : name + ext`), donc un nom déjà
|
||||
suffiché n'est jamais doublé.
|
||||
|
||||
### A2 — Un vrai classeur côté backend
|
||||
|
||||
Le point non évident : `POST /api/file/{vault}` **écrit du texte**
|
||||
(`file_path.write_text(content)`), et la modale poste `content: ""`. Un `.xlsx`
|
||||
n'est pas un fichier texte mais une **archive OPC** (un ZIP) : écrire la charge
|
||||
utile vide produisait un fichier de 0 octet que ni `openpyxl`, ni la visionneuse,
|
||||
ni Excel n'ouvrent — l'utilisateur aurait vu une erreur à l'ouverture, ou pire,
|
||||
un fichier corrompu.
|
||||
|
||||
`backend/services/mutations.py` :
|
||||
|
||||
- `_empty_xlsx_bytes()` construit l'archive avec openpyxl (une feuille `Feuille1`,
|
||||
le titre par défaut déjà utilisé par l'outil IA `create_xlsx`) — import paresseux,
|
||||
comme le reste du module qui garde openpyxl optionnel à l'import ;
|
||||
- `create_file()` écrit ces octets (`write_bytes`) pour cette extension et ignore
|
||||
`content`, qui n'a pas de sens pour un classeur ; `size` renvoie le nombre
|
||||
d'octets réellement écrits (et non `len(content)`, qui aurait valu 0) ;
|
||||
- même garde-fou que `.excalidraw` : le squelette est injecté par le **service**,
|
||||
donc la palette, le glisser-déposer, l'IA et l'API en bénéficient, pas seulement
|
||||
la modale.
|
||||
|
||||
`openpyxl` reste importé à la demande : un vault sans tableur ne paie pas l'import.
|
||||
|
||||
### A3 — Tests
|
||||
|
||||
- `tests/test_api_main.py::TestFileCRUD` — `test_create_xlsx_empty_workbook` : le
|
||||
fichier créé est relu par `openpyxl` (`sheetnames == ["Feuille1"]`) **et** servi
|
||||
par `GET /api/file/{vault}` avec `is_xlsx: true` (donc lisible par la visionneuse) ;
|
||||
`test_create_xlsx_in_new_subdir` couvre la création dans un dossier inexistant
|
||||
(auto-créé). Non-régression : sans A2, l'archive serait vide et les deux tests
|
||||
échoueraient.
|
||||
- `tests/e2e/xlsx-viewer.spec.js` — bloc « création depuis l'UI » : l'option
|
||||
`.xlsx` existe et son libellé vient des locales, la création passe par le menu
|
||||
contextuel d'un dossier, et le classeur s'ouvre dans la grille (`.xlsx-table` +
|
||||
onglet `Feuille1`) ; l'API est interrogée pour confirmer `is_xlsx`. Le fichier est
|
||||
supprimé en fin de test (le run ne doit pas salir `test_vault/`).
|
||||
|
||||
## 3. Limites assumées
|
||||
|
||||
- **Le nom de feuille est `Feuille1`**, en dur. Un vrai *nouveau classeur* de
|
||||
Google Sheets le demande ; ici le classeur s'ouvre sur sa seule feuille et
|
||||
l'utilisateur la renomme via le menu Structure. La modale reste celle d'un
|
||||
éditeur de texte, volontairement simple.
|
||||
- **`.csv` reste absent de la liste**, alors que le backend l'accepte
|
||||
(`SUPPORTED_EXTENSIONS`) et que la visionneuse sait l'éditer comme tableur
|
||||
(#156-A14). L'écart est réel mais hors périmètre de #186 ; le même correctif
|
||||
s'appliquerait (une ligne dans le `<select>`).
|
||||
- **`frontend/app.js` (monolithe obsolète, non chargé par `index.html`)**
|
||||
duplique l'ancienne liste sans `.xlsx`. Le code vivant est dans `frontend/js/`;
|
||||
ce fichier n'a pas été touché.
|
||||
- L'outil IA `create_xlsx` reste le chemin pour un classeur **rempli** : la modale
|
||||
crée une grille vierge, comme un `.md` vierge.
|
||||
|
||||
## 4. Historique
|
||||
|
||||
| Date | Événement |
|
||||
|---|---|
|
||||
| 2026-10-05 | #186 ouvert puis livré dans la foulée : option `.xlsx` + squelette openpyxl dans `create_file()` |
|
||||
@@ -0,0 +1,272 @@
|
||||
# Chat intégré par fichier — #169
|
||||
|
||||
- **Statut :** ✅ livré le 2026-10-08
|
||||
- **Effort :** 3-4 j (réalisé en 1 session) | **Impact :** 🟢
|
||||
- **Transport :** SSE existant (#62) — pas de second WebSocket
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### Backend
|
||||
|
||||
- **Store `backend/file_chat.py`** — historique JSON par paire
|
||||
`(vault, path)` sous `data/chats/` :
|
||||
- nom de fichier = SHA-256 de `vault\0path` (32 premiers hex) : aucun
|
||||
séparateur de chemin utilisateur dans le nom → traversal impossible ;
|
||||
- plafond **500 messages** par fichier (les plus vieux supprimés) ;
|
||||
- texte tronqué à **4000 caractères** ;
|
||||
- écriture atomique (tmp + move) ; fichier corrompu → historique vide
|
||||
(fallback, pas de crash).
|
||||
- **Routes `backend/routers/file_chat.py`** (tag OpenAPI « Files » dérivé) :
|
||||
- `GET /api/file/{vault}/chat?path=…` → historique chronologique ;
|
||||
- `POST /api/file/{vault}/chat` `{path, text}` → ajout + broadcast SSE ;
|
||||
- garde commune : `require_auth` + `check_vault_access` +
|
||||
`resolve_safe_path` (traversal → 403/500 via le handler `ServiceError`) ;
|
||||
- `text` ou `path` vides → 400 ; vault inconnu → 404.
|
||||
- **Schémas** `ChatMessageItem` / `ChatHistoryResponse` /
|
||||
`ChatMessageResponse` dans `backend/schemas.py` (`response_model` sur les
|
||||
deux routes).
|
||||
- **Broadcast** : `sse_manager.broadcast("chat_message", {vault, path, message})`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **`frontend/js/filechat.js`** — panneau latéral :
|
||||
- bouton 💬 (`.btn-chat`) dans le groupe nav de la toolbar fichier
|
||||
(viewer.js), toggle ouvre/ferme ;
|
||||
- historique rendu chronologiquement, bulles « mine » à droite,
|
||||
`textContent` pour le corps (pas d'injection HTML) ;
|
||||
- envoi POST optimiste + dédoublonnage par `id` à la réception SSE ;
|
||||
- panneau fermé / autre fichier → **toast** « Nouveau message de … »
|
||||
(silencier si c'est son propre message).
|
||||
- **`frontend/js/sync.js`** — listener SSE `chat_message` avec import
|
||||
dynamique de `filechat.js` (pas de cycle d'imports).
|
||||
- **CSS** bloc `#169` : panneau fixe 340 px, **plein écran ≤ 768 px**,
|
||||
variables CSS avec fallbacks, input 16 px (anti-zoom iOS).
|
||||
- **i18n** : 10 clés `chat.*` dans `fr.json` et `en.json`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_file_chat.py` — 15 tests : roundtrip store, isolation par
|
||||
fichier, plafond de rétention, troncature, traversal hashée, fichier
|
||||
corrompu, routes (200/400/404/403), fixture autouse isolant `CHAT_DIR`.
|
||||
- `tests/frontend/filechat.test.mjs` — 6 tests JSDOM : empty state, rendu
|
||||
chronologique + classe `mine`, anti-injection HTML, append live + dédup
|
||||
par id, isolation inter-fichiers, toggle. Locales réellement chargées
|
||||
(fetch simulé vers `frontend/locales/`).
|
||||
- Les deux sont ajoutés au pipeline CI (`.gitea/workflows/ci.yml`).
|
||||
|
||||
## #190 — Suivi du document, chat général, ordre des onglets (2026-10-08)
|
||||
|
||||
### A. Le panneau suit le document
|
||||
|
||||
- En-tête du panneau : `.file-chat-target` affiche le titre du document
|
||||
(l'attribut `title` porte le chemin complet).
|
||||
- `followFileChat(vault, path, title)` est appelé en tête de `renderFile()` :
|
||||
panneau ouvert + autre document → re-cible (reload de l'historique) ;
|
||||
panneau fermé → no-op. Le bouton 💬 transmet `data.title`.
|
||||
|
||||
### B. Chat général en onglet sidebar
|
||||
|
||||
- Onglet `#sidebar-tab-chat` (dernier, `message-circle`) + panneau
|
||||
`#sidebar-panel-chat` (liste + 📎 + saisie + Envoyer, Entrée = envoyer).
|
||||
- **Store** : rien de nouveau — les sentinelles `GLOBAL_VAULT="__global__"` /
|
||||
`GLOBAL_PATH="general"` réutilisent `add_message`/`get_messages`.
|
||||
- **Routes** : `GET/POST /api/chat`, `POST /api/chat/upload`,
|
||||
`GET /api/chat/attachment/{name}`.
|
||||
- **Pièces jointes** : allow-list d'extensions (11 : images + vidéos), 25 MB,
|
||||
nom de stockage = UUID (le nom client n'est jamais utilisé), résolution
|
||||
par comparaison au nom + allow-list (aucun chemin utilisateur).
|
||||
- **Messages non lus** : timestamp de dernière visite dans
|
||||
`localStorage['obsigate-chat-unread']`, comptage des messages plus récents
|
||||
d'un autre auteur, pastille rouge `99+` sur l'onglet, effacée à l'ouverture.
|
||||
- **URL** : linkification `http(s)://` au rendu (texte autour en `TextNode`,
|
||||
donc pas d'injection).
|
||||
- **SSE** : `vault === "__global__"` route vers la liste sidebar (append +
|
||||
clear) ou vers badge + toast selon que l'onglet est actif.
|
||||
|
||||
### C. Ordre des onglets + filtre = recherche chat
|
||||
|
||||
- `index.html` : le bloc `.sidebar-filter` est **déplacé après** `.sidebar-tabs`.
|
||||
- `switchSidebarTab` : placeholder `sidebar.filter_chat`, dispatch `chat`.
|
||||
- `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent
|
||||
vers `filterChatMessages()` (texte **et** auteur, insensible à la casse).
|
||||
|
||||
## #191 — Suppression, messages privés, boîte compacte, link preview (2026-10-08)
|
||||
|
||||
### A. Suppression d'un post
|
||||
|
||||
- `DELETE /api/chat/{id}` et `DELETE /api/chat/dm/{peer}/{id}` :
|
||||
**auteur du message ou admin** (403 sinon), 404 si l'id n'existe pas.
|
||||
- Store : `delete_message(vault, path, id)` — écriture atomique,
|
||||
`False` si absent ; broadcast SSE `chat_deleted` `{vault, path, id}`.
|
||||
- Frontend : bouton 🗑 (`.file-chat-del`, visible au survol) rendu dans
|
||||
`_messageEl` uniquement si l'URL de suppression est fournie **et**
|
||||
que l'utilisateur est auteur/admin ; `confirm()` natif ; retrait local
|
||||
+ retrait en direct chez tous via `onChatDeleted` (relais `sync.js`).
|
||||
|
||||
### B. Messages privés 2 utilisateurs
|
||||
|
||||
- Store : `DM_VAULT = "__dm__"`, `dm_path(a, b)` = paire **triée** → les
|
||||
deux participants lisent/écrivent le même document, indépendamment de
|
||||
l'ordre.
|
||||
- Routes : `GET /api/chat/users` (destinataires — usernames +
|
||||
display_names, **sans soi-même**, aucun hash/mot de passe exposé),
|
||||
`GET/POST /api/chat/dm/{username}` (404 utilisateur inconnu, 400 DM à
|
||||
soi), `DELETE /api/chat/dm/{username}/{id}`.
|
||||
- Frontend : rangée de pastilles `.chat-channels` (« Général » + un
|
||||
bouton par utilisateur) au-dessus du fil ; `_channel` état courant,
|
||||
`_channelKey`/`_channelUrl`/`_channelDelUrl` ; placeholder dédié
|
||||
`chat.placeholder_dm` ; non-lus **par canal** (compteurs dans
|
||||
`localStorage`, l'ancien mélange timestamp/compteur est devenu un
|
||||
compteur unique `{"general": n, "dm:x": n}`, remis à zéro à l'ouverture,
|
||||
pastille = total) ; les DM SSE routés par `_pairOf(path)`.
|
||||
|
||||
### C. Boîte d'édition compacte
|
||||
|
||||
- Bouton d'envoi réduit à une icône `send` (`.file-chat-send-icon`) dans
|
||||
les deux formulaires (panneau document + sidebar) ; la saisie garde
|
||||
toute la largeur. `safeCreateIcons()` à la fin du `_renderShell`.
|
||||
|
||||
### D. Link preview
|
||||
|
||||
- `build_preview(text)` (store) : 1ʳᵉ URL du texte → garde SSRF
|
||||
`_assert_public_http_url` (host privé/loopback rejeté, schémas non
|
||||
http refusés) → `httpx.get` 5 s, 512 Ko, `follow_redirects` (limite
|
||||
httpx = 20) → parse OG par regex (`og:title` avec repli `<title>`,
|
||||
`og:description`, `og:image`, `og:site_name`) →
|
||||
`{url, title, description, image, site}`.
|
||||
- **Best-effort** : n'importe quelle erreur (URL morte, timeout, garde)
|
||||
→ `None`, le message passe quand même ; cache borné **200 entrées**.
|
||||
- Frontend : `_previewEl` rend une carte cliquable (vignette lazy
|
||||
`no-referrer`, site en capitales, titre/description tronqués 2 lignes)
|
||||
sous le corps du message.
|
||||
- Schéma : `ChatMessageItem.preview`.
|
||||
|
||||
### Tests #191
|
||||
|
||||
- pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (8,
|
||||
dont le happy path avec `httpx.get` mocké — un `max_redirects`
|
||||
inexistante sur `httpx.get` levait un TypeError silencieusement avalé
|
||||
par le `try`, testé par `test_build_preview_happy_path_parses_og`).
|
||||
- JSDOM `filechat.test.mjs` +5 : carte preview, bouton supprimer
|
||||
(droit auteur/admin), `onChatDeleted`, compteurs non-lus,
|
||||
routage DM isolé du général (16 au total).
|
||||
|
||||
## #192 — Saisie auto-agrandissante, accusé de réception, vignette de lien (2026-10-08)
|
||||
|
||||
### A. Boîte de saisie qui grandit avec le texte
|
||||
|
||||
`<input type="text">` → `<textarea rows="1">` (panneau document créé dans
|
||||
`_renderShell()`, sidebar statique dans `index.html`). `_autoGrow(el)`
|
||||
pose `height: auto` puis `min(scrollHeight, 160)px` à chaque `input`, la
|
||||
règle CSS `textarea.file-chat-input` ajoute `resize: none` +
|
||||
`overflow-y: auto` (au-delà du plafond la zone défile) et la hauteur repasse
|
||||
à une ligne après l'envoi. `Entrée` (sans `Maj`) est interceptée dans les
|
||||
deux formulaires → envoi, `Maj+Entrée` garde le retour à la ligne. Le
|
||||
placeholder de la sidebar est aussi appliqué à l'init (`t()`), le HTML ne
|
||||
portant que le FR.
|
||||
|
||||
### B. Accusé de réception (✓ envoyé / ✓✓ lu)
|
||||
|
||||
- **Store** (`backend/file_chat.py`) : le document de conversation gagne
|
||||
`read = {username: dernier_ts}` (`get_read()` / `mark_read()`), sous le
|
||||
`_LOCK` du read-modify-write partagé avec l'ajout et la suppression.
|
||||
- **API** : les trois `GET` d'historique renvoient `read` (schéma
|
||||
`ChatHistoryResponse.read`) ; nouveau `POST /api/chat/read`
|
||||
(`ChatReadResponse`) avec `_check_read()` — général = tout membre, DM =
|
||||
pair uniquement (403 sinon), fichier = ACL vault + `resolve_safe_path` —
|
||||
qui marque la lecture et diffuse `chat_read`
|
||||
`{vault, path, user, ts, read}` en SSE.
|
||||
- **Client** (`filechat.js`) : carte de lecture par surface (`_fileRead`,
|
||||
`_channelRead`). Un message est « lu » dès qu'un **autre** participant a
|
||||
un `ts ≥` celui du message (ma propre lecture ne compte pas).
|
||||
`_messageEl()` affiche `✓`/`✓✓` (`.file-chat-status`, bleu accent quand
|
||||
lu) ; `_applyRead()` fait basculer les indicateurs affichés ;
|
||||
`_syncRead()` fusionne une carte reçue ; `_markRead(vault, path)` (POST
|
||||
fire-and-forget) est émis à l'ouverture d'une conversation **et** à la
|
||||
réception d'un message pendant qu'elle est affichée. `onChatRead()`
|
||||
(relais `chat_read` ajouté dans `sync.js`) actualise en direct et
|
||||
notifie l'expéditeur par toast `chat.read_by` quand un de ses messages
|
||||
passe à ✓✓ — jamais pour son propre écho.
|
||||
|
||||
### C. BUG-109 — vignette des tuiles de lien
|
||||
|
||||
`img-src 'self' data: blob:` interdit toute `og:image` distante, et une
|
||||
`og:image` relative était résolue contre l'origine d'ObsiGate (404).
|
||||
`_proxy_image(img_url, page_url)` téléverse la vignette à l'envoi dans
|
||||
`chat_uploads` via `save_attachment()` (allow-list + nom UUID) et renvoie
|
||||
`/api/chat/attachment/<uuid>` — résolution `urljoin` contre la page,
|
||||
garde SSRF réutilisée, plafond `PREVIEW_IMAGE_MAX` (2 Mo), dérivation de
|
||||
l'extension depuis le `content-type`. Échec isolé → `image = ""`, la
|
||||
carte reste (jamais de message perdu).
|
||||
|
||||
### Tests #192
|
||||
|
||||
- pytest `TestReadReceipts` (7) : roundtrip store, messages préservés,
|
||||
`POST /api/chat/read` (enregistrement + restitution par le `GET`),
|
||||
403 DM hors pair, 400 champs manquants, ACL vault, broadcast `chat_read`
|
||||
(spy sur `sse_manager`) ; `TestLinkPreview` +2 (proxy same-origin avec
|
||||
fichier écrit sur disque, dégradation sans vignette).
|
||||
- JSDOM `filechat.test.mjs` +3 (19) : indicateur ✓/✓✓ (ma propre carte de
|
||||
lecture exclue, aucun indicateur sur les messages des autres), bascule en
|
||||
direct + notification + pas d'écho, textarea (tagName/rows, hauteur
|
||||
pilotée, `Entrée` intercepté / `Maj+Entrée` non).
|
||||
|
||||
## #193 — Rendu markdown des posts (2026-10-09)
|
||||
|
||||
Un post du chat s'affiche désormais **comme un document** : titres, listes,
|
||||
tableaux, citations, blocs de code colorés — au lieu du texte brut avec liens
|
||||
cliquables.
|
||||
|
||||
### A. Le rendu vient du serveur (pas de second moteur markdown)
|
||||
|
||||
- `backend/file_chat.py::_decorate()` ajoute un champ `html` à chaque message
|
||||
en réutilisant `backend/render.py::_render_markdown()` — **le pipeline exact
|
||||
des documents** : mistune (tableaux, strikethrough, notes, listes de tâches),
|
||||
résolution des wikilinks, normalisation des sauts de ligne, masquage des
|
||||
secrets (#188) et **sanitizer BUG-021** en sortie. Aucun markdown côté client,
|
||||
donc aucun XSS nouveau à traiter : la sanitisation est déjà éprouvée.
|
||||
- `ChatMessageItem.html` (schéma de réponse) : sans ce champ, `response_model`
|
||||
filtrait la clé.
|
||||
- Le `html` est calculé **à la lecture** (`get_messages()`) et à l'ajout
|
||||
(`_append()`), donc il n'est **jamais persisté** dans `data/chats/*.json` :
|
||||
un message reste du texte, le rendu suit les évolutions du pipeline.
|
||||
- L'écho SSE part avec le `html` : les autres clients voient le rendu sans
|
||||
attendre un refresh.
|
||||
- Un échec de rendu n'emporte pas le message : repli silencieux (`html = ""`),
|
||||
le client affiche le texte brut.
|
||||
|
||||
### B. Frontend : `.md-content` + highlight.js
|
||||
|
||||
- `_fillBody()` injecte le `html` dans un `<div class="md-content">` (la
|
||||
typographie des documents) puis appelle `safeHighlight()` sur chaque
|
||||
`pre code` — le même helper (et les mêmes alias de langages) que le viewer.
|
||||
Mermaid n'est pas déclenché dans le chat.
|
||||
- Repli intact : un message sans `html` (écho optimiste d'un autre client)
|
||||
repasse par l'ancien chemin texte + URL cliquable.
|
||||
- CSS : `.file-chat-body.file-chat-md` annule le `white-space: pre-wrap` hérité
|
||||
de la bulle (sinon les retours à la ligne du HTML source créaient des lignes
|
||||
vides) et compacte `.md-content` (tailles, marges, `pre`/`table` en
|
||||
`overflow-x: auto`) pour tenir dans une bulle de 92 % de large.
|
||||
|
||||
### Tests #193
|
||||
|
||||
- pytest `TestMarkdownRendering` (7) : `html` présent à l'ajout **et** à la
|
||||
lecture, `html` **absent** du JSON stocké, classe `language-xxx` conservée
|
||||
(ce sur quoi hljs se branche), tableaux/listes, `<script>` neutralisé,
|
||||
routes `POST/GET` (chat de fichier + chat général) et écho SSE porteur du
|
||||
`html`. Suite : 1689 passed.
|
||||
- JSDOM `filechat.test.mjs` +2 (27) : post avec `html` → `.md-content`,
|
||||
`<strong>`, `pre code.language-python` ; post sans `html` → texte brut et URL
|
||||
cliquable conservés.
|
||||
|
||||
## Décisions
|
||||
|
||||
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
|
||||
`/api/events`, auth par cookie) existe déjà côté client dans `sync.js` ;
|
||||
un WS par fichier aurait dupliqué reconnexion/heartbeat/auth pour zéro
|
||||
bénéfice sur du messages-postés.
|
||||
- **Pas d'événement dans le panneau de sync** (`_addEvent`) : les messages
|
||||
de chat gonfleraient l'historique d'événements d'index sans valeur.
|
||||
- **Tag OpenAPI** : pas de tag maison (`file-chat`) — non déclaré dans
|
||||
`TAGS_METADATA`, ce qui cassait `test_used_tags_are_declared`. Les routes
|
||||
`/api/file/*` sont dérivées automatiquement sous « Files ».
|
||||
@@ -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,107 @@
|
||||
# #194 — Dossier personnel par utilisateur
|
||||
|
||||
> **Statut :** livré | **Ouvert :** 2026-10-09 | **Type :** feature (auth + vaults)
|
||||
|
||||
## Problème
|
||||
|
||||
Un utilisateur créé dans ObsiGate n'avait aucun espace propre : il ne voyait
|
||||
que les vaults qu'un admin lui attribuait à la main, et rien n'était préparé
|
||||
sur le disque à sa création.
|
||||
|
||||
## Décision d'architecture
|
||||
|
||||
Deux formes étaient possibles :
|
||||
|
||||
1. **Un vault par utilisateur** (retenu) — `<racine>/<user>` devient le vault
|
||||
`home-<user>`, octroyé dans `user.vaults`. L'ACL par vault existante
|
||||
(`backend/auth/middleware.py::check_vault_access`, appliquée partout :
|
||||
API, MCP, search, push, tools) fait l'isolation **sans rien inventer**.
|
||||
2. Un vault `Home` unique + ACL par sous-dossier (écartée) — il n'existe
|
||||
aucune ACL par chemin dans le code ; il aurait fallu la brancher dans
|
||||
`files_read`, `files_media`, `history`, `backups`, `mcp/tools`, `push`…
|
||||
Surface de sécurité trop grande pour un gain marginal.
|
||||
|
||||
Décisions produit validées : nom `home-<user>`, dossier **conservé** à la
|
||||
suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home` montée `/vaults/Home`.
|
||||
|
||||
## Mise en place
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Service idempotent `ensure_user_home()` / `release_user_home()` / `ensure_all_user_homes()` | `backend/user_home.py` |
|
||||
| Registre persistant des vaults dynamiques `data/vaults.json`, fusionné par `load_vault_config()` | `backend/indexer.py` (`persist_vault` / `unpersist_vault`) |
|
||||
| Appel à la création et à la suppression d'un compte | `backend/auth/router.py` (`POST/DELETE /api/auth/admin/users`) |
|
||||
| Passe de réparation/migration au démarrage | `backend/main.py` (`lifespan` → après le watcher) |
|
||||
| Persistance des vaults ajoutés par `/api/vaults/add` + retrait | `backend/routers/vaults.py` |
|
||||
| Montage + variable d'environnement | `docker-compose.yml` (`/NFS/OBSIDIAN_DOC/Home:/vaults/Home`, `OBSIGATE_HOME_ROOT=/vaults/Home`) |
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- **Fonctionnalité inactive sans `OBSIGATE_HOME_ROOT`** : dev, tests et
|
||||
desktop n'ont aucun comportement modifié.
|
||||
- **Validation du nom** : `^[a-zA-Z0-9_-]{2,32}$` (même grammaire que
|
||||
`CreateUserRequest.username_valid`), re-vérifiée avant de construire un
|
||||
chemin — défense en profondeur contre le path traversal.
|
||||
- **Nom de vault sans `/` ni `::`** : le nom sert de segment d'URL
|
||||
(`/api/file/{vault}/…`) et de clé d'index `vault::path`.
|
||||
- **Auto-réparation** : `ensure_all_user_homes()` au démarrage crée les
|
||||
dossiers manquants, ré-enregistre les vaults perdus et octroie l'accès aux
|
||||
users créés hors API. Toute erreur disque (NFS indisponible) est
|
||||
journalisée et rattrapée au boot.
|
||||
- **Suppression de compte** : index/watcher/registre refermés, dossier sur
|
||||
disque conservé.
|
||||
|
||||
## Bug corrigé au passage
|
||||
|
||||
- `load_vault_config()` ne lisait que les env `VAULT_N_*`/`DIR_N_*` : tout
|
||||
vault ajouté à la volée (y compris via `/api/vaults/add`) disparaissait au
|
||||
premier reindex complet et au redémarrage. → `data/vaults.json`.
|
||||
- `reload_single_vault()` perdait l'entrée `vault_config[vault]` après
|
||||
`remove_vault_from_index()`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_user_home.py` (10) : création (dossier + vault + octroi +
|
||||
registre), idempotence, username invalide, désactivation sans variable,
|
||||
relecture du registre par `load_vault_config`, `release` (dossier
|
||||
conservé), isolation `check_vault_access` entre users, passe de réparation.
|
||||
- `tests/test_auth_api.py::TestUserHome` (2) : provisionnement via l'API
|
||||
(création → dossier, vault visible, alice ne voit que son home, suppression
|
||||
→ vault refermé / dossier conservé) + absence de dossier sans
|
||||
`OBSIGATE_HOME_ROOT`.
|
||||
|
||||
## UI — arborescence (incrément)
|
||||
|
||||
- **Ordre** : tri stable `home-*` en premier, appliqué à la source
|
||||
(`frontend/js/sidebar.js::_homeFirst`, sur les deux sites qui alimentent
|
||||
`state.allVaults`) → arborescence, sélecteurs et refreshs homogènes.
|
||||
- **Icône** : `getVaultIcon()` (`frontend/js/viewer.js`) renvoie
|
||||
`icon("home", size)` pour `home-*` → même pipeline lucide que le reste de
|
||||
l'app (`safeCreateIcons`, `stroke: currentColor`, suit le thème).
|
||||
- **Test local** : `docker-compose.test-win.yml` monte
|
||||
`test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home`
|
||||
(`test_home/` est dans `.gitignore`).
|
||||
- **Vérifié live** (instance 2020, Playwright) : ordre `home-admin, TestDir,
|
||||
TestVault` (l'admin ne voit plus le home des autres), `svg.lucide-home`
|
||||
rendu, placeholder `<i>` remplacé.
|
||||
|
||||
## Isolation — « * » ne couvre pas les dossiers persos
|
||||
|
||||
- **Racine** : `check_vault_access` (`backend/auth/middleware.py`) renvoie
|
||||
``vault_name in vaults`` pour tout vault ``home-*``, quel que soit le ``*``.
|
||||
Toutes les routes (89 usages) passent par cette fonction.
|
||||
- **Filtres « * » en dur remplacés** par cet appel : dashboard
|
||||
(`routers/config.py`), conflits, liens retour, favoris, abonnements push.
|
||||
- **Recherche** : `services/search.search_vaults(is_allowed=…)` filtre les
|
||||
bruts **avant** pagination — ``total`` et page restent justes.
|
||||
- **Octroi** : `_grant` n'écarte plus les comptes ``*`` → l'admin reçoit son
|
||||
propre ``home-admin`` (réparé au démarrage pour les comptes existants).
|
||||
- **Reste ouvert** (fuite pré-existante, hors #194) : `/api/search/advanced`,
|
||||
`/api/tags`, `/api/tags/suggest`, `/api/tree-search` et `/api/suggest` ne
|
||||
filtrent pas par vault accessible — à traiter dans une tâche dédiée.
|
||||
|
||||
## Impact utilisateur
|
||||
|
||||
Aucune nouvelle chaîne i18n : les noms de vault sont des données, la liste
|
||||
provient déjà de `GET /api/vaults`. Le dossier apparaît tout seul dans le
|
||||
sélecteur de vaults de l'utilisateur concerné.
|
||||
@@ -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.
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
|
||||
|
||||
<!-- Lucide : requis par safeCreateIcons() (icônes des modales, ex. bouton fermer) -->
|
||||
<script defer src="https://unpkg.com/[email protected]/dist/umd/lucide.min.js"></script>
|
||||
|
||||
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
+120
-42
@@ -812,46 +812,6 @@
|
||||
role="navigation"
|
||||
aria-label="Navigation des vaults"
|
||||
>
|
||||
<!-- Sidebar filter -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div
|
||||
class="sidebar-tabs"
|
||||
@@ -928,6 +888,61 @@
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-tab"
|
||||
id="sidebar-tab-chat"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="sidebar-panel-chat"
|
||||
data-tab="chat"
|
||||
title="Chat"
|
||||
>
|
||||
<i
|
||||
data-lucide="message-circle"
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar filter (#190 C : sous les onglets ; sert de
|
||||
recherche dans l'onglet Chat) -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vaults panel -->
|
||||
@@ -1050,6 +1065,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General chat panel (#190 B) -->
|
||||
<div
|
||||
class="sidebar-tab-panel sidebar-chat-panel"
|
||||
id="sidebar-panel-chat"
|
||||
role="tabpanel"
|
||||
aria-labelledby="sidebar-tab-chat"
|
||||
>
|
||||
<!-- #191 : rangée de canaux (Général + utilisateurs) -->
|
||||
<div
|
||||
id="sidebar-panel-chat-channels"
|
||||
class="chat-channels"
|
||||
role="tablist"
|
||||
aria-label="Canaux de discussion"
|
||||
></div>
|
||||
<div
|
||||
id="sidebar-panel-chat-list"
|
||||
class="file-chat-list sidebar-chat-list"
|
||||
></div>
|
||||
<div id="sidebar-panel-chat-pending" class="sidebar-chat-pending"></div>
|
||||
<form
|
||||
id="sidebar-panel-chat-form"
|
||||
class="file-chat-form sidebar-chat-form"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-panel-chat-attach"
|
||||
class="file-chat-attach"
|
||||
title="Joindre une image ou une vidéo"
|
||||
aria-label="Joindre une image ou une vidéo"
|
||||
>
|
||||
<i
|
||||
data-lucide="paperclip"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
<textarea
|
||||
id="sidebar-panel-chat-input"
|
||||
class="file-chat-input"
|
||||
rows="1"
|
||||
maxlength="4000"
|
||||
placeholder="Écrire un message…"
|
||||
autocomplete="off"
|
||||
aria-label="Écrire un message"
|
||||
></textarea>
|
||||
<!-- #191 : bouton compact (icône) pour laisser la place à la saisie -->
|
||||
<button
|
||||
type="submit"
|
||||
class="file-chat-send file-chat-send-icon"
|
||||
title="Envoyer"
|
||||
aria-label="Envoyer"
|
||||
>
|
||||
<i
|
||||
data-lucide="send"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- User section (bottom of the sidebar, #112) -->
|
||||
<div class="sidebar-user" id="sidebar-user" hidden>
|
||||
<button
|
||||
@@ -5302,8 +5376,12 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Secret redactor</strong><span data-i18n="help.desc_9846fc07"> : Masque
|
||||
JWT, clés API, tokens GitHub dans les
|
||||
aperçus</span></li>
|
||||
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</span></li>
|
||||
<li>
|
||||
<strong>Path traversal</strong> : Validation
|
||||
des chemins contre les attaques
|
||||
|
||||
+19
-38
@@ -394,54 +394,35 @@ function _wireAuditFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section nav (scrollspy) ───────────────────────────────────────────
|
||||
// ── Section nav (une seule section visible à la fois) ──────────────────
|
||||
|
||||
/**
|
||||
* Wire the sticky section nav: smooth-scroll on click + highlight the section
|
||||
* currently in view (IntersectionObserver). Falls back to default anchor jump
|
||||
* if the observer is unavailable.
|
||||
* Wire the sticky section nav: clicking a link shows ONLY that section
|
||||
* (the others are hidden). The first section (Stats) is shown on load.
|
||||
*/
|
||||
function _wireSectionNav() {
|
||||
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
|
||||
if (!links.length) return;
|
||||
const targets = links
|
||||
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
|
||||
.filter(Boolean);
|
||||
const sections = Array.from(document.querySelectorAll("#admin-main .admin-section"));
|
||||
|
||||
// Smooth scroll on click
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
const href = a.getAttribute("href");
|
||||
if (!href || !href.startsWith("#")) return;
|
||||
const t = document.getElementById(href.slice(1));
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
t.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
// Scrollspy: mark the section currently at the top of the viewport
|
||||
let active = null;
|
||||
const setActive = (id) => {
|
||||
if (active === id) return;
|
||||
active = id;
|
||||
const show = (id) => {
|
||||
sections.forEach((s) => { s.style.display = s.id === id ? "" : "none"; });
|
||||
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
|
||||
};
|
||||
|
||||
if ("IntersectionObserver" in window) {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((en) => {
|
||||
if (en.isIntersecting) setActive(en.target.id);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
targets.forEach((t) => io.observe(t));
|
||||
} else {
|
||||
// Fallback: highlight the first section only (no scroll observation)
|
||||
links[0] && links[0].classList.add("active");
|
||||
}
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const href = a.getAttribute("href") || "";
|
||||
if (!href.startsWith("#")) return;
|
||||
show(href.slice(1));
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
|
||||
// État initial : seule la section demandée par le hash (ou la première) est visible.
|
||||
const initial = (location.hash && sections.find((s) => "#" + s.id === location.hash)) || sections[0];
|
||||
if (initial) show(initial.id);
|
||||
}
|
||||
|
||||
// ── Auth gate ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +26,7 @@ import { initDesktopIntegration, isTauriEnv } from './desktop.js';
|
||||
import { initPush } from './push.js';
|
||||
import { initPlugins } from './plugins.js';
|
||||
import { populateVersions } from './config.js';
|
||||
import { initSidebarChat } from './filechat.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -61,6 +62,7 @@ async function init() {
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
initSidebarChat(); // #190 B — formulaire du chat général (sidebar)
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
Auth.initLoginForm();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, setLocale, getLocale } from './i18n.js';
|
||||
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
|
||||
import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js';
|
||||
import { openSidebarChat, filterChatMessages as _filterChat } from './filechat.js';
|
||||
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
@@ -305,6 +306,7 @@ function switchSidebarTab(tab) {
|
||||
recent: t("sidebar.filter_recent"),
|
||||
saved: t("sidebar.filter_saved"),
|
||||
ai: t("sidebar.filter_ai"),
|
||||
chat: t("sidebar.filter_chat"),
|
||||
};
|
||||
filterInput.placeholder = placeholders[tab] || "";
|
||||
}
|
||||
@@ -315,6 +317,7 @@ function switchSidebarTab(tab) {
|
||||
else if (tab === "recent") filterRecentFiles(query);
|
||||
else if (tab === "saved") filterSavedSearches(query);
|
||||
else if (tab === "ai") filterAIHistory(query);
|
||||
else if (tab === "chat") _filterChat(query);
|
||||
}
|
||||
// Auto-load recent files when switching to the recent tab
|
||||
if (tab === "recent") {
|
||||
@@ -333,6 +336,11 @@ function switchSidebarTab(tab) {
|
||||
if (filterInput) _aiQuery = filterInput.value.trim();
|
||||
loadAISessionList();
|
||||
}
|
||||
// #190 B — Auto-load the general chat when entering its tab (fresh history;
|
||||
// openSidebarChat clears the unread badge).
|
||||
if (tab === "chat") {
|
||||
openSidebarChat();
|
||||
}
|
||||
}
|
||||
|
||||
function initHelpModal() {
|
||||
|
||||
@@ -0,0 +1,920 @@
|
||||
// frontend/js/filechat.js — Chat fichier (#169) + chat général sidebar (#190)
|
||||
//
|
||||
// Deux surfaces, un module :
|
||||
// 1. Panneau latéral du document (#169) — historique + saisie, suivi du
|
||||
// document ouvert (followFileChat le re-cible à chaque renderFile).
|
||||
// 2. Onglet « Chat » de la sidebar (#190) — conversation générale, pièces
|
||||
// jointes (image/vidéo/URL), indicateur de messages non lus, recherche
|
||||
// via la barre de filtre de la sidebar.
|
||||
//
|
||||
// Les deux reçoivent les messages en direct par SSE (`chat_message`, relayé
|
||||
// depuis sync.js) : vault `__global__` = chat général, sinon chat fichier.
|
||||
import { api, AuthManager } from "./auth.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast, TabManager } from "./ui.js";
|
||||
import { escapeHtml, safeCreateIcons, safeHighlight } from "./utils.js";
|
||||
import { icon } from "./viewer.js";
|
||||
|
||||
const PANEL_ID = "file-chat-panel";
|
||||
const GLOBAL_VAULT = "__global__";
|
||||
const GLOBAL_PATH = "general";
|
||||
const DM_VAULT = "__dm__";
|
||||
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts}
|
||||
const MAX_INPUT_H = 160; // #192 — plafond de la boîte de saisie auto-agrandie
|
||||
|
||||
let _vault = null;
|
||||
let _path = null;
|
||||
let _title = "";
|
||||
let _sending = false;
|
||||
|
||||
// État du chat sidebar (#190 B + #191 : canaux général / privé)
|
||||
let _channel = { type: "general" }; // ou {type: "dm", user: "bob"}
|
||||
let _channelMessages = [];
|
||||
let _channelLoaded = false;
|
||||
let _usersCache = null; // liste des destinataires DM (une seule requête)
|
||||
let _pendingAttach = null;
|
||||
|
||||
// #192 — accusé de réception : {username: dernier_ts_de_lecture} par surface.
|
||||
let _fileRead = {};
|
||||
let _channelRead = {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Partagé
|
||||
// ---------------------------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return escapeHtml(String(s == null ? "" : s));
|
||||
}
|
||||
|
||||
function _formatTime(ts) {
|
||||
try {
|
||||
return new Date(ts * 1000).toLocaleString(undefined, {
|
||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function _currentUser() {
|
||||
const u = AuthManager.getUser();
|
||||
return (u && u.username) || "";
|
||||
}
|
||||
|
||||
// #192 — chemin de la conversation du canal courant (général ou privé).
|
||||
function _channelVaultPath() {
|
||||
return _channel.type === "dm"
|
||||
? { vault: DM_VAULT, path: _dmPath(_channel.user) }
|
||||
: { vault: GLOBAL_VAULT, path: GLOBAL_PATH };
|
||||
}
|
||||
|
||||
/** Paire DM triée côté client — même règle que ``dm_path()`` serveur. */
|
||||
function _dmPath(user) {
|
||||
return [_currentUser(), user].sort().join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — la boîte de saisie suit le contenu : hauteur auto, plafonné à
|
||||
* ``MAX_INPUT_H`` (au-delà la zone défile).
|
||||
*/
|
||||
function _autoGrow(el) {
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_H)}px`;
|
||||
}
|
||||
|
||||
/** #192 — un message est lu dès qu'un AUTRE participant l'a ouvert. */
|
||||
function _isRead(ts, readMap, me) {
|
||||
return Object.keys(readMap || {}).some((u) => u !== me && Number(readMap[u]) >= Number(ts));
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — passe les indicateurs de mes messages (liste affichée) à ✓✓.
|
||||
* Retourne le nombre de messages qui venaient de passer à « lu ».
|
||||
*/
|
||||
function _applyRead(list, readMap, me) {
|
||||
if (!list || !readMap) return 0;
|
||||
let flipped = 0;
|
||||
list.querySelectorAll(".file-chat-msg.mine").forEach((el) => {
|
||||
if (el.classList.contains("read")) return;
|
||||
if (!_isRead(el.dataset.ts, readMap, me)) return;
|
||||
el.classList.add("read");
|
||||
const st = el.querySelector(".file-chat-status");
|
||||
if (st) {
|
||||
st.textContent = "✓✓";
|
||||
st.title = t("chat.status_read");
|
||||
}
|
||||
flipped++;
|
||||
});
|
||||
return flipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — fusionne *readMap* dans la conversation affichée à l'écran et
|
||||
* actualise les indicateurs. Retourne le nombre de mes messages passés à lu.
|
||||
*/
|
||||
function _syncRead(vault, path, readMap) {
|
||||
if (!readMap) return 0;
|
||||
const me = _currentUser();
|
||||
let flipped = 0;
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
|
||||
_fileRead = Object.assign({}, _fileRead, readMap);
|
||||
flipped += _applyRead(panel.querySelector(".file-chat-list"), _fileRead, me);
|
||||
}
|
||||
const key =
|
||||
vault === DM_VAULT ? `dm:${_pairOf(path)}` : vault === GLOBAL_VAULT ? "general" : null;
|
||||
const sidebar = document.getElementById("sidebar-panel-chat");
|
||||
if (key && key === _channelKey() && sidebar && sidebar.classList.contains("active")) {
|
||||
_channelRead = Object.assign({}, _channelRead, readMap);
|
||||
flipped += _applyRead(_sidebarList(), _channelRead, me);
|
||||
}
|
||||
return flipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — signale au serveur que la conversation est affichée (accusé de
|
||||
* réception). Fire-and-forget : un réseau indisponible ne casse rien.
|
||||
*/
|
||||
async function _markRead(vault, path) {
|
||||
try {
|
||||
const data = await api("/api/chat/read", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ vault, path }),
|
||||
});
|
||||
_syncRead(vault, path, data && data.read ? data.read : {});
|
||||
} catch (_) {
|
||||
/* réseau indisponible : pas d'accusé de réception, ni d'erreur */
|
||||
}
|
||||
}
|
||||
|
||||
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
|
||||
/**
|
||||
* Remplit le corps d'un message (#193).
|
||||
*
|
||||
* Quand le serveur a fourni le rendu markdown (`html`, même pipeline que les
|
||||
* documents : mistune + sanitizer BUG-021), on l'injecte dans un `.md-content`
|
||||
* — titres, listes, tableaux, blocs de code — et on colore les blocs avec
|
||||
* highlight.js (`safeHighlight`), exactement comme dans le viewer.
|
||||
*
|
||||
* Sans `html` (message optimiste d'un autre client, cache ancien), on retombe
|
||||
* sur le texte brut avec liens cliquables, comme avant.
|
||||
*/
|
||||
function _fillBody(body, m) {
|
||||
const text = typeof m === "string" ? m : (m && m.text) || "";
|
||||
const html = m && typeof m === "object" ? m.html || "" : "";
|
||||
if (html) {
|
||||
body.classList.add("file-chat-md");
|
||||
const div = document.createElement("div");
|
||||
div.className = "md-content";
|
||||
div.innerHTML = html; // rendu + assaini côté serveur
|
||||
body.appendChild(div);
|
||||
div.querySelectorAll("pre code").forEach((block) => {
|
||||
if (block.querySelector(".secret-mask")) return;
|
||||
safeHighlight(block);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
|
||||
parts.forEach((part) => {
|
||||
if (!part) return;
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
const a = document.createElement("a");
|
||||
a.href = part;
|
||||
a.textContent = part;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.className = "file-chat-link";
|
||||
body.appendChild(a);
|
||||
} else {
|
||||
body.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// #110 — image jointe : clic = même visualiseur que pour une image de vault
|
||||
// (ouvert dans un onglet de l'app), boutons discrets (icônes seules) sous
|
||||
// l'image, alignés à droite.
|
||||
function _attachEl(att) {
|
||||
if (!att || !att.url) return null;
|
||||
if (att.kind === "image") {
|
||||
const container = document.createElement("div");
|
||||
container.className = "file-chat-image-container";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = att.url;
|
||||
img.alt = att.name || "image";
|
||||
img.loading = "lazy";
|
||||
img.className = "file-chat-image";
|
||||
img.title = t("chat.view_image");
|
||||
img.addEventListener("click", () => TabManager.openChatImage(att));
|
||||
container.appendChild(img);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "file-chat-image-actions";
|
||||
const mkBtn = (iconName, label, cls, onClick) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `file-chat-image-btn ${cls}`;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.appendChild(icon(iconName, 14));
|
||||
b.addEventListener("click", (e) => { e.stopPropagation(); onClick(); });
|
||||
return b;
|
||||
};
|
||||
actions.appendChild(mkBtn("download", t("chat.download_image"), "file-chat-image-download", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = att.url;
|
||||
a.download = att.name || "image";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}));
|
||||
actions.appendChild(mkBtn("maximize", t("chat.view_image"), "file-chat-open", () => TabManager.openChatImage(att)));
|
||||
container.appendChild(actions);
|
||||
|
||||
return container;
|
||||
}
|
||||
if (att.kind === "video") {
|
||||
const v = document.createElement("video");
|
||||
v.src = att.url;
|
||||
v.controls = true;
|
||||
v.preload = "metadata";
|
||||
v.className = "file-chat-video";
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// #191 — suppression : auteur ou admin, avec confirmation native.
|
||||
function _canDelete(m, currentUser) {
|
||||
const u = AuthManager.getUser();
|
||||
if (!u || !u.username) return false;
|
||||
return u.role === "admin" || m.user === u.username;
|
||||
}
|
||||
|
||||
// #110 — suppression différée : la ligne passe tout de suite à « supprimé »
|
||||
// avec un bouton Annuler ; le DELETE n'est envoyé qu'au bout de 10 s.
|
||||
// ponytail: un rechargement complet pendant la fenêtre annule la suppression
|
||||
// (le message reste côté serveur) — connu, acceptable.
|
||||
const _pendingDeletes = new Map(); // id -> {timer}
|
||||
|
||||
export function _deleteMessage(delUrl, id, wrap) {
|
||||
if (_pendingDeletes.has(id)) return;
|
||||
const parent = wrap.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
const holder = document.createElement("div");
|
||||
holder.className = "file-chat-msg file-chat-deleted";
|
||||
holder.innerHTML =
|
||||
`<div class="file-chat-body file-chat-deleted-text">${t("chat.deleted_message")}` +
|
||||
`<button class="file-chat-undo" type="button">${t("chat.undo")}</button></div>`;
|
||||
parent.replaceChild(holder, wrap);
|
||||
|
||||
const undo = () => {
|
||||
const entry = _pendingDeletes.get(id);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timer);
|
||||
_pendingDeletes.delete(id);
|
||||
holder.replaceWith(wrap);
|
||||
};
|
||||
const timer = setTimeout(async () => {
|
||||
_pendingDeletes.delete(id);
|
||||
try {
|
||||
await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
holder.remove();
|
||||
} catch (_) {
|
||||
showToast(t("chat.delete_error"), "error");
|
||||
holder.replaceWith(wrap); // suppression ratée : le message revient
|
||||
}
|
||||
}, 10000);
|
||||
_pendingDeletes.set(id, { timer });
|
||||
holder.querySelector(".file-chat-undo").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
undo();
|
||||
});
|
||||
}
|
||||
|
||||
// #191 — carte de lien : site, titre, description, vignette.
|
||||
function _previewEl(p) {
|
||||
const card = document.createElement("a");
|
||||
card.className = "file-chat-preview";
|
||||
card.href = p.url;
|
||||
card.target = "_blank";
|
||||
card.rel = "noopener noreferrer";
|
||||
if (p.image) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "file-chat-preview-img";
|
||||
img.src = p.image;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
img.referrerPolicy = "no-referrer";
|
||||
card.appendChild(img);
|
||||
}
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "file-chat-preview-meta";
|
||||
const site = document.createElement("div");
|
||||
site.className = "file-chat-preview-site";
|
||||
site.textContent = p.site || "";
|
||||
const title = document.createElement("div");
|
||||
title.className = "file-chat-preview-title";
|
||||
title.textContent = p.title || p.url;
|
||||
meta.appendChild(site);
|
||||
meta.appendChild(title);
|
||||
if (p.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "file-chat-preview-desc";
|
||||
desc.textContent = p.description;
|
||||
meta.appendChild(desc);
|
||||
}
|
||||
card.appendChild(meta);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one message.
|
||||
* *delUrl* (#191) is the DELETE endpoint base for this conversation; when
|
||||
* provided the author/admin sees a trash button on their own messages.
|
||||
* *readMap* (#192) drives the ✓/✓✓ receipt shown on my own messages.
|
||||
*/
|
||||
export function _messageEl(m, currentUser, delUrl, readMap) {
|
||||
const mine = currentUser && m.user === currentUser;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
|
||||
wrap.dataset.id = m.id || "";
|
||||
wrap.dataset.ts = m.ts || 0;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "file-chat-meta";
|
||||
meta.innerHTML =
|
||||
`<span class="file-chat-user">${_esc(m.user)}</span>` +
|
||||
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
|
||||
if (mine) {
|
||||
const read = _isRead(m.ts, readMap, currentUser);
|
||||
const status = document.createElement("span");
|
||||
status.className = "file-chat-status" + (read ? " read" : "");
|
||||
status.textContent = read ? "✓✓" : "✓";
|
||||
status.title = t(read ? "chat.status_read" : "chat.status_sent");
|
||||
meta.appendChild(status);
|
||||
}
|
||||
if (delUrl && _canDelete(m, currentUser)) {
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "file-chat-del";
|
||||
del.title = t("chat.delete");
|
||||
del.setAttribute("aria-label", t("chat.delete"));
|
||||
del.textContent = "🗑";
|
||||
del.addEventListener("click", () => _deleteMessage(delUrl, m.id, wrap));
|
||||
meta.appendChild(del);
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-chat-body";
|
||||
_fillBody(body, m);
|
||||
wrap.appendChild(meta);
|
||||
if (m.text) wrap.appendChild(body);
|
||||
const att = _attachEl(m.attachment);
|
||||
if (att) wrap.appendChild(att);
|
||||
if (m.preview) wrap.appendChild(_previewEl(m.preview));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderChatMessages(container, messages, currentUser, delUrl, readMap) {
|
||||
container.innerHTML = "";
|
||||
if (!messages || messages.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "file-chat-empty";
|
||||
empty.textContent = t("chat.empty");
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl, readMap)));
|
||||
container.scrollTop = container.scrollHeight;
|
||||
safeCreateIcons(); // #110 — hydrate les icônes des boutons de pièce jointe
|
||||
}
|
||||
|
||||
function _appendMessage(list, m, delUrl) {
|
||||
const empty = list.querySelector(".file-chat-empty");
|
||||
if (empty) empty.remove();
|
||||
const id = m.id;
|
||||
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
|
||||
list.appendChild(_messageEl(m, _currentUser(), delUrl));
|
||||
list.scrollTop = list.scrollHeight;
|
||||
safeCreateIcons();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Panneau du document (#169)
|
||||
// ---------------------------------------------------------------------------
|
||||
function _panel() {
|
||||
let el = document.getElementById(PANEL_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("aside");
|
||||
el.id = PANEL_ID;
|
||||
el.className = "file-chat-panel hidden";
|
||||
el.setAttribute("aria-label", t("chat.title"));
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
async function _load() {
|
||||
const panel = _panel();
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`,
|
||||
);
|
||||
_fileRead = data.read || {};
|
||||
renderChatMessages(list, data.messages || [], _currentUser(), null, _fileRead);
|
||||
_markRead(_vault, _path); // #192 — accusé de réception
|
||||
} catch (_) {
|
||||
list.innerHTML = "";
|
||||
const err = document.createElement("div");
|
||||
err.className = "file-chat-empty";
|
||||
err.textContent = t("chat.load_error");
|
||||
list.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderShell(panel) {
|
||||
panel.innerHTML = "";
|
||||
const header = document.createElement("div");
|
||||
header.className = "file-chat-header";
|
||||
const titles = document.createElement("div");
|
||||
titles.className = "file-chat-titles";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = t("chat.title");
|
||||
titles.appendChild(title);
|
||||
// #190 A — le document ciblé est affiché dans l'en-tête
|
||||
const target = document.createElement("span");
|
||||
target.className = "file-chat-target";
|
||||
target.textContent = _title || _path || "";
|
||||
target.title = _path || "";
|
||||
titles.appendChild(target);
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "file-chat-close";
|
||||
close.title = t("chat.close");
|
||||
close.setAttribute("aria-label", t("chat.close"));
|
||||
close.textContent = "✕";
|
||||
close.addEventListener("click", closeFileChat);
|
||||
header.appendChild(titles);
|
||||
header.appendChild(close);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "file-chat-list";
|
||||
|
||||
const form = document.createElement("form");
|
||||
form.className = "file-chat-form";
|
||||
// #192 — textarea auto-agrandie : on lit le message entier avant l'envoi.
|
||||
const input = document.createElement("textarea");
|
||||
input.className = "file-chat-input";
|
||||
input.rows = 1;
|
||||
input.placeholder = t("chat.placeholder");
|
||||
input.maxLength = 4000;
|
||||
input.setAttribute("aria-label", t("chat.placeholder"));
|
||||
input.addEventListener("input", () => _autoGrow(input));
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault(); // Shift+Enter = retour à la ligne
|
||||
_send(input);
|
||||
}
|
||||
});
|
||||
const send = document.createElement("button");
|
||||
send.type = "submit";
|
||||
send.className = "file-chat-send file-chat-send-icon";
|
||||
send.title = t("chat.send");
|
||||
send.setAttribute("aria-label", t("chat.send"));
|
||||
// #191 : icône compacte — la saisie garde toute la largeur
|
||||
send.innerHTML = '<i data-lucide="send" style="width:15px;height:15px"></i>';
|
||||
form.appendChild(input);
|
||||
form.appendChild(send);
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
_send(input);
|
||||
});
|
||||
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(list);
|
||||
panel.appendChild(form);
|
||||
safeCreateIcons(); // #191 — hydrate l'icône d'envoi
|
||||
}
|
||||
|
||||
export async function openFileChat(vault, path, title) {
|
||||
_vault = vault;
|
||||
_path = path;
|
||||
_title = title || path || "";
|
||||
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
|
||||
const panel = _panel();
|
||||
_renderShell(panel);
|
||||
panel.classList.remove("hidden");
|
||||
await _load();
|
||||
const input = panel.querySelector(".file-chat-input");
|
||||
if (input) input.focus();
|
||||
}
|
||||
|
||||
export function closeFileChat() {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (panel) panel.classList.add("hidden");
|
||||
}
|
||||
|
||||
export function toggleFileChat(vault, path, title) {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
|
||||
closeFileChat();
|
||||
return;
|
||||
}
|
||||
openFileChat(vault, path, title);
|
||||
}
|
||||
|
||||
// #190 A — appelé à chaque renderFile : le panneau ouvert suit le document.
|
||||
export function followFileChat(vault, path, title) {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (!panel || panel.classList.contains("hidden")) return;
|
||||
if (_vault === vault && _path === path) return;
|
||||
openFileChat(vault, path, title);
|
||||
}
|
||||
|
||||
async function _send(input) {
|
||||
if (_sending) return;
|
||||
const text = (input.value || "").trim();
|
||||
if (!text) return;
|
||||
_sending = true;
|
||||
try {
|
||||
const data = await api(`/api/file/${encodeURIComponent(_vault)}/chat`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path: _path, text }),
|
||||
});
|
||||
input.value = "";
|
||||
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
|
||||
_appendMessage(_panel().querySelector(".file-chat-list"), data.message);
|
||||
} catch (_) {
|
||||
showToast(t("chat.send_error"), "error");
|
||||
} finally {
|
||||
_sending = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Chat sidebar (#190 B) + canaux privé #191
|
||||
// ---------------------------------------------------------------------------
|
||||
function _sidebarList() {
|
||||
return document.getElementById("sidebar-panel-chat-list");
|
||||
}
|
||||
|
||||
/** Clé de la conversation courante (unread + sélection). */
|
||||
function _channelKey() {
|
||||
return _channel.type === "dm" ? `dm:${_channel.user}` : "general";
|
||||
}
|
||||
|
||||
/** URL d'API de base du canal courant. */
|
||||
function _channelUrl() {
|
||||
return _channel.type === "dm"
|
||||
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
|
||||
: "/api/chat";
|
||||
}
|
||||
|
||||
/** URL de base de suppression du canal courant. */
|
||||
function _channelDelUrl() {
|
||||
return _channel.type === "dm"
|
||||
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
|
||||
: "/api/chat";
|
||||
}
|
||||
|
||||
/** Rend la rangée de canaux : Général + un bouton par utilisateur. */
|
||||
function _renderChannels(users) {
|
||||
const holder = document.getElementById("sidebar-panel-chat-channels");
|
||||
if (!holder) return;
|
||||
holder.innerHTML = "";
|
||||
const mk = (label, active, onClick, title) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "chat-channel" + (active ? " active" : "");
|
||||
b.textContent = label;
|
||||
if (title) b.title = title;
|
||||
b.addEventListener("click", onClick);
|
||||
holder.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
mk(t("chat.channel_general"), _channel.type === "general", () => switchChannel(null));
|
||||
(users || []).forEach((u) => {
|
||||
mk(u.display_name || u.username, _channel.type === "dm" && _channel.user === u.username,
|
||||
() => switchChannel(u.username), `@${u.username}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Charge la liste des utilisateurs (une fois) puis rend les canaux. */
|
||||
async function _loadUsers() {
|
||||
if (_usersCache) return _usersCache;
|
||||
try {
|
||||
const data = await api("/api/chat/users");
|
||||
_usersCache = Array.isArray(data) ? data : [];
|
||||
} catch (_) {
|
||||
_usersCache = [];
|
||||
}
|
||||
return _usersCache;
|
||||
}
|
||||
|
||||
/** Charge (ou recharge) l'historique du canal courant dans la sidebar. */
|
||||
export async function openSidebarChat() {
|
||||
const list = _sidebarList();
|
||||
if (!list) return;
|
||||
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
|
||||
const users = await _loadUsers();
|
||||
_renderChannels(users);
|
||||
try {
|
||||
const data = await api(_channelUrl());
|
||||
_channelMessages = (data.messages || []).slice();
|
||||
_channelLoaded = true;
|
||||
_channelRead = data.read || {};
|
||||
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl(), _channelRead);
|
||||
_clearUnread();
|
||||
const vp = _channelVaultPath();
|
||||
_markRead(vp.vault, vp.path); // #192 — accusé de réception
|
||||
} catch (_) {
|
||||
list.innerHTML = "";
|
||||
const err = document.createElement("div");
|
||||
err.className = "file-chat-empty";
|
||||
err.textContent = t("chat.load_error");
|
||||
list.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Change de canal (#191) : null = général, sinon l'username du pair. */
|
||||
export function switchChannel(user) {
|
||||
_channel = user ? { type: "dm", user } : { type: "general" };
|
||||
const input = _sendInput();
|
||||
if (input) {
|
||||
input.placeholder = user ? t("chat.placeholder_dm", { user }) : t("chat.placeholder");
|
||||
}
|
||||
openSidebarChat();
|
||||
}
|
||||
|
||||
/** Recherche dans le canal courant (barre de filtre sidebar) — #190 C. */
|
||||
export function filterChatMessages(query) {
|
||||
const list = _sidebarList();
|
||||
if (!list || !_channelLoaded) return;
|
||||
const q = String(query || "").toLowerCase().trim();
|
||||
const shown = q
|
||||
? _channelMessages.filter((m) =>
|
||||
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
|
||||
)
|
||||
: _channelMessages;
|
||||
renderChatMessages(list, shown, _currentUser(), _channelDelUrl(), _channelRead);
|
||||
}
|
||||
|
||||
function _sendInput() {
|
||||
return document.getElementById("sidebar-panel-chat-input");
|
||||
}
|
||||
|
||||
function _renderPending() {
|
||||
const holder = document.getElementById("sidebar-panel-chat-pending");
|
||||
if (!holder) return;
|
||||
holder.innerHTML = "";
|
||||
if (!_pendingAttach) return;
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "file-chat-pending";
|
||||
chip.textContent =
|
||||
(_pendingAttach.kind === "video" ? "🎬 " : "🖼 ") + (_pendingAttach.name || "pièce jointe");
|
||||
const x = document.createElement("button");
|
||||
x.type = "button";
|
||||
x.textContent = "✕";
|
||||
x.setAttribute("aria-label", t("chat.attach_remove"));
|
||||
x.addEventListener("click", () => {
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
});
|
||||
chip.appendChild(x);
|
||||
holder.appendChild(chip);
|
||||
}
|
||||
|
||||
/** Envoi d'un message du chat général (texte + pièce jointe éventuelle). */
|
||||
export async function sendGeneralMessage() {
|
||||
const input = _sendInput();
|
||||
if (!input) return;
|
||||
const text = (input.value || "").trim();
|
||||
if (!text && !_pendingAttach) return;
|
||||
const body = { text };
|
||||
if (_pendingAttach) body.attachment = _pendingAttach;
|
||||
try {
|
||||
const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) });
|
||||
input.value = "";
|
||||
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
const list = _sidebarList();
|
||||
if (list) {
|
||||
_channelMessages.push(data.message);
|
||||
_appendMessage(list, data.message, _channelDelUrl());
|
||||
}
|
||||
input.focus();
|
||||
} catch (_) {
|
||||
showToast(t("chat.send_error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sélecteur de pièce jointe (image/vidéo) → upload → envoi libre. */
|
||||
export async function pickAttachment() {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*,video/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
fileInput.remove();
|
||||
if (!file) return resolve(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await api("/api/chat/upload", { method: "POST", body: form });
|
||||
_pendingAttach = res.attachment;
|
||||
_renderPending();
|
||||
resolve(_pendingAttach);
|
||||
} catch (_) {
|
||||
showToast(t("chat.attach_error"), "error");
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Messages non lus (#190 B / #191) --------------------------------------
|
||||
// Compteurs par clé de canal : {"general": 2, "dm:bob": 1}. Ouverture du
|
||||
// canal → remise à zéro de sa clé.
|
||||
function _readUnread() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _writeUnread(u) {
|
||||
try {
|
||||
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
|
||||
} catch (_) { /* quota */ }
|
||||
}
|
||||
|
||||
function _clearUnread() {
|
||||
const u = _readUnread();
|
||||
u[_channelKey()] = 0;
|
||||
_writeUnread(u);
|
||||
_renderBadge(_totalUnread());
|
||||
_renderChannels(_usersCache);
|
||||
}
|
||||
|
||||
function _totalUnread() {
|
||||
const u = _readUnread();
|
||||
return Object.values(u).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0);
|
||||
}
|
||||
|
||||
function _renderBadge(count) {
|
||||
const tab = document.getElementById("sidebar-tab-chat");
|
||||
if (!tab) return;
|
||||
// #110 — point de couleur sur l'icône chat tant qu'il reste des non-lus
|
||||
tab.classList.toggle("chat-has-unread", count > 0);
|
||||
let badge = tab.querySelector(".chat-unread-badge");
|
||||
if (!count) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "chat-unread-badge";
|
||||
tab.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? "99+" : String(count);
|
||||
}
|
||||
|
||||
/** Affiche le total des messages non lus (tous canaux confondus). */
|
||||
export function refreshUnreadBadge() {
|
||||
_renderBadge(_totalUnread());
|
||||
}
|
||||
|
||||
/** #110 — notification OS quand l'app n'est pas au premier plan. */
|
||||
function _notifyNewMessage(m) {
|
||||
const Notif = window.Notification;
|
||||
if (!Notif) return;
|
||||
if (!document.hidden && document.hasFocus()) return; // visible : le toast suffit
|
||||
if (Notif.permission !== "granted") return;
|
||||
try {
|
||||
const n = new Notif(t("chat.new_message", { user: m.user }), {
|
||||
body: (m.text || "").slice(0, 140),
|
||||
icon: "/static/icons/icon-192x192.svg",
|
||||
tag: "obsigate-chat",
|
||||
});
|
||||
n.onclick = () => { try { window.focus(); } catch (_) { /* ignore */ } n.close(); };
|
||||
} catch (_) { /* notifications indisponibles */ }
|
||||
}
|
||||
|
||||
/** Demande la permission au premier clic utilisateur (ouverture du chat). */
|
||||
function _ensureNotifyPermission() {
|
||||
const Notif = window.Notification;
|
||||
if (!Notif || Notif.permission !== "default") return;
|
||||
try { Notif.requestPermission(); } catch (_) { /* ignoré */ }
|
||||
}
|
||||
|
||||
/** Initialisation : formulaire + raccourcis clavier. */
|
||||
export function initSidebarChat() {
|
||||
const form = document.getElementById("sidebar-panel-chat-form");
|
||||
if (!form) return;
|
||||
const input = document.getElementById("sidebar-panel-chat-input");
|
||||
if (input) {
|
||||
input.placeholder = t("chat.placeholder"); // le HTML embarque le FR
|
||||
input.addEventListener("input", () => _autoGrow(input)); // #192
|
||||
}
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
sendGeneralMessage();
|
||||
});
|
||||
const attach = document.getElementById("sidebar-panel-chat-attach");
|
||||
if (attach) attach.addEventListener("click", () => pickAttachment());
|
||||
if (input) {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendGeneralMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE relay (appelé par sync.js sur l'événement `chat_message`)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function onChatMessage(data) {
|
||||
if (!data || !data.message) return;
|
||||
|
||||
// --- Canaux sidebar : général (#190) ou privé (#191) ---
|
||||
if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) {
|
||||
const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general";
|
||||
const isActive = key === _channelKey();
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
const visible = panel && panel.classList.contains("active");
|
||||
if (isActive) _channelMessages.push(data.message);
|
||||
if (visible && isActive) {
|
||||
_appendMessage(_sidebarList(), data.message, _channelDelUrl());
|
||||
_clearUnread();
|
||||
// #192 — le canal est affiché : on accuse réception aussitôt.
|
||||
if (data.message.user !== _currentUser()) _markRead(data.vault, data.path);
|
||||
} else if (data.message.user !== _currentUser()) {
|
||||
_bumpUnread(key);
|
||||
refreshUnreadBadge();
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
_notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat d'un document (#169)
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
const isOpen =
|
||||
panel && !panel.classList.contains("hidden") &&
|
||||
_vault === data.vault && _path === data.path;
|
||||
if (isOpen) {
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
if (list) _appendMessage(list, data.message);
|
||||
if (data.message.user !== _currentUser()) {
|
||||
// #192 — panneau affiché : accusé de réception immédiat.
|
||||
_markRead(data.vault, data.path);
|
||||
_notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.message.user === _currentUser()) return; // son propre message
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
_notifyNewMessage(data.message); // #110
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — SSE `chat_read` : quelqu'un vient d'ouvrir la conversation. Les
|
||||
* indicateurs de MES messages passent à ✓✓ et, si un message change d'état,
|
||||
* l'expéditeur est notifié.
|
||||
*/
|
||||
export function onChatRead(data) {
|
||||
if (!data || !data.user || data.user === _currentUser()) return; // notre propre écho
|
||||
const flipped = _syncRead(data.vault, data.path, data.read || { [data.user]: data.ts });
|
||||
if (flipped) showToast(t("chat.read_by", { user: data.user }), "info");
|
||||
}
|
||||
|
||||
/** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */
|
||||
function _pairOf(path) {
|
||||
const me = _currentUser();
|
||||
const parts = String(path || "").split("|");
|
||||
return parts[0] === me ? parts[1] : parts[0];
|
||||
}
|
||||
|
||||
function _bumpUnread(key) {
|
||||
const u = _readUnread();
|
||||
u[key] = (u[key] || 0) + 1;
|
||||
_writeUnread(u);
|
||||
}
|
||||
|
||||
/** Suppression d'un post (#191) — relayée par sync.js. */
|
||||
export function onChatDeleted(data) {
|
||||
if (!data || !data.id) return;
|
||||
const sel = `[data-id="${CSS.escape(data.id)}"]`;
|
||||
document.querySelectorAll(sel).forEach((el) => el.remove());
|
||||
}
|
||||
+12
-1
@@ -6,6 +6,7 @@ import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRe
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
||||
import { performAdvancedSearch } from './search.js';
|
||||
import { t } from './i18n.js';
|
||||
import { filterChatMessages as _filterChat } from './filechat.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault context switching
|
||||
@@ -219,8 +220,15 @@ async function loadVaultSettings() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar — Vault tree
|
||||
// ---------------------------------------------------------------------------
|
||||
// #194 : le dossier personnel (vault « home-<user> ») s'affiche en premier
|
||||
// dans l'arborescence. Tri stable fait sur place, à la source de toutes les
|
||||
// listes (arborescence, sélecteurs, state.allVaults).
|
||||
const _homeFirst = (a, b) =>
|
||||
(String(b?.name).startsWith("home-") ? 1 : 0) - (String(a?.name).startsWith("home-") ? 1 : 0);
|
||||
|
||||
async function loadVaults() {
|
||||
const vaults = await api("/api/vaults");
|
||||
vaults.sort(_homeFirst);
|
||||
state.allVaults = vaults;
|
||||
const container = document.getElementById("vault-tree");
|
||||
container.innerHTML = "";
|
||||
@@ -425,6 +433,7 @@ export async function refreshSidebarTreePreservingState() {
|
||||
// 2. Soft update: vault names/counts without wiping the tree
|
||||
try {
|
||||
const vaults = await api("/api/vaults");
|
||||
vaults.sort(_homeFirst);
|
||||
state.allVaults = vaults;
|
||||
vaults.forEach((v) => {
|
||||
const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`);
|
||||
@@ -704,13 +713,14 @@ function initSidebarFilter() {
|
||||
const caseBtn = document.getElementById("sidebar-filter-case-btn");
|
||||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||||
|
||||
// Route the query to the active tab's own filter (#98/#99).
|
||||
// Route the query to the active tab's own filter (#98/#99, chat #190 C).
|
||||
const routeFilter = async (q) => {
|
||||
const tab = state.activeSidebarTab;
|
||||
if (tab === "vaults") await performTreeSearch(q);
|
||||
else if (tab === "recent") filterRecentFiles(q);
|
||||
else if (tab === "saved") filterSavedSearches(q);
|
||||
else if (tab === "ai") filterAIHistory(q);
|
||||
else if (tab === "chat") _filterChat(q);
|
||||
else filterTagCloud(q);
|
||||
};
|
||||
const routeClear = async () => {
|
||||
@@ -719,6 +729,7 @@ function initSidebarFilter() {
|
||||
else if (tab === "recent") filterRecentFiles("");
|
||||
else if (tab === "saved") filterSavedSearches("");
|
||||
else if (tab === "ai") filterAIHistory("");
|
||||
else if (tab === "chat") _filterChat("");
|
||||
else filterTagCloud("");
|
||||
};
|
||||
|
||||
|
||||
@@ -120,6 +120,36 @@ export const IndexUpdateManager = (() => {
|
||||
}
|
||||
});
|
||||
|
||||
// #169/#190 — Chat : relais des broadcasts SSE vers les panneaux
|
||||
eventSource.addEventListener("chat_message", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
import("./filechat.js").then((m) => m.onChatMessage(data)).catch(() => {});
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// #191 — suppression d'un post
|
||||
eventSource.addEventListener("chat_deleted", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {});
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// #192 — accusé de réception (un participant vient d'ouvrir la conversation)
|
||||
eventSource.addEventListener("chat_read", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
import("./filechat.js").then((m) => m.onChatRead(data)).catch(() => {});
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connectionState = "disconnected";
|
||||
_updateBadge();
|
||||
|
||||
@@ -587,6 +587,26 @@ function applyTheme(themeKey, mode) {
|
||||
Object.keys(vars).forEach(function(key) {
|
||||
root.style.setProperty(key, vars[key]);
|
||||
});
|
||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
||||
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
|
||||
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
||||
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
||||
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||
// cell background on most themes, so the grid was invisible). The line is now
|
||||
// a blend of the text colour into the sheet surface: the SAME single value
|
||||
// for every line, and legible on both dark and light grounds.
|
||||
var _gridSurface = vars['--surface'] || vars['--bg-secondary'] || '#000000';
|
||||
var _gridInk = vars['--text-primary'] || vars['--text'] || '#888888';
|
||||
var _grid = {
|
||||
'--grid-bg': _gridSurface,
|
||||
'--grid-header-bg': vars['--bg-hover'] || _gridSurface,
|
||||
'--grid-header-text': _gridInk,
|
||||
'--grid-border': 'color-mix(in srgb, ' + _gridInk + ' 22%, ' + _gridSurface + ')',
|
||||
'--grid-zebra': 'color-mix(in srgb, ' + _gridInk + ' 4%, transparent)'
|
||||
};
|
||||
Object.keys(_grid).forEach(function(key) {
|
||||
if (_grid[key]) root.style.setProperty(key, _grid[key]);
|
||||
});
|
||||
// Store resolved vars on the theme for export/preview
|
||||
if (!theme.modes[mode]) theme.modes[mode] = vars;
|
||||
|
||||
|
||||
+52
-10
@@ -1,7 +1,7 @@
|
||||
/* ObsiGate — UI module */
|
||||
import { api, AuthManager, initLoginForm } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { openFile, showWelcome, renderFile, el } from './viewer.js';
|
||||
import { openFile, showWelcome, renderFile, renderImageViewer, el } from './viewer.js';
|
||||
import { safeCreateIcons, getFileIcon, escapeHtml, detachInlineEditor } from './utils.js';
|
||||
import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { GraphViewManager } from './graph.js';
|
||||
@@ -795,15 +795,16 @@ const FileOperations = {
|
||||
<div class="modal-form-group">
|
||||
<label class="modal-label">${t('modal.file_type')}</label>
|
||||
<select class="modal-select" id="file-ext-select">
|
||||
<option value=".md">Markdown (.md)</option>
|
||||
<option value=".txt">Texte (.txt)</option>
|
||||
<option value=".py">Python (.py)</option>
|
||||
<option value=".js">JavaScript (.js)</option>
|
||||
<option value=".json">JSON (.json)</option>
|
||||
<option value=".excalidraw">Excalidraw (.excalidraw)</option>
|
||||
<option value=".yaml">YAML (.yaml)</option>
|
||||
<option value=".sh">Shell (.sh)</option>
|
||||
<option value=".ps1">PowerShell (.ps1)</option>
|
||||
<option value=".md">${t('modal.ext_md')}</option>
|
||||
<option value=".txt">${t('modal.ext_txt')}</option>
|
||||
<option value=".py">${t('modal.ext_py')}</option>
|
||||
<option value=".js">${t('modal.ext_js')}</option>
|
||||
<option value=".json">${t('modal.ext_json')}</option>
|
||||
<option value=".xlsx">${t('modal.ext_xlsx')}</option>
|
||||
<option value=".excalidraw">${t('modal.ext_excalidraw')}</option>
|
||||
<option value=".yaml">${t('modal.ext_yaml')}</option>
|
||||
<option value=".sh">${t('modal.ext_sh')}</option>
|
||||
<option value=".ps1">${t('modal.ext_ps1')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2121,6 +2122,28 @@ export const TabManager = {
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** #110 — ouvre une image jointe au chat dans un onglet, avec le même
|
||||
* visualiseur que pour une image de vault (zoom/pan/plein écran). La pièce
|
||||
* jointe est servie par ``/api/chat/attachment/`` : pas de couple
|
||||
* (vault, path) derrière, donc onglet dédié qui rend le viewer directement
|
||||
* depuis l'URL (aucun fetch de l'API fichiers).
|
||||
* ponytail: l'onglet reste sur le TabManager singleton (les PaneTabManager
|
||||
* n'ont pas cette branche) — suffisant pour un cas limite du mode split. */
|
||||
async openChatImage(att) {
|
||||
if (!att || !att.url) return;
|
||||
const tabId = `chatimg::${att.url}`;
|
||||
const name = att.name || "image";
|
||||
const existing = this._tabs.find(t => t.id === tabId);
|
||||
if (existing) {
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
this._tabs.push({ id: tabId, vault: null, path: name, name, icon: "image", chatImage: true });
|
||||
this._tabCache[tabId] = { chatImage: true, url: att.url, mime: att.mime, name, title: name };
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Open (or retarget) the navigation tab for a vault directory (#158).
|
||||
* One navigation tab per vault: clicking another directory reuses it and
|
||||
* updates its directory, so open file tabs are never displaced. */
|
||||
@@ -2228,6 +2251,25 @@ export const TabManager = {
|
||||
return;
|
||||
}
|
||||
|
||||
// #110 — onglet image de chat : rendu direct depuis l'URL de la pièce
|
||||
// jointe, sans passer par l'API fichiers (l'image n'est pas dans un vault).
|
||||
if (cache.chatImage) {
|
||||
state.currentVault = null;
|
||||
state.currentPath = null;
|
||||
const chatArea = document.getElementById("content-area");
|
||||
if (!chatArea) return;
|
||||
renderImageViewer(chatArea, {
|
||||
vault: null,
|
||||
path: cache.name,
|
||||
title: cache.name,
|
||||
url: cache.url,
|
||||
image_mime: cache.mime,
|
||||
});
|
||||
const dash = document.getElementById("dashboard-home");
|
||||
if (dash) dash.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// Update global state
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path;
|
||||
|
||||
@@ -695,6 +695,21 @@ async function reloadExternalWrite(vault, path, force = false) {
|
||||
}
|
||||
// Not editing: refresh the read view when it shows the written document.
|
||||
if (state.currentVault === vault && state.currentPath === path) {
|
||||
// The spreadsheet viewer applies its own writes dynamically (values,
|
||||
// styles and structure stay in the DOM): reloading it here would wipe the
|
||||
// grid behind a loading spinner on every save. Skip the reload when the
|
||||
// event is the watcher echo of our own write (recent stamp) or when
|
||||
// unsaved edits are still on screen (they would be clobbered). Forced
|
||||
// reloads (assistant writes) still go through.
|
||||
const xlsxOpen = !force
|
||||
&& document.querySelector("#content-area .xlsx-viewer");
|
||||
if (xlsxOpen) {
|
||||
const own = window.__xlsxLastOwnWrite;
|
||||
const recentOwn = own && own.vault === vault && own.path === path
|
||||
&& (Date.now() - (own.at || 0)) < 15000;
|
||||
const hasDirty = Boolean(document.querySelector("#content-area td.xlsx-dirty"));
|
||||
if (recentOwn || hasDirty) return;
|
||||
}
|
||||
// Excalidraw owns its iframe: re-rendering would recreate it (visible page
|
||||
// refresh) and discard the in-editor scene. Its own save already persisted
|
||||
// the file, so there is nothing to reload here.
|
||||
|
||||
+3690
-190
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
/* ObsiGate — XLSX colour picker panel (#183).
|
||||
*
|
||||
* Replaces the native `<input type="color">` of the spreadsheet ribbon and of
|
||||
* the Mise en forme menu with a Google-Sheets-like panel:
|
||||
*
|
||||
* ┌ header ─ « Réinitialiser » ───────────────────┐
|
||||
* │ palette 8 x 10 : ligne 1 = échelles de gris, │
|
||||
* │ lignes 2-10 = 8 teintes x 9 │
|
||||
* │ nuances (clair -> foncé) │
|
||||
* │ STANDARD 8 pastilles prédéfinies │
|
||||
* │ PERSONNALISÉ pastille + bouton « + » + crayon │
|
||||
* │ pied Mise en forme conditionnelle, │
|
||||
* │ Couleurs en alternance │
|
||||
* └────────────────────────────────────────────────┘
|
||||
*
|
||||
* Picking a colour closes the panel and applies it (the caller owns the
|
||||
* write). The panel markup is built here, positioning and dismissal are the
|
||||
* viewer's (it already owns `anchorUnder` + `trackDismissable` for the other
|
||||
* spreadsheet menus), so this module stays a pure builder.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
// 8 columns = 8 hues, in the Google Sheets order: red, orange, yellow, light
|
||||
// green, cyan, blue, violet, magenta.
|
||||
const HUES = [0, 30, 55, 100, 180, 215, 270, 320];
|
||||
// 9 value rows, light (top) -> dark (bottom).
|
||||
const ROWS = 9;
|
||||
const GREYS = 8;
|
||||
|
||||
/**
|
||||
* HSL → `#rrggbb`.
|
||||
*
|
||||
* The palette is generated in HSL for readability, but it MUST leave this
|
||||
* module as hex: the write path (`normHex` in viewer.js) and the backend only
|
||||
* accept `#rrggbb`, so an `hsl(...)` swatch would be silently dropped on save.
|
||||
*/
|
||||
export function hslToHex(h, s, l) {
|
||||
const hue = ((h % 360) + 360) % 360;
|
||||
const sat = Math.min(1, Math.max(0, s));
|
||||
const lig = Math.min(1, Math.max(0, l));
|
||||
const c = (1 - Math.abs(2 * lig - 1)) * sat;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = lig - c / 2;
|
||||
const seg = Math.floor(hue / 60) % 6;
|
||||
const rgb = [
|
||||
[c, x, 0], [x, c, 0], [0, c, x],
|
||||
[0, x, c], [x, 0, c], [c, 0, x],
|
||||
][seg] || [0, 0, 0];
|
||||
return `#${rgb.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
/** Relative luminance (WCAG-ish) of a `#rrggbb` string, 0 (black) → 1 (white). */
|
||||
export function luminanceOf(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || "").trim());
|
||||
if (!m) return 0;
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = ((n >> 16) & 255) / 255;
|
||||
const g = ((n >> 8) & 255) / 255;
|
||||
const b = (n & 255) / 255;
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 80 swatches: a first row of greys then 9 x 8 hues.
|
||||
* @returns {Array<Array<string>>} 10 rows of 8 CSS colours
|
||||
*/
|
||||
export function buildPalette() {
|
||||
const rows = [];
|
||||
const greys = [];
|
||||
for (let i = 0; i < GREYS; i++) {
|
||||
// black -> white in 8 steps (l stays a 0..1 ratio, converted by hslToHex)
|
||||
greys.push(hslToHex(0, 0, i / (GREYS - 1)));
|
||||
}
|
||||
rows.push(greys);
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const l = 0.90 - r * (0.60 / (ROWS - 1)); // 0.90 -> 0.30
|
||||
const s = 0.60 + r * (0.25 / (ROWS - 1)); // 0.60 -> 0.85
|
||||
rows.push(HUES.map((h) => hslToHex(h, s, l)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The 8 predefined colours of the STANDARD row. */
|
||||
export const STANDARD_COLOURS = [
|
||||
{ key: "black", hex: "#000000" },
|
||||
{ key: "white", hex: "#ffffff" },
|
||||
{ key: "blue", hex: "#1a73e8" },
|
||||
{ key: "red", hex: "#d93025" },
|
||||
{ key: "yellow", hex: "#f4b400" },
|
||||
{ key: "green", hex: "#188038" },
|
||||
{ key: "orange", hex: "#e8710a" },
|
||||
{ key: "cyan", hex: "#00bcd4" },
|
||||
];
|
||||
|
||||
const CHECK_SVG =
|
||||
'<svg class="xlsx-colour-check" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true" focusable="false">'
|
||||
+ '<path fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/></svg>';
|
||||
|
||||
const icon = (name) =>
|
||||
`<i data-lucide="${name}" class="xlsx-colour-icon" aria-hidden="true"></i>`;
|
||||
|
||||
const button = (cls, label, ariaLabel) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `btn-action ${cls}`;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", ariaLabel || label);
|
||||
return b;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single round swatch.
|
||||
* @param {string} colour - CSS colour
|
||||
* @param {string} label - tooltip / aria-label
|
||||
* @param {boolean} selected - draw the check mark
|
||||
*/
|
||||
const swatchButton = (colour, label, selected) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "xlsx-colour-swatch-btn";
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.style.background = colour;
|
||||
b.dataset.colour = colour;
|
||||
if (selected) {
|
||||
b.classList.add("selected");
|
||||
b.setAttribute("aria-pressed", "true");
|
||||
// White check on dark grounds, black on light ones — otherwise the mark
|
||||
// disappears (the same contrast rule as the design).
|
||||
b.style.color = luminanceOf(colour) > 0.55 ? "#000000" : "#ffffff";
|
||||
b.innerHTML = CHECK_SVG;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the panel.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {"font"|"fill"} opts.kind - drives the panel label and the footer
|
||||
* @param {string} [opts.value] - current colour (`""` = none)
|
||||
* @param {string} [opts.custom] - colour shown in the PERSONNALISÉ slot
|
||||
* @param {Function} opts.onPick - (cssColour) => void; the panel is closed by
|
||||
* the caller right after
|
||||
* @param {Function} [opts.onReset] - clear the colour entirely
|
||||
* @param {Function} [opts.onCustom] - open the native picker for a custom
|
||||
* colour, called with no argument
|
||||
* @param {Function} [opts.onUnavailable] - (i18nLabel) => void for the footer
|
||||
* entries that ObsiGate does not implement yet
|
||||
* @returns {{ el: HTMLElement, sync: Function }}
|
||||
*/
|
||||
export function createColourPicker({
|
||||
kind = "font",
|
||||
value = "",
|
||||
custom = "",
|
||||
onPick,
|
||||
onReset,
|
||||
onCustom,
|
||||
onUnavailable,
|
||||
} = {}) {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "xlsx-colour-panel";
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute(
|
||||
"aria-label",
|
||||
kind === "fill" ? t("xlsx.colour_panel_fill") : t("xlsx.colour_panel_font")
|
||||
);
|
||||
const isFill = kind === "fill";
|
||||
const norm = (c) => String(c || "").trim().toLowerCase();
|
||||
|
||||
// ── Header : Réinitialiser ────────────────────────────────────────────────
|
||||
const head = document.createElement("div");
|
||||
head.className = "xlsx-colour-head";
|
||||
const reset = button(
|
||||
"xlsx-colour-reset",
|
||||
t("xlsx.colour_reset"),
|
||||
isFill ? t("xlsx.colour_reset_fill") : t("xlsx.colour_reset_font")
|
||||
);
|
||||
reset.innerHTML = `${icon(isFill ? "eraser" : "paintbrush")}<span>${t("xlsx.colour_reset")}</span>`;
|
||||
reset.addEventListener("click", () => {
|
||||
if (onReset) onReset();
|
||||
});
|
||||
head.appendChild(reset);
|
||||
panel.appendChild(head);
|
||||
|
||||
// ── Palette 8 x 10 ───────────────────────────────────────────────────────
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "xlsx-colour-grid";
|
||||
grid.setAttribute("role", "group");
|
||||
grid.setAttribute("aria-label", t("xlsx.colour_palette"));
|
||||
const current = norm(value);
|
||||
buildPalette().forEach((row) => {
|
||||
row.forEach((colour) => {
|
||||
const b = swatchButton(colour, colour, norm(colour) === current);
|
||||
b.addEventListener("click", () => {
|
||||
if (onPick) onPick(colour);
|
||||
});
|
||||
grid.appendChild(b);
|
||||
});
|
||||
});
|
||||
panel.appendChild(grid);
|
||||
|
||||
// ── STANDARD ─────────────────────────────────────────────────────────────
|
||||
const standard = document.createElement("div");
|
||||
standard.className = "xlsx-colour-section";
|
||||
const stdHead = document.createElement("div");
|
||||
stdHead.className = "xlsx-colour-title";
|
||||
stdHead.appendChild(document.createTextNode(t("xlsx.colour_standard")));
|
||||
const stdEdit = button(
|
||||
"xlsx-colour-edit",
|
||||
t("xlsx.colour_edit_standard"),
|
||||
t("xlsx.colour_edit_standard")
|
||||
);
|
||||
stdEdit.innerHTML = icon("pencil");
|
||||
stdEdit.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
stdHead.appendChild(stdEdit);
|
||||
standard.appendChild(stdHead);
|
||||
|
||||
const stdRow = document.createElement("div");
|
||||
stdRow.className = "xlsx-colour-row";
|
||||
STANDARD_COLOURS.forEach(({ key, hex }) => {
|
||||
const label = t(`xlsx.colour_${key}`);
|
||||
const b = swatchButton(hex, label, norm(hex) === current);
|
||||
b.addEventListener("click", () => {
|
||||
if (onPick) onPick(hex);
|
||||
});
|
||||
stdRow.appendChild(b);
|
||||
});
|
||||
standard.appendChild(stdRow);
|
||||
panel.appendChild(standard);
|
||||
|
||||
// ── PERSONNALISÉ ─────────────────────────────────────────────────────────
|
||||
const customSection = document.createElement("div");
|
||||
customSection.className = "xlsx-colour-section";
|
||||
const cusHead = document.createElement("div");
|
||||
cusHead.className = "xlsx-colour-title";
|
||||
cusHead.appendChild(document.createTextNode(t("xlsx.colour_custom")));
|
||||
customSection.appendChild(cusHead);
|
||||
|
||||
const cusRow = document.createElement("div");
|
||||
cusRow.className = "xlsx-colour-row";
|
||||
const customColour = custom || value;
|
||||
const cusSwatch = swatchButton(
|
||||
customColour || "transparent",
|
||||
t("xlsx.colour_custom"),
|
||||
Boolean(customColour) && norm(customColour) === current
|
||||
);
|
||||
cusSwatch.addEventListener("click", () => {
|
||||
if (customColour && onPick) onPick(customColour);
|
||||
});
|
||||
cusRow.appendChild(cusSwatch);
|
||||
|
||||
const add = button("xlsx-colour-add", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
|
||||
add.innerHTML = icon("plus");
|
||||
add.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
cusRow.appendChild(add);
|
||||
|
||||
const cusEdit = button("xlsx-colour-edit", t("xlsx.colour_custom_pick"), t("xlsx.colour_custom_pick"));
|
||||
cusEdit.innerHTML = icon("pencil");
|
||||
cusEdit.addEventListener("click", () => {
|
||||
if (onCustom) onCustom();
|
||||
});
|
||||
cusRow.appendChild(cusEdit);
|
||||
customSection.appendChild(cusRow);
|
||||
panel.appendChild(customSection);
|
||||
|
||||
// ── Footer ───────────────────────────────────────────────────────────────
|
||||
// Both entries are announced by ObsiGate but not implemented yet: they call
|
||||
// back with their label so the caller can tell the user instead of leaving a
|
||||
// dead button. « Couleurs en alternance » only shows once a colour is set,
|
||||
// exactly like the reference screenshots.
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "xlsx-colour-foot";
|
||||
const footEntry = (label, key) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "xlsx-colour-foot-item";
|
||||
b.textContent = label;
|
||||
b.dataset.colourAction = key;
|
||||
b.addEventListener("click", () => {
|
||||
if (onUnavailable) onUnavailable(label);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
foot.appendChild(footEntry(t("xlsx.colour_conditional"), "conditional"));
|
||||
if (value) foot.appendChild(footEntry(t("xlsx.colour_banded"), "banded"));
|
||||
panel.appendChild(foot);
|
||||
|
||||
/** Repaint the check mark after the value changed (custom colour applied). */
|
||||
const sync = (next, nextCustom) => {
|
||||
const want = norm(next);
|
||||
panel.querySelectorAll(".xlsx-colour-swatch-btn").forEach((b) => {
|
||||
const on = norm(b.dataset.colour) === want;
|
||||
b.classList.toggle("selected", on);
|
||||
b.setAttribute("aria-pressed", String(on));
|
||||
b.innerHTML = on
|
||||
? (b.style.color = luminanceOf(b.dataset.colour) > 0.55 ? "#000000" : "#ffffff", CHECK_SVG)
|
||||
: "";
|
||||
});
|
||||
if (nextCustom) {
|
||||
const slot = panel.querySelector(".xlsx-colour-section:nth-of-type(3) .xlsx-colour-row .xlsx-colour-swatch-btn");
|
||||
if (slot) slot.style.background = nextCustom;
|
||||
}
|
||||
};
|
||||
|
||||
return { el: panel, sync };
|
||||
}
|
||||
+128
-25
@@ -1,19 +1,29 @@
|
||||
/* ObsiGate — XLSX command bar builder (#154-A5).
|
||||
/* ObsiGate — XLSX command bar builder (Sheets-like ribbon).
|
||||
*
|
||||
* Pure string builder for the spreadsheet shell (sheet tabs + grouped action
|
||||
* buttons + status pills). Extracted from frontend/js/viewer.js so the markup
|
||||
* is testable and the viewer only wires behaviour.
|
||||
*
|
||||
* Order follows Google Sheets: search, undo/redo, print, paint, zoom, number
|
||||
* formats, font size, B/I/S/U, colours, borders, merge, alignments, wrap,
|
||||
* rotation, overflow menu — then the ObsiGate-specific groups (structure,
|
||||
* format, view, gridlines) kept with their stable ids. File outputs
|
||||
* (Download / CSV / Markdown / HTML / Print) moved to the **Fichier** menu of
|
||||
* the menu bar (#185, frontend/js/xlsx/menus.js); only Save stays on the
|
||||
* sheet-tab strip, pinned at its right end.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
const IC = (name) => `<i data-lucide="${name}" style="width:14px;height:14px" aria-hidden="true"></i>`;
|
||||
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* @param {Array<{name: string}>} ctx.sheets
|
||||
* @param {boolean} ctx.isCsv
|
||||
* @param {boolean} ctx.readOnly
|
||||
* @param {boolean} ctx.editable - not a CSV and not read-only
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string }}
|
||||
* @returns {{ tabs: string, actionsHtml: string, statusBar: string, fileHtml: string }}
|
||||
*/
|
||||
export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
// Sheet tabs are always rendered (even for a single sheet) so the strip reads
|
||||
@@ -26,19 +36,23 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
? `<button class="xlsx-tab-add" id="xlsx-tab-add" type="button" title="${escapeHtml(t("xlsx.tabs_add_sheet"))}" aria-label="${escapeHtml(t("xlsx.tabs_add_sheet"))}"><i data-lucide="plus" style="width:14px;height:14px" aria-hidden="true"></i></button>`
|
||||
: ""}</div>`;
|
||||
|
||||
// Status pills make the viewer's limits visible up front.
|
||||
// Status pills make the viewer's limits visible up front (#180: the
|
||||
// "formulas computed locally" pill is gone — local computation is the
|
||||
// normal behaviour, not a limit worth a badge).
|
||||
const statusPills = [
|
||||
readOnly
|
||||
? `<span class="xlsx-status-pill xlsx-status-readonly" title="${escapeHtml(t("xlsx.readonly_hint"))}"><i data-lucide="lock" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.readonly_badge"))}</span>`
|
||||
: "",
|
||||
!isCsv
|
||||
? `<span class="xlsx-status-pill xlsx-status-formula" title="${escapeHtml(t("xlsx.formulas_note_title"))}"><i data-lucide="sigma" class="xlsx-status-icon"></i>${escapeHtml(t("xlsx.formulas_note"))}</span>`
|
||||
: "",
|
||||
].filter(Boolean).join("");
|
||||
const statusBar = statusPills ? `<div class="xlsx-status-bar">${statusPills}</div>` : "";
|
||||
|
||||
// Command groups, separated by thin rules.
|
||||
// Command groups, separated by thin rules (Sheets order).
|
||||
const actionGroups = [];
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="search">
|
||||
<button class="btn-action" id="xs-search" type="button" title="${escapeHtml(t("xlsx.tb_search"))}" aria-label="${escapeHtml(t("xlsx.tb_search"))}">
|
||||
${IC("search")}
|
||||
</button>
|
||||
</span>`);
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="history">
|
||||
<button class="btn-action" id="xlsx-undo-btn" type="button" title="${escapeHtml(t("xlsx.undo"))}" disabled>
|
||||
@@ -48,6 +62,97 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
<i data-lucide="redo-2" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="print">
|
||||
<button class="btn-action" id="xs-print" type="button" title="${escapeHtml(t("xlsx.print"))}" aria-label="${escapeHtml(t("xlsx.print"))}">
|
||||
${IC("printer")}
|
||||
</button>
|
||||
</span>`);
|
||||
if (editable) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="paint">
|
||||
<button class="btn-action" id="xs-paint" type="button" title="${escapeHtml(t("xlsx.tb_paint"))}" aria-label="${escapeHtml(t("xlsx.tb_paint"))}" aria-pressed="false">
|
||||
${IC("paint-roller")}
|
||||
</button>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="zoom">
|
||||
<select class="xlsx-select" id="xs-zoom" title="${escapeHtml(t("xlsx.tb_zoom"))}" aria-label="${escapeHtml(t("xlsx.tb_zoom"))}">
|
||||
${[50, 75, 90, 100, 125, 150, 200].map((z) =>
|
||||
`<option value="${z}"${z === 100 ? " selected" : ""}>${z} %</option>`).join("")}
|
||||
</select>
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="number">
|
||||
<button class="btn-action" id="xs-cur" type="button" title="${escapeHtml(t("xlsx.tb_currency"))}" aria-label="${escapeHtml(t("xlsx.tb_currency"))}">
|
||||
${IC("dollar-sign")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-pct" type="button" title="${escapeHtml(t("xlsx.tb_percent"))}" aria-label="${escapeHtml(t("xlsx.tb_percent"))}">
|
||||
${IC("percent")}
|
||||
</button>
|
||||
<button class="btn-action xlsx-dec" id="xs-decminus" type="button" title="${escapeHtml(t("xlsx.tb_dec_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_minus"))}">.0←</button>
|
||||
<button class="btn-action xlsx-dec" id="xs-decplus" type="button" title="${escapeHtml(t("xlsx.tb_dec_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_dec_plus"))}">.00→</button>
|
||||
<button class="btn-action" id="xs-num123" type="button" title="${escapeHtml(t("xlsx.tb_number"))}" aria-label="${escapeHtml(t("xlsx.tb_number"))}">123</button>
|
||||
<select class="xlsx-select" id="xs-numfmt" title="${escapeHtml(t("xlsx.tb_numfmt"))}" aria-label="${escapeHtml(t("xlsx.tb_numfmt"))}">
|
||||
<option value="" selected>${escapeHtml(t("xlsx.tb_numfmt"))}</option>
|
||||
<option value="General">${escapeHtml(t("xlsx.format_num_general"))}</option>
|
||||
<option value="0.00">${escapeHtml(t("xlsx.format_num_number"))}</option>
|
||||
<option value="0.0%">${escapeHtml(t("xlsx.format_num_percent"))}</option>
|
||||
<option value="#,##0.00 "€"">${escapeHtml(t("xlsx.format_num_currency"))}</option>
|
||||
<option value="DD/MM/YYYY">${escapeHtml(t("xlsx.format_num_date"))}</option>
|
||||
<option value="@">${escapeHtml(t("xlsx.format_num_text"))}</option>
|
||||
</select>
|
||||
</span>`);
|
||||
// #183 — colour commands open the Sheets-like panel built by
|
||||
// xlsx/color-picker.js (the native <input type="color"> is kept hidden and
|
||||
// only driven by the panel's « + » / crayon buttons).
|
||||
const colourPick = (id, label, iconName, swatchId) =>
|
||||
`<span class="xlsx-colorpick-wrap">`
|
||||
+ `<button class="btn-action xlsx-colorpick" id="${id}" type="button"`
|
||||
+ ` data-xlsx-colour="${swatchId === "xs-fgcolor" ? "font" : "fill"}"`
|
||||
+ ` title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}"`
|
||||
+ ` aria-haspopup="dialog" aria-expanded="false">`
|
||||
+ (iconName === "fill" ? IC("paint-bucket") : '<span class="xlsx-colorletter" aria-hidden="true">A</span>')
|
||||
+ `<span class="xlsx-color-swatch" data-swatch-for="${swatchId}" aria-hidden="true"></span>`
|
||||
+ `</button>`
|
||||
+ `<input type="color" class="xlsx-color-native" data-xlsx-native-for="${swatchId}" tabindex="-1" aria-hidden="true">`
|
||||
+ `</span>`;
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="font">
|
||||
<button class="btn-action" id="xs-fsminus" type="button" title="${escapeHtml(t("xlsx.tb_font_minus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_minus"))}">−</button>
|
||||
<select class="xlsx-select xlsx-fontsize" id="xs-fssize" title="${escapeHtml(t("xlsx.tb_font_size"))}" aria-label="${escapeHtml(t("xlsx.tb_font_size"))}">
|
||||
${[6, 7, 8, 9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) =>
|
||||
`<option value="${s}"${s === 11 ? " selected" : ""}>${s}</option>`).join("")}
|
||||
</select>
|
||||
<button class="btn-action" id="xs-fsplus" type="button" title="${escapeHtml(t("xlsx.tb_font_plus"))}" aria-label="${escapeHtml(t("xlsx.tb_font_plus"))}">+</button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-b" type="button" title="${escapeHtml(t("xlsx.format_bold"))}" aria-label="${escapeHtml(t("xlsx.format_bold"))}" aria-pressed="false"><b>B</b></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-i" type="button" title="${escapeHtml(t("xlsx.format_italic"))}" aria-label="${escapeHtml(t("xlsx.format_italic"))}" aria-pressed="false"><i>I</i></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-s" type="button" title="${escapeHtml(t("xlsx.format_strike"))}" aria-label="${escapeHtml(t("xlsx.format_strike"))}" aria-pressed="false"><s>S</s></button>
|
||||
<button class="btn-action xlsx-fontstyle" id="xs-u" type="button" title="${escapeHtml(t("xlsx.format_underline"))}" aria-label="${escapeHtml(t("xlsx.format_underline"))}" aria-pressed="false"><u>U</u></button>
|
||||
${colourPick("xs-fgcolor", t("xlsx.format_font_color"), "font", "xs-fgcolor")}
|
||||
${colourPick("xs-bgcolor", t("xlsx.format_fill_color"), "fill", "xs-bgcolor")}
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="arrange">
|
||||
<button class="btn-action" id="xs-borders" type="button" title="${escapeHtml(t("xlsx.tb_borders"))}" aria-label="${escapeHtml(t("xlsx.tb_borders"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("grid-2x2")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-merge" type="button" title="${escapeHtml(t("xlsx.tb_merge"))}" aria-label="${escapeHtml(t("xlsx.tb_merge"))}">
|
||||
${IC("combine")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-alignh" type="button" title="${escapeHtml(t("xlsx.tb_alignh"))}" aria-label="${escapeHtml(t("xlsx.tb_alignh"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("align-left")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-alignv" type="button" title="${escapeHtml(t("xlsx.tb_alignv"))}" aria-label="${escapeHtml(t("xlsx.tb_alignv"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("align-center-vertical")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-wrap" type="button" title="${escapeHtml(t("xlsx.tb_wrap"))}" aria-label="${escapeHtml(t("xlsx.tb_wrap"))}" aria-pressed="false">
|
||||
${IC("wrap-text")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-rot" type="button" title="${escapeHtml(t("xlsx.tb_rotation"))}" aria-label="${escapeHtml(t("xlsx.tb_rotation"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("rotate-cw")}
|
||||
</button>
|
||||
<button class="btn-action" id="xs-more" type="button" title="${escapeHtml(t("xlsx.tb_more"))}" aria-label="${escapeHtml(t("xlsx.tb_more"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
${IC("more-vertical")}
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
if (!readOnly) {
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="formulas">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
@@ -64,26 +169,24 @@ export function buildCommandBar({ sheets, isCsv, readOnly, editable }) {
|
||||
</span>`);
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" id="xlsx-view-group" data-group="view"></span>`);
|
||||
}
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="file">
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
// #185 — the ribbon now reads like Google Sheets': flat icon buttons, no
|
||||
// chrome, grouped by thin vertical rules. The gridline switch belongs to it
|
||||
// (the View ▸ Show menu toggles the very same state).
|
||||
actionGroups.push(`<span class="xlsx-cmd-group" data-group="grid">
|
||||
<button class="btn-action active" id="xs-grid" type="button" aria-pressed="true"
|
||||
title="${escapeHtml(t("xlsx.mi_show_grid"))}" aria-label="${escapeHtml(t("xlsx.mi_show_grid"))}">
|
||||
${IC("grid-3x3")}
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<span class="export-dropdown xlsx-export-dropdown">
|
||||
<button class="btn-action" id="xlsx-export-btn" type="button" title="${escapeHtml(t("xlsx.export_title"))}" aria-haspopup="menu" aria-expanded="false">
|
||||
<i data-lucide="file-down" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.export_btn"))}
|
||||
</button>
|
||||
<div class="export-menu xlsx-export-menu" id="xlsx-export-menu" style="display:none" role="menu">
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="md"><i data-lucide="file-text" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_md"))}</span></button>
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="html"><i data-lucide="code" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.export_html"))}</span></button>
|
||||
<button class="export-menu-item" type="button" role="menuitem" data-xlsx-export="print"><i data-lucide="printer" class="xlsx-menu-icon" aria-hidden="true"></i><span class="xlsx-menu-label">${escapeHtml(t("xlsx.print"))}</span></button>
|
||||
</div>
|
||||
</span>
|
||||
<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled><i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>
|
||||
</span>`);
|
||||
const actionsHtml = actionGroups.join('<span class="xlsx-cmd-sep" aria-hidden="true"></span>');
|
||||
|
||||
return { tabs, actionsHtml, statusBar };
|
||||
// #185 — file outputs moved to the **Fichier** menu of the menu bar
|
||||
// (frontend/js/xlsx/menus.js), which keeps the `#xlsx-download-btn` /
|
||||
// `#xlsx-csv-btn` ids and the `[data-xlsx-export]` markers. Only **Save**
|
||||
// stays on the sheet-tab strip, pinned at its right end.
|
||||
const fileHtml =
|
||||
`<button class="btn-action xlsx-save-primary" id="xlsx-save-btn" disabled>`
|
||||
+ `<i data-lucide="save" style="width:14px;height:14px" aria-hidden="true"></i> ${t("common.save")}</button>`;
|
||||
|
||||
return { tabs, actionsHtml, statusBar, fileHtml };
|
||||
}
|
||||
|
||||
@@ -14,14 +14,15 @@ import { safeCreateIcons } from '../utils.js';
|
||||
|
||||
let _menu = null;
|
||||
let _openedAt = 0;
|
||||
// Only a touch long-press can emit a click right after the menu opened.
|
||||
let _openedByTouch = false;
|
||||
let _dismissBound = false;
|
||||
|
||||
export function closeContextMenu() {
|
||||
// BUG-108: drop the reference BEFORE touching the DOM — removing a menu
|
||||
// that holds the focus fires blur/focusout synchronously, which re-enters
|
||||
// this very function; the second remove() on the mid-flight node throws
|
||||
// NotFoundError and aborts the caller's click handler before its action
|
||||
// runs (contextual « Coller » silently dead, e2e xlsx-viewer A5).
|
||||
// Drop the reference BEFORE touching the DOM: removing a menu that holds
|
||||
// the focus fires blur/focusout synchronously, which re-enters this very
|
||||
// function — a second remove() on the mid-flight node throws NotFoundError
|
||||
// and aborts the caller's click handler before its action runs.
|
||||
const menu = _menu;
|
||||
_menu = null;
|
||||
if (menu) menu.remove();
|
||||
@@ -36,9 +37,11 @@ function bindDismiss() {
|
||||
_dismissBound = true;
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!_menu) return;
|
||||
// Ignore the very gesture that opened the menu (touch long-press can emit
|
||||
// a click right after).
|
||||
if (Date.now() - _openedAt < 250) return;
|
||||
// Ignore the very gesture that opened the menu — but only for a touch
|
||||
// long-press, which is the one that emits a trailing click. A mouse
|
||||
// right-click does not, so a deliberate outside click made right after
|
||||
// must close the menu instead of being swallowed.
|
||||
if (_openedByTouch && Date.now() - _openedAt < 250) return;
|
||||
if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return;
|
||||
closeContextMenu();
|
||||
});
|
||||
@@ -56,8 +59,12 @@ function bindDismiss() {
|
||||
});
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("blur", closeContextMenu);
|
||||
// A scroll or resize detaches the viewport-anchored menu from its cell.
|
||||
window.addEventListener("scroll", closeContextMenu, true);
|
||||
// A scroll detaches the viewport-anchored menu from its cell — except the
|
||||
// tail of the smooth scroll that revealed the right-clicked cell (global
|
||||
// `scroll-behavior: smooth` keeps firing after the menu opened).
|
||||
window.addEventListener("scroll", () => {
|
||||
if (Date.now() - _openedAt > 400) closeContextMenu();
|
||||
}, true);
|
||||
window.addEventListener("resize", closeContextMenu);
|
||||
}
|
||||
}
|
||||
@@ -111,25 +118,50 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
|
||||
if (onClose) onClose();
|
||||
else menuEl.remove();
|
||||
};
|
||||
// A re-render can detach the menu without dismissing it: unbind silently
|
||||
// instead of leaking listeners on a dead node.
|
||||
const dropIfDetached = () => {
|
||||
if (!menuEl.isConnected) {
|
||||
if (!done) { done = true; unbind(); }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const onDocClick = (e) => {
|
||||
if (done) return;
|
||||
if (!menuEl.isConnected) { done = true; unbind(); return; }
|
||||
if (done || dropIfDetached()) return;
|
||||
const t = e.target;
|
||||
if (menuEl.contains(t)) return;
|
||||
if (toggle && (toggle === t || toggle.contains(t))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape" && menuEl.isConnected) dismiss();
|
||||
if (dropIfDetached()) return;
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
const onFocusOut = (e) => {
|
||||
if (done || !menuEl.isConnected) return;
|
||||
if (done || dropIfDetached()) return;
|
||||
const next = e.relatedTarget;
|
||||
if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return;
|
||||
dismiss();
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (menuEl.isConnected) dismiss();
|
||||
// Toolbar-anchored menus travel WITH their button: a scroll inside the
|
||||
// hosting toolbar (focus scroll-into-view, horizontal scroll, the menu's
|
||||
// own overflow) must not dismiss them — only an outside scroll detaches.
|
||||
// Same grace as the grid menu for the smooth-scroll tail of the opening.
|
||||
// A body-level menu has no travelling anchor: every scroll detaches it.
|
||||
const rawAnchor = (toggle && toggle.closest && toggle.closest(".xlsx-toolbar"))
|
||||
|| menuEl.parentElement
|
||||
|| null;
|
||||
const anchor = rawAnchor && rawAnchor !== document && rawAnchor !== document.body
|
||||
? rawAnchor
|
||||
: null;
|
||||
const openedAt = Date.now();
|
||||
const onScroll = (e) => {
|
||||
if (dropIfDetached()) return;
|
||||
if (Date.now() - openedAt < 400) return;
|
||||
const t = e && e.target;
|
||||
if (t && anchor && (t === anchor || (anchor.contains && anchor.contains(t)))) return;
|
||||
dismiss();
|
||||
};
|
||||
document.addEventListener("click", onDocClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
@@ -147,7 +179,7 @@ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {})
|
||||
* @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items
|
||||
* @returns {HTMLElement} the menu element
|
||||
*/
|
||||
export function openContextMenu(x, y, items) {
|
||||
export function openContextMenu(x, y, items, { byTouch = false } = {}) {
|
||||
bindDismiss();
|
||||
closeContextMenu();
|
||||
const menu = document.createElement("div");
|
||||
@@ -198,6 +230,7 @@ export function openContextMenu(x, y, items) {
|
||||
|
||||
_menu = menu;
|
||||
_openedAt = Date.now();
|
||||
_openedByTouch = byTouch;
|
||||
// Keyboard users land on the first entry right away; mouse users are
|
||||
// unaffected (hover/click still work). preventScroll avoids yanking the grid.
|
||||
const first = menu.querySelector(".xlsx-context-item:not(:disabled)");
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
/* ObsiGate — minimal client-side spreadsheet formula engine.
|
||||
*
|
||||
* Evaluates `=…` / `@…` cell content locally (same sheet, plus loaded
|
||||
* cross-sheet panels) so functions work without a round-trip: arithmetic,
|
||||
* comparisons, %, cell/range references and ~30 FR/EN functions.
|
||||
*
|
||||
* Safe by construction: own tokenizer + recursive-descent parser, no eval().
|
||||
* Anything the parser rejects keeps its original rendering (Excel stays the
|
||||
* source of truth on save); evaluation failures surface as Excel-style error
|
||||
* codes instead.
|
||||
*
|
||||
* Limits (documented): same-workbook only, no whole-column (A:A) references,
|
||||
* TODAY()/NOW() refresh on edit/render (no timer), array results show their
|
||||
* top-left value.
|
||||
*/
|
||||
|
||||
const MAX_COL = 18278; // XFD
|
||||
const MAX_ROW = 1048576;
|
||||
|
||||
/** FR aliases accepted next to the EN canonical names. */
|
||||
const ALIASES = {
|
||||
SOMME: "SUM", MOYENNE: "AVERAGE", NB: "COUNT", NBVAL: "COUNTA",
|
||||
PRODUIT: "PRODUCT", MEDIANE: "MEDIAN", ARRONDI: "ROUND", ENT: "INT",
|
||||
RACINE: "SQRT", PUISSANCE: "POWER", SI: "IF", ET: "AND", OU: "OR",
|
||||
NON: "NOT", SIERREUR: "IFERROR", CONCATENER: "CONCAT",
|
||||
MAJUSCULES: "UPPER", MINUSCULES: "LOWER", NBCAR: "LEN",
|
||||
SUPPRESPACE: "TRIM", GAUCHE: "LEFT", DROITE: "RIGHT", STXT: "MID",
|
||||
AUJOURDHUI: "TODAY", MAINTENANT: "NOW", JOUR: "DAY", MOIS: "MONTH",
|
||||
ANNEE: "YEAR", LIEN: "HYPERLINK",
|
||||
};
|
||||
|
||||
const ERRORS = {
|
||||
fr: { DIV: "#DIV/0!", VALUE: "#VALEUR!", NAME: "#NOM?", REF: "#REF!", CYCLE: "#CYCLE!" },
|
||||
en: { DIV: "#DIV/0!", VALUE: "#VALUE!", NAME: "#NAME?", REF: "#REF!", CIRC: "#CIRC!" },
|
||||
};
|
||||
|
||||
export function errorCodes(locale) {
|
||||
return ERRORS[locale === "fr" ? "fr" : "en"];
|
||||
}
|
||||
|
||||
export function isFormulaText(s) {
|
||||
if (typeof s !== "string") return false;
|
||||
const t = s.trim();
|
||||
return t.length > 1 && (t[0] === "=" || t[0] === "@");
|
||||
}
|
||||
|
||||
/** Column letters (no $) → 1-based index, or 0 when invalid. */
|
||||
export function colToIndex(letters) {
|
||||
let n = 0;
|
||||
const up = String(letters || "").toUpperCase();
|
||||
if (!/^[A-Z]{1,3}$/.test(up)) return 0;
|
||||
for (const ch of up) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n > MAX_COL ? 0 : n;
|
||||
}
|
||||
|
||||
export function indexToCol(n) {
|
||||
let s = "";
|
||||
while (n > 0) {
|
||||
const m = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + m) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Parse "[$]A[$]1" (already stripped of any sheet prefix) → {col,row} | null. */
|
||||
export function parseA1(ref) {
|
||||
const m = /^\$?([A-Za-z]{1,3})\$?([0-9]+)$/.exec(String(ref || "").trim());
|
||||
if (!m) return null;
|
||||
const col = colToIndex(m[1]);
|
||||
const row = Number(m[2]);
|
||||
if (!col || !(row >= 1 && row <= MAX_ROW)) return null;
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function tokenize(src, sep) {
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
while (i < n) {
|
||||
const c = src[i];
|
||||
if (isSpace(c)) { i++; continue; }
|
||||
if (c === '"') {
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n) {
|
||||
if (src[j] === '"') {
|
||||
if (src[j + 1] === '"') { out += '"'; j += 2; }
|
||||
else break;
|
||||
} else { out += src[j]; j++; }
|
||||
}
|
||||
if (j >= n) throw new Error("string");
|
||||
tokens.push({ t: "str", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
// Quoted sheet name: 'Mon Sheet'!A1
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n && src[j] !== "'") { out += src[j]; j++; }
|
||||
if (j >= n) throw new Error("sheet");
|
||||
tokens.push({ t: "sheet", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
const numMatch = /^[0-9]+(?:[.,][0-9]+)?/.exec(src.slice(i));
|
||||
if (numMatch && (sep === ";" || !numMatch[0].includes(","))) {
|
||||
tokens.push({ t: "num", v: numMatch[0].replace(",", ".") });
|
||||
i += numMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
const absRef = /^\$?[A-Za-z]{1,3}\$?[0-9]+/.exec(src.slice(i));
|
||||
if (absRef) {
|
||||
tokens.push({ t: "ref", v: absRef[0] });
|
||||
i += absRef[0].length;
|
||||
continue;
|
||||
}
|
||||
const two = src.slice(i, i + 2);
|
||||
if (["<=", ">=", "<>"].includes(two)) {
|
||||
tokens.push({ t: "op", v: two === "<>" ? "!=" : two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("+-*/^%=()<>,;:&!".includes(c)) {
|
||||
tokens.push({ t: c === "," || c === ";" ? "sep" : "op", v: c });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const word = /^[A-Za-zÀ-ÿ_][A-Za-zÀ-ÿ0-9_.]*/.exec(src.slice(i));
|
||||
if (word) {
|
||||
const w = word[0];
|
||||
// A bare cell reference (A1, $B$12) — but not TRUE/FALSE yet (bools).
|
||||
if (/^\$?[A-Za-z]{1,3}\$?[0-9]+$/.test(w)) {
|
||||
tokens.push({ t: "ref", v: w });
|
||||
} else {
|
||||
const up = w.toUpperCase();
|
||||
if (up === "TRUE" || up === "VRAI") tokens.push({ t: "bool", v: true });
|
||||
else if (up === "FALSE" || up === "FAUX") tokens.push({ t: "bool", v: false });
|
||||
else tokens.push({ t: "word", v: w });
|
||||
}
|
||||
i += w.length;
|
||||
continue;
|
||||
}
|
||||
throw new Error("char:" + c);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── Parser (recursive descent, builds a small AST) ─────────────────────────
|
||||
|
||||
function makeParser(tokens, sep) {
|
||||
let pos = 0;
|
||||
const peek = () => tokens[pos] || null;
|
||||
const next = () => tokens[pos++] || null;
|
||||
const expectOp = (v) => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "op" || t.v !== v) throw new Error("expected " + v);
|
||||
};
|
||||
const expectSep = () => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "sep") throw new Error("expected separator");
|
||||
};
|
||||
|
||||
function parseExpr() { return parseComparison(); }
|
||||
function parseComparison() {
|
||||
let left = parseConcat();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || !["=", "!=", "<", "<=", ">", ">="].includes(t.v)) return left;
|
||||
next();
|
||||
left = [t.v, left, parseConcat()];
|
||||
}
|
||||
}
|
||||
function parseConcat() {
|
||||
let left = parseAdd();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || t.v !== "&") return left;
|
||||
next();
|
||||
left = ["&", left, parseAdd()];
|
||||
}
|
||||
}
|
||||
function parseAdd() {
|
||||
let left = parseMul();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "+" && t.v !== "-")) return left;
|
||||
next();
|
||||
left = [t.v, left, parseMul()];
|
||||
}
|
||||
}
|
||||
function parseMul() {
|
||||
let left = parsePower();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "*" && t.v !== "/")) return left;
|
||||
next();
|
||||
left = [t.v, left, parsePower()];
|
||||
}
|
||||
}
|
||||
// Excel-compatible: a leading sign binds to the base (-2^2 = (-2)^2 = 4),
|
||||
// and to the exponent on the right (2^-2).
|
||||
function parseSigned() {
|
||||
let neg = false;
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && (t.v === "+" || t.v === "-")) {
|
||||
if (t.v === "-") neg = !neg;
|
||||
next();
|
||||
} else break;
|
||||
}
|
||||
const node = parsePostfix();
|
||||
return neg ? ["neg", node] : node;
|
||||
}
|
||||
function parsePower() {
|
||||
const base = parseSigned();
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "^") {
|
||||
next();
|
||||
return ["^", base, parseSigned()];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
function parsePostfix() {
|
||||
let node = parsePrimary();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "%") { next(); node = ["%", node]; }
|
||||
else return node;
|
||||
}
|
||||
}
|
||||
function parsePrimary() {
|
||||
const t = next();
|
||||
if (!t) throw new Error("empty");
|
||||
if (t.t === "num") return ["n", Number(t.v)];
|
||||
if (t.t === "str") return ["s", t.v];
|
||||
if (t.t === "bool") return ["b", t.v];
|
||||
if (t.t === "ref") {
|
||||
const after = peek();
|
||||
if (after && after.t === "op" && after.v === ":") {
|
||||
next();
|
||||
const t2 = next();
|
||||
if (!t2 || t2.t !== "ref") throw new Error("range");
|
||||
return ["range", normRef(t.v), normRef(t2.v)];
|
||||
}
|
||||
return ["ref", normRef(t.v)];
|
||||
}
|
||||
if (t.t === "word" || t.t === "sheet") {
|
||||
const isSheet = t.t === "sheet";
|
||||
let name = t.v;
|
||||
if (!isSheet) {
|
||||
// Unquoted sheet name: Name!A1
|
||||
const bang = peek();
|
||||
if (bang && bang.t === "op" && bang.v === "!") {
|
||||
next();
|
||||
} else {
|
||||
// Function call?
|
||||
const paren = peek();
|
||||
if (paren && paren.t === "op" && paren.v === "(") {
|
||||
next();
|
||||
const args = [];
|
||||
const closing = peek();
|
||||
if (!(closing && closing.t === "op" && closing.v === ")")) {
|
||||
for (;;) {
|
||||
args.push(parseExpr());
|
||||
const s = peek();
|
||||
if (s && s.t === "sep") { next(); continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
expectOp(")");
|
||||
return ["call", canonName(name), args];
|
||||
}
|
||||
throw new Error("name:" + name);
|
||||
}
|
||||
} else {
|
||||
const bang = peek();
|
||||
if (!bang || bang.t !== "op" || bang.v !== "!") throw new Error("sheetref");
|
||||
next();
|
||||
}
|
||||
const r1 = next();
|
||||
if (!r1 || r1.t !== "ref") throw new Error("sheetref");
|
||||
const colon = peek();
|
||||
if (colon && colon.t === "op" && colon.v === ":") {
|
||||
next();
|
||||
const r2 = next();
|
||||
if (!r2 || r2.t !== "ref") throw new Error("sheetrange");
|
||||
return ["sheetrange", name, normRef(r1.v), normRef(r2.v)];
|
||||
}
|
||||
return ["sheetref", name, normRef(r1.v)];
|
||||
}
|
||||
if (t.t === "op" && t.v === "(") {
|
||||
const e = parseExpr();
|
||||
expectOp(")");
|
||||
return e;
|
||||
}
|
||||
throw new Error("primary");
|
||||
}
|
||||
function normRef(r) {
|
||||
return r.replace(/\$/g, "").toUpperCase();
|
||||
}
|
||||
function canonName(name) {
|
||||
const up = String(name).toUpperCase();
|
||||
return ALIASES[up] || up;
|
||||
}
|
||||
return { parseExpr, atEnd: () => pos >= tokens.length };
|
||||
}
|
||||
|
||||
export function parseFormula(text) {
|
||||
const body = String(text || "").trim().replace(/^[=@]/, "");
|
||||
const sep = body.includes(";") ? ";" : ",";
|
||||
// A comma is a decimal mark (FR style) when ';' is the separator, or when
|
||||
// the expression holds no letters at all ("=1,5+1" can only mean 1.5 + 1).
|
||||
const commaDecimal = sep === ";" || !/[A-Za-zÀ-ÿ]/.test(body);
|
||||
const { parseExpr, atEnd } = makeParser(tokenize(body, commaDecimal ? ";" : sep), sep);
|
||||
const ast = parseExpr();
|
||||
if (!atEnd()) throw new Error("trailing");
|
||||
return ast;
|
||||
}
|
||||
|
||||
// ── Evaluator ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Engine scope: resolve raw cell values + locale + cycle guard. */
|
||||
export function createScope({ readRaw, sheet, locale = "en", seen = null } = {}) {
|
||||
const scope = {
|
||||
sheet: sheet || "",
|
||||
locale: locale === "fr" ? "fr" : "en",
|
||||
seen: seen || new Set(),
|
||||
memo: new Map(),
|
||||
/** Engine value of `sheet!ref` (formulas recurse, cycles guarded). */
|
||||
getCell(targetSheet, ref) {
|
||||
const key = `${targetSheet || scope.sheet}!${String(ref).toUpperCase()}`;
|
||||
const raw = readRaw ? readRaw(targetSheet || scope.sheet, ref) : null;
|
||||
return evalRefValue(raw, scope, key);
|
||||
},
|
||||
};
|
||||
return scope;
|
||||
}
|
||||
|
||||
const err = (code) => ({ t: "e", code });
|
||||
const isErr = (v) => v && v.t === "e";
|
||||
const isBlank = (v) => !v || v.t === "blank";
|
||||
|
||||
function toNumber(v) {
|
||||
if (!v || v.t === "blank") return 0;
|
||||
if (v.t === "n") return v.v;
|
||||
if (v.t === "b") return v.v ? 1 : 0;
|
||||
if (v.t === "s") {
|
||||
const s = v.v.trim().replace(",", ".");
|
||||
if (s === "") return 0;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toText(v, locale) {
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function numText(n) {
|
||||
if (!Number.isFinite(n)) return String(n);
|
||||
if (Number.isInteger(n) && Math.abs(n) < 1e15) return String(n);
|
||||
return String(parseFloat(n.toPrecision(10)));
|
||||
}
|
||||
|
||||
function flattenArgs(vals) {
|
||||
const out = [];
|
||||
const walk = (v) => {
|
||||
if (v && v.t === "a") v.rows.forEach((r) => r.forEach(walk));
|
||||
else out.push(v);
|
||||
};
|
||||
vals.forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
function evalRefValue(raw, scope, key) {
|
||||
// Raw storage text → engine value (formulas recurse, cycles guarded).
|
||||
if (raw === null || raw === undefined) return { t: "blank" };
|
||||
if (typeof raw === "number") return { t: "n", v: raw };
|
||||
if (typeof raw === "boolean") return { t: "b", v: raw };
|
||||
const s = String(raw);
|
||||
if (scope.memo.has(key)) return scope.memo.get(key);
|
||||
let out;
|
||||
if (isFormulaText(s)) {
|
||||
if (scope.seen.has(key)) {
|
||||
out = err("CYCLE");
|
||||
} else {
|
||||
scope.seen.add(key);
|
||||
try {
|
||||
out = evaluate(parseFormula(s), scope);
|
||||
} catch (_) {
|
||||
out = err("NAME");
|
||||
}
|
||||
scope.seen.delete(key);
|
||||
}
|
||||
} else if (s.trim() === "") {
|
||||
out = { t: "blank" };
|
||||
} else {
|
||||
const n = Number(s.trim().replace(",", "."));
|
||||
out = s.trim() !== "" && Number.isFinite(n) && /^[-+0-9.,\s]+$/.test(s.trim())
|
||||
? { t: "n", v: n }
|
||||
: { t: "s", v: s };
|
||||
}
|
||||
scope.memo.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function rangeValues(r1, r2, scope) {
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rStart = Math.min(a.row, b.row);
|
||||
const rEnd = Math.max(a.row, b.row);
|
||||
const cStart = Math.min(a.col, b.col);
|
||||
const cEnd = Math.max(a.col, b.col);
|
||||
if ((rEnd - rStart + 1) * (cEnd - cStart + 1) > 100000) return err("VALUE");
|
||||
const rows = [];
|
||||
for (let r = rStart; r <= rEnd; r++) {
|
||||
const line = [];
|
||||
for (let c = cStart; c <= cEnd; c++) {
|
||||
const ref = indexToCol(c) + r;
|
||||
line.push(scope.getCell(scope.sheet, ref));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
|
||||
function firstOf(v) {
|
||||
if (v && v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return cell;
|
||||
}
|
||||
return { t: "blank" };
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function cmpValues(a, b) {
|
||||
// Excel-ish: numbers < text; blank sorts with text "".
|
||||
const rank = (v) => (!v || v.t === "blank" ? 0 : v.t === "n" ? 1 : v.t === "b" ? 2 : 3);
|
||||
const ra = rank(a);
|
||||
const rb = rank(b);
|
||||
if (ra !== rb) return ra - rb;
|
||||
if (ra === 1) return a.v - b.v;
|
||||
if (ra === 2) return (a.v ? 1 : 0) - (b.v ? 1 : 0);
|
||||
const sa = toText(a);
|
||||
const sb = toText(b);
|
||||
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||
}
|
||||
|
||||
const FUNCTIONS = {
|
||||
SUM: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc + n;
|
||||
}, 0)),
|
||||
AVERAGE: (vals, scope) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
if (!nums.length) return err("DIV");
|
||||
return num(nums.reduce((a, b) => a + b, 0) / nums.length);
|
||||
},
|
||||
MIN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.min(...nums)) : num(0);
|
||||
},
|
||||
MAX: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.max(...nums)) : num(0);
|
||||
},
|
||||
COUNT: (vals) => num(flattenArgs(vals).filter((v) => v && v.t === "n").length),
|
||||
COUNTA: (vals) => num(flattenArgs(vals).filter((v) => !isBlank(v)).length),
|
||||
PRODUCT: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc * n;
|
||||
}, 1)),
|
||||
MEDIAN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null).sort((a, b) => a - b);
|
||||
if (!nums.length) return err("DIV");
|
||||
const m = Math.floor(nums.length / 2);
|
||||
return num(nums.length % 2 ? nums[m] : (nums[m - 1] + nums[m]) / 2);
|
||||
},
|
||||
ABS: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.abs(n));
|
||||
},
|
||||
ROUND: ([v, d]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
const digits = d === undefined ? 0 : toNumber(firstOf(d));
|
||||
if (n === null || digits === null) return err("VALUE");
|
||||
const f = 10 ** Math.trunc(digits);
|
||||
return num(Math.round(n * f) / f);
|
||||
},
|
||||
INT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.floor(n));
|
||||
},
|
||||
SQRT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
if (n === null) return err("VALUE");
|
||||
if (n < 0) return err("VALUE");
|
||||
return num(Math.sqrt(n));
|
||||
},
|
||||
POWER: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null) return err("VALUE");
|
||||
const r = x ** y;
|
||||
return Number.isFinite(r) ? num(r) : err("VALUE");
|
||||
},
|
||||
MOD: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null || y === 0) return y === 0 ? err("DIV") : err("VALUE");
|
||||
return num(((x % y) + y) % y);
|
||||
},
|
||||
CONCAT: (vals, scope) => ({ t: "s", v: flattenArgs(vals).map((v) => toText(v, scope.locale)).join("") }),
|
||||
UPPER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toUpperCase() }),
|
||||
LOWER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toLowerCase() }),
|
||||
LEN: ([v], scope) => num([...toText(firstOf(v), scope.locale)].length),
|
||||
TRIM: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).trim().replace(/\s+/g, " ") }),
|
||||
LEFT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: s.slice(0, Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
RIGHT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: k <= 0 ? "" : s.slice(-Math.trunc(k)) };
|
||||
},
|
||||
MID: ([v, start, len]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const a = toNumber(firstOf(start));
|
||||
const k = len === undefined ? s.length : toNumber(firstOf(len));
|
||||
if (a === null || k === null || a < 1) return err("VALUE");
|
||||
return { t: "s", v: s.slice(Math.trunc(a) - 1, Math.trunc(a) - 1 + Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
TODAY: () => ({ t: "s", v: new Date().toLocaleDateString() }),
|
||||
NOW: () => ({ t: "s", v: new Date().toLocaleString() }),
|
||||
DAY: ([v]) => datePart(v, "day"),
|
||||
MONTH: ([v]) => datePart(v, "month"),
|
||||
YEAR: ([v]) => datePart(v, "year"),
|
||||
NOT: ([v]) => ({ t: "b", v: !toBool(firstOf(v)) }),
|
||||
HYPERLINK: ([url, label], scope) => {
|
||||
const u = toText(firstOf(url), scope.locale).trim();
|
||||
if (!/^https?:\/\//i.test(u)) return err("VALUE");
|
||||
const text = label === undefined ? u : toText(firstOf(label), scope.locale);
|
||||
return { t: "link", url: u, label: text };
|
||||
},
|
||||
};
|
||||
|
||||
function num(n) {
|
||||
return { t: "n", v: n };
|
||||
}
|
||||
|
||||
function toBool(v) {
|
||||
if (!v || v.t === "blank") return false;
|
||||
if (v.t === "b") return v.v;
|
||||
if (v.t === "n") return v.v !== 0;
|
||||
const s = v.t === "s" ? v.v.trim().toUpperCase() : "";
|
||||
if (["TRUE", "VRAI", "1", "YES", "OUI"].includes(s)) return true;
|
||||
if (["FALSE", "FAUX", "0", "", "NO", "NON"].includes(s)) return false;
|
||||
return s !== "";
|
||||
}
|
||||
|
||||
function datePart(v, part) {
|
||||
const first = firstOf(v);
|
||||
let d = null;
|
||||
if (first && first.t === "s") {
|
||||
const parsed = new Date(first.v);
|
||||
if (!Number.isNaN(parsed.getTime())) d = parsed;
|
||||
} else if (first && first.t === "n") {
|
||||
// Excel serial date (1900 system).
|
||||
const base = Date.UTC(1899, 11, 30);
|
||||
d = new Date(base + Math.trunc(first.v) * 86400000);
|
||||
}
|
||||
if (!d) return err("VALUE");
|
||||
if (part === "day") return num(d.getDate());
|
||||
if (part === "month") return num(d.getMonth() + 1);
|
||||
return num(d.getFullYear());
|
||||
}
|
||||
|
||||
function callFunction(name, argNodes, scope) {
|
||||
if (name === "IF") {
|
||||
if (argNodes.length < 2 || argNodes.length > 3) return err("NAME");
|
||||
const cond = evaluate(argNodes[0], scope);
|
||||
if (isErr(cond)) return cond;
|
||||
const branch = toBool(cond) ? argNodes[1] : argNodes[2];
|
||||
if (!branch) return { t: "b", v: false };
|
||||
return evaluate(branch, scope);
|
||||
}
|
||||
if (name === "AND" || name === "OR") {
|
||||
let out = name === "AND";
|
||||
for (const node of argNodes) {
|
||||
const v = evaluate(node, scope);
|
||||
if (isErr(v)) return v;
|
||||
const b = toBool(v);
|
||||
if (name === "AND" && !b) return { t: "b", v: false };
|
||||
if (name === "OR" && b) return { t: "b", v: true };
|
||||
out = b;
|
||||
}
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
if (name === "IFERROR") {
|
||||
if (!argNodes.length || argNodes.length > 2) return err("NAME");
|
||||
const v = evaluate(argNodes[0], scope);
|
||||
if (!isErr(v)) return v;
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") return v;
|
||||
return argNodes[1] ? evaluate(argNodes[1], scope) : { t: "blank" };
|
||||
}
|
||||
const fn = FUNCTIONS[name];
|
||||
if (!fn) return err("NAME");
|
||||
const vals = argNodes.map((node) => evaluate(node, scope));
|
||||
for (const v of vals) {
|
||||
if (isErr(v)) {
|
||||
// Errors propagate, except through aggregations over ranges.
|
||||
if (["SUM", "AVERAGE", "MIN", "MAX", "COUNT", "COUNTA", "PRODUCT", "MEDIAN", "CONCAT"].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return fn(vals, scope);
|
||||
}
|
||||
|
||||
export function evaluate(node, scope) {
|
||||
if (Array.isArray(node)) {
|
||||
const [kind, ...rest] = node;
|
||||
switch (kind) {
|
||||
case "n": return { t: "n", v: rest[0] };
|
||||
case "s": return { t: "s", v: rest[0] };
|
||||
case "b": return { t: "b", v: rest[0] };
|
||||
case "neg": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(-n);
|
||||
}
|
||||
case "%": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(n / 100);
|
||||
}
|
||||
case "ref": {
|
||||
const p = parseA1(rest[0]);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(scope.sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "range": return rangeValues(rest[0], rest[1], scope);
|
||||
case "sheetref": {
|
||||
const [, sheet, ref] = node;
|
||||
const p = parseA1(ref);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "sheetrange": {
|
||||
const [, sheet, r1, r2] = node;
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rows = [];
|
||||
for (let r = Math.min(a.row, b.row); r <= Math.max(a.row, b.row); r++) {
|
||||
const line = [];
|
||||
for (let c = Math.min(a.col, b.col); c <= Math.max(a.col, b.col); c++) {
|
||||
line.push(scope.getCell(sheet, indexToCol(c) + r));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
case "call": return callFunction(rest[0], rest[1], scope);
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
case "^":
|
||||
case "&": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
if (kind === "&") return { t: "s", v: toText(l, scope.locale) + toText(r, scope.locale) };
|
||||
const a = toNumber(l);
|
||||
const b = toNumber(r);
|
||||
if (a === null || b === null) return err("VALUE");
|
||||
if (kind === "+") return num(a + b);
|
||||
if (kind === "-") return num(a - b);
|
||||
if (kind === "*") return num(a * b);
|
||||
if (kind === "/") return b === 0 ? err("DIV") : num(a / b);
|
||||
if (kind === "^") {
|
||||
const v = a ** b;
|
||||
return Number.isFinite(v) ? num(v) : err("VALUE");
|
||||
}
|
||||
return err("VALUE");
|
||||
}
|
||||
case "=":
|
||||
case "!=":
|
||||
case "<":
|
||||
case "<=":
|
||||
case ">":
|
||||
case ">=": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
const c = cmpValues(l, r);
|
||||
const out = kind === "=" ? c === 0 : kind === "!=" ? c !== 0
|
||||
: kind === "<" ? c < 0 : kind === "<=" ? c <= 0
|
||||
: kind === ">" ? c > 0 : c >= 0;
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
default:
|
||||
return err("NAME");
|
||||
}
|
||||
}
|
||||
return err("NAME");
|
||||
}
|
||||
|
||||
/** Render an evaluated value for display (localized booleans/errors). */
|
||||
export function displayValue(v, locale = "en") {
|
||||
const codes = errorCodes(locale);
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
if (v.t === "e") {
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") {
|
||||
return locale === "fr" ? codes.CYCLE : codes.CIRC;
|
||||
}
|
||||
return codes[v.code] || codes.VALUE;
|
||||
}
|
||||
if (v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return displayValue(cell, locale);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* ObsiGate — XLSX menu bar (the ten Google Sheets menus).
|
||||
*
|
||||
* Pure declarative data + a string builder, in the same spirit as
|
||||
* `command-bar.js`: the menu tree lives here and stays testable on its own,
|
||||
* while `viewer.js` only resolves each entry's symbolic `action` against its
|
||||
* own flows. No DOM here beyond `innerHTML` of the shell.
|
||||
*
|
||||
* Entry shape
|
||||
* -----------
|
||||
* { labelKey, icon, action, children, unavailable, danger, needs, sep }
|
||||
* - `labelKey` i18n key of the visible label (never raw text).
|
||||
* - `icon` Lucide icon name, hydrated later by `safeCreateIcons()`.
|
||||
* - `action` symbolic name resolved by the viewer. An entry with no
|
||||
* action — or flagged `unavailable` — is rendered honestly and
|
||||
* reports that ObsiGate does not implement it: a documented
|
||||
* dead end, never a silent no-op.
|
||||
* - `children` a Sheets-style accordion section (Nombre, Thème, Zoom, …):
|
||||
* collapsed, expanded by a click on its parent row.
|
||||
* - `needs` `"editable"` (hide unless the workbook can be written) or
|
||||
* `"notReadOnly"` (hide only for .xls/.ods).
|
||||
* - `sep` a thin rule instead of an entry.
|
||||
*
|
||||
* The Fichier menu keeps the historical `#xlsx-file-btn` / `#xlsx-file-menu` /
|
||||
* `#xlsx-download-btn` / `#xlsx-csv-btn` ids and the `[data-xlsx-export]`
|
||||
* markers: the File menu moved from the sheet strip to the menu bar, but every
|
||||
* consumer (viewer wiring, tests, E2E) keeps working untouched.
|
||||
*/
|
||||
import { t } from '../i18n.js';
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
const IC = (name) => `<i data-lucide="${name}" class="xlsx-menu-icon" aria-hidden="true"></i>`;
|
||||
|
||||
/** Google Sheets' menu bar: same order, same entries, same French labels. */
|
||||
export const SHEETS_MENUS = [
|
||||
{
|
||||
id: "file",
|
||||
labelKey: "xlsx.mbar_file",
|
||||
btnId: "xlsx-file-btn",
|
||||
dropId: "xlsx-file-menu",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_new", icon: "file-plus", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_open", icon: "folder-open", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_import", icon: "file-input", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_make_copy", icon: "copy", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_share", icon: "share-2", action: "share" },
|
||||
{ labelKey: "xlsx.mi_email", icon: "mail", unavailable: true },
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_download",
|
||||
icon: "download",
|
||||
children: [
|
||||
{ labelKey: "viewer.download", icon: "download", id: "xlsx-download-btn", action: "download" },
|
||||
{ labelKey: "xlsx.export_csv", icon: "file-spreadsheet", id: "xlsx-csv-btn", action: "export-csv", titleKey: "xlsx.csv_export" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.export_md", icon: "file-text", export: "md", action: "export-md" },
|
||||
{ labelKey: "xlsx.export_html", icon: "code", export: "html", action: "export-html" },
|
||||
{ labelKey: "xlsx.print", icon: "printer", export: "print", action: "print" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_rename", icon: "pencil", action: "rename" },
|
||||
{ labelKey: "xlsx.mi_trash", icon: "trash-2", action: "trash", danger: true },
|
||||
{ labelKey: "xlsx.mi_history", icon: "history", action: "history" },
|
||||
{ labelKey: "xlsx.mi_offline", icon: "cloud-off", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_details", icon: "info", action: "details" },
|
||||
{ labelKey: "xlsx.mi_settings", icon: "settings", action: "settings" },
|
||||
{ labelKey: "xlsx.print", icon: "printer", action: "print" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "edit",
|
||||
labelKey: "xlsx.mbar_edit",
|
||||
items: [
|
||||
{ labelKey: "xlsx.undo", icon: "undo-2", action: "undo", needs: "notReadOnly" },
|
||||
{ labelKey: "xlsx.redo", icon: "redo-2", action: "redo", needs: "notReadOnly" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.cut", icon: "scissors", action: "cut", needs: "editable" },
|
||||
{ labelKey: "xlsx.copy", icon: "copy", action: "copy" },
|
||||
{ labelKey: "xlsx.paste", icon: "clipboard-paste", action: "paste", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_paste_special",
|
||||
icon: "clipboard-paste",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_paste_all", icon: "clipboard-paste", action: "paste" },
|
||||
{ labelKey: "xlsx.mi_paste_values", icon: "pilcrow", action: "paste-values" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_move",
|
||||
icon: "move",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_move_rows", icon: "rows", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_move_cols", icon: "columns", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_delete",
|
||||
icon: "trash-2",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_delete_rows", icon: "rows", action: "delete-rows", danger: true },
|
||||
{ labelKey: "xlsx.mi_delete_cols", icon: "columns", action: "delete-cols", danger: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.clear_contents", icon: "eraser", action: "clear-contents" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_select_all", icon: "check-check", action: "select-all" },
|
||||
{ labelKey: "xlsx.mi_replace", icon: "replace", action: "find-replace", needs: "editable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
labelKey: "xlsx.mbar_view",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_freeze",
|
||||
icon: "snowflake",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_freeze_here", icon: "snowflake", action: "freeze" },
|
||||
{ labelKey: "xlsx.mi_freeze_row", icon: "rows", action: "freeze-row" },
|
||||
{ labelKey: "xlsx.mi_freeze_col", icon: "columns", action: "freeze-col" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.format_unfreeze", icon: "sun", action: "unfreeze" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_show",
|
||||
icon: "eye",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_show_formula", icon: "chevrons-right", action: "toggle-formula-bar" },
|
||||
{ labelKey: "xlsx.mi_show_grid", icon: "grid-3x3", action: "toggle-grid" },
|
||||
{ labelKey: "xlsx.mi_show_headers", icon: "rows-3", action: "toggle-headers" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_show_dashboard", icon: "layout-dashboard", action: "dashboard" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_group",
|
||||
icon: "group",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_group_rows", icon: "rows", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_group_cols", icon: "columns", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_ungroup_rows", icon: "ungroup", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_ungroup_cols", icon: "ungroup", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_hidden_sheets",
|
||||
icon: "eye-off",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_hide_sheet", icon: "eye-off", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_unhide_sheets", icon: "eye", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_zoom",
|
||||
icon: "zoom-in",
|
||||
children: [50, 75, 90, 100, 125, 150, 200].map((z) => ({
|
||||
labelKey: null,
|
||||
label: `${z} %`,
|
||||
icon: z === 100 ? "crosshair" : null,
|
||||
action: "zoom",
|
||||
arg: z,
|
||||
})),
|
||||
},
|
||||
{ labelKey: "xlsx.mi_fullscreen", icon: "fullscreen", action: "fullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "insert",
|
||||
labelKey: "xlsx.mbar_insert",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_insert_rows",
|
||||
icon: "rows",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.insert_row_above", icon: "arrow-up", action: "insert-rows-above" },
|
||||
{ labelKey: "xlsx.insert_row_below", icon: "arrow-down", action: "insert-rows-below" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_insert_cols",
|
||||
icon: "columns",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.insert_col_left", icon: "arrow-left", action: "insert-cols-left" },
|
||||
{ labelKey: "xlsx.insert_col_right", icon: "arrow-right", action: "insert-cols-right" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_insert_cells", icon: "grid-2x2-plus", action: "insert-cells", needs: "editable" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_image", icon: "image", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_chart", icon: "bar-chart-3", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_drawing", icon: "pen-tool", unavailable: true },
|
||||
{ labelKey: "xlsx.link_add", icon: "link", action: "link", needs: "editable" },
|
||||
{ labelKey: "xlsx.comment_add", icon: "message-square", action: "comment", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_function",
|
||||
icon: "function-square",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_function_hint",
|
||||
action: "function",
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_pivot", icon: "table-2", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_smart_chips", icon: "tags", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_form", icon: "clipboard-list", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
labelKey: "xlsx.mbar_format",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_number",
|
||||
icon: "hash",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_num_general", icon: "type", action: "numfmt", arg: "General" },
|
||||
{ labelKey: "xlsx.format_num_number", icon: "hash", action: "numfmt", arg: "0.00" },
|
||||
{ labelKey: "xlsx.format_num_percent", icon: "percent", action: "numfmt", arg: "0.0%" },
|
||||
{ labelKey: "xlsx.format_num_currency", icon: "euro", action: "numfmt", arg: '#,##0.00 "€"' },
|
||||
{ labelKey: "xlsx.format_num_date", icon: "calendar", action: "numfmt", arg: "DD/MM/YYYY" },
|
||||
{ labelKey: "xlsx.format_num_text", icon: "pilcrow", action: "numfmt", arg: "@" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_theme",
|
||||
icon: "swatch-book",
|
||||
// Filled on demand by the viewer (the theme list is built from
|
||||
// themes.js, so it must not be hard-coded here).
|
||||
children: [],
|
||||
action: "theme-menu",
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.format_bold", icon: "bold", action: "bold", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_italic", icon: "italic", action: "italic", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_underline", icon: "underline", action: "underline", needs: "editable" },
|
||||
{ labelKey: "xlsx.format_strike", icon: "strikethrough", action: "strike", needs: "editable" },
|
||||
{
|
||||
labelKey: "xlsx.mi_font_size",
|
||||
icon: "type",
|
||||
needs: "editable",
|
||||
children: [9, 10, 11, 12, 14, 18, 24, 36, 40].map((s) => ({
|
||||
labelKey: null,
|
||||
label: String(s),
|
||||
icon: null,
|
||||
action: "fontsize",
|
||||
arg: s,
|
||||
})),
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_align",
|
||||
icon: "align-justify",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{
|
||||
labelKey: "xlsx.mi_align_h",
|
||||
icon: "align-left",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_align_left", icon: "align-left", action: "align", arg: "left" },
|
||||
{ labelKey: "xlsx.format_align_center", icon: "align-center", action: "align", arg: "center" },
|
||||
{ labelKey: "xlsx.format_align_right", icon: "align-right", action: "align", arg: "right" },
|
||||
{ labelKey: "xlsx.format_align_justify", icon: "align-justify", action: "align", arg: "justify" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_align_v",
|
||||
icon: "align-center-vertical",
|
||||
children: [
|
||||
{ labelKey: "xlsx.valign_top", icon: "align-start-vertical", action: "valign", arg: "top" },
|
||||
{ labelKey: "xlsx.valign_middle", icon: "align-center-vertical", action: "valign", arg: "middle" },
|
||||
{ labelKey: "xlsx.valign_bottom", icon: "align-end-vertical", action: "valign", arg: "bottom" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_wrap",
|
||||
icon: "wrap-text",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_wrap_on", icon: "wrap-text", action: "wrap-on" },
|
||||
{ labelKey: "xlsx.mi_wrap_off", icon: "remove-formatting", action: "wrap-off" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_rotation",
|
||||
icon: "rotate-cw",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.rotation_0", icon: "minus", action: "rotate", arg: 0 },
|
||||
{ labelKey: "xlsx.rotation_45", icon: "trending-up", action: "rotate", arg: 45 },
|
||||
{ labelKey: "xlsx.rotation_315", icon: "trending-down", action: "rotate", arg: -45 },
|
||||
{ labelKey: "xlsx.rotation_90", icon: "arrow-up", action: "rotate", arg: 90 },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
// Tailles de colonnes / de lignes — même emplacement que Google Sheets
|
||||
// (Format ▸ Redimensionner) et Excel (Format ▸ Colonne / Ligne) :
|
||||
// saisie manuelle (unités Excel : caractères / points) + ajustement
|
||||
// automatique au contenu (AutoFit / « Fit to data »).
|
||||
{
|
||||
labelKey: "xlsx.mi_resize",
|
||||
icon: "move-horizontal",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.format_col_width", icon: "move-horizontal", action: "col-width" },
|
||||
{ labelKey: "xlsx.format_col_fit", icon: "maximize-2", action: "col-fit" },
|
||||
{ labelKey: "xlsx.format_row_height", icon: "move-vertical", action: "row-height" },
|
||||
{ labelKey: "xlsx.format_row_fit", icon: "maximize-2", action: "row-fit" },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_conditional", icon: "git-compare", unavailable: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_banded",
|
||||
icon: "rows-3",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_banded_hint",
|
||||
action: "banded",
|
||||
},
|
||||
{ labelKey: "xlsx.format_clear", icon: "remove-formatting", action: "clear-format", needs: "editable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
labelKey: "xlsx.mbar_data",
|
||||
items: [
|
||||
{
|
||||
labelKey: "xlsx.mi_sort",
|
||||
icon: "arrow-up-down",
|
||||
children: [
|
||||
{ labelKey: "xlsx.sort_asc", icon: "arrow-up", action: "sort-asc" },
|
||||
{ labelKey: "xlsx.sort_desc", icon: "arrow-down", action: "sort-desc" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.sort_reset", icon: "rotate-ccw", action: "sort-reset" },
|
||||
],
|
||||
},
|
||||
{ labelKey: "xlsx.mi_filter", icon: "filter", titleKey: "xlsx.mi_filter_hint", action: "filter-column" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_validation", icon: "list-checks", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_named", icon: "bookmark", action: "named-ranges" },
|
||||
{ labelKey: "xlsx.mi_protect", icon: "lock", unavailable: true },
|
||||
{ sep: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_cleanup",
|
||||
icon: "wand-2",
|
||||
needs: "editable",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_cleanup_dupes", icon: "copy-x", action: "cleanup-dupes" },
|
||||
{ labelKey: "xlsx.mi_cleanup_trim", icon: "scissors", action: "cleanup-trim" },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "xlsx.mi_split",
|
||||
icon: "columns-2",
|
||||
needs: "editable",
|
||||
titleKey: "xlsx.mi_split_hint",
|
||||
action: "split-text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
labelKey: "xlsx.mbar_tools",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_form_create", icon: "clipboard-list", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_spellcheck", icon: "spell-check", unavailable: true },
|
||||
{
|
||||
labelKey: "xlsx.mi_autocomplete",
|
||||
icon: "text-cursor-input",
|
||||
children: [
|
||||
{ labelKey: "xlsx.mi_autocomplete_on", icon: "check", action: "autocomplete", arg: true },
|
||||
{ labelKey: "xlsx.mi_autocomplete_off", icon: "x", action: "autocomplete", arg: false },
|
||||
],
|
||||
},
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_notifications", icon: "bell", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_macros", icon: "workflow", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_script_editor", icon: "code", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_a11y", icon: "accessibility", action: "a11y" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
labelKey: "xlsx.mbar_gemini",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_ask_gemini", icon: "sparkles", action: "gemini" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_gem_table", icon: "table-2", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_formula", icon: "function-square", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_insights", icon: "lightbulb", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_chart", icon: "bar-chart-3", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_gem_summarize", icon: "file-text", unavailable: true },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_gem_actions", icon: "wand-sparkles", action: "gemini" },
|
||||
{ labelKey: "xlsx.mi_gem_more", icon: "ellipsis", action: "gemini" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "extensions",
|
||||
labelKey: "xlsx.mbar_extensions",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_addons", icon: "blocks", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_apps_script", icon: "code", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_appsheet", icon: "app-window", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_looker", icon: "bar-chart", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "help",
|
||||
labelKey: "xlsx.mbar_help",
|
||||
items: [
|
||||
{ labelKey: "xlsx.mi_help_sheets", icon: "life-buoy", action: "help" },
|
||||
{ labelKey: "xlsx.mi_training", icon: "graduation-cap", unavailable: true },
|
||||
{ labelKey: "xlsx.mi_whatsnew", icon: "sparkles", action: "whatsnew" },
|
||||
{ sep: true },
|
||||
{ labelKey: "xlsx.mi_shortcuts", icon: "keyboard", action: "shortcuts" },
|
||||
{ labelKey: "xlsx.mi_report", icon: "bug", action: "report" },
|
||||
{ labelKey: "xlsx.mi_abuse", icon: "flag", unavailable: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Drop an entry when the workbook cannot honour it. */
|
||||
const allowed = (entry, { editable, readOnly }) => {
|
||||
if (entry.needs === "editable") return editable;
|
||||
if (entry.needs === "notReadOnly") return !readOnly;
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Label of one entry: an explicit label, else the i18n key, else nothing. */
|
||||
const labelOf = (entry) => (entry.label !== undefined
|
||||
? entry.label
|
||||
: (entry.labelKey ? t(entry.labelKey) : ""));
|
||||
|
||||
/** One clickable row. `depth` > 0 = nested inside an accordion section. */
|
||||
const rowHtml = (entry, depth, ctx) => {
|
||||
if (entry.sep) return `<div class="xlsx-structure-sep" role="separator"></div>`;
|
||||
const unavailable = entry.unavailable || (!entry.children && !entry.action && !entry.export);
|
||||
const id = entry.id ? ` id="${entry.id}"` : "";
|
||||
const exportAttr = entry.export ? ` data-xlsx-export="${entry.export}"` : "";
|
||||
const argAttr = entry.arg !== undefined ? ` data-xlsx-arg="${escapeHtml(String(entry.arg))}"` : "";
|
||||
const title = entry.titleKey ? ` title="${escapeHtml(t(entry.titleKey))}"` : "";
|
||||
const cls = [
|
||||
"btn-action",
|
||||
"xlsx-structure-item",
|
||||
"xlsx-menu-item",
|
||||
unavailable ? "xlsx-menu-item-na" : "",
|
||||
entry.danger ? "xlsx-context-danger" : "",
|
||||
depth ? `xlsx-menu-item-d${depth}` : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const caret = entry.children
|
||||
? `<i data-lucide="chevron-right" class="xlsx-menu-caret" aria-hidden="true"></i>`
|
||||
: "";
|
||||
const body = entry.children
|
||||
// A section may ALSO declare an action: the viewer runs it instead of
|
||||
// toggling, which is how the empty « Thème » section gets filled.
|
||||
? `<button type="button" class="${cls}" role="menuitem" aria-haspopup="menu" aria-expanded="false"`
|
||||
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}${title}${id}>`
|
||||
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span>${caret}</button>`
|
||||
+ `<div class="xlsx-menu-sub" hidden>${entry.children
|
||||
.filter((c) => allowed(c, ctx))
|
||||
.map((c) => rowHtml(c, depth + 1, ctx))
|
||||
.join("")}</div>`
|
||||
: `<button type="button" class="${cls}" role="menuitem"`
|
||||
+ `${unavailable ? ' data-xlsx-unavailable="1"' : ""}`
|
||||
+ `${entry.action ? ` data-xlsx-action="${entry.action}"` : ""}`
|
||||
+ `${argAttr}${exportAttr}${title}${id}>`
|
||||
+ `${entry.icon ? IC(entry.icon) : ""}<span class="xlsx-menu-label">${escapeHtml(labelOf(entry))}</span></button>`;
|
||||
return body;
|
||||
};
|
||||
|
||||
/** One dropdown: the filtered rows of a menu. */
|
||||
const menuItemsHtml = (menu, ctx) => menu.items
|
||||
.filter((entry) => allowed(entry, ctx))
|
||||
.map((entry) => rowHtml(entry, 0, ctx))
|
||||
.join("");
|
||||
|
||||
/**
|
||||
* Build the menu bar: one button per menu plus its (hidden) dropdown.
|
||||
*
|
||||
* @param {object} ctx
|
||||
* @param {boolean} ctx.editable - .xlsx that can be written
|
||||
* @param {boolean} ctx.readOnly - .xls / .ods
|
||||
* @returns {string} the `.xlsx-menubar` markup
|
||||
*/
|
||||
export function buildMenuBar({ editable = true, readOnly = false } = {}) {
|
||||
const ctx = { editable, readOnly };
|
||||
const bars = SHEETS_MENUS.map((menu) => {
|
||||
const btnId = menu.btnId || `xlsx-menu-btn-${menu.id}`;
|
||||
const dropId = menu.dropId || `xlsx-menu-${menu.id}`;
|
||||
const label = escapeHtml(t(menu.labelKey));
|
||||
return `<span class="xlsx-menuwrap${menu.id === "gemini" ? " xlsx-menuwrap-gemini" : ""}">`
|
||||
+ `<button type="button" class="xlsx-menubtn" id="${btnId}" role="menuitem"`
|
||||
+ ` aria-haspopup="menu" aria-expanded="false" aria-controls="${dropId}"`
|
||||
+ ` data-xlsx-menu="${menu.id}">${label}</button>`
|
||||
+ `<div class="xlsx-menudrop" id="${dropId}" role="menu"`
|
||||
+ ` aria-label="${label}" style="display:none">`
|
||||
+ `${menuItemsHtml(menu, ctx)}`
|
||||
+ `</div></span>`;
|
||||
});
|
||||
return `<div class="xlsx-menubar" role="menubar" aria-label="${escapeHtml(t("xlsx.mbar_label"))}">`
|
||||
+ `${bars.join("")}</div>`;
|
||||
}
|
||||
@@ -24,9 +24,36 @@ export function columnName(col) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// #184 — cell lookup cache. `findTd` used to run a `querySelector` per call;
|
||||
// painting the grid out to A → Z × 1000 rows means a single "select column"
|
||||
// asks for 26 000 cells, which was quadratic on the DOM. The index is built
|
||||
// once per panel and rebuilt on demand whenever an entry no longer matches its
|
||||
// own `data-cell` (the signature of a renumbered grid after a structure op).
|
||||
const _cellIndex = new WeakMap();
|
||||
|
||||
function buildCellIndex(panel) {
|
||||
const map = new Map();
|
||||
panel.querySelectorAll("td[data-cell]").forEach((td) => {
|
||||
map.set(td.dataset.cell, td);
|
||||
});
|
||||
_cellIndex.set(panel, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Drop a panel's lookup cache (bulk DOM changes). */
|
||||
export function invalidateCellIndex(panel) {
|
||||
if (panel) _cellIndex.delete(panel);
|
||||
}
|
||||
|
||||
/** Find a rendered cell inside a sheet panel by (row, col). */
|
||||
export function findTd(panel, row, col) {
|
||||
return panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||
const ref = `${columnName(col)}${row}`;
|
||||
const cached = _cellIndex.get(panel);
|
||||
const hit = cached && cached.get(ref);
|
||||
if (hit && hit.dataset.cell === ref) return hit;
|
||||
// Miss, or a stale entry (the row was renumbered): rebuild once and retry.
|
||||
const fresh = buildCellIndex(panel).get(ref);
|
||||
return fresh && fresh.dataset.cell === ref ? fresh : null;
|
||||
}
|
||||
|
||||
/** Sheet prefix of a range ref ("'Mon onglet'!$A$1" → "Mon onglet"), or null. */
|
||||
|
||||
+272
-5
@@ -319,6 +319,30 @@
|
||||
"backups.restore_confirm": "to the selected backup version?\nThe current version will be saved.",
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Cancel",
|
||||
"chat.attach_error": "Attachment upload failed",
|
||||
"chat.channel_general": "General",
|
||||
"chat.delete": "Delete this message",
|
||||
"chat.delete_error": "Delete failed",
|
||||
"chat.deleted_message": "This message has been deleted",
|
||||
"chat.undo": "Undo",
|
||||
"chat.download_image": "Download image",
|
||||
"chat.view_image": "View full size",
|
||||
"chat.attach_remove": "Remove attachment",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuss this file",
|
||||
"chat.close": "Close the discussion panel",
|
||||
"chat.empty": "No messages yet. Start the conversation!",
|
||||
"chat.load_error": "Could not load the discussion",
|
||||
"chat.new_message": "New message from {user}",
|
||||
"chat.placeholder": "Write a message…",
|
||||
"chat.placeholder_dm": "Private message to {user}…",
|
||||
"chat.read_by": "{user} read your message",
|
||||
"chat.send": "Send",
|
||||
"chat.send_error": "Failed to send the message",
|
||||
"chat.tab": "General chat",
|
||||
"chat.status_read": "Read",
|
||||
"chat.status_sent": "Sent",
|
||||
"chat.title": "Discussion",
|
||||
"common.actions": "Actions",
|
||||
"common.all": "All",
|
||||
"common.back": "Back",
|
||||
@@ -1004,7 +1028,7 @@
|
||||
"help.desc_8f0bffe3": "JWT tokens",
|
||||
"help.desc_9186a3a3": "Explorez l'arborescence : Naviguez dans les dossiers",
|
||||
"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_991307d6": " — Generate content from the selection:",
|
||||
"help.desc_995a711f": ": Opens the CodeMirror editor",
|
||||
@@ -1461,6 +1485,16 @@
|
||||
"modal.delete_permanently": "Delete permanently",
|
||||
"modal.delete_warning": "Warning!",
|
||||
"modal.dir_to_delete": "Folder to delete:",
|
||||
"modal.ext_excalidraw": "Excalidraw (.excalidraw)",
|
||||
"modal.ext_js": "JavaScript (.js)",
|
||||
"modal.ext_json": "JSON (.json)",
|
||||
"modal.ext_md": "Markdown (.md)",
|
||||
"modal.ext_ps1": "PowerShell (.ps1)",
|
||||
"modal.ext_py": "Python (.py)",
|
||||
"modal.ext_sh": "Shell (.sh)",
|
||||
"modal.ext_txt": "Plain text (.txt)",
|
||||
"modal.ext_xlsx": "Excel (.xlsx)",
|
||||
"modal.ext_yaml": "YAML (.yaml)",
|
||||
"modal.file_name": "File name",
|
||||
"modal.file_to_delete": "File to delete:",
|
||||
"modal.file_type": "File type",
|
||||
@@ -1687,6 +1721,7 @@
|
||||
"sidebar.file_icons": "Icons",
|
||||
"sidebar.filter_instant": "Instant search",
|
||||
"sidebar.filter_ai": "Filter AI history...",
|
||||
"sidebar.filter_chat": "Search the chat...",
|
||||
"sidebar.filter_recent": "Filter recent files...",
|
||||
"sidebar.filter_saved": "Filter saved searches...",
|
||||
"sidebar.filter_path": "Path filters",
|
||||
@@ -1847,8 +1882,8 @@
|
||||
"xlsx.tabs_add_sheet": "Add a sheet",
|
||||
"xlsx.readonly_badge": "Read-only",
|
||||
"xlsx.readonly_hint": "This format (.xls / .ods) cannot be edited in ObsiGate — convert it to .xlsx to edit.",
|
||||
"xlsx.formulas_note": "Formulas not recalculated",
|
||||
"xlsx.formulas_note_title": "ObsiGate shows the formula as stored: Excel recalculates it on open. Dependent cells do not refresh on screen.",
|
||||
"xlsx.formulas_note": "Formulas computed locally",
|
||||
"xlsx.formulas_note_title": "ObsiGate computes common formulas on screen (arithmetic, SUM, AVERAGE, IF…). Unsupported functions show the formula as stored: Excel recalculates it on open.",
|
||||
"xlsx.truncated_title": "Truncated sheet",
|
||||
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
|
||||
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
|
||||
@@ -1876,8 +1911,31 @@
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} sheets",
|
||||
"xlsx.csv_export": "Export the sheet or the selection as CSV",
|
||||
"xlsx.colour_panel_font": "Text colour",
|
||||
"xlsx.colour_panel_fill": "Fill colour",
|
||||
"xlsx.colour_palette": "Colour palette",
|
||||
"xlsx.colour_reset": "Reset",
|
||||
"xlsx.colour_reset_font": "Reset the text colour",
|
||||
"xlsx.colour_reset_fill": "Reset the fill colour",
|
||||
"xlsx.colour_standard": "Standard",
|
||||
"xlsx.colour_custom": "Custom",
|
||||
"xlsx.colour_edit_standard": "Pick a custom colour",
|
||||
"xlsx.colour_custom_pick": "Add a custom colour",
|
||||
"xlsx.colour_conditional": "Conditional formatting",
|
||||
"xlsx.colour_banded": "Banded colours",
|
||||
"xlsx.colour_unavailable": "“{feature}” is not available in ObsiGate yet.",
|
||||
"xlsx.colour_black": "Black",
|
||||
"xlsx.colour_white": "White",
|
||||
"xlsx.colour_blue": "Blue",
|
||||
"xlsx.colour_red": "Red",
|
||||
"xlsx.colour_yellow": "Yellow",
|
||||
"xlsx.colour_green": "Green",
|
||||
"xlsx.colour_orange": "Orange",
|
||||
"xlsx.colour_cyan": "Cyan",
|
||||
"xlsx.export_btn": "Export",
|
||||
"xlsx.export_csv": "Export as CSV",
|
||||
"xlsx.export_title": "Export the selection or the sheet",
|
||||
"xlsx.file_menu": "File",
|
||||
"xlsx.export_md": "Export as Markdown",
|
||||
"xlsx.export_html": "Export as HTML",
|
||||
"xlsx.print": "Print",
|
||||
@@ -1885,6 +1943,47 @@
|
||||
"xlsx.export_scope_selection": "the selection",
|
||||
"xlsx.export_done": "Exported {scope}",
|
||||
"xlsx.format_btn": "Formatting",
|
||||
"xlsx.format_strike": "Strikethrough",
|
||||
"xlsx.format_align_justify": "Justified",
|
||||
"xlsx.tb_search": "Search in sheet",
|
||||
"xlsx.tb_paint": "Paint format",
|
||||
"xlsx.paint_pick_target": "Click the target cell or range",
|
||||
"xlsx.tb_zoom": "Zoom",
|
||||
"xlsx.tb_currency": "Currency format",
|
||||
"xlsx.tb_percent": "Percent format",
|
||||
"xlsx.tb_dec_minus": "Decrease decimals",
|
||||
"xlsx.tb_dec_plus": "Increase decimals",
|
||||
"xlsx.tb_number": "Number format",
|
||||
"xlsx.tb_numfmt": "Number format",
|
||||
"xlsx.tb_font_minus": "Decrease font size",
|
||||
"xlsx.tb_font_plus": "Increase font size",
|
||||
"xlsx.tb_font_size": "Font size",
|
||||
"xlsx.tb_borders": "Borders",
|
||||
"xlsx.border_all": "All borders",
|
||||
"xlsx.border_outer": "Outer borders",
|
||||
"xlsx.border_none": "No borders",
|
||||
"xlsx.tb_merge": "Merge cells",
|
||||
"xlsx.tb_alignh": "Horizontal alignment",
|
||||
"xlsx.tb_alignv": "Vertical alignment",
|
||||
"xlsx.valign_top": "Top",
|
||||
"xlsx.valign_middle": "Middle",
|
||||
"xlsx.valign_bottom": "Bottom",
|
||||
"xlsx.tb_wrap": "Text wrapping",
|
||||
"xlsx.tb_rotation": "Text rotation",
|
||||
"xlsx.rotation_0": "Horizontal",
|
||||
"xlsx.rotation_45": "Diagonal up",
|
||||
"xlsx.rotation_315": "Diagonal down",
|
||||
"xlsx.rotation_90": "Vertical",
|
||||
"xlsx.tb_more": "More options",
|
||||
"xlsx.comment_add": "Comment",
|
||||
"xlsx.comment_prompt": "Comment text (empty to delete):",
|
||||
"xlsx.link_add": "Insert link",
|
||||
"xlsx.link_url": "Link address (https://…):",
|
||||
"xlsx.link_label": "Label:",
|
||||
"xlsx.link_invalid": "Invalid address (https://… expected)",
|
||||
"xlsx.sum_insert": "Sum",
|
||||
"xlsx.sum_empty": "Nothing to total (numbers above or a selection)",
|
||||
"xlsx.paint_pick_source": "Select a formatted source cell first",
|
||||
"xlsx.format_bold": "Bold",
|
||||
"xlsx.format_italic": "Italic",
|
||||
"xlsx.format_underline": "Underline",
|
||||
@@ -1906,9 +2005,13 @@
|
||||
"xlsx.format_freeze": "Freeze panes here",
|
||||
"xlsx.format_unfreeze": "Unfreeze panes",
|
||||
"xlsx.format_col_width": "Column width…",
|
||||
"xlsx.format_col_prompt": "Width of column {col} (0 to 255)",
|
||||
"xlsx.format_col_prompt": "Width of column {col} — in characters (0 to 255, 0 = default width)",
|
||||
"xlsx.format_row_height": "Row height…",
|
||||
"xlsx.format_row_prompt": "Height of row {row} (0 to 409)",
|
||||
"xlsx.format_row_prompt": "Height of row {row} — in points (0 to 409, 0 = default height)",
|
||||
"xlsx.format_col_fit": "Fit column width to data",
|
||||
"xlsx.format_row_fit": "Fit row height to data",
|
||||
"xlsx.mi_resize": "Resize",
|
||||
"xlsx.fit_nothing": "Nothing to fit (empty content or already fitted)",
|
||||
"xlsx.format_applied": "Formatting saved",
|
||||
"xlsx.format_error": "Formatting failed",
|
||||
"xlsx.sort_asc": "Sort column A→Z",
|
||||
@@ -1969,6 +2072,167 @@
|
||||
"xlsx.feature_signature": "digital signature",
|
||||
"xlsx.feature_rich_comments": "rich comments",
|
||||
"xlsx.feature_macros": "macros",
|
||||
|
||||
"xlsx.mbar_label": "Spreadsheet menus",
|
||||
"xlsx.cmdbar_label": "Spreadsheet command bar",
|
||||
"xlsx.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_file": "Download file",
|
||||
"viewer.pretty": "Pretty",
|
||||
@@ -1983,6 +2247,9 @@
|
||||
"viewer.export_html": "Export as HTML",
|
||||
"viewer.export_md_bundle": "Export as Markdown bundle (.zip)",
|
||||
"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.forge_brand": "Forge",
|
||||
"viewer.forge_title": "Forge (new editor)",
|
||||
|
||||
+272
-5
@@ -319,6 +319,30 @@
|
||||
"backups.restore_confirm": "a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.",
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Annuler",
|
||||
"chat.attach_error": "Échec de l'envoi de la pièce jointe",
|
||||
"chat.channel_general": "Général",
|
||||
"chat.delete": "Supprimer ce message",
|
||||
"chat.delete_error": "Échec de la suppression",
|
||||
"chat.deleted_message": "Ce message a été supprimé",
|
||||
"chat.undo": "Annuler",
|
||||
"chat.download_image": "Télécharger l'image",
|
||||
"chat.view_image": "Afficher en grand",
|
||||
"chat.attach_remove": "Retirer la pièce jointe",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuter de ce fichier",
|
||||
"chat.close": "Fermer le panneau de discussion",
|
||||
"chat.empty": "Aucun message pour l'instant. Lancez la discussion !",
|
||||
"chat.load_error": "Impossible de charger la discussion",
|
||||
"chat.new_message": "Nouveau message de {user}",
|
||||
"chat.placeholder": "Écrire un message…",
|
||||
"chat.placeholder_dm": "Message privé à {user}…",
|
||||
"chat.read_by": "{user} a lu votre message",
|
||||
"chat.send": "Envoyer",
|
||||
"chat.send_error": "Échec de l'envoi du message",
|
||||
"chat.tab": "Chat général",
|
||||
"chat.status_read": "Lu",
|
||||
"chat.status_sent": "Envoyé",
|
||||
"chat.title": "Discussion",
|
||||
"common.actions": "Actions",
|
||||
"common.all": "Tous",
|
||||
"common.back": "Retour",
|
||||
@@ -1004,7 +1028,7 @@
|
||||
"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_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_991307d6": "— Génère du\n contenu à partir de la sélection :",
|
||||
"help.desc_995a711f": ": Ouvre l'éditeur\n CodeMirror",
|
||||
@@ -1461,6 +1485,16 @@
|
||||
"modal.delete_permanently": "Supprimer définitivement",
|
||||
"modal.delete_warning": "Attention !",
|
||||
"modal.dir_to_delete": "Dossier à supprimer:",
|
||||
"modal.ext_excalidraw": "Excalidraw (.excalidraw)",
|
||||
"modal.ext_js": "JavaScript (.js)",
|
||||
"modal.ext_json": "JSON (.json)",
|
||||
"modal.ext_md": "Markdown (.md)",
|
||||
"modal.ext_ps1": "PowerShell (.ps1)",
|
||||
"modal.ext_py": "Python (.py)",
|
||||
"modal.ext_sh": "Shell (.sh)",
|
||||
"modal.ext_txt": "Texte (.txt)",
|
||||
"modal.ext_xlsx": "Excel (.xlsx)",
|
||||
"modal.ext_yaml": "YAML (.yaml)",
|
||||
"modal.file_name": "Nom du fichier",
|
||||
"modal.file_to_delete": "Fichier à supprimer:",
|
||||
"modal.file_type": "Type de fichier",
|
||||
@@ -1687,6 +1721,7 @@
|
||||
"sidebar.file_icons": "Icônes",
|
||||
"sidebar.filter_instant": "Recherche instantanée",
|
||||
"sidebar.filter_ai": "Filtrer l'historique IA...",
|
||||
"sidebar.filter_chat": "Rechercher dans le chat...",
|
||||
"sidebar.filter_recent": "Filtrer les fichiers récents...",
|
||||
"sidebar.filter_saved": "Filtrer les recherches sauvegardées...",
|
||||
"sidebar.filter_path": "Filtres de chemin",
|
||||
@@ -1847,8 +1882,8 @@
|
||||
"xlsx.tabs_add_sheet": "Ajouter une feuille",
|
||||
"xlsx.readonly_badge": "Lecture seule",
|
||||
"xlsx.readonly_hint": "Ce format (.xls / .ods) n'est pas modifiable dans ObsiGate — convertissez-le en .xlsx pour l'éditer.",
|
||||
"xlsx.formulas_note": "Formules non recalculées",
|
||||
"xlsx.formulas_note_title": "ObsiGate affiche la formule telle qu'elle est enregistrée : Excel la recalcule à l'ouverture. Les cellules dépendantes ne se rafraîchissent pas à l'écran.",
|
||||
"xlsx.formulas_note": "Formules calculées localement",
|
||||
"xlsx.formulas_note_title": "ObsiGate calcule les formules courantes à l'écran (arithmétique, SOMME, MOYENNE, SI…). Les fonctions non prises en charge affichent la formule telle qu'enregistrée : Excel la recalcule à l'ouverture.",
|
||||
"xlsx.truncated_title": "Feuille tronquée",
|
||||
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
|
||||
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
|
||||
@@ -1876,8 +1911,31 @@
|
||||
"xlsx.find_count": "{index}/{count}",
|
||||
"xlsx.find_count_sheets": "{index}/{count} · {sheets} feuilles",
|
||||
"xlsx.csv_export": "Exporter la feuille ou la sélection en CSV",
|
||||
"xlsx.colour_panel_font": "Couleur du texte",
|
||||
"xlsx.colour_panel_fill": "Couleur de fond",
|
||||
"xlsx.colour_palette": "Palette de couleurs",
|
||||
"xlsx.colour_reset": "Réinitialiser",
|
||||
"xlsx.colour_reset_font": "Réinitialiser la couleur du texte",
|
||||
"xlsx.colour_reset_fill": "Réinitialiser la couleur de fond",
|
||||
"xlsx.colour_standard": "Standard",
|
||||
"xlsx.colour_custom": "Personnalisé",
|
||||
"xlsx.colour_edit_standard": "Choisir une couleur personnalisée",
|
||||
"xlsx.colour_custom_pick": "Ajouter une couleur personnalisée",
|
||||
"xlsx.colour_conditional": "Mise en forme conditionnelle",
|
||||
"xlsx.colour_banded": "Couleurs en alternance",
|
||||
"xlsx.colour_unavailable": "« {feature} » n'est pas encore disponible dans ObsiGate.",
|
||||
"xlsx.colour_black": "Noir",
|
||||
"xlsx.colour_white": "Blanc",
|
||||
"xlsx.colour_blue": "Bleu",
|
||||
"xlsx.colour_red": "Rouge",
|
||||
"xlsx.colour_yellow": "Jaune",
|
||||
"xlsx.colour_green": "Vert",
|
||||
"xlsx.colour_orange": "Orange",
|
||||
"xlsx.colour_cyan": "Cyan",
|
||||
"xlsx.export_btn": "Exporter",
|
||||
"xlsx.export_csv": "Exporter en CSV",
|
||||
"xlsx.export_title": "Exporter la sélection ou la feuille",
|
||||
"xlsx.file_menu": "Fichier",
|
||||
"xlsx.export_md": "Exporter en Markdown",
|
||||
"xlsx.export_html": "Exporter en HTML",
|
||||
"xlsx.print": "Imprimer",
|
||||
@@ -1885,6 +1943,47 @@
|
||||
"xlsx.export_scope_selection": "la sélection",
|
||||
"xlsx.export_done": "Export de {scope} terminé",
|
||||
"xlsx.format_btn": "Mise en forme",
|
||||
"xlsx.format_strike": "Barré",
|
||||
"xlsx.format_align_justify": "Justifié",
|
||||
"xlsx.tb_search": "Rechercher dans la feuille",
|
||||
"xlsx.tb_paint": "Peindre le format",
|
||||
"xlsx.paint_pick_target": "Cliquez sur la cellule ou la plage cible du format",
|
||||
"xlsx.tb_zoom": "Zoom",
|
||||
"xlsx.tb_currency": "Format monétaire",
|
||||
"xlsx.tb_percent": "Format pourcentage",
|
||||
"xlsx.tb_dec_minus": "Diminuer les décimales",
|
||||
"xlsx.tb_dec_plus": "Augmenter les décimales",
|
||||
"xlsx.tb_number": "Format nombre",
|
||||
"xlsx.tb_numfmt": "Format de nombre",
|
||||
"xlsx.tb_font_minus": "Réduire la taille de police",
|
||||
"xlsx.tb_font_plus": "Augmenter la taille de police",
|
||||
"xlsx.tb_font_size": "Taille de police",
|
||||
"xlsx.tb_borders": "Bordures",
|
||||
"xlsx.border_all": "Toutes les bordures",
|
||||
"xlsx.border_outer": "Bordures extérieures",
|
||||
"xlsx.border_none": "Aucune bordure",
|
||||
"xlsx.tb_merge": "Fusionner les cellules",
|
||||
"xlsx.tb_alignh": "Alignement horizontal",
|
||||
"xlsx.tb_alignv": "Alignement vertical",
|
||||
"xlsx.valign_top": "En haut",
|
||||
"xlsx.valign_middle": "Au milieu",
|
||||
"xlsx.valign_bottom": "En bas",
|
||||
"xlsx.tb_wrap": "Retour à la ligne automatique",
|
||||
"xlsx.tb_rotation": "Rotation du texte",
|
||||
"xlsx.rotation_0": "Horizontal",
|
||||
"xlsx.rotation_45": "Diagonale vers le haut",
|
||||
"xlsx.rotation_315": "Diagonale vers le bas",
|
||||
"xlsx.rotation_90": "Vertical",
|
||||
"xlsx.tb_more": "Plus d'options",
|
||||
"xlsx.comment_add": "Commentaire",
|
||||
"xlsx.comment_prompt": "Texte du commentaire (vide pour supprimer) :",
|
||||
"xlsx.link_add": "Insérer un lien",
|
||||
"xlsx.link_url": "Adresse du lien (https://…) :",
|
||||
"xlsx.link_label": "Libellé :",
|
||||
"xlsx.link_invalid": "Adresse invalide (https://… attendue)",
|
||||
"xlsx.sum_insert": "Somme",
|
||||
"xlsx.sum_empty": "Aucune plage à totaliser (nombres au-dessus ou sélection)",
|
||||
"xlsx.paint_pick_source": "Sélectionnez d'abord une cellule source mise en forme",
|
||||
"xlsx.format_bold": "Gras",
|
||||
"xlsx.format_italic": "Italique",
|
||||
"xlsx.format_underline": "Souligné",
|
||||
@@ -1906,9 +2005,13 @@
|
||||
"xlsx.format_freeze": "Figer les volets ici",
|
||||
"xlsx.format_unfreeze": "Libérer les volets",
|
||||
"xlsx.format_col_width": "Largeur de colonne…",
|
||||
"xlsx.format_col_prompt": "Largeur de la colonne {col} (0 à 255)",
|
||||
"xlsx.format_col_prompt": "Largeur de la colonne {col} — en caractères (0 à 255, 0 = largeur par défaut)",
|
||||
"xlsx.format_row_height": "Hauteur de ligne…",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} (0 à 409)",
|
||||
"xlsx.format_row_prompt": "Hauteur de la ligne {row} — en points (0 à 409, 0 = hauteur par défaut)",
|
||||
"xlsx.format_col_fit": "Ajuster la largeur au contenu",
|
||||
"xlsx.format_row_fit": "Ajuster la hauteur au contenu",
|
||||
"xlsx.mi_resize": "Redimensionner",
|
||||
"xlsx.fit_nothing": "Rien à ajuster (contenu vide ou déjà à la bonne taille)",
|
||||
"xlsx.format_applied": "Mise en forme enregistrée",
|
||||
"xlsx.format_error": "Échec de la mise en forme",
|
||||
"xlsx.sort_asc": "Trier la colonne A→Z",
|
||||
@@ -1969,6 +2072,167 @@
|
||||
"xlsx.feature_signature": "signature numérique",
|
||||
"xlsx.feature_rich_comments": "commentaires enrichis",
|
||||
"xlsx.feature_macros": "macros",
|
||||
|
||||
"xlsx.mbar_label": "Menus du tableur",
|
||||
"xlsx.cmdbar_label": "Barre de commandes du tableur",
|
||||
"xlsx.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_file": "Télécharger le fichier",
|
||||
"viewer.pretty": "Pretty",
|
||||
@@ -1983,6 +2247,9 @@
|
||||
"viewer.export_html": "Exporter en HTML",
|
||||
"viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)",
|
||||
"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.forge_brand": "Forge",
|
||||
"viewer.forge_title": "Forge (nouvel éditeur)",
|
||||
|
||||
+1364
-52
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.53.3",
|
||||
"version": "2.65.2",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
+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
|
||||
// committed next to the other fixture; nothing in the suite writes to it.
|
||||
const LARGE = 'sample-xlsx-large.xlsx';
|
||||
const LARGE_PATH = path.resolve(process.cwd(), 'test_vault', LARGE);
|
||||
let originalLargeBytes = null;
|
||||
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||
|
||||
@@ -70,6 +72,14 @@ async function openXlsx(page, file) {
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
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)', () => {
|
||||
@@ -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
|
||||
// formula results (BUG-085), so the shadow line only exists on a pristine
|
||||
// fixture.
|
||||
test('affiche la valeur calculée en cache sous la formule (#153 A12)', async ({ page }) => {
|
||||
test('calcule les formules à l’écran, garde le texte en barre (#153 A12)', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>).
|
||||
// B2 is "=B1*2" and the package keeps its last result (<v>200</v>): the
|
||||
// local engine computes it, so the cell shows the value (not the shadow).
|
||||
const formulaCell = page.locator('#content-area td[data-cell="B2"]');
|
||||
await expect(formulaCell).toContainText('=B1*2');
|
||||
|
||||
const cached = formulaCell.locator('.xlsx-cached');
|
||||
await expect(cached).toHaveCount(1);
|
||||
await expect(cached).toHaveText('200');
|
||||
// The tooltip is translated client-side, never hardcoded by the backend.
|
||||
await expect(cached).toHaveAttribute('title', /Excel/);
|
||||
await expect(formulaCell).toHaveText('200');
|
||||
await expect(formulaCell.locator('.xlsx-cached')).toHaveCount(0);
|
||||
await formulaCell.click();
|
||||
await expect(page.locator('#xlsx-formula-input')).toHaveValue('=B1*2');
|
||||
|
||||
// A plain value cell must not be duplicated with a shadow line.
|
||||
await expect(page.locator('#content-area td[data-cell="B1"] .xlsx-cached')).toHaveCount(0);
|
||||
@@ -286,11 +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.
|
||||
await cellOf('B12').click();
|
||||
await page.keyboard.press('Control+a');
|
||||
await expect(nameBox).toHaveValue('A1:C500');
|
||||
// #184 — la feuille rendue va jusqu'à Z (la grille est posée A → Z).
|
||||
await expect(nameBox).toHaveValue('A1:Z500');
|
||||
await page.keyboard.press('Control+Home');
|
||||
await expect(nameBox).toHaveValue('A1');
|
||||
await page.keyboard.press('Control+End');
|
||||
await expect(nameBox).toHaveValue('C500');
|
||||
await expect(nameBox).toHaveValue('Z500');
|
||||
|
||||
// — A5 : copier une plage puis la coller ailleurs. Le menu contextuel
|
||||
// utilise le presse-papiers interne de la grille : aucune permission
|
||||
@@ -358,33 +367,181 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
await page.locator('#xlsx-structure-btn').click();
|
||||
const toggle = page.locator('#xlsx-structure-btn');
|
||||
await toggle.click();
|
||||
const menu = page.locator('.xlsx-structure-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.locator('.xlsx-structure-item i[data-lucide], .xlsx-structure-item svg').first()).toBeAttached();
|
||||
// The menu opens right under its button, inside the toolbar width.
|
||||
const [btnBox, menuBox, toolbarBox] = await Promise.all([
|
||||
toggle.boundingBox(), menu.boundingBox(),
|
||||
page.locator('#content-area .xlsx-toolbar').boundingBox(),
|
||||
]);
|
||||
expect(menuBox.y).toBeGreaterThanOrEqual(btnBox.y + btnBox.height - 2);
|
||||
expect(menuBox.y).toBeLessThanOrEqual(btnBox.y + btnBox.height + 24);
|
||||
// Le menu reste DANS la barre : près du bord droit il est décalé vers la
|
||||
// gauche (clamp de anchorUnder) au lieu de dépasser.
|
||||
expect(menuBox.x).toBeGreaterThanOrEqual(toolbarBox.x - 2);
|
||||
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 2);
|
||||
// Clic dans la grille = le focus quitte le menu → fermeture.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await expect(page.locator('.xlsx-structure-menu')).toHaveCount(0);
|
||||
await expect(page.locator('#xlsx-structure-btn')).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('le menu Export se ferme au clic extérieur', async ({ page }) => {
|
||||
test('le menu Fichier (Télécharger/CSV/Export) se ferme au clic extérieur', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFixture(page);
|
||||
|
||||
await page.locator('#xlsx-export-btn').click();
|
||||
const menu = page.locator('#xlsx-export-menu');
|
||||
await page.locator('#xlsx-file-btn').click();
|
||||
const menu = page.locator('#xlsx-file-menu');
|
||||
await expect(menu).toBeVisible();
|
||||
// Les 5 sorties sont réunies dans ce seul menu (#185 : il est passé dans
|
||||
// la barre de menus, au-dessus du ruban), repliées dans la section
|
||||
// « Télécharger » — les sections du menu sont des accordéons.
|
||||
await menu.locator('.xlsx-menu-item', { hasText: 'Télécharger' }).first().click();
|
||||
await expect(menu.locator('#xlsx-download-btn')).toBeVisible();
|
||||
await expect(menu.locator('#xlsx-csv-btn')).toBeVisible();
|
||||
await expect(page.locator('#content-area .xlsx-sheetbar-end #xlsx-save-btn')).toBeVisible();
|
||||
await page.locator('#content-area td[data-cell="A1"]').click();
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('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');
|
||||
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 });
|
||||
|
||||
// #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.
|
||||
const overflow = await page.evaluate(() => {
|
||||
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);
|
||||
|
||||
// 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.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #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,471 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the per-file chat panel (#169):
|
||||
* `frontend/js/filechat.js`.
|
||||
*
|
||||
* Covers:
|
||||
* - `renderChatMessages()`: chronological render + empty state + escaping
|
||||
* - `onChatMessage()`: live append when open on the same file, dedupe by id,
|
||||
* no duplicate append when the panel shows another file
|
||||
*
|
||||
* Usage: node tests/frontend/filechat.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html><html><body><div id="toast-container"></div>
|
||||
<div class="sidebar-tab-panel active" id="sidebar-panel-chat">
|
||||
<div id="sidebar-panel-chat-list" class="file-chat-list"></div>
|
||||
<div id="sidebar-panel-chat-pending"></div>
|
||||
<form id="sidebar-panel-chat-form"><textarea id="sidebar-panel-chat-input"></textarea></form>
|
||||
</div>
|
||||
<button id="sidebar-tab-chat"></button>
|
||||
</body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.CSS = w.CSS || { escape: (s) => s };
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.requestAnimationFrame = w.requestAnimationFrame || ((cb) => setTimeout(cb, 16));
|
||||
globalThis.cancelAnimationFrame = w.cancelAnimationFrame || clearTimeout;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales");
|
||||
|
||||
// Route fetch: locales from disk, auth 401, API chat endpoints from a stub map.
|
||||
const apiStubs = new Map();
|
||||
const fetchCalls = []; // #110 — journal des appels {url, method}
|
||||
globalThis.fetch = (url, opts = {}) => {
|
||||
const u = String(url);
|
||||
fetchCalls.push({ url: u, method: (opts.method || "GET").toUpperCase() });
|
||||
if (u.includes("/static/locales/")) {
|
||||
const name = u.split("/").pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(LOCALES_DIR, name), "utf-8");
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(JSON.parse(body)) });
|
||||
} catch (_) {
|
||||
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
||||
}
|
||||
}
|
||||
if (u.includes("/api/auth/me")) {
|
||||
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({}) });
|
||||
}
|
||||
if (apiStubs.has(u)) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(apiStubs.get(u)) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ messages: [] }) });
|
||||
};
|
||||
|
||||
const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
|
||||
await initI18n();
|
||||
|
||||
// #191 — the delete right needs a logged-in identity
|
||||
const { AuthManager } = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href);
|
||||
AuthManager.getUser = () => ({ username: "bruno", role: "user" });
|
||||
|
||||
const mod = await import("../../frontend/js/filechat.js");
|
||||
const {
|
||||
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
|
||||
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
|
||||
onChatDeleted, onChatRead, _deleteMessage,
|
||||
} = mod;
|
||||
|
||||
let pass = 0;
|
||||
const cases = [];
|
||||
function test(name, fn) {
|
||||
cases.push([name, fn]);
|
||||
}
|
||||
|
||||
function makeList() {
|
||||
const list = document.createElement("div");
|
||||
list.className = "file-chat-list";
|
||||
return list;
|
||||
}
|
||||
|
||||
test("renderChatMessages: empty/missing shows the empty state", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(list, [], "bruno");
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0);
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
renderChatMessages(list, undefined, "bruno");
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
});
|
||||
|
||||
test("renderChatMessages: renders chronologically with mine class", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(
|
||||
list,
|
||||
[
|
||||
{ id: "1", user: "alice", text: "bonjour", ts: 1700000000 },
|
||||
{ id: "2", user: "bruno", text: "salut !", ts: 1700000060 },
|
||||
],
|
||||
"bruno",
|
||||
);
|
||||
const msgs = list.querySelectorAll(".file-chat-msg");
|
||||
assert.strictEqual(msgs.length, 2);
|
||||
assert.strictEqual(msgs[0].querySelector(".file-chat-user").textContent, "alice");
|
||||
assert.strictEqual(msgs[0].classList.contains("mine"), false);
|
||||
assert.strictEqual(msgs[1].classList.contains("mine"), true);
|
||||
assert.strictEqual(msgs[1].querySelector(".file-chat-body").textContent, "salut !");
|
||||
});
|
||||
|
||||
test("renderChatMessages: message text is text (no HTML injection)", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(list, [{ id: "1", user: "<img>", text: "<b>x</b>", ts: 1 }], "u");
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").innerHTML, "<b>x</b>");
|
||||
assert.strictEqual(list.querySelectorAll("b").length, 0);
|
||||
});
|
||||
|
||||
test("onChatMessage: appends live message to the open panel", async () => {
|
||||
await openFileChat("V", "note.md");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
// Seed an empty state so we can assert its removal
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
renderChatMessages(list, [], "bruno");
|
||||
onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty"), null);
|
||||
// Same id again → deduped
|
||||
onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
closeFileChat();
|
||||
assert.strictEqual(document.getElementById("file-chat-panel").classList.contains("hidden"), true);
|
||||
});
|
||||
|
||||
test("onChatMessage: ignores events for another file (no cross-file append)", async () => {
|
||||
await openFileChat("V", "note.md");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
renderChatMessages(list, [], "bruno");
|
||||
onChatMessage({ vault: "V", path: "OTHER.md", message: { id: "x", user: "alice", text: "no", ts: 3 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0);
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
test("toggleFileChat: opens then closes on the same file", async () => {
|
||||
await toggleFileChat("V", "t.md");
|
||||
let panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
await toggleFileChat("V", "t.md");
|
||||
panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), true);
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
test("#190 A — followFileChat re-targets the open panel", async () => {
|
||||
await openFileChat("V", "one.md", "Doc Un");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
followFileChat("V", "two.md", "Doc Deux");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const target = panel.querySelector(".file-chat-target");
|
||||
assert.strictEqual(target.textContent, "Doc Deux");
|
||||
assert.strictEqual(target.title, "two.md");
|
||||
closeFileChat();
|
||||
followFileChat("V", "three.md", "Doc Trois");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.strictEqual(panel.querySelector(".file-chat-target").textContent, "Doc Deux");
|
||||
});
|
||||
|
||||
test("#190 B — openSidebarChat renders the general history", async () => {
|
||||
await openSidebarChat();
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
});
|
||||
|
||||
test("#190 B — onChatMessage routes __global__ to the sidebar list", async () => {
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "salut tout le monde");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
});
|
||||
|
||||
test("#190 C — filterChatMessages narrows by text/user", async () => {
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "f1", user: "alice", text: "recette de pizza", ts: 10 } });
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "f2", user: "bob", text: "deploiement docker", ts: 11 } });
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
filterChatMessages("pizza");
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "recette de pizza");
|
||||
filterChatMessages("bob"); // la recherche couvre aussi l'auteur
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
filterChatMessages("");
|
||||
assert.ok(list.querySelectorAll(".file-chat-msg").length >= 3);
|
||||
});
|
||||
|
||||
test("#190 — URL rendered as a link, surrounding text stays plain", async () => {
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "u1", user: "alice", text: "voici https://example.com/doc et du texte", ts: 20 } });
|
||||
const msg = list.querySelector('[data-id="u1"]');
|
||||
const a = msg.querySelector("a.file-chat-link");
|
||||
assert.strictEqual(a.href, "https://example.com/doc");
|
||||
assert.strictEqual(a.target, "_blank");
|
||||
assert.ok(msg.textContent.includes("du texte"));
|
||||
});
|
||||
|
||||
test("#191 — preview card rendered with site/title/description", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{
|
||||
id: "p1", user: "alice", text: "voir https://example.com", ts: 40,
|
||||
preview: { url: "https://example.com", title: "Mon site", description: "Le desc", image: "", site: "example.com" },
|
||||
}], "bruno");
|
||||
const card = list.querySelector("a.file-chat-preview");
|
||||
assert.ok(card, "carte preview présente");
|
||||
assert.strictEqual(card.href, "https://example.com/");
|
||||
assert.strictEqual(card.target, "_blank");
|
||||
assert.strictEqual(card.querySelector(".file-chat-preview-title").textContent, "Mon site");
|
||||
assert.strictEqual(card.querySelector(".file-chat-preview-desc").textContent, "Le desc");
|
||||
assert.strictEqual(card.querySelector(".file-chat-preview-site").textContent, "example.com");
|
||||
renderChatMessages(list, [{ id: "p2", user: "alice", text: "https://x.test", ts: 41 }], "bruno");
|
||||
assert.strictEqual(list.querySelector("a.file-chat-preview"), null);
|
||||
});
|
||||
|
||||
test("#191 — delete button hidden without delUrl, shown for own post", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno");
|
||||
assert.strictEqual(list.querySelector(".file-chat-del"), null);
|
||||
renderChatMessages(list, [{ id: "d2", user: "bruno", text: "x", ts: 51 }], "bruno", "/api/chat");
|
||||
assert.ok(list.querySelector(".file-chat-del"), "bouton suppression visible");
|
||||
renderChatMessages(list, [{ id: "d3", user: "alice", text: "x", ts: 52 }], "bruno", "/api/chat");
|
||||
assert.strictEqual(list.querySelector(".file-chat-del"), null);
|
||||
});
|
||||
|
||||
test("#191 — onChatDeleted removes the message from any list", async () => {
|
||||
await openSidebarChat();
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "del1", user: "alice", text: "a bientot", ts: 60 } });
|
||||
assert.strictEqual(list.querySelector('[data-id="del1"]') !== null, true);
|
||||
onChatDeleted({ vault: "__global__", path: "general", id: "del1" });
|
||||
assert.strictEqual(list.querySelector('[data-id="del1"]'), null);
|
||||
});
|
||||
|
||||
test("#191 — unread counters accumulate then reset on open", async () => {
|
||||
localStorage.removeItem("obsigate-chat-unread");
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
panel.classList.remove("active");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "u-1", user: "bob", text: "1", ts: 70 } });
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "u-2", user: "bob", text: "2", ts: 71 } });
|
||||
let u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
|
||||
assert.strictEqual(u.general, 2);
|
||||
refreshUnreadBadge();
|
||||
assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge").textContent, "2");
|
||||
panel.classList.add("active");
|
||||
await openSidebarChat();
|
||||
u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
|
||||
assert.strictEqual(u.general, 0);
|
||||
assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge"), null);
|
||||
});
|
||||
|
||||
test("#191 — DM routed by pair path, isolated from general", async () => {
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
const before = list.querySelectorAll(".file-chat-msg").length;
|
||||
onChatMessage({ vault: "__dm__", path: "alice|bruno", message: { id: "dm-1", user: "alice", text: "prive", ts: 80 } });
|
||||
const u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
|
||||
assert.strictEqual(u["dm:alice"], 1);
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before);
|
||||
});
|
||||
|
||||
// --- #192 : accusé de réception + boîte de saisie auto-agrandie --------------
|
||||
|
||||
test("#192 — receipt shows ✓ then ✓✓ once another participant read it", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "r1", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, {});
|
||||
let st = list.querySelector(".file-chat-status");
|
||||
assert.strictEqual(st.textContent, "✓");
|
||||
assert.strictEqual(st.classList.contains("read"), false);
|
||||
// MY OWN read timestamp must never count as "the peer read it"
|
||||
renderChatMessages(list, [{ id: "r2", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { bruno: 500 });
|
||||
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
|
||||
renderChatMessages(list, [{ id: "r3", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { alice: 150 });
|
||||
st = list.querySelector(".file-chat-status");
|
||||
assert.strictEqual(st.textContent, "✓✓");
|
||||
assert.strictEqual(st.classList.contains("read"), true);
|
||||
// messages of others carry no receipt
|
||||
renderChatMessages(list, [{ id: "r4", user: "alice", text: "ok", ts: 100 }], "bruno", null, {});
|
||||
assert.strictEqual(list.querySelector(".file-chat-status"), null);
|
||||
});
|
||||
|
||||
test("#192 — onChatRead flips ✓ → ✓✓ live and notifies the sender", async () => {
|
||||
await openSidebarChat();
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
renderChatMessages(list, [{ id: "s1", user: "bruno", text: "msg", ts: 200 }], "bruno", "/api/chat", {});
|
||||
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
|
||||
const container = document.getElementById("toast-container");
|
||||
const before = container.children.length;
|
||||
onChatRead({ vault: "__global__", path: "general", user: "alice", ts: 300, read: { bruno: 250, alice: 300 } });
|
||||
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓✓");
|
||||
assert.ok(container.children.length > before, "notification de lecture affichée");
|
||||
// notre propre écho ne notifie pas
|
||||
const same = container.children.length;
|
||||
onChatRead({ vault: "__global__", path: "general", user: "bruno", ts: 310, read: { bruno: 310 } });
|
||||
assert.strictEqual(container.children.length, same);
|
||||
});
|
||||
|
||||
test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => {
|
||||
assert.strictEqual(document.getElementById("sidebar-panel-chat-input").tagName, "TEXTAREA");
|
||||
await openFileChat("V", "grow.md");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
const input = panel.querySelector(".file-chat-input");
|
||||
assert.strictEqual(input.tagName, "TEXTAREA");
|
||||
assert.strictEqual(input.rows, 1);
|
||||
input.value = "ligne1\nligne2";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.ok(input.style.height, "hauteur pilotée en JS");
|
||||
let prevented = false;
|
||||
const ev = new w.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "preventDefault", { value: () => { prevented = true; } });
|
||||
input.value = ""; // rien à envoyer : on ne teste que le geste
|
||||
input.dispatchEvent(ev);
|
||||
assert.strictEqual(prevented, true, "Enter (sans Shift) est intercepté");
|
||||
const nl = new w.KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true });
|
||||
let nlPrevented = false;
|
||||
Object.defineProperty(nl, "preventDefault", { value: () => { nlPrevented = true; } });
|
||||
input.dispatchEvent(nl);
|
||||
assert.strictEqual(nlPrevented, false, "Shift+Enter garde le retour à la ligne");
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
// --- #110 : boutons discrets, image en onglet, point non-lu, delete différé -
|
||||
|
||||
test("#110 — bouton supprimer visible (pas opacity:0)", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno", "/api/chat");
|
||||
const del = list.querySelector(".file-chat-del");
|
||||
assert.ok(del, "bouton suppression présent");
|
||||
assert.strictEqual(del.style.opacity, "", "pas de opacity:0 — bouton visible");
|
||||
});
|
||||
|
||||
test("#110 — image : boutons icônes discrets sous l'image, à droite", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{
|
||||
id: "img1", user: "alice", text: "", ts: 100,
|
||||
attachment: { kind: "image", url: "/api/chat/attachment/photo.png", name: "photo.png", mime: "image/png" },
|
||||
}], "bruno");
|
||||
const container = list.querySelector(".file-chat-image-container");
|
||||
assert.ok(container, "conteneur image présent");
|
||||
const actions = container.querySelector(".file-chat-image-actions");
|
||||
assert.ok(actions, "rangée de boutons sous l'image");
|
||||
assert.ok(actions.querySelector(".file-chat-image-download"), "bouton téléchargement présent");
|
||||
assert.ok(actions.querySelector(".file-chat-open"), "bouton visualisation présent");
|
||||
actions.querySelectorAll("button").forEach((b) => {
|
||||
assert.strictEqual(b.textContent.trim(), "", "bouton icône seule (sans libellé texte)");
|
||||
assert.ok(b.getAttribute("aria-label"), "aria-label présent");
|
||||
});
|
||||
});
|
||||
|
||||
test("#110 — clic sur l'image : ouverture dans un onglet de l'app", async () => {
|
||||
const ui = await import(pathToFileURL(path.join(JS_DIR, "ui.js")).href);
|
||||
const seen = [];
|
||||
const orig = ui.TabManager.openChatImage;
|
||||
ui.TabManager.openChatImage = (att) => seen.push(att);
|
||||
try {
|
||||
const list = document.createElement("div");
|
||||
const att = { kind: "image", url: "/api/chat/attachment/p2.png", name: "p2.png" };
|
||||
renderChatMessages(list, [{ id: "img2", user: "alice", text: "", ts: 101, attachment: att }], "bruno");
|
||||
list.querySelector(".file-chat-image").click();
|
||||
assert.strictEqual(seen.length, 1, "openChatImage appelé au clic sur l'image");
|
||||
assert.strictEqual(seen[0].url, att.url, "même pièce jointe transmise");
|
||||
list.querySelector(".file-chat-open").click();
|
||||
assert.strictEqual(seen.length, 2, "le bouton discret ouvre aussi l'onglet");
|
||||
} finally {
|
||||
ui.TabManager.openChatImage = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test("#110 — point de couleur sur l'icône chat tant qu'il reste des non-lus", () => {
|
||||
const tab = document.getElementById("sidebar-tab-chat");
|
||||
assert.ok(tab, "onglet chat présent");
|
||||
localStorage.setItem("obsigate-chat-unread", JSON.stringify({ general: 3 }));
|
||||
refreshUnreadBadge();
|
||||
assert.ok(tab.classList.contains("chat-has-unread"), "classe chat-has-unread posée");
|
||||
localStorage.setItem("obsigate-chat-unread", JSON.stringify({}));
|
||||
refreshUnreadBadge();
|
||||
assert.ok(!tab.classList.contains("chat-has-unread"), "classe retirée quand tout est lu");
|
||||
});
|
||||
|
||||
test("#110 — suppression différée : Annuler remet le message, aucun DELETE", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "del9", user: "bruno", text: "mon message", ts: 200 }], "bruno", "/api/chat");
|
||||
const before = fetchCalls.length;
|
||||
list.querySelector(".file-chat-del").click();
|
||||
assert.ok(list.querySelector(".file-chat-deleted"), "ligne remplacée par « supprimé »");
|
||||
assert.ok(list.querySelector(".file-chat-undo"), "bouton Annuler présent");
|
||||
assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "aucun DELETE parti pendant la fenêtre");
|
||||
list.querySelector(".file-chat-undo").click();
|
||||
assert.ok(list.querySelector("[data-id='del9']"), "message d'origine restauré");
|
||||
assert.strictEqual(list.querySelector(".file-chat-deleted"), null, "ligne « supprimé » retirée");
|
||||
assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "toujours aucun DELETE");
|
||||
});
|
||||
|
||||
test("#110 — notification OS quand l'app est en arrière-plan", () => {
|
||||
const fired = [];
|
||||
class FakeNotification {
|
||||
constructor(title, opts) { fired.push({ title, opts }); }
|
||||
close() {}
|
||||
}
|
||||
FakeNotification.permission = "granted";
|
||||
window.Notification = FakeNotification;
|
||||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||||
try {
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
panel.classList.remove("active");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "n-1", user: "bob", text: "coucou", ts: 300 } });
|
||||
assert.strictEqual(fired.length, 1, "notification affichée");
|
||||
assert.ok(String(fired[0].opts.body).includes("coucou"), "aperçu du message dans la notification");
|
||||
} finally {
|
||||
Object.defineProperty(document, "hidden", { value: false, configurable: true });
|
||||
window.Notification = { permission: "denied" };
|
||||
}
|
||||
});
|
||||
|
||||
// --- #193 : un post est rendu comme un document (markdown + code coloré) -----
|
||||
|
||||
test("#193 — post avec html rendu : markdown + bloc de code", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{
|
||||
id: "md1", user: "alice", text: "**gras**\n\n```python\nprint(1)\n```", ts: 400,
|
||||
html: '<p><strong>gras</strong></p><pre><code class="language-python">print(1)</code></pre>',
|
||||
}], "bruno");
|
||||
const body = list.querySelector("[data-id='md1'] .file-chat-body");
|
||||
assert.ok(body.classList.contains("file-chat-md"), "classe md posée sur le corps");
|
||||
assert.ok(body.querySelector(".md-content"), "conteneur md-content");
|
||||
assert.ok(body.querySelector(".md-content strong"), "markdown rendu (strong)");
|
||||
assert.ok(body.querySelector("pre code.language-python"), "bloc de code conservé");
|
||||
});
|
||||
|
||||
test("#193 — sans html, repli texte brut + URL cliquable", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "md2", user: "alice", text: "voir https://ex.com/a", ts: 401 }], "bruno");
|
||||
const body = list.querySelector("[data-id='md2'] .file-chat-body");
|
||||
assert.ok(!body.classList.contains("file-chat-md"), "pas de rendu markdown");
|
||||
assert.ok(body.querySelector("a.file-chat-link"), "URL toujours cliquable");
|
||||
assert.strictEqual(body.textContent, "voir https://ex.com/a", "texte intact");
|
||||
});
|
||||
|
||||
// Sequential execution: each case depends on panel state left by the previous.
|
||||
for (const [name, fn] of cases) {
|
||||
try {
|
||||
await fn();
|
||||
pass++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
console.log(`\n${pass} passed`);
|
||||
@@ -176,6 +176,19 @@ await test("filterSavedSearches combines with the type pills", async () => {
|
||||
assert.equal(visibleSaved(), 3, "clearing both filters restores everything");
|
||||
});
|
||||
|
||||
// ── #194 : icône du dossier personnel ──
|
||||
await test("getVaultIcon returns the lucide house icon for home-* vaults", async () => {
|
||||
const homeIcon = viewerMod.getVaultIcon("home-bruno", 16);
|
||||
assert.equal(homeIcon.tagName.toLowerCase(), "i", "placeholder lucide (comme les autres icônes)");
|
||||
assert.equal(homeIcon.getAttribute("data-lucide"), "home", "icône maison lucide");
|
||||
assert.equal(homeIcon.style.width, "16px", "taille demandée respectée");
|
||||
});
|
||||
|
||||
await test("getVaultIcon is unchanged for regular vaults", async () => {
|
||||
const vaultIcon = viewerMod.getVaultIcon("Recettes", 16);
|
||||
assert.equal(vaultIcon.tagName.toLowerCase(), "svg", "gemme violette SVG d'origine");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
@@ -39,7 +39,7 @@ test("viewer.js — group nav: TOC markdown-only, then pop-out, bookmark", () =>
|
||||
const m = viewer.match(/const navBtns = \[\];([\s\S]*?)const editBtns/);
|
||||
assert.ok(m, "navBtns block not found");
|
||||
assert.match(m[1], /if \(data\.is_markdown\) navBtns\.push\(tocBtn\);/);
|
||||
assert.match(m[1], /navBtns\.push\(openNewWindowBtn,\s*bookmarkBtn\);/);
|
||||
assert.ok(m[1].includes("navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);"), "navBtns group order");
|
||||
});
|
||||
|
||||
test("viewer.js — group edit = [edit, source, .ext, forge?, pretty?] in order", () => {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — unit tests for the spreadsheet formula engine
|
||||
* (frontend/js/xlsx/formula.js). Pure logic, no DOM needed.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-formula.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
const modUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "formula.js")
|
||||
).href;
|
||||
const {
|
||||
isFormulaText, parseFormula, evaluate, createScope, displayValue,
|
||||
colToIndex, indexToCol, parseA1,
|
||||
} = await import(modUrl);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (e) {
|
||||
console.error(` ✗ ${name}`);
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Evaluate `text` against a fake sheet {ref: raw}. */
|
||||
function run(text, cells = {}, locale = "en") {
|
||||
const scope = createScope({
|
||||
readRaw: (_sheet, ref) => {
|
||||
const hit = Object.entries(cells).find(
|
||||
([k]) => k.toUpperCase() === String(ref).toUpperCase()
|
||||
);
|
||||
return hit ? hit[1] : null;
|
||||
},
|
||||
sheet: "F",
|
||||
locale,
|
||||
});
|
||||
return displayValue(evaluate(parseFormula(text), scope), locale);
|
||||
}
|
||||
|
||||
await test("detects formula text", () => {
|
||||
assert.ok(isFormulaText("=1+1"));
|
||||
assert.ok(isFormulaText(" @A1 "));
|
||||
assert.ok(!isFormulaText("hello"));
|
||||
assert.ok(!isFormulaText("="));
|
||||
});
|
||||
|
||||
await test("refs helpers", () => {
|
||||
assert.equal(colToIndex("A"), 1);
|
||||
assert.equal(colToIndex("Z"), 26);
|
||||
assert.equal(colToIndex("AA"), 27);
|
||||
assert.equal(indexToCol(28), "AB");
|
||||
assert.deepEqual(parseA1("$B$12"), { col: 2, row: 12 });
|
||||
assert.equal(parseA1("ZZZ"), null);
|
||||
});
|
||||
|
||||
await test("arithmetic and Excel precedence (-2^2 = 4)", () => {
|
||||
assert.equal(run("=1+2*3"), "7");
|
||||
assert.equal(run("=(1+2)*3"), "9");
|
||||
assert.equal(run("=2^3"), "8");
|
||||
assert.equal(run("=-2^2"), "4");
|
||||
assert.equal(run("=10/4"), "2.5");
|
||||
assert.equal(run("=50%"), "0.5");
|
||||
assert.equal(run("=5*10%"), "0.5");
|
||||
});
|
||||
|
||||
await test("comparisons and concatenation", () => {
|
||||
assert.equal(run("=1+1=2"), "TRUE");
|
||||
assert.equal(run('="a"&"b"'), "ab");
|
||||
assert.equal(run("=3>2"), "TRUE");
|
||||
assert.equal(run("=1<>1"), "FALSE");
|
||||
});
|
||||
|
||||
await test("cell refs and ranges", () => {
|
||||
const cells = { A1: 2, A2: 4, B1: "x" };
|
||||
assert.equal(run("=A1*3", cells), "6");
|
||||
assert.equal(run("=SOMME(A1:A2)", cells), "6");
|
||||
assert.equal(run("=SUM(A1:A2)", cells), "6");
|
||||
assert.equal(run("=MOYENNE(A1:A2)", cells), "3");
|
||||
assert.equal(run("=A1&B1", cells), "2x");
|
||||
});
|
||||
|
||||
await test("French decimal comma with ; separator", () => {
|
||||
assert.equal(run("=1,5+1", {}, "fr"), "2.5");
|
||||
assert.equal(run("=SOMME(1;2;3)"), "6");
|
||||
assert.equal(run("=SI(1>2;10;20)"), "20");
|
||||
});
|
||||
|
||||
await test("functions FR/EN", () => {
|
||||
assert.equal(run("=MAX(1;5;3)"), "5");
|
||||
assert.equal(run("=MIN(1;5;3)"), "1");
|
||||
assert.equal(run("=ABS(-4)"), "4");
|
||||
assert.equal(run("=ARRONDI(2.456;2)"), "2.46");
|
||||
assert.equal(run("=RACINE(9)"), "3");
|
||||
assert.equal(run("=PUISSANCE(2;10)"), "1024");
|
||||
assert.equal(run("=NB(1;\"x\";3)"), "2");
|
||||
assert.equal(run("=NBVAL(1;\"x\";3)"), "3");
|
||||
assert.equal(run("=NBCAR(\"héllo\")"), "5");
|
||||
assert.equal(run("=MAJUSCULES(\"abc\")"), "ABC");
|
||||
assert.equal(run("=GAUCHE(\"hello\";2)"), "he");
|
||||
assert.equal(run("=DROITE(\"hello\";2)"), "lo");
|
||||
assert.equal(run("=STXT(\"hello\";2;3)"), "ell");
|
||||
assert.equal(run("=ET(1;1)"), "TRUE");
|
||||
assert.equal(run("=OU(0;1)"), "TRUE");
|
||||
assert.equal(run("=NON(1)"), "FALSE");
|
||||
assert.equal(run('=SIERREUR(1/0;"raté")'), "raté");
|
||||
assert.equal(run("=MEDIANE(3;1;2)"), "2");
|
||||
assert.equal(run("=PRODUIT(2;3;4)"), "24");
|
||||
assert.equal(run("=MOD(7;3)"), "1");
|
||||
assert.equal(run("=ENT(2.9)"), "2");
|
||||
});
|
||||
|
||||
await test("nested formulas and chained refs", () => {
|
||||
const cells = { A1: "=B1*2", B1: 21 };
|
||||
assert.equal(run("=A1+1", cells), "43");
|
||||
});
|
||||
|
||||
await test("errors surface as localized codes", () => {
|
||||
assert.equal(run("=1/0"), "#DIV/0!");
|
||||
assert.equal(run("=1/0", {}, "fr"), "#DIV/0!");
|
||||
assert.equal(run("=NOSUCHFN(1)"), "#NAME?");
|
||||
assert.equal(run("=NOSUCHFN(1)", {}, "fr"), "#NOM?");
|
||||
assert.equal(run('="a"+1'), "#VALUE!");
|
||||
assert.equal(run('="a"+1', {}, "fr"), "#VALEUR!");
|
||||
assert.equal(run("=A99999999"), "#REF!");
|
||||
});
|
||||
|
||||
await test("cycles are detected", () => {
|
||||
const cells = { A1: "=B1", B1: "=A1" };
|
||||
assert.equal(run("=A1", cells), "#CIRC!");
|
||||
assert.equal(run("=A1", cells, "fr"), "#CYCLE!");
|
||||
});
|
||||
|
||||
await test("booleans localize", () => {
|
||||
assert.equal(run("=VRAI"), "TRUE");
|
||||
assert.equal(run("=VRAI", {}, "fr"), "VRAI");
|
||||
assert.equal(run("=TRUE"), "TRUE");
|
||||
assert.equal(run("=1=1", {}, "fr"), "VRAI");
|
||||
});
|
||||
|
||||
await test("HYPERLINK yields a link value", async () => {
|
||||
const scope = createScope({ readRaw: () => null, sheet: "F", locale: "en" });
|
||||
const v = evaluate(parseFormula('=LIEN("https://x.test";"ici")'), scope);
|
||||
assert.equal(v.t, "link");
|
||||
assert.equal(v.url, "https://x.test");
|
||||
assert.equal(v.label, "ici");
|
||||
assert.equal(displayValue(v), "ici");
|
||||
});
|
||||
|
||||
await test("garbage is rejected by the parser", () => {
|
||||
assert.throws(() => parseFormula("=1+"), Error);
|
||||
assert.throws(() => parseFormula("=SUM("), Error);
|
||||
assert.throws(() => parseFormula("=(1"), Error);
|
||||
});
|
||||
|
||||
await test("quoted and absolute refs", () => {
|
||||
const cells = { A1: 5 };
|
||||
assert.equal(run("=$A$1+1", cells), "6");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} passed`);
|
||||
process.exit(process.exitCode || 0);
|
||||
@@ -1,14 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the spreadsheet menus polish (ROADMAP #179).
|
||||
* ObsiGate — JSDOM tests for the spreadsheet menus (ROADMAP #179, #185).
|
||||
*
|
||||
* Covers frontend/js/xlsx/context-menu.js and frontend/js/xlsx/command-bar.js:
|
||||
* Covers frontend/js/xlsx/context-menu.js, command-bar.js and menus.js:
|
||||
* - every grid-menu entry renders its representative Lucide icon + safe label;
|
||||
* - the grid menu closes on Escape / outside click / focus loss / scroll /
|
||||
* resize, and offers arrow-key navigation with autofocus on open;
|
||||
* - trackDismissable() gives Structure / Mise en forme / Export the same
|
||||
* dismissal contract (toggle exempt, custom onClose);
|
||||
* - the ribbon carries the new icons (save, tab "+", export entries).
|
||||
* - the ribbon carries its icons (save, tab "+", gridlines);
|
||||
* - #185: the menu bar lists the ten Google Sheets menus, every entry is
|
||||
* actionable or honestly flagged unavailable, labels come from the catalog,
|
||||
* editing entries disappear on a read-only workbook, and the historical
|
||||
* Fichier ids (#xlsx-file-btn / -menu / #xlsx-download-btn / #xlsx-csv-btn)
|
||||
* survive the move from the sheet strip.
|
||||
*
|
||||
* Usage: node tests/frontend/xlsx-menus.test.mjs
|
||||
*/
|
||||
@@ -43,6 +48,18 @@ globalThis.localStorage = w.localStorage;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
||||
|
||||
// #185 — the menu bar renders localized labels, so the real FR catalog has to
|
||||
// be reachable: serve /static/locales/*.json from disk and nothing else.
|
||||
const { readFileSync } = await import("node:fs");
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/static/locales/")) {
|
||||
const file = path.join(REPO_ROOT, "frontend", "locales", "fr.json");
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(readFileSync(file, "utf8")) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}), text: async () => "" };
|
||||
};
|
||||
|
||||
// ── Module under test ───────────────────────────────────────────────────────
|
||||
const ctxMenuUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")
|
||||
@@ -50,9 +67,17 @@ const ctxMenuUrl = pathToFileURL(
|
||||
const cmdBarUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "command-bar.js")
|
||||
).href;
|
||||
const menusUrl = pathToFileURL(
|
||||
path.join(REPO_ROOT, "frontend", "js", "xlsx", "menus.js")
|
||||
).href;
|
||||
const { openContextMenu, closeContextMenu, isContextMenuOpen, trackDismissable } =
|
||||
await import(ctxMenuUrl);
|
||||
const { buildCommandBar } = await import(cmdBarUrl);
|
||||
const { buildMenuBar, SHEETS_MENUS } = await import(menusUrl);
|
||||
const { initI18n } = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "i18n.js")).href
|
||||
);
|
||||
await initI18n();
|
||||
|
||||
// ── Mini runner ─────────────────────────────────────────────────────────────
|
||||
let testCount = 0;
|
||||
@@ -163,13 +188,32 @@ await test("focus leaving the menu closes it, focus inside keeps it", () => {
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
await test("scroll and resize close the menu", () => {
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(!isContextMenuOpen(), "scroll closes");
|
||||
await test("double close never throws and a late click still runs its action", () => {
|
||||
// Regression: in a real browser, remove() fires focusout synchronously,
|
||||
// which re-entered closeContextMenu — the second remove() threw
|
||||
// NotFoundError and aborted the item's onClick before it ran.
|
||||
let runs = 0;
|
||||
const menu = openContextMenu(10, 10, [{ icon: "copy", label: "Copy", onClick: () => { runs++; } }]);
|
||||
const btn = menu.querySelector(".xlsx-context-item");
|
||||
closeContextMenu();
|
||||
closeContextMenu(); // idempotent: no-op, no throw
|
||||
assert.ok(!isContextMenuOpen());
|
||||
btn.dispatchEvent(new w.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(runs, 1, "click on the detached entry still runs its action");
|
||||
});
|
||||
|
||||
await test("scroll and resize close the menu", async () => {
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
window.dispatchEvent(new w.Event("resize"));
|
||||
assert.ok(!isContextMenuOpen(), "resize closes");
|
||||
openContextMenu(10, 10, itemsOf());
|
||||
// The smooth-scroll tail of the opening gesture is forgiven…
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(isContextMenuOpen(), "immediate scroll does not close");
|
||||
// …but a later scroll detaches the menu.
|
||||
await sleep(450);
|
||||
window.dispatchEvent(new w.Event("scroll"));
|
||||
assert.ok(!isContextMenuOpen(), "later scroll closes");
|
||||
});
|
||||
|
||||
// ── trackDismissable (Structure / Format / Export) ──────────────────────────
|
||||
@@ -226,26 +270,143 @@ await test("trackDismissable: Escape and focus loss dismiss", () => {
|
||||
toggle.remove();
|
||||
});
|
||||
|
||||
// ── Ribbon icons ────────────────────────────────────────────────────────────
|
||||
await test("the ribbon carries save, tab-plus and export icons", () => {
|
||||
const { actionsHtml, tabs } = buildCommandBar({
|
||||
// ── Ribbon icons (#185: the File menu moved to the menu bar) ────────────────
|
||||
await test("the ribbon carries save, tab-plus and gridline icons", () => {
|
||||
const { actionsHtml, tabs, fileHtml } = buildCommandBar({
|
||||
sheets: [{ name: "F1" }],
|
||||
isCsv: false,
|
||||
readOnly: false,
|
||||
editable: true,
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = tabs + actionsHtml;
|
||||
host.innerHTML = tabs + actionsHtml + fileHtml;
|
||||
const saveIcon = host.querySelector("#xlsx-save-btn i[data-lucide]");
|
||||
assert.ok(saveIcon, "save button has an icon");
|
||||
assert.equal(saveIcon.getAttribute("data-lucide"), "save");
|
||||
const plusIcon = host.querySelector("#xlsx-tab-add i[data-lucide]");
|
||||
assert.ok(plusIcon, "tab + button has an icon");
|
||||
assert.equal(plusIcon.getAttribute("data-lucide"), "plus");
|
||||
const exportIcons = [...host.querySelectorAll("#xlsx-export-menu i[data-lucide]")].map((i) =>
|
||||
i.getAttribute("data-lucide")
|
||||
const gridIcon = host.querySelector("#xs-grid i[data-lucide]");
|
||||
assert.ok(gridIcon, "gridline switch has an icon");
|
||||
assert.equal(gridIcon.getAttribute("data-lucide"), "grid-3x3");
|
||||
// #185 — the sheet strip keeps Save only; the file outputs live in the menu bar.
|
||||
assert.equal(host.querySelector("#xlsx-file-btn"), null, "no file dropdown left on the strip");
|
||||
});
|
||||
|
||||
// ── #185 — the menu bar itself ──────────────────────────────────────────────
|
||||
await test("buildMenuBar renders the ten Sheets menus with their labels", () => {
|
||||
assert.deepEqual(
|
||||
SHEETS_MENUS.map((m) => m.id),
|
||||
["file", "edit", "view", "insert", "format", "data", "tools", "gemini", "extensions", "help"],
|
||||
);
|
||||
assert.deepEqual(exportIcons, ["file-text", "code", "printer"]);
|
||||
const html = buildMenuBar({ editable: true, readOnly: false });
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
const menus = [...host.querySelectorAll(".xlsx-menubar > .xlsx-menuwrap")];
|
||||
assert.deepEqual(
|
||||
menus.map((w) => w.querySelector(".xlsx-menubtn").textContent.trim()),
|
||||
["Fichier", "Modifier", "Affichage", "Insertion", "Format", "Données", "Outils", "Gemini", "Extensions", "Aide"],
|
||||
);
|
||||
// Every button controls its own dropdown, hidden until opened.
|
||||
menus.forEach((w) => {
|
||||
const btn = w.querySelector(".xlsx-menubtn");
|
||||
const drop = host.querySelector(`#${btn.getAttribute("aria-controls")}`);
|
||||
assert.ok(drop, `${btn.dataset.xlsxMenu} has a dropdown`);
|
||||
assert.equal(drop.getAttribute("role"), "menu");
|
||||
assert.equal(drop.style.display, "none", "dropdown starts closed");
|
||||
assert.equal(btn.getAttribute("aria-expanded"), "false");
|
||||
});
|
||||
});
|
||||
|
||||
await test("the Fichier menu keeps the historical ids and export markers", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
assert.ok(host.querySelector("#xlsx-file-btn"), "file button id kept");
|
||||
assert.ok(host.querySelector("#xlsx-file-menu"), "file menu id kept");
|
||||
assert.ok(host.querySelector("#xlsx-download-btn"), "download entry kept its id");
|
||||
assert.ok(host.querySelector("#xlsx-csv-btn"), "csv entry kept its id");
|
||||
assert.deepEqual(
|
||||
[...host.querySelectorAll("#xlsx-file-menu [data-xlsx-export]")].map((n) => n.dataset.xlsxExport),
|
||||
["md", "html", "print"],
|
||||
);
|
||||
});
|
||||
|
||||
await test("every menu entry is either actionable or honestly flagged", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const rows = [...host.querySelectorAll(".xlsx-menu-item")];
|
||||
assert.ok(rows.length > 120, `all Sheets entries are listed (${rows.length})`);
|
||||
rows.forEach((row) => {
|
||||
const actionable = row.hasAttribute("data-xlsx-action") || row.hasAttribute("data-xlsx-export");
|
||||
const flagged = row.hasAttribute("data-xlsx-unavailable");
|
||||
assert.ok(actionable || flagged || row.hasAttribute("aria-haspopup"),
|
||||
`"${row.textContent.trim()}" is actionable, a section, or flagged unavailable`);
|
||||
});
|
||||
// A declared action always resolves against the viewer's registry (#185).
|
||||
const ACTIONS = [
|
||||
"share", "download", "export-csv", "rename", "trash", "history", "details", "settings", "print",
|
||||
"undo", "redo", "cut", "copy", "paste", "paste-values", "delete-rows", "delete-cols",
|
||||
"clear-contents", "select-all", "find-replace", "freeze", "freeze-row", "freeze-col",
|
||||
"unfreeze", "toggle-formula-bar", "toggle-grid", "toggle-headers", "dashboard", "zoom",
|
||||
"fullscreen", "insert-rows-above", "insert-rows-below", "insert-cols-left",
|
||||
"insert-cols-right", "insert-cells", "link", "comment", "function", "numfmt", "theme-menu",
|
||||
"bold", "italic", "underline", "strike", "fontsize", "align", "valign",
|
||||
"wrap-on", "wrap-off", "rotate", "clear-format", "banded", "sort-asc", "sort-desc",
|
||||
"sort-reset", "filter-column", "named-ranges", "cleanup-dupes", "cleanup-trim",
|
||||
"split-text", "autocomplete", "a11y", "gemini", "help", "whatsnew", "shortcuts", "report",
|
||||
];
|
||||
const declared = new Set(
|
||||
[...host.querySelectorAll("[data-xlsx-action]")].map((n) => n.dataset.xlsxAction),
|
||||
);
|
||||
ACTIONS.forEach((name) => assert.ok(declared.has(name), `menu declares "${name}"`));
|
||||
// "apply-theme" is deliberately absent: the theme list is built on demand.
|
||||
assert.equal(declared.has("apply-theme"), false, "themes are not hard-coded in the markup");
|
||||
});
|
||||
|
||||
await test("Format ▸ Thème is an empty section the viewer fills on demand (#185)", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const row = host.querySelector('[data-xlsx-menu="format"]')
|
||||
.closest(".xlsx-menuwrap")
|
||||
.querySelector('[data-xlsx-action="theme-menu"]');
|
||||
assert.ok(row, "the Thème section exists");
|
||||
const sub = row.nextElementSibling;
|
||||
assert.equal(sub.className, "xlsx-menu-sub", "it is a section, not a leaf");
|
||||
assert.equal(sub.hidden, true, "collapsed");
|
||||
assert.equal(sub.children.length, 0, "empty until the viewer fills it");
|
||||
assert.equal(row.getAttribute("aria-expanded"), "false");
|
||||
// The next entry is untouched by the empty section.
|
||||
assert.ok(host.querySelector('[data-xlsx-action="bold"]'), "Bold still listed");
|
||||
});
|
||||
|
||||
await test("editing entries disappear on a read-only workbook, view entries stay", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: false, readOnly: true });
|
||||
assert.equal(host.querySelector('[data-xlsx-action="bold"]'), null, "no Bold");
|
||||
assert.equal(host.querySelector('[data-xlsx-action="insert-cells"]'), null, "no Insert cells");
|
||||
assert.equal(host.querySelector('[data-xlsx-action="undo"]'), null, "no Undo");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="copy"]'), "Copy stays available");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="zoom"]'), "Zoom stays available");
|
||||
assert.ok(host.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay available");
|
||||
});
|
||||
|
||||
await test("menu labels come from the i18n catalog, never from raw text", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
const labels = [...host.querySelectorAll(".xlsx-menu-label")].map((n) => n.textContent);
|
||||
assert.ok(labels.length > 100);
|
||||
assert.equal(labels.some((l) => /xlsx\./.test(l)), false, "no unresolved key is rendered");
|
||||
assert.ok(labels.includes("Nouveau"), "Google Sheets' own wording is kept");
|
||||
assert.ok(labels.includes("Couleurs alternées"));
|
||||
assert.ok(labels.includes("Diviser le texte en colonnes"));
|
||||
});
|
||||
|
||||
await test("a sheet name or cell value cannot leak HTML into a menu label", () => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = buildMenuBar({ editable: true, readOnly: false });
|
||||
host.querySelectorAll(".xlsx-menu-label").forEach((n) => {
|
||||
assert.equal(n.querySelector("img, script"), null, "labels stay text nodes");
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} passed`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
# tests/test_api_main.py — Integration tests for main.py API endpoints
|
||||
# Uses the existing app_with_vault / client fixtures from conftest.py
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Health & Metadata
|
||||
@@ -200,6 +203,45 @@ class TestFileCRUD:
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
|
||||
def test_create_xlsx_empty_workbook(self, client, test_vault_dir):
|
||||
"""#186 - "Create a file" > Excel must produce a readable workbook.
|
||||
|
||||
An .xlsx is a ZIP archive, not a text file: writing the empty payload
|
||||
of the modal as text would leave a file the viewer cannot open.
|
||||
"""
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
|
||||
resp = client.post("/api/file/TestVault", json={
|
||||
"path": "nouveau.xlsx",
|
||||
"content": "",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
# The archive is a real workbook: one empty sheet, loadable by openpyxl.
|
||||
wb = openpyxl.load_workbook(Path(test_vault_dir) / "nouveau.xlsx")
|
||||
assert wb.sheetnames == ["Feuille1"]
|
||||
|
||||
# And the viewer serves it as a spreadsheet.
|
||||
resp2 = client.get("/api/file/TestVault", params={"path": "nouveau.xlsx"})
|
||||
assert resp2.status_code == 200
|
||||
body = resp2.json()
|
||||
assert body["is_xlsx"] is True
|
||||
assert [s["name"] for s in body["xlsx_sheets"]] == ["Feuille1"]
|
||||
|
||||
def test_create_xlsx_in_new_subdir(self, client):
|
||||
"""#186 - same in a subdirectory that does not exist yet."""
|
||||
pytest.importorskip("openpyxl")
|
||||
|
||||
resp = client.post("/api/file/TestVault", json={
|
||||
"path": "Tableaux/annee.xlsx",
|
||||
"content": "",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
resp2 = client.get("/api/file/TestVault", params={"path": "Tableaux/annee.xlsx"})
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["is_xlsx"] is True
|
||||
|
||||
def test_save_file(self, client):
|
||||
"""Save (update) an existing file"""
|
||||
resp = client.put("/api/file/TestVault/save?path=note1.md", json={
|
||||
@@ -803,6 +845,95 @@ class TestSecretRedactor:
|
||||
text = f"value {blob} end"
|
||||
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)
|
||||
|
||||
+63
-1
@@ -481,4 +481,66 @@ class TestSecureCookies:
|
||||
assert "secure" in resp.headers.get("set-cookie", "").lower()
|
||||
finally:
|
||||
if hasattr(https_client, "close"):
|
||||
https_client.close()
|
||||
https_client.close()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Dossier personnel par utilisateur (#194)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestUserHome:
|
||||
def _login(self, client, username, password):
|
||||
resp = client.post("/api/auth/login", json={
|
||||
"username": username, "password": password,
|
||||
})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def test_create_user_provisions_home(self, auth_client, monkeypatch):
|
||||
"""POST /admin/users → dossier + vault home-<user> + isolation + retrait."""
|
||||
from pathlib import Path
|
||||
|
||||
from backend import watcher_state
|
||||
|
||||
monkeypatch.setattr(watcher_state, "get_watcher", lambda: None)
|
||||
home = Path.cwd() / "Home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("OBSIGATE_HOME_ROOT", str(home))
|
||||
|
||||
admin = {"Authorization": f"Bearer {self._login(auth_client, 'admin', 'chab30')}"}
|
||||
|
||||
resp = auth_client.post("/api/auth/admin/users", headers=admin, json={
|
||||
"username": "alice", "password": "testpass", "role": "user",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert (home / "alice").is_dir()
|
||||
|
||||
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
|
||||
# #194 : « * » n'inclut pas les dossiers persos — l'admin ne voit pas
|
||||
# le home d'alice (il ne verrait sinon celui de chaque utilisateur).
|
||||
assert "home-alice" not in names
|
||||
assert "TestVault" in names
|
||||
# L'octroi atterrit bien dans le user
|
||||
assert "home-alice" in resp.json()["vaults"]
|
||||
|
||||
# Isolation : alice ne voit QUE son propre dossier
|
||||
alice = {"Authorization": f"Bearer {self._login(auth_client, 'alice', 'testpass')}"}
|
||||
alice_names = [v["name"] for v in auth_client.get("/api/vaults", headers=alice).json()]
|
||||
assert alice_names == ["home-alice"]
|
||||
|
||||
# Suppression du compte → vault refermé, dossier conservé
|
||||
resp = auth_client.delete("/api/auth/admin/users/alice", headers=admin)
|
||||
assert resp.status_code == 200
|
||||
assert (home / "alice").is_dir()
|
||||
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
|
||||
assert "home-alice" not in names
|
||||
|
||||
def test_create_user_without_home_root_is_unchanged(self, auth_client):
|
||||
"""Sans OBSIGATE_HOME_ROOT : aucun dossier, comportement historique."""
|
||||
from pathlib import Path
|
||||
|
||||
assert Path.cwd().exists()
|
||||
admin = {"Authorization": f"Bearer {self._login(auth_client, 'admin', 'chab30')}"}
|
||||
resp = auth_client.post("/api/auth/admin/users", headers=admin, json={
|
||||
"username": "nohome", "password": "testpass", "role": "user",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert not (Path.cwd() / "Home").exists()
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
# tests/test_file_chat.py — Chat par fichier (#169)
|
||||
"""Store + REST routes for the per-file chat history."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import file_chat as _store
|
||||
|
||||
|
||||
def _login(client, username="admin", password="chab30") -> dict:
|
||||
"""Log in against the auth-enabled ``admin_client`` fixture (#191)."""
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_chat_dir(tmp_path, monkeypatch):
|
||||
"""Keep every test (store AND routes) out of the real ``data/chats``/
|
||||
``data/chat_uploads``."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
monkeypatch.setattr(_store, "UPLOAD_DIR", tmp_path / "uploads")
|
||||
|
||||
|
||||
class TestStore:
|
||||
def test_get_messages_empty_when_missing(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
assert _store.get_messages("V", "a/b.md") == []
|
||||
|
||||
def test_add_then_get_roundtrip(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a/b.md", "bruno", "Bonjour !")
|
||||
assert msg["user"] == "bruno"
|
||||
assert msg["text"] == "Bonjour !"
|
||||
assert msg["id"]
|
||||
assert msg["ts"] <= time.time() + 1
|
||||
got = _store.get_messages("V", "a/b.md")
|
||||
assert len(got) == 1
|
||||
assert got[0]["id"] == msg["id"]
|
||||
|
||||
def test_conversation_is_per_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
_store.add_message("V", "a.md", "u", "pour a")
|
||||
_store.add_message("V", "b.md", "u", "pour b")
|
||||
assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["pour a"]
|
||||
assert [m["text"] for m in _store.get_messages("V", "b.md")] == ["pour b"]
|
||||
|
||||
def test_path_traversal_never_escapes_chat_dir(self, tmp_path, monkeypatch):
|
||||
"""The chat file name is hashed: '../../etc' cannot escape CHAT_DIR."""
|
||||
chat_dir = tmp_path / "chats"
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
|
||||
_store.add_message("V", "../../etc/passwd", "u", "x")
|
||||
files = list(chat_dir.iterdir())
|
||||
assert len(files) == 1
|
||||
assert files[0].parent == chat_dir
|
||||
assert "/" not in files[0].name and "\\" not in files[0].name
|
||||
|
||||
def test_retention_cap_drops_oldest(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
n = _store.MAX_MESSAGES + 25
|
||||
for i in range(n):
|
||||
_store.add_message("V", "a.md", "u", f"m{i}")
|
||||
got = _store.get_messages("V", "a.md")
|
||||
assert len(got) == _store.MAX_MESSAGES
|
||||
assert got[0]["text"] == f"m{25}" # first 25 dropped
|
||||
assert got[-1]["text"] == f"m{n - 1}"
|
||||
|
||||
def test_text_truncated_to_max(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
_store.add_message("V", "a.md", "u", "x" * (_store.MAX_TEXT + 500))
|
||||
got = _store.get_messages("V", "a.md")
|
||||
assert len(got[0]["text"]) == _store.MAX_TEXT
|
||||
|
||||
def test_corrupt_file_falls_back_to_empty(self, tmp_path, monkeypatch):
|
||||
chat_dir = tmp_path / "chats"
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
|
||||
chat_dir.mkdir(parents=True)
|
||||
target = _store._chat_file("V", "a.md")
|
||||
target.write_text("{not json", encoding="utf-8")
|
||||
assert _store.get_messages("V", "a.md") == []
|
||||
|
||||
|
||||
class TestRoutes:
|
||||
def test_history_requires_existing_vault(self, client, test_vault_dir):
|
||||
r = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"messages": [], "read": {}}
|
||||
|
||||
def test_post_and_get_roundtrip(self, client, test_vault_dir):
|
||||
r = client.post(
|
||||
"/api/file/TestVault/chat",
|
||||
json={"path": "note1.md", "text": "Vu hier ?"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["message"]["text"] == "Vu hier ?"
|
||||
r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
|
||||
assert [m["text"] for m in r2.json()["messages"]] == ["Vu hier ?"]
|
||||
|
||||
def test_post_requires_text(self, client, test_vault_dir):
|
||||
r = client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": " "})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_post_requires_path(self, client, test_vault_dir):
|
||||
r = client.post("/api/file/TestVault/chat", json={"path": "", "text": "hi"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_unknown_vault_404(self, client, test_vault_dir):
|
||||
r = client.get("/api/file/Nope/chat", params={"path": "x.md"})
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_path_traversal_rejected(self, client, test_vault_dir):
|
||||
r = client.get(
|
||||
"/api/file/TestVault/chat", params={"path": "../../etc/passwd"}
|
||||
)
|
||||
assert r.status_code in (403, 500)
|
||||
r2 = client.post(
|
||||
"/api/file/TestVault/chat",
|
||||
json={"path": "../../etc/passwd", "text": "x"},
|
||||
)
|
||||
assert r2.status_code in (403, 500)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["note1.md", "sub/deep/file.md"])
|
||||
def test_history_per_path_isolated(client, test_vault_dir, path):
|
||||
client.post("/api/file/TestVault/chat", json={"path": path, "text": "m"})
|
||||
other = "note1.md" if path != "note1.md" else "note2.md"
|
||||
r = client.get("/api/file/TestVault/chat", params={"path": other})
|
||||
assert r.json()["messages"] == []
|
||||
|
||||
|
||||
# --- #190 : chat général + pièces jointes -----------------------------------
|
||||
|
||||
class TestGlobalChat:
|
||||
def test_history_empty_by_default(self, client, test_vault_dir):
|
||||
r = client.get("/api/chat")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"messages": [], "read": {}}
|
||||
|
||||
def test_post_and_get_roundtrip(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={"text": "Bonjour à tous"})
|
||||
assert r.status_code == 200
|
||||
msg = r.json()["message"]
|
||||
assert msg["text"] == "Bonjour à tous"
|
||||
assert msg["user"] # authenticated user recorded
|
||||
got = client.get("/api/chat").json()["messages"]
|
||||
assert [m["id"] for m in got] == [msg["id"]]
|
||||
|
||||
def test_empty_text_rejected(self, client, test_vault_dir):
|
||||
assert client.post("/api/chat", json={"text": " "}).status_code == 400
|
||||
|
||||
def test_general_chat_isolated_from_file_chats(self, client, test_vault_dir):
|
||||
client.post("/api/chat", json={"text": "général"})
|
||||
client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": "fichier"})
|
||||
assert len(client.get("/api/chat").json()["messages"]) == 1
|
||||
assert len(client.get("/api/file/TestVault/chat", params={"path": "note1.md"}).json()["messages"]) == 1
|
||||
|
||||
def test_unknown_attachment_rejected(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={
|
||||
"text": "pièce jointe",
|
||||
"attachment": {"name": "nope.png", "url": "javascript:alert(1)", "kind": "image"},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_external_url_attachment_accepted(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={
|
||||
"text": "un lien",
|
||||
"attachment": {"name": "", "url": "https://example.com/x.png", "kind": "image"},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["url"] == "https://example.com/x.png"
|
||||
|
||||
|
||||
class TestAttachments:
|
||||
def test_upload_and_serve_roundtrip(self, client, test_vault_dir):
|
||||
files = {"file": ("shot.png", b"\x89PNG\r\n\x1a\nfake", "image/png")}
|
||||
r = client.post("/api/chat/upload", files=files)
|
||||
assert r.status_code == 200
|
||||
att = r.json()["attachment"]
|
||||
assert att["kind"] == "image"
|
||||
assert att["mime"] == "image/png"
|
||||
# served back with the right content type
|
||||
r2 = client.get(f"/api/chat/attachment/{att['name']}")
|
||||
assert r2.status_code == 200
|
||||
assert r2.headers["content-type"].startswith("image/png")
|
||||
|
||||
def test_video_kind_detection(self, client, test_vault_dir):
|
||||
files = {"file": ("clip.mp4", b"\x00\x00\x00\x18ftyp", "video/mp4")}
|
||||
att = client.post("/api/chat/upload", files=files).json()["attachment"]
|
||||
assert att["kind"] == "video"
|
||||
|
||||
def test_extension_not_allowed(self, client, test_vault_dir):
|
||||
files = {"file": ("evil.exe", b"MZ...", "application/octet-stream")}
|
||||
assert client.post("/api/chat/upload", files=files).status_code == 400
|
||||
|
||||
def test_empty_file_rejected(self, client, test_vault_dir):
|
||||
files = {"file": ("empty.png", b"", "image/png")}
|
||||
assert client.post("/api/chat/upload", files=files).status_code == 400
|
||||
|
||||
def test_attachment_traversal_rejected(self, client, test_vault_dir):
|
||||
# A traversal/unknown name must never produce a media response: the
|
||||
# route 404s, or the SPA catch-all answers HTML (200 + text/html).
|
||||
for bad in ("../secret.png", "sub/evil.png", "..%2Fsecret.png", "nope.png"):
|
||||
r = client.get(f"/api/chat/attachment/{bad}")
|
||||
ct = r.headers.get("content-type", "")
|
||||
assert not ct.startswith(("image/", "video/")), (bad, r.status_code, ct)
|
||||
assert r.status_code in (404, 400, 422, 200), bad
|
||||
|
||||
def test_message_with_uploaded_attachment(self, client, test_vault_dir):
|
||||
files = {"file": ("pic.webp", b"RIFF0000WEBP", "image/webp")}
|
||||
att = client.post("/api/chat/upload", files=files).json()["attachment"]
|
||||
r = client.post("/api/chat", json={"text": "voici", "attachment": att})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["kind"] == "image"
|
||||
|
||||
|
||||
# --- #191 : suppression, messages privés, link preview ----------------------
|
||||
|
||||
class TestDelete:
|
||||
def test_author_deletes_own_post(self, client, test_vault_dir):
|
||||
msg = client.post("/api/chat", json={"text": "a supprimer"}).json()["message"]
|
||||
r = client.delete(f"/api/chat/{msg['id']}")
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/chat").json()["messages"] == []
|
||||
|
||||
def test_delete_unknown_404(self, client, test_vault_dir):
|
||||
assert client.delete("/api/chat/nope").status_code == 404
|
||||
|
||||
def test_removed_message_cannot_be_deleted_twice(self, client, test_vault_dir):
|
||||
msg = client.post("/api/chat", json={"text": "x"}).json()["message"]
|
||||
client.delete(f"/api/chat/{msg['id']}")
|
||||
assert client.delete(f"/api/chat/{msg['id']}").status_code == 404
|
||||
|
||||
|
||||
class TestPrivateChat:
|
||||
"""DM flows need a real logged-in identity (auth enabled)."""
|
||||
|
||||
def test_dm_roundtrip(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/normaluser", json={"text": "salut"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["text"] == "salut"
|
||||
|
||||
def test_dm_unknown_user_404(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/ghost", json={"text": "hi"}, headers=h)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_dm_to_self_400(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/admin", json={"text": "hi"}, headers=h)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_dm_isolated_from_general(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat", json={"text": "public"}, headers=h)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "prive"}, headers=h)
|
||||
gen = admin_client.get("/api/chat", headers=h).json()["messages"]
|
||||
assert [m["text"] for m in gen] == ["public"]
|
||||
dm = admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]
|
||||
assert [m["text"] for m in dm] == ["prive"]
|
||||
|
||||
def test_dm_history(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "hystorique"}, headers=h)
|
||||
r = admin_client.get("/api/chat/dm/normaluser", headers=h)
|
||||
assert [m["text"] for m in r.json()["messages"]] == ["hystorique"]
|
||||
|
||||
def test_dm_pair_is_shared_not_duplicated(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "a"}, headers=h)
|
||||
assert _store.dm_path("admin", "normaluser") == _store.dm_path("normaluser", "admin")
|
||||
assert len(admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]) == 1
|
||||
|
||||
def test_dm_delete(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
msg = admin_client.post(
|
||||
"/api/chat/dm/normaluser", json={"text": "adieu"}, headers=h
|
||||
).json()["message"]
|
||||
r = admin_client.delete(f"/api/chat/dm/normaluser/{msg['id']}", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"] == []
|
||||
|
||||
def test_requires_auth(self, admin_client, test_vault_dir):
|
||||
assert admin_client.get("/api/chat/dm/normaluser").status_code == 401
|
||||
assert admin_client.get("/api/chat/users").status_code == 401
|
||||
|
||||
def test_user_list_for_picker(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.get("/api/chat/users", headers=h)
|
||||
assert r.status_code == 200
|
||||
names = [u["username"] for u in r.json()]
|
||||
assert "normaluser" in names
|
||||
assert "admin" not in names # oneself is not a DM target
|
||||
assert all("password" not in u and "hash" not in str(u) for u in r.json())
|
||||
|
||||
|
||||
class TestLinkPreview:
|
||||
def test_no_url_no_preview(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(_store, "build_preview", lambda text: None)
|
||||
msg = client.post("/api/chat", json={"text": "pas d url"}).json()["message"]
|
||||
# the schema always serialises the key (null) — it must stay falsy
|
||||
assert not msg.get("preview")
|
||||
|
||||
def test_preview_attached_when_fetched(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
_store, "build_preview",
|
||||
lambda text: {"url": "https://x.test", "title": "Titre", "description": "", "image": "", "site": "x.test"},
|
||||
)
|
||||
msg = client.post("/api/chat", json={"text": "voir https://x.test"}).json()["message"]
|
||||
assert msg["preview"]["title"] == "Titre"
|
||||
|
||||
def test_preview_persisted_in_history(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
_store, "build_preview",
|
||||
lambda text: {"url": "https://y.test", "title": "Y", "description": "", "image": "", "site": "y.test"},
|
||||
)
|
||||
client.post("/api/chat", json={"text": "https://y.test"})
|
||||
got = client.get("/api/chat").json()["messages"]
|
||||
assert got[-1]["preview"]["site"] == "y.test"
|
||||
|
||||
def test_build_preview_rejects_non_http(self):
|
||||
assert _store.build_preview("javascript:alert(1)") is None
|
||||
assert _store.build_preview("aucune URL") is None
|
||||
|
||||
def test_build_preview_blocks_private_hosts(self, monkeypatch):
|
||||
# SSRF guard rejects loopback before any network round-trip
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
assert _store.build_preview("http://127.0.0.1/x") is None
|
||||
|
||||
def test_build_preview_happy_path_parses_og(self, monkeypatch):
|
||||
"""The fetch itself (a bad kwarg here used to fail silently)."""
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
# the SSRF guard resolves DNS for real — out of scope for this test
|
||||
from backend.tools import web as _web
|
||||
|
||||
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = (
|
||||
"<html><head><title>Fallback</title>"
|
||||
'<meta property="og:title" content="Titre OG">'
|
||||
'<meta property="og:description" content="Desc">'
|
||||
'<meta property="og:site_name" content="Site">'
|
||||
"</head></html>"
|
||||
)
|
||||
|
||||
called = {}
|
||||
|
||||
def _fake_get(url, **kw):
|
||||
called["url"] = url
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(_store.httpx, "get", _fake_get)
|
||||
preview = _store.build_preview("voir https://example.test/page")
|
||||
assert called["url"] == "https://example.test/page"
|
||||
assert preview == {
|
||||
"url": "https://example.test/page",
|
||||
"title": "Titre OG",
|
||||
"description": "Desc",
|
||||
"image": "",
|
||||
"site": "Site",
|
||||
}
|
||||
|
||||
def test_preview_image_is_proxied_same_origin(self, monkeypatch):
|
||||
"""BUG-109: a remote og:image is blocked by ``img-src 'self'``.
|
||||
|
||||
A *relative* og:image must be resolved against the page and downloaded
|
||||
into ``chat_uploads`` so the browser can load it same-origin.
|
||||
"""
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
from backend.tools import web as _web
|
||||
|
||||
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
|
||||
|
||||
class _Page:
|
||||
status_code = 200
|
||||
text = (
|
||||
'<meta property="og:title" content="Titre">'
|
||||
'<meta property="og:image" content="/img/pic.png">'
|
||||
)
|
||||
|
||||
class _Img:
|
||||
status_code = 200
|
||||
content = b"\x89PNG-fake-bytes"
|
||||
headers = {"content-type": "image/png"}
|
||||
|
||||
def _fake_get(url, **kw):
|
||||
return _Img() if url.endswith(".png") else _Page()
|
||||
|
||||
monkeypatch.setattr(_store.httpx, "get", _fake_get)
|
||||
preview = _store.build_preview("voir https://site.test/a")
|
||||
assert preview["title"] == "Titre"
|
||||
assert preview["image"].startswith("/api/chat/attachment/")
|
||||
assert preview["image"].endswith(".png")
|
||||
stored = _store.UPLOAD_DIR / preview["image"].rsplit("/", 1)[1]
|
||||
assert stored.exists()
|
||||
assert stored.read_bytes() == b"\x89PNG-fake-bytes"
|
||||
|
||||
def test_preview_image_failure_keeps_the_card(self, monkeypatch):
|
||||
"""A dead thumbnail degrades to an empty image, never a lost card."""
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
from backend.tools import web as _web
|
||||
|
||||
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
|
||||
|
||||
class _Page:
|
||||
status_code = 200
|
||||
text = (
|
||||
'<meta property="og:title" content="Titre">'
|
||||
'<meta property="og:image" content="https://cdn.test/broken.jpg">'
|
||||
)
|
||||
|
||||
class _Dead:
|
||||
status_code = 404
|
||||
content = b""
|
||||
headers = {}
|
||||
|
||||
def _fake_get(url, **kw):
|
||||
return _Dead() if url.endswith(".jpg") else _Page()
|
||||
|
||||
monkeypatch.setattr(_store.httpx, "get", _fake_get)
|
||||
preview = _store.build_preview("voir https://site.test/b")
|
||||
assert preview["title"] == "Titre"
|
||||
assert preview["image"] == ""
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
class TestReadReceipts:
|
||||
def test_mark_read_roundtrip(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
assert _store.get_read("V", "a.md") == {}
|
||||
read = _store.mark_read("V", "a.md", "bruno")
|
||||
assert read["bruno"] > 0
|
||||
assert _store.get_read("V", "a.md") == read
|
||||
|
||||
def test_mark_read_preserves_messages(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
_store.add_message("V", "a.md", "alice", "salut")
|
||||
_store.mark_read("V", "a.md", "bruno")
|
||||
assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["salut"]
|
||||
assert "bruno" in _store.get_read("V", "a.md")
|
||||
|
||||
def test_post_read_records_for_the_caller(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
|
||||
assert r.status_code == 200, r.text
|
||||
read = r.json()["read"]
|
||||
assert read["anonymous"] > 0 # auth disabled in tests → fake user
|
||||
# the history endpoint exposes the same map (#192)
|
||||
assert client.get("/api/chat").json()["read"] == read
|
||||
|
||||
def test_post_read_refuses_a_dm_we_are_not_in(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat/read", json={"vault": "__dm__", "path": "alice|bob"})
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_post_read_requires_vault_and_path(self, client, test_vault_dir):
|
||||
assert client.post("/api/chat/read", json={}).status_code == 400
|
||||
|
||||
def test_post_read_follows_vault_acl(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat/read", json={"vault": "TestVault", "path": "note1.md"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "anonymous" in r.json()["read"]
|
||||
|
||||
def test_post_read_broadcasts_chat_read(self, client, test_vault_dir, monkeypatch):
|
||||
from backend.routers import file_chat as _router
|
||||
|
||||
events = []
|
||||
|
||||
class _Spy:
|
||||
async def broadcast(self, event, data):
|
||||
events.append((event, data))
|
||||
|
||||
monkeypatch.setattr(_router, "sse_manager", _Spy())
|
||||
client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
|
||||
assert [e for e, _ in events] == ["chat_read"]
|
||||
assert events[0][1]["user"] == "anonymous"
|
||||
assert events[0][1]["vault"] == "__global__"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# #193 — un post est rendu comme un document markdown
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMarkdownRendering:
|
||||
"""Le post porte son HTML (mistune + sanitizer), comme une prévisualisation
|
||||
de document : le client n'a plus à réimplémenter le markdown."""
|
||||
|
||||
def test_message_carries_rendered_html(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "**gras** et `code`")
|
||||
assert "<strong>gras</strong>" in msg["html"]
|
||||
assert "<code>code</code>" in msg["html"]
|
||||
got = _store.get_messages("V", "a.md")
|
||||
assert "<strong>gras</strong>" in got[0]["html"]
|
||||
|
||||
def test_html_is_not_persisted(self, tmp_path, monkeypatch):
|
||||
"""`html` est recalculé à chaque lecture, jamais écrit dans le store."""
|
||||
chat_dir = tmp_path / "chats"
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
|
||||
_store.add_message("V", "a.md", "u", "**gras**")
|
||||
raw = json.loads(next(chat_dir.glob("*.json")).read_text(encoding="utf-8"))
|
||||
assert "html" not in raw["messages"][0]
|
||||
|
||||
def test_fenced_code_block_keeps_its_language(self, tmp_path, monkeypatch):
|
||||
"""La classe `language-xxx` est ce sur quoi hljs se branche (comme dans
|
||||
le viewer)."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "```python\nprint(1)\n```")
|
||||
assert 'class="language-python"' in msg["html"]
|
||||
|
||||
def test_tables_and_lists_are_rendered(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "- un\n- deux")
|
||||
assert "<ul>" in msg["html"] and "<li>un</li>" in msg["html"]
|
||||
|
||||
def test_raw_html_is_sanitized(self, tmp_path, monkeypatch):
|
||||
"""Pas de XSS par le chat : le sanitizer des documents s'applique."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "<script>alert(1)</script>ok")
|
||||
assert "<script" not in msg["html"]
|
||||
|
||||
def test_routes_return_html(self, client, test_vault_dir):
|
||||
r = client.post(
|
||||
"/api/file/TestVault/chat",
|
||||
json={"path": "note1.md", "text": "## Titre\n\n- item"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
html = r.json()["message"]["html"]
|
||||
assert "<h2" in html and "<li>item</li>" in html
|
||||
r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
|
||||
assert "<h2" in r2.json()["messages"][0]["html"]
|
||||
|
||||
def test_sse_broadcast_carries_html(self, client, test_vault_dir, monkeypatch):
|
||||
"""L'écho SSE part avec le html : les autres clients n'attendent pas un
|
||||
refresh pour voir le rendu."""
|
||||
from backend.routers import file_chat as _router
|
||||
|
||||
events = []
|
||||
|
||||
class _Spy:
|
||||
async def broadcast(self, event, data):
|
||||
events.append((event, data))
|
||||
|
||||
monkeypatch.setattr(_router, "sse_manager", _Spy())
|
||||
client.post("/api/chat", json={"text": "**fort**"})
|
||||
assert events and events[0][0] == "chat_message"
|
||||
assert "<strong>fort</strong>" in events[0][1]["message"]["html"]
|
||||
@@ -0,0 +1,266 @@
|
||||
# tests/test_user_home.py — #194 dossier personnel par utilisateur
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home_env(tmp_path, monkeypatch):
|
||||
"""Racine Home + registre vaults + users.json isolés, globals sauvegardés."""
|
||||
root = tmp_path / "Home"
|
||||
root.mkdir()
|
||||
monkeypatch.setenv("OBSIGATE_HOME_ROOT", str(root))
|
||||
|
||||
from backend import indexer
|
||||
|
||||
monkeypatch.setattr(indexer, "DYNAMIC_VAULTS_FILE", tmp_path / "vaults.json")
|
||||
|
||||
from backend.auth import user_store
|
||||
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
|
||||
|
||||
from backend import watcher_state
|
||||
|
||||
monkeypatch.setattr(watcher_state, "get_watcher", lambda: None)
|
||||
|
||||
saved_index = dict(indexer.index)
|
||||
saved_config = dict(indexer.vault_config)
|
||||
yield root
|
||||
indexer.index.clear()
|
||||
indexer.index.update(saved_index)
|
||||
indexer.vault_config.clear()
|
||||
indexer.vault_config.update(saved_config)
|
||||
|
||||
|
||||
def _create_user(name: str) -> dict:
|
||||
from backend.auth.user_store import create_user
|
||||
|
||||
return create_user(name, "Motdepasse1!", role="user")
|
||||
|
||||
|
||||
def _registry() -> dict:
|
||||
"""Contenu du registre dynamique (chemin monkeypatché par home_env)."""
|
||||
from backend import indexer
|
||||
|
||||
return json.loads(indexer.DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestEnsureUserHome:
|
||||
def test_creates_folder_vault_and_grant(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("alice")
|
||||
name = _run(ensure_user_home("alice"))
|
||||
|
||||
assert name == "home-alice"
|
||||
assert (home_env / "alice").is_dir()
|
||||
assert "home-alice" in index
|
||||
assert "home-alice" in get_user("alice")["vaults"]
|
||||
# Registre persistant rempli → survit au prochain rebuild/redémarrage
|
||||
assert "home-alice" in _registry()["vaults"]
|
||||
|
||||
def test_is_idempotent(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("bob")
|
||||
_run(ensure_user_home("bob"))
|
||||
index_snapshot = dict(index)
|
||||
_run(ensure_user_home("bob"))
|
||||
|
||||
assert get_user("bob")["vaults"].count("home-bob") == 1
|
||||
assert index == index_snapshot
|
||||
|
||||
def test_invalid_username_never_touches_disk(self, home_env):
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
assert _run(ensure_user_home("../evil")) is None
|
||||
assert _run(ensure_user_home("a/b")) is None
|
||||
assert not (home_env.parent / "evil").exists()
|
||||
assert not any(name.startswith("home-") for name in index)
|
||||
|
||||
def test_disabled_without_env(self, home_env, monkeypatch):
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_HOME_ROOT")
|
||||
assert _run(ensure_user_home("alice")) is None
|
||||
assert not (home_env / "alice").exists()
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_registry_reloaded_by_load_vault_config(self, home_env):
|
||||
"""data/vaults.json doit être relu par load_vault_config (rebuild/restart)."""
|
||||
from backend import indexer
|
||||
|
||||
_create_user("carol")
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_run(ensure_user_home("carol"))
|
||||
|
||||
# Simule un redémarrage : vault_config vidé, rechargé depuis la source
|
||||
indexer.vault_config.clear()
|
||||
config = indexer.load_vault_config()
|
||||
assert config["home-carol"]["path"] == str(home_env / "carol")
|
||||
|
||||
def test_unpersist_drops_registry_entry(self, home_env):
|
||||
from backend import indexer
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("dan")
|
||||
_run(ensure_user_home("dan"))
|
||||
indexer.unpersist_vault("home-dan")
|
||||
assert "home-dan" not in _registry()["vaults"]
|
||||
|
||||
|
||||
class TestRelease:
|
||||
def test_release_closes_vault_but_keeps_folder(self, home_env):
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home, release_user_home
|
||||
|
||||
_create_user("erin")
|
||||
_run(ensure_user_home("erin"))
|
||||
assert "home-erin" in index
|
||||
|
||||
_run(release_user_home("erin"))
|
||||
assert "home-erin" not in index
|
||||
assert (home_env / "erin").is_dir() # décision : dossier conservé
|
||||
assert "home-erin" not in _registry()["vaults"]
|
||||
|
||||
|
||||
class TestOrphanSweep:
|
||||
def test_orphan_home_released_when_user_deleted_outside_route(self, home_env):
|
||||
"""create_admin.py delete ne passe pas par la route → réparé au boot."""
|
||||
from backend.auth.user_store import delete_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_all_user_homes, ensure_user_home
|
||||
|
||||
_create_user("hank")
|
||||
_run(ensure_user_home("hank"))
|
||||
delete_user("hank")
|
||||
assert "home-hank" in index
|
||||
|
||||
_run(ensure_all_user_homes())
|
||||
assert "home-hank" not in index
|
||||
assert (home_env / "hank").is_dir()
|
||||
|
||||
def test_vault_named_home_outside_root_is_left_alone(self, home_env):
|
||||
"""Un vault admin nommé « home-… » ailleurs dans le FS n'est pas touché."""
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
|
||||
other = home_env.parent / "Elsewhere"
|
||||
other.mkdir()
|
||||
_run(add_vault("home-keep", str(other)))
|
||||
|
||||
_run(ensure_all_user_homes())
|
||||
assert "home-keep" in index
|
||||
|
||||
|
||||
def add_vault(name: str, path: str):
|
||||
from backend.indexer import add_vault_to_index
|
||||
|
||||
return add_vault_to_index(name, path)
|
||||
|
||||
|
||||
class TestIsolation:
|
||||
def test_user_cannot_access_another_home_vault(self, home_env):
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("alice")
|
||||
_create_user("bob")
|
||||
_run(ensure_user_home("alice"))
|
||||
_run(ensure_user_home("bob"))
|
||||
|
||||
assert check_vault_access("home-alice", get_user("alice")) is True
|
||||
assert check_vault_access("home-alice", get_user("bob")) is False
|
||||
assert check_vault_access("home-bob", get_user("alice")) is False
|
||||
|
||||
def test_wildcard_admin_does_not_cover_homes(self, home_env):
|
||||
"""#194 : « * » n'inclut JAMAIS un dossier perso, même pour l'admin."""
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.auth.user_store import create_user, get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
create_user("root", "Motdepasse1!", role="admin", vaults=["*"])
|
||||
_create_user("alice")
|
||||
_run(ensure_user_home("alice"))
|
||||
|
||||
admin = get_user("root")
|
||||
assert check_vault_access("home-alice", admin) is False # pas le sien
|
||||
assert check_vault_access("TestVault", admin) is True # « * » intact
|
||||
|
||||
# L'octroi explicite suffit (y compris pour l'admin, cf. _grant)
|
||||
_run(ensure_user_home("root"))
|
||||
assert check_vault_access("home-root", get_user("root")) is True
|
||||
|
||||
|
||||
class TestSearchFiltering:
|
||||
def test_search_service_filters_before_pagination(self, monkeypatch):
|
||||
"""#194 : /api/search ne doit pas renvoyer les notes d'un autre user."""
|
||||
import backend.search as search_mod
|
||||
from backend.services import search as search_service
|
||||
|
||||
hits = [
|
||||
{"vault": "home-bruno", "path": "secret.md"},
|
||||
{"vault": "TestVault", "path": "note.md"},
|
||||
{"vault": "home-bruno", "path": "autre.md"},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
search_mod, "search",
|
||||
lambda q, vault_filter="all", tag_filter=None: list(hits),
|
||||
)
|
||||
|
||||
page = search_service.search_vaults("x", limit=50, is_allowed=lambda v: v == "TestVault")
|
||||
assert page["total"] == 1, "total = résultats filtrés"
|
||||
assert [r["vault"] for r in page["results"]] == ["TestVault"]
|
||||
|
||||
# Sans filtre (tool layer, qui filtre lui-même) : inchangé.
|
||||
assert search_service.search_vaults("x", limit=50)["total"] == 3
|
||||
|
||||
|
||||
class TestEnsureAll:
|
||||
def test_repairs_every_existing_user(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
|
||||
_create_user("fab")
|
||||
_create_user("gab")
|
||||
# Dossier supprimé à la main + user créé hors API (pas de vault)
|
||||
(home_env / "fab").mkdir()
|
||||
(home_env / "fab").rmdir()
|
||||
|
||||
created = _run(ensure_all_user_homes())
|
||||
|
||||
assert created == 2
|
||||
assert (home_env / "fab").is_dir()
|
||||
assert (home_env / "gab").is_dir()
|
||||
assert "home-fab" in get_user("fab")["vaults"]
|
||||
assert "home-gab" in get_user("gab")["vaults"]
|
||||
|
||||
|
||||
def test_home_root_env_contract():
|
||||
"""OBSIGATE_HOME_ROOT absent → fonctionnalité inactive (dev/tests/desktop)."""
|
||||
from backend.user_home import home_root
|
||||
|
||||
saved = os.environ.pop("OBSIGATE_HOME_ROOT", None)
|
||||
try:
|
||||
assert home_root() is None
|
||||
finally:
|
||||
if saved is not None:
|
||||
os.environ["OBSIGATE_HOME_ROOT"] = saved
|
||||
@@ -129,6 +129,36 @@ class TestRowColOps:
|
||||
assert wb["Data"]["B1"].value == "Valeur"
|
||||
wb.close()
|
||||
|
||||
def test_insert_past_the_last_row_and_column_takes_effect(self, client, book):
|
||||
# openpyxl's sparse model silently ignores inserts past the used
|
||||
# extent: the service materialises the missing rows/columns first so
|
||||
# « below the last row » / « right of the last column » behave.
|
||||
wb = openpyxl.load_workbook(book)
|
||||
last_row, last_col = wb["Data"].max_row, wb["Data"].max_column
|
||||
wb.close()
|
||||
assert _put(client, actions=[
|
||||
{"op": "col_insert", "sheet": "Data", "at": last_col + 1},
|
||||
]).status_code == 200
|
||||
assert _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": last_row + 1},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
ws = wb["Data"]
|
||||
assert ws.max_column == last_col + 1, "one blank column appended"
|
||||
assert ws.max_row == last_row + 1, "one blank row appended"
|
||||
wb.close()
|
||||
|
||||
def test_insert_far_past_the_extent_extends_the_sheet(self, client, book):
|
||||
wb = openpyxl.load_workbook(book)
|
||||
last_row = wb["Data"].max_row
|
||||
wb.close()
|
||||
assert _put(client, actions=[
|
||||
{"op": "row_insert", "sheet": "Data", "at": last_row + 3},
|
||||
]).status_code == 200
|
||||
wb = openpyxl.load_workbook(book)
|
||||
assert wb["Data"].max_row >= last_row + 3
|
||||
wb.close()
|
||||
|
||||
|
||||
class TestGuards:
|
||||
def test_unknown_sheet_is_400(self, client, book):
|
||||
|
||||
@@ -250,6 +250,115 @@ class TestStyleWriting:
|
||||
assert backups, "the rewrite is backed up first"
|
||||
|
||||
|
||||
class TestExtendedStyles:
|
||||
"""Nouvelles clés de style (barre façon Sheets) : taille, barré,
|
||||
alignements verticaux, retour ligne, rotation, bordures — et commentaires."""
|
||||
|
||||
def _style(self, client, path, ops, **extra):
|
||||
return client.put(
|
||||
f"/api/file/{VAULT}/xlsx/style",
|
||||
params={"path": path},
|
||||
json={"ops": ops, **extra},
|
||||
)
|
||||
|
||||
def test_font_size_strike_valign_wrap_rotation(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{
|
||||
"op": "cell", "sheet": "Data", "range": "A1",
|
||||
"style": {
|
||||
"font_size": 14, "strike": True, "valign": "middle",
|
||||
"wrap": True, "rotation": -45,
|
||||
},
|
||||
},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
cell = wb["Data"]["A1"]
|
||||
assert cell.font.size == 14
|
||||
assert cell.font.strike is True
|
||||
assert cell.alignment.vertical == "center"
|
||||
assert cell.alignment.wrap_text is True
|
||||
assert cell.alignment.textRotation == 135
|
||||
wb.close()
|
||||
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
sheet = resp.json()["xlsx_sheets"][0]
|
||||
assert "font-size:14pt" in sheet["styles"]["A1"]
|
||||
assert "line-through" in sheet["styles"]["A1"]
|
||||
assert "vertical-align:middle" in sheet["styles"]["A1"]
|
||||
assert "white-space:normal" in sheet["styles"]["A1"]
|
||||
# La diagonale (-45° → 135 OOXML) est relue en `rotate()` CSS.
|
||||
assert "transform:rotate(45deg)" in sheet["styles"]["A1"]
|
||||
|
||||
def test_borders_all_outer_none(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1:B2",
|
||||
"style": {"border": "all", "border_color": "#112233"}},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
side = wb["Data"]["A1"].border.left
|
||||
assert side.style == "thin" and side.color.rgb == "FF112233"
|
||||
wb.close()
|
||||
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1:B2", "style": {"border": "none"}},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
cleared = wb["Data"]["A1"].border.left
|
||||
assert cleared is None or cleared.style is None
|
||||
wb.close()
|
||||
|
||||
def test_comment_set_clear_and_readback(self, client, plain_book):
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": "À vérifier"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
assert wb["Data"]["B2"].comment.text == "À vérifier"
|
||||
wb.close()
|
||||
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
assert resp.json()["xlsx_sheets"][0]["comments"]["B2"] == "À vérifier"
|
||||
|
||||
resp = self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_clear", "sheet": "Data", "ref": "B2"},
|
||||
])
|
||||
assert resp.status_code == 200
|
||||
wb = openpyxl.load_workbook(plain_book)
|
||||
assert wb["Data"]["B2"].comment is None
|
||||
wb.close()
|
||||
|
||||
def test_formats_map_lists_number_formats(self, client, plain_book):
|
||||
self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "B2", "style": {"number_format": "0.00"}},
|
||||
])
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
|
||||
assert resp.json()["xlsx_sheets"][0]["formats"]["B2"] == "0.00"
|
||||
|
||||
def test_extended_invalid_inputs_are_refused(self, client, plain_book):
|
||||
style = lambda s: self._style(client, "plain.xlsx", [
|
||||
{"op": "cell", "sheet": "Data", "range": "A1", "style": s},
|
||||
]).status_code
|
||||
assert style({"font_size": 200}) == 400
|
||||
assert style({"font_size": "grand"}) == 400
|
||||
assert style({"valign": "diagonal"}) == 400
|
||||
# 180° (texte vertical retourné, relu tel quel) est accepté — seules
|
||||
# les valeurs vraiment hors bornes sont refusées.
|
||||
assert style({"rotation": 180}) == 200
|
||||
assert style({"rotation": 181}) == 400
|
||||
assert style({"rotation": -91}) == 400
|
||||
assert style({"border": "dotted-circle"}) == 400
|
||||
assert style({"border_style": "wavy"}) == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": ""},
|
||||
]).status_code == 400
|
||||
assert self._style(client, "plain.xlsx", [
|
||||
{"op": "comment_set", "sheet": "Data", "ref": "B2:C3", "text": "x"},
|
||||
]).status_code == 400
|
||||
|
||||
|
||||
class TestEndpointWiring:
|
||||
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
|
||||
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
||||
|
||||
Reference in New Issue
Block a user