Compare commits

...
5 Commits
Author SHA1 Message Date
bruno ae06436f91 feat: chat — suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre #190
CI / lint (push) Successful in 2m48s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 1m30s
CI / e2e (push) Successful in 17m5s
- **A — Suivi du document** : l'en-tête du panneau chat affiche le document
  ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()`
  re-cible le panneau ouvert à chaque changement de document.
- **B — Chat général** : onglet « Chat » (dernier) dans la sidebar,
  conversation centrale stockée dans le store #169 via les sentinelles
  `__global__/general` (`GET/POST /api/chat`) ; pastille de messages non
  lus sur l'onglet ; pièces jointes image/vidéo (`POST /api/chat/upload` :
  allow-list d'extensions, 25 MB, nom UUID ; `GET /api/chat/attachment/{name}`
  résolu contre l'allow-list) ; URL cliquables au rendu ; date/heure d'envoi.
- **C — Ordre des onglets** : la barre de filtre passe sous les onglets et
  sert de recherche dans le chat (texte + auteur) quand l'onglet Chat est
  actif (`switchSidebarTab` + `routeFilter`).
- Transport : broadcast SSE `chat_message` réutilisé (`vault __global__`
  route vers la sidebar, sinon le panneau fichier) — pas de 2ᵉ WebSocket.
- i18n FR/EN (6 clés), CSS bloc #190, `initSidebarChat()` dans l'orchestrateur.
- Tests : pytest 27 chat (12 nouveaux) ; JSDOM `filechat.test.mjs` 11 (5
  nouveaux) ; suite 1655 passed, ruff/mypy 0, validate-imports 42 modules.
- Docs : CHANGELOG [Unreleased], ROADMAP #190 → index, fiche
  `file-chat-169.md` §#190, guide « Chat général », journal des interventions.
2026-10-08 20:40:54 -04:00
bruno d0e10d4cc6 docs: journal des interventions AI #189/#169 + versions d'index resynchronisées
CI / lint (push) Successful in 2m47s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m24s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m37s
- `docs/ISSUES_TODOLIST.md` : deux lignes ajoutées au journal (features
  #189 mobile tableur et #169 chat par fichier, avec vérifications et
  résultats CI), exigées par le DoD pour tout passage d'IA modifiant du code.
- `docs/ROADMAP.md` : colonne version de l'index corrigée — #189 → 2.56.0,
  #169 → 2.57.0 (le hook de bump ne resynchronise pas ces rangées).
2026-10-08 18:42:31 -04:00
bruno c94f065f80 feat: chat intégré par fichier — panneau latéral, historique, temps réel SSE #169
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m11s
- Store backend `backend/file_chat.py` : messages JSON par (vault, path)
  sous `data/chats/` (nom hashé SHA-256 → traversal impossible), plafond
  500 messages, texte tronqué à 4000 caractères, écriture atomique.
- Routes `GET/POST /api/file/{vault}/chat` : auth + accès vault +
  `resolve_safe_path`, schémas Pydantic (`response_model`), broadcast SSE
  `chat_message` sur le transport existant (#62) — pas de second WebSocket.
- Panneau latéral `frontend/js/filechat.js` : bouton 💬 dans la toolbar
  fichier, historique chronologique, envoi optimiste + dédoublonnage par id,
  toast « Nouveau message » si le panneau est fermé/autre fichier.
- Relais SSE dans `sync.js` (import dynamique), CSS bloc #169 (plein écran
  ≤ 768 px, input 16 px anti-zoom), i18n FR/EN (10 clés `chat.*`).
- Tests : `tests/test_file_chat.py` (15) + `tests/frontend/filechat.test.mjs`
  (6, ajouté au pipeline CI), regex toolbar-order mise à jour.
- Docs : CHANGELOG [Unreleased], ROADMAP #169 → livré + index, fiche
  `docs/features/file-chat-169.md`, guide « Discuter d'un fichier ».
2026-10-08 17:03:29 -04:00
bruno 36b4962efb feat: tableur mobile — barres repliées par défaut, bascule unique #189
CI / lint (push) Successful in 2m42s
CI / security (push) Successful in 1m38s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 17m12s
Sous 768 px, la barre de menus, le ruban et le groupe Recherche sont
repliés par défaut : la barre de formule (fx), les onglets et Enregistrer
restent visibles et la grille récupère la hauteur d'écran. Un bouton
unique (#xlsx-tools-toggle) dans la barre des feuilles déplie/replie
l'ensemble (aria-expanded, état actif) ; desktop inchangé.

Ergonomie 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.

Tests : JSDOM xlsx-viewer.test.mjs (+2, 165/165), E2E mobile (repli par
défaut, dépliage, no-overflow ruban, cibles 44 px) ; openXlsx() déplie
automatiquement ≤ 768 px pour les tests desktop.
2026-10-08 15:45:39 -04:00
bruno e01e837a2a feat: secrets masqués — couverture universelle clés API/mots de passe + clic pour copier (#188)
CI / lint (push) Successful in 2m40s
CI / security (push) Successful in 1m33s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 16m56s
2026-10-08 13:22:27 -04:00
40 changed files with 2712 additions and 134 deletions
+2
View File
@@ -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 ─────────────────────────────────────────────────────────
+106 -1
View File
@@ -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.54.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.58.0**.
---
@@ -14,6 +14,111 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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é
+5 -5
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.54.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.58.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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
@@ -802,7 +802,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
- **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives)
- **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 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.54.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.58.0).
---
*Projet : ObsiGate | Version : 2.54.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.58.0 | Dernière mise à jour : Septembre 2026*
+5 -5
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.54.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.58.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -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
@@ -964,7 +964,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
- **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts)
- **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 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.54.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.58.0).
---
*Project: ObsiGate | Version: 2.54.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.58.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.54.0
2.58.0
+191
View File
@@ -0,0 +1,191 @@
# 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 json
import logging
import shutil
import time
import uuid
from pathlib import Path
from typing import Any
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
# #190 — general (non file-bound) conversation, stored like any other one.
GLOBAL_VAULT = "__global__"
GLOBAL_PATH = "general"
# #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",
}
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)."""
return list(_read(vault, path).get("messages", []))
def add_message(
vault: str,
path: str,
user: str,
text: str,
attachment: 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.
"""
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
doc = _read(vault, path)
return _append(vault, path, doc, msg)
def _append(
vault: str,
path: str,
doc: dict[str, Any],
msg: dict[str, Any],
) -> dict[str, Any]:
"""Cap, persist and return *msg* (shared by file and global chats)."""
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 msg
# --- #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,
) -> dict[str, Any]:
"""Append a message to the general chat. Returns the stored message."""
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment)
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,
}
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",
}
+2
View File
@@ -509,6 +509,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 +535,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
+23 -4
View File
@@ -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)
+138
View File
@@ -0,0 +1,138 @@
# 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.indexer import get_vault_data
from backend.schemas import ChatHistoryResponse, ChatMessageResponse
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)}
@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()}
@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")
msg = _store.add_global_message(
current_user.get("username", ""), text, _attachment(body)
)
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)
+1 -1
View File
@@ -648,7 +648,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,
+26
View File
@@ -151,6 +151,32 @@ 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}",
)
class ChatHistoryResponse(BaseModel):
"""Response for ``GET /api/file/{vault}/chat``."""
messages: list[ChatMessageItem] = Field(description="Messages, chronological")
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
View File
@@ -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.
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.54.0"
version = "2.58.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.54.0"
version = "2.58.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.54.0",
"version": "2.58.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+25 -1
View File
@@ -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
+17
View File
@@ -178,6 +178,23 @@ 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.
---
## 7. Rechercher
+8
View File
@@ -155,6 +155,14 @@ regroupe figeage, tri, filtre, dimensions, lien, commentaire ; les menus
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
+3
View File
@@ -328,6 +328,9 @@ 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 | #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) |
---
+69 -23
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.54.0 | **Dernière mise à jour :** 2026-10-08
> **Version :** 2.58.0 | **Dernière mise à jour :** 2026-10-08
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -40,26 +40,6 @@
- [ ] **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
---
## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2)
@@ -494,6 +474,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
@@ -576,7 +618,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) | ✅ |
@@ -647,7 +689,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é) |
@@ -686,6 +728,10 @@
| 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 | Unreleased | [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) |
+103
View File
@@ -0,0 +1,103 @@
# 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).
## 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 ».
+57
View File
@@ -0,0 +1,57 @@
# Fiche — #188 · Secrets masqués : couverture universelle + clic pour copier
- **Statut** : ✅ livré le 2026-10-08 (ouvert le 2026-10-08)
- **Effort** : ~1 jour · **Impact** : 🟡
- **Zones** : `backend/secret_redactor.py`, `backend/render.py`,
`backend/routers/files_read.py`, `frontend/js/viewer.js`,
`frontend/style.css`, `frontend/locales/{fr,en}.json`
## Problème
Le redacteur (`secret_redactor.py`) ne masquait que les JWT, les clés
`sk-`/`pk-`/`rk-`, `AKIA…`, `ghp_…`, les connection strings et les
affectations `api_key=`/`token=`/`secret=` de **20 caractères ou plus**.
Résultat : la majorité des clés API réelles (Google, Slack, Stripe, GitLab,
Hugging Face, npm…) et **tous les mots de passe courts** passaient en clair
dans l'aperçu markdown, et un masque affiché (`[CLÉ API MASQUÉE]`) n'était
pas récupérable : il fallait ouvrir la source pour relire la valeur.
## Solution
1. **Détection universelle** — une alternation unique de formats
fournisseur (OpenAI/Anthropic `sk-`, Stripe `sk_live_`/`whsec_`, GitLab
`glpat-`, GitHub `ghp_`/`github_pat_`, Google `AIza…`/`ya29.`, AWS
`AKIA`/`ASIA`, Slack `xoxb-`, SendGrid `SG.`, Hugging Face `hf_`, npm,
Docker, Resend, Square, Atlassian, Discord, Telegram, `Bearer …`) en
plus des motifs déjà présents ; le plancher des affectations génériques
passe de 20 à 8 caractères.
2. **Mots de passe** — motif dédié (`[MOT DE PASSE MASQUÉ]`) valable toute
longueur et toute quote (`password=`, `"passwd": "…"`, `db_password=`,
`passphrase=`, `mot de passe=`), qualificateur inclus. `PWD=` (dossier de
travail shell) reste épargné : `pwd` n'est reconnu que qualifié.
3. **Clic → copie** — le pipeline de rendu passe par des
**placeholders** (`\uE000n\uE001`) qui survivent à la conversion markdown
(un `<span>` littéral dans un bloc de code serait affiché tel quel), puis
`restore_masks()` les réécrit après le rendu :
- **aperçu authentifié** (`GET /api/file/…`, `click_to_copy=True`) :
`<span class="secret-mask" data-secret="valeur">[CLÉ API MASQUÉE]</span>` —
la valeur n'est jamais affichée, un clic la copie (listener délégué
unique sur la zone de contenu, toast i18n FR/EN, infobulle) ;
- **partages publics, exports PDF, contexte IA / MCP** : libellé seul,
la valeur ne quitte pas le serveur.
Les blocs `<pre><code>` portant un masque ne sont pas re-colorés
(highlight.js reconstruirait le markup et perdrait le badge).
## Hors périmètre (volontaire)
- La vue « source » (fichier brut) et les aperçus de fichiers
**non-markdown** ne sont pas masqués : c'est le fichier lui-même qui est
affiché, le redacteur ne s'y appliquait jamais.
- Le serveur MCP / l'assistant IA continuent de recevoir les libellés seuls
(inchangé).
## Tests
`tests/test_api_main.py::TestSecretRedactor` : formats fournisseurs
(paramétrés), mots de passe (paramétrés), `PWD=` non masqué, round-trip
placeholder → span cliquable, rendu markdown cliquable vs libellé seul.
+81
View File
@@ -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.
+102 -42
View File
@@ -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,47 @@
</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"
>
<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: 16px; height: 16px"
></i>
</button>
<input
type="text"
id="sidebar-panel-chat-input"
class="file-chat-input"
maxlength="4000"
placeholder="Écrire un message…"
autocomplete="off"
aria-label="Écrire un message"
/>
<button type="submit" class="file-chat-send">Envoyer</button>
</form>
</div>
<!-- User section (bottom of the sidebar, #112) -->
<div class="sidebar-user" id="sidebar-user" hidden>
<button
@@ -5302,8 +5358,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
+2
View File
@@ -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
View File
@@ -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() {
+488
View File
@@ -0,0 +1,488 @@
// 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 } from "./ui.js";
import { escapeHtml } from "./utils.js";
const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__";
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts}
let _vault = null;
let _path = null;
let _title = "";
let _sending = false;
// État du chat général (onglet sidebar)
let _generalMessages = [];
let _generalLoaded = false;
let _pendingAttach = null;
// ---------------------------------------------------------------------------
// 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) || "";
}
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
function _fillBody(body, text) {
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));
}
});
}
function _attachEl(att) {
if (!att || !att.url) return null;
if (att.kind === "image") {
const img = document.createElement("img");
img.src = att.url;
img.alt = att.name || "image";
img.loading = "lazy";
img.className = "file-chat-image";
return img;
}
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;
}
export function _messageEl(m, currentUser) {
const mine = currentUser && m.user === currentUser;
const wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
wrap.dataset.id = m.id || "";
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>`;
const body = document.createElement("div");
body.className = "file-chat-body";
_fillBody(body, m.text);
wrap.appendChild(meta);
if (m.text) wrap.appendChild(body);
const att = _attachEl(m.attachment);
if (att) wrap.appendChild(att);
return wrap;
}
export function renderChatMessages(container, messages, currentUser) {
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)));
container.scrollTop = container.scrollHeight;
}
function _appendMessage(list, m) {
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()));
list.scrollTop = list.scrollHeight;
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)}`,
);
renderChatMessages(list, data.messages || [], _currentUser());
} 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";
const input = document.createElement("input");
input.type = "text";
input.className = "file-chat-input";
input.placeholder = t("chat.placeholder");
input.maxLength = 4000;
input.setAttribute("aria-label", t("chat.placeholder"));
const send = document.createElement("button");
send.type = "submit";
send.className = "file-chat-send";
send.textContent = t("chat.send");
form.appendChild(input);
form.appendChild(send);
form.addEventListener("submit", (e) => {
e.preventDefault();
_send(input);
});
panel.appendChild(header);
panel.appendChild(list);
panel.appendChild(form);
}
export async function openFileChat(vault, path, title) {
_vault = vault;
_path = path;
_title = title || path || "";
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 = "";
_appendMessage(_panel().querySelector(".file-chat-list"), data.message);
} catch (_) {
showToast(t("chat.send_error"), "error");
} finally {
_sending = false;
input.focus();
}
}
// ---------------------------------------------------------------------------
// 2. Chat général, onglet sidebar (#190 B)
// ---------------------------------------------------------------------------
function _sidebarList() {
return document.getElementById("sidebar-panel-chat-list");
}
/** Charge (ou recharge) l'historique du chat général dans la sidebar. */
export async function openSidebarChat() {
const list = _sidebarList();
if (!list) return;
try {
const data = await api("/api/chat");
_generalMessages = (data.messages || []).slice();
_generalLoaded = true;
renderChatMessages(list, _generalMessages, _currentUser());
_clearUnread();
} catch (_) {
list.innerHTML = "";
const err = document.createElement("div");
err.className = "file-chat-empty";
err.textContent = t("chat.load_error");
list.appendChild(err);
}
}
/** Recherche dans le chat général (barre de filtre sidebar) — #190 C. */
export function filterChatMessages(query) {
const list = _sidebarList();
if (!list || !_generalLoaded) return;
const q = String(query || "").toLowerCase().trim();
const shown = q
? _generalMessages.filter((m) =>
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
)
: _generalMessages;
renderChatMessages(list, shown, _currentUser());
}
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("/api/chat", { method: "POST", body: JSON.stringify(body) });
input.value = "";
_pendingAttach = null;
_renderPending();
const list = _sidebarList();
if (list) {
_generalMessages.push(data.message);
_appendMessage(list, data.message);
}
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) --------------------------------------------
function _readUnread() {
try {
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
} catch (_) {
return {};
}
}
function _clearUnread() {
const u = _readUnread();
u.general = Math.floor(Date.now() / 1000);
try {
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
} catch (_) { /* quota */ }
_renderBadge(0);
}
function _renderBadge(count) {
const tab = document.getElementById("sidebar-tab-chat");
if (!tab) return;
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);
}
/** Compte les messages reçus depuis la dernière visite. */
export function refreshUnreadBadge(messages) {
const list = messages || _generalMessages;
if (!list.length) return _renderBadge(0);
const seen = _readUnread().general || 0;
const count = list.filter((m) => (m.ts || 0) > seen && m.user !== _currentUser()).length;
_renderBadge(count);
}
/** 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");
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;
const isGeneral = data.vault === GLOBAL_VAULT;
if (isGeneral) {
_generalMessages.push(data.message);
const list = _sidebarList();
const panel = document.getElementById("sidebar-panel-chat");
const visible = panel && panel.classList.contains("active");
if (list && visible) {
_appendMessage(list, data.message);
_clearUnread();
} else if (data.message.user !== _currentUser()) {
refreshUnreadBadge(_generalMessages);
showToast(t("chat.new_message", { user: data.message.user }), "info");
}
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);
return;
}
if (data.message.user === _currentUser()) return; // son propre message
showToast(t("chat.new_message", { user: data.message.user }), "info");
}
+4 -1
View File
@@ -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
@@ -704,13 +705,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 +721,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("");
};
+10
View File
@@ -120,6 +120,16 @@ export const IndexUpdateManager = (() => {
}
});
// #169 — Chat par fichier : relais du broadcast SSE vers le panneau
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);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
+53 -3
View File
@@ -16,6 +16,7 @@ import { t, getLocale } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
import { openAssistant } from './ai-fab.js';
import { toggleFileChat, followFileChat } from './filechat.js';
import { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
import { createColourPicker } from './xlsx/color-picker.js';
import { buildCommandBar } from './xlsx/command-bar.js';
@@ -1473,6 +1474,11 @@ const GRID_CHUNK_ROWS = 100; // rows materialised per extension step
<span class="xlsx-toolbar-actions">${actionsHtml}</span>
</div>
<div class="xlsx-sheetbar">
<button class="btn-action xlsx-tools-toggle" id="xlsx-tools-toggle" type="button"
aria-expanded="false" title="${escapeHtml(t("xlsx.tools_toggle"))}"
aria-label="${escapeHtml(t("xlsx.tools_toggle"))}">
<i data-lucide="menu" style="width:18px;height:18px" aria-hidden="true"></i>
</button>
${tabs}
<span class="xlsx-sheetbar-end">${fileHtml}</span>
</div>
@@ -1526,6 +1532,20 @@ const GRID_CHUNK_ROWS = 100; // rows materialised per extension step
// A re-render invalidates any open context menu (it targets the old DOM).
closeContextMenu();
// #189 — mobile : la barre de menus + le ruban sont repliés par défaut
// (cf. style.css ≤768px). Ce bouton, seul dans la barre des feuilles,
// déplie/replie le tout sans jamais masquer la barre de formule.
const toolsToggle = area.querySelector("#xlsx-tools-toggle");
const toolsBar = area.querySelector(".xlsx-toolbar");
if (toolsToggle && toolsBar) {
toolsToggle.addEventListener("click", () => {
const open = toolsBar.classList.toggle("xlsx-tools-open");
toolsToggle.setAttribute("aria-expanded", String(open));
toolsToggle.classList.toggle("active", open);
safeCreateIcons();
});
}
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
// Fetched lazily on first open of the dashboard tab; a workbook without
// anything notable hides the button entirely (no empty panel).
@@ -6877,6 +6897,8 @@ export function renderFile(data) {
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
// instead of being wiped mid-session by a tab switch / sidebar click.
if (isInlineEditorActive()) detachInlineEditor();
// #190 A — an open chat panel follows the document being rendered.
followFileChat(data.vault, data.path, data.title);
// #108 — release the image viewer's document-level shortcuts before swapping
// the content area (otherwise they leak on every re-render).
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
@@ -7203,6 +7225,12 @@ export function renderFile(data) {
// Bookmark button — check if already bookmarked
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
// #169 — Chat intégré par fichier (panneau latéral)
const chatBtn = el("button", { class: "btn-action btn-chat", title: t("chat.button_title") }, [icon("message-square", 14), document.createTextNode(t("chat.button"))]);
chatBtn.addEventListener("click", () => {
toggleFileChat(data.vault, data.path, data.title);
});
// Check bookmark status and color the button
(async () => {
try {
@@ -7276,7 +7304,10 @@ export function renderFile(data) {
prettyBtn.classList.add("active");
} else {
mdDiv.innerHTML = data.html;
mdDiv.querySelectorAll("pre code").forEach((block) => safeHighlight(block));
mdDiv.querySelectorAll("pre code").forEach((block) => {
if (block.querySelector(".secret-mask")) return;
safeHighlight(block);
});
prettyBtn.classList.remove("active");
}
});
@@ -7292,7 +7323,7 @@ export function renderFile(data) {
// spacer de groupe vide retire.
const navBtns = [];
if (data.is_markdown) navBtns.push(tocBtn);
navBtns.push(openNewWindowBtn, bookmarkBtn);
navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);
const editBtns = [];
if (!data.unsupported) editBtns.push(editBtn);
editBtns.push(sourceBtn);
@@ -7327,8 +7358,10 @@ export function renderFile(data) {
renderBacklinksPanel(data.vault, data.path, area);
}
// Highlight code blocks
// Highlight code blocks — a block carrying a secret mask (#188) must be
// left alone: highlight.js rebuilds the markup and would drop the badge.
area.querySelectorAll("pre code").forEach((block) => {
if (block.querySelector(".secret-mask")) return;
safeHighlight(block);
});
@@ -7370,6 +7403,23 @@ export function renderFile(data) {
});
});
// Secret masks (#188): click copies the real value to the clipboard.
// The handler is delegated (and attached once) so it survives re-renders.
area.querySelectorAll(".secret-mask").forEach((mask) => {
mask.title = t("viewer.secret_copy_hint");
});
if (!area.dataset.secretMaskWired) {
area.dataset.secretMaskWired = "1";
area.addEventListener("click", (e) => {
const mask = e.target instanceof Element ? e.target.closest(".secret-mask") : null;
if (!mask || !area.contains(mask)) return;
const secret = mask.getAttribute("data-secret");
if (!secret) return;
const ok = copyToClipboard(secret);
showToast(ok ? t("viewer.secret_copied") : t("viewer.secret_copy_failed"), ok ? "success" : "error");
});
}
safeCreateIcons();
area.scrollTop = 0;
+19 -1
View File
@@ -319,6 +319,19 @@
"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.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.send": "Send",
"chat.send_error": "Failed to send the message",
"chat.tab": "General chat",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "All",
"common.back": "Back",
@@ -1004,7 +1017,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",
@@ -1697,6 +1710,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",
@@ -2050,6 +2064,7 @@
"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",
@@ -2221,6 +2236,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)",
+19 -1
View File
@@ -319,6 +319,19 @@
"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.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.send": "Envoyer",
"chat.send_error": "Échec de l'envoi du message",
"chat.tab": "Chat général",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "Tous",
"common.back": "Retour",
@@ -1004,7 +1017,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",
@@ -1697,6 +1710,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",
@@ -2050,6 +2064,7 @@
"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",
@@ -2221,6 +2236,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)",
+341
View File
@@ -2324,6 +2324,18 @@ select {
cursor: default;
}
/* Secret masks (#188) — click copies the real value to the clipboard */
.secret-mask {
cursor: pointer;
padding: 0 3px;
border-radius: 3px;
border-bottom: 1px dashed var(--text-muted);
background: var(--bg-secondary);
}
.secret-mask:hover {
background: var(--bg-hover);
}
/* Image placeholders */
.image-not-found {
display: inline-block;
@@ -12160,6 +12172,85 @@ body.desktop-mode .editor-container {
max-width: 92vw;
}
}
/* #189 — mise en page mobile : par défaut seuls restent la barre des
feuilles (avec le basculeur) et la barre de formule. La barre de menus et
le ruban sont repliés derrière #xlsx-tools-toggle ; le groupe Recherche,
logé dans la barre de formule, suit le même état pour ne pas écraser le
champ de saisie. */
.xlsx-tools-toggle {
display: none;
}
@media (max-width: 768px) {
.xlsx-tools-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
flex: 0 0 auto;
}
.xlsx-menubar,
.xlsx-cmdbar,
.xlsx-find-group {
display: none;
}
.xlsx-toolbar.xlsx-tools-open .xlsx-menubar {
display: flex;
}
.xlsx-toolbar.xlsx-tools-open .xlsx-cmdbar {
display: flex;
}
.xlsx-toolbar.xlsx-tools-open .xlsx-find-group {
display: flex;
}
/* La barre de formule ne déborde jamais : elle passe en 2 lignes quand le
groupe Recherche réapparaît avec les outils ouverts. */
.xlsx-toolbar.xlsx-tools-open .xlsx-formula-bar {
flex-wrap: wrap;
}
/* Une seule ligne de feuilles : les onglets défilent horizontalement au
lieu d'enrouler et de grignoter la hauteur de la grille. */
.xlsx-sheetbar {
flex-wrap: nowrap;
}
.xlsx-sheetbar .xlsx-tabs {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.xlsx-sheetbar .xlsx-tabs::-webkit-scrollbar {
display: none;
}
/* Cibles tactiles : le ruban replié n'existe que derrière le basculeur,
mais une fois ouvert chaque commande reste atteignable au doigt. */
.xlsx-toolbar .btn-action {
min-width: 44px;
height: 44px;
}
/* À 44px un groupe (police, nombre…) dépasse la largeur d'un téléphone :
il doit pouvoir se réduire ET s'enrouler plutôt que déborder. */
.xlsx-cmdbar .xlsx-cmd-group {
flex: 0 1 auto;
min-width: 0;
flex-wrap: wrap;
}
.xlsx-menubtn {
padding: 12px 14px;
min-height: 44px;
}
.xlsx-tab,
.xlsx-tab-add {
padding: 10px 14px;
min-height: 44px;
}
/* 16px minimum : en dessous iOS zoome automatiquement la page dès que le
champ reçoit le focus, ce qui fait « sauter » l'interface. */
.xlsx-active-cell,
.xlsx-formula-input,
.xlsx-find-input {
font-size: 16px;
}
}
.xlsx-context-item {
text-align: left;
border: none;
@@ -13657,3 +13748,253 @@ body.reading-mode .file-actions {
color: var(--text-muted);
font-size: 0.85rem;
}
/* ---------------------------------------------------------------------------
#169 — Chat intégré par fichier (panneau latéral)
--------------------------------------------------------------------------- */
.file-chat-panel {
position: fixed;
top: 0;
right: 0;
width: 340px;
max-width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary, #0d1117);
border-left: 1px solid var(--border, #21262d);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.35);
z-index: 1500;
}
.file-chat-panel.hidden {
display: none;
}
.file-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--border, #21262d);
}
.file-chat-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #e6edf3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-chat-close {
border: none;
background: transparent;
color: var(--text-muted, #8b949e);
font-size: 1rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
flex-shrink: 0;
}
.file-chat-close:hover {
background: var(--bg-hover, rgba(128, 128, 128, 0.18));
color: var(--text-primary, #e6edf3);
}
.file-chat-list {
flex: 1;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.file-chat-empty {
color: var(--text-muted, #8b949e);
font-size: 0.85rem;
text-align: center;
padding: 24px 8px;
}
.file-chat-msg {
background: var(--bg-secondary, #161b22);
border: 1px solid var(--border, #21262d);
border-radius: 10px;
padding: 8px 10px;
max-width: 92%;
}
.file-chat-msg.mine {
align-self: flex-end;
background: var(--bg-hover, rgba(88, 166, 255, 0.12));
border-color: var(--accent, #58a6ff);
}
.file-chat-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.72rem;
color: var(--text-muted, #8b949e);
margin-bottom: 4px;
}
.file-chat-user {
font-weight: 600;
color: var(--accent, #58a6ff);
}
.file-chat-body {
font-size: 0.88rem;
color: var(--text-primary, #e6edf3);
white-space: pre-wrap;
word-break: break-word;
}
.file-chat-form {
display: flex;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--border, #21262d);
}
.file-chat-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 16px; /* anti-zoom iOS */
border: 1px solid var(--border, #21262d);
border-radius: 8px;
background: var(--bg-secondary, #161b22);
color: var(--text-primary, #e6edf3);
}
.file-chat-input:focus {
outline: none;
border-color: var(--accent, #58a6ff);
}
.file-chat-send {
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
background: var(--accent, #58a6ff);
color: #fff;
flex-shrink: 0;
}
.file-chat-send:disabled {
opacity: 0.6;
cursor: default;
}
@media (max-width: 768px) {
.file-chat-panel {
width: 100vw;
border-left: none;
}
}
/* ---------------------------------------------------------------------------
#190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge
--------------------------------------------------------------------------- */
.file-chat-titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.file-chat-target {
font-size: 0.72rem;
color: var(--text-muted, #8b949e);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-tab {
position: relative;
}
.chat-unread-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 15px;
height: 15px;
padding: 0 3px;
border-radius: 8px;
background: #e5534b;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
line-height: 15px;
text-align: center;
pointer-events: none;
}
.sidebar-chat-panel {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
}
.sidebar-chat-panel.active {
display: flex;
}
.sidebar-chat-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px;
}
.sidebar-chat-pending {
padding: 0 10px;
}
.sidebar-chat-form {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid var(--border, #21262d);
}
.file-chat-attach {
border: 1px solid var(--border, #21262d);
border-radius: 8px;
background: var(--bg-secondary, #161b22);
color: var(--text-muted, #8b949e);
cursor: pointer;
padding: 6px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.file-chat-attach:hover {
color: var(--accent, #58a6ff);
border-color: var(--accent, #58a6ff);
}
.file-chat-pending {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 4px 0;
padding: 3px 8px;
border: 1px dashed var(--border, #21262d);
border-radius: 8px;
font-size: 0.75rem;
color: var(--text-muted, #8b949e);
}
.file-chat-pending button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.file-chat-image {
max-width: 100%;
max-height: 220px;
border-radius: 8px;
margin-top: 6px;
display: block;
}
.file-chat-video {
max-width: 100%;
max-height: 240px;
border-radius: 8px;
margin-top: 6px;
display: block;
}
.file-chat-link {
color: var(--accent, #58a6ff);
word-break: break-all;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.54.0",
"version": "2.58.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+36 -2
View File
@@ -72,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)', () => {
@@ -513,12 +521,27 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
await expect(page.locator('#xlsx-minibar')).toBeVisible();
});
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
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');
@@ -526,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');
+225
View File
@@ -0,0 +1,225 @@
#!/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"><input id="sidebar-panel-chat-input" /></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();
globalThis.fetch = (url) => {
const u = String(url);
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();
const mod = await import("../../frontend/js/filechat.js");
const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
} = 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, "&lt;b&gt;x&lt;/b&gt;");
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"));
});
// 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`);
+1 -1
View File
@@ -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", () => {
+48
View File
@@ -2775,6 +2775,54 @@ await test("a read-only workbook keeps the view menus but drops the editing ones
assert.ok(area.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay");
});
// ── #189 — mise en page mobile : barres repliées derrière un basculeur ──────
await test("the sheet strip carries the tools toggle that flips the toolbar (#189)", () => {
const area = mountGrid();
const strip = area.querySelector(".xlsx-sheetbar");
const toggle = area.querySelector("#xlsx-tools-toggle");
assert.ok(toggle, "toggle rendered");
assert.equal(toggle.closest(".xlsx-sheetbar"), strip, "it lives in the sheet strip");
assert.equal(toggle.getAttribute("aria-expanded"), "false", "starts collapsed");
const toolbar = area.querySelector(".xlsx-toolbar");
assert.ok(!toolbar.classList.contains("xlsx-tools-open"), "toolbar starts closed");
toggle.click();
assert.ok(toolbar.classList.contains("xlsx-tools-open"), "one click opens the tools");
assert.equal(toggle.getAttribute("aria-expanded"), "true");
assert.ok(toggle.classList.contains("active"), "the toggle shows its own state");
toggle.click();
assert.ok(!toolbar.classList.contains("xlsx-tools-open"), "a second click closes them");
assert.equal(toggle.getAttribute("aria-expanded"), "false");
});
await test("style.css hides menus/ribbon/find by default under 768px and reveals them on .xlsx-tools-open (#189)", () => {
const css = readFileSync(path.join(REPO_ROOT, "frontend", "style.css"), "utf8");
// The desktop default: the toggle itself is out of the way.
assert.match(css, /\.xlsx-tools-toggle\s*\{\s*display:\s*none/, "toggle hidden on desktop");
// Mobile block: collapsed by default… (CRLF-safe slice, from the #189
// comment up to the next top-level rule).
const from = css.indexOf("/* #189");
assert.ok(from > 0, "the #189 mobile block exists after the coarse-pointer rules");
const to = css.indexOf(".xlsx-context-item", from);
assert.ok(to > from, "the #189 block is followed by the next rule");
const block = css.slice(from, to);
for (const cls of [".xlsx-menubar", ".xlsx-cmdbar", ".xlsx-find-group"]) {
const hidden = new RegExp(cls.replace(".", "\\.") + "[^}]*display:\\s*none").test(block)
|| new RegExp(cls.replace(".", "\\.") + ",[\\s\\S]{0,120}?display:\\s*none").test(block);
assert.ok(hidden, `${cls} hidden by default on mobile`);
assert.ok(
block.includes(`.xlsx-toolbar.xlsx-tools-open ${cls}`),
`${cls} revealed by .xlsx-tools-open`,
);
}
// The formula bar always stays, and the open state can wrap it (find group).
assert.ok(block.includes(".xlsx-toolbar.xlsx-tools-open .xlsx-formula-bar"), "formula bar wraps when open");
// Touch: 44px targets, tabs on one scrollable row, 16px inputs (no iOS zoom).
assert.match(block, /min-width:\s*44px/, "44px touch targets");
assert.match(block, /\.xlsx-sheetbar \.xlsx-tabs\s*\{[^}]*overflow-x:\s*auto/, "tabs scroll horizontally");
assert.match(block, /font-size:\s*16px/, "inputs at 16px avoid the iOS focus zoom");
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+89
View File
@@ -845,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)
+209
View File
@@ -0,0 +1,209 @@
# tests/test_file_chat.py — Chat par fichier (#169)
"""Store + REST routes for the per-file chat history."""
import time
import pytest
from backend import file_chat as _store
@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": []}
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": []}
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"