Compare commits

...
11 Commits
Author SHA1 Message Date
bruno 943005328c feat: barre d'outils de lecture epinglee, coloration syntaxique des fichiers de code et avatars predefinis (#115, #117, BUG-078)
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m27s
CI / test (push) Successful in 4m10s
CI / build (push) Successful in 1m23s
CI / e2e (push) Successful in 13m55s
2026-09-24 16:21:45 -04:00
bruno e1842043d8 feat: assistant IA — approbation groupee des actions, bloc d'etapes, refresh UI et bouton Stop (BUG-074, BUG-075, BUG-076, BUG-077)
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 13m38s
2026-09-24 10:05:32 -04:00
bruno 9fb094f505 feat: refonte mobile de la section Configurations #114
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m26s
CI / test (push) Successful in 4m50s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 15m34s
2026-09-24 08:43:18 -04:00
bruno d142049216 fix: degagement mobile sous la barre de navigation du bas (clearance retargetee .main-body) BUG-073
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m26s
CI / test (push) Successful in 3m41s
CI / build (push) Successful in 1m21s
CI / e2e (push) Successful in 14m0s
2026-09-23 23:36:35 -04:00
bruno 33fe1a3439 feat: ordre naturel des sections Configurations et avatar utilisateur #113
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m28s
CI / test (push) Successful in 4m14s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 13m43s
2026-09-23 22:18:29 -04:00
bruno a726ad8511 feat: en-tete allege, compte utilisateur en sidebar et pellicule d'images defilable (molette + fleches) #112
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m29s
CI / test (push) Successful in 4m59s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 13m49s
2026-09-23 21:06:41 -04:00
bruno b926f01b85 feat: visionneuse d'images - navigation fluide, image ajustee au cadre, fleches laterales et pellicule persistante #111
CI / lint (push) Successful in 2m2s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 4m23s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 13m27s
2026-09-23 14:37:41 -04:00
bruno 8264e7ffae fix: conserver plein ecran et panneau metadonnees dans la visionneuse d'images (sidebar droite) + lanceur E2E Windows BUG-072
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 17m45s
2026-09-23 13:43:47 -04:00
bruno 80852374a8 fix: positionnement lecteur media (mobile/desktop) et drag libre de la mini-video #110
CI / lint (push) Successful in 2m0s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m36s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m7s
2026-09-23 11:42:23 -04:00
bruno e3c6789776 feat: lecteur média persistant Now Playing (dock audio, mini-vidéo PiP, Media Session, mobile) #110
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m23s
CI / test (push) Successful in 3m57s
CI / build (push) Successful in 1m18s
CI / e2e (push) Successful in 13m18s
2026-09-23 10:41:15 -04:00
bruno e2417cb5ab feat: support audio & vidéo — lecteurs HTML5 intégrés #109
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Successful in 2m6s
CI / e2e (push) Successful in 12m57s
2026-09-23 09:14:34 -04:00
80 changed files with 7148 additions and 733 deletions
+3
View File
@@ -38,9 +38,12 @@ jobs:
- name: Frontend unit tests
run: |
node tests/frontend/unit.test.mjs
node tests/frontend/image-viewer.test.mjs
node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs
node tests/frontend/config-mobile.test.mjs
node tests/frontend/settings-order-avatar.test.mjs
node tests/frontend/mobile-toolbar.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
run: |
+5 -1
View File
@@ -44,9 +44,13 @@ node tests/frontend/unit.test.mjs
# Tests JSDOM : node_modules dans tests/frontend/ (npm install là-bas si absent), ex :
node tests/frontend/pane-manager.test.mjs
# E2E (si UI touchée, ~5 min) : reproduit le job CI e2e (port 2029, auth désactivée)
# E2E (si UI touchée, ~10 min) : reproduit le job CI e2e (port 2029, auth désactivée)
npm run test:e2e # prérequis : uv, Node >= 20, npx playwright install chromium
bash scripts/run-e2e-local.sh -g "nom du test" # filtre / --headed
# Windows sans bash exploitable (WSL HS, git-bash bloqué par App Control) :
npm run test:e2e:ps # équivalent PowerShell, mêmes conditions que le CI
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','nom du test')
```
- Un seul test backend : `.\.venv\Scripts\python.exe -m pytest tests/test_search.py -q`.
+277 -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.17.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.25.0**.
---
@@ -14,6 +14,282 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.25.0] — 2026-09-24
### Ajouté
- **#115 — barre d'outils de lecture toujours visible.** La barre d'actions d'un
document (pop-out, bookmark, Editer, Source, Copier, Partager…) est désormais
**épinglée en haut** de la zone de lecture : elle reste accessible en permanence,
même au bas d'un document long, au lieu de disparaître au défilement. Elle est
rendue comme un enfant direct du conteneur de défilement (`.file-toolbar`), masquée
en mode lecture, et alignée dans la fenêtre pop-out.
- **#117 — avatars prédéfinis dans le profil.** La section **Profil** propose une
galerie de **12 avatars** fournis avec l'application (`frontend/icons/avatar/`) :
un clic charge l'image, la recadre au format carré 256 px (même pipeline que
l'import) et l'enregistre sur le compte. L'avatar actif est surligné ; l'import
d'une photo personnalisée et la suppression restent disponibles.
### Corrigé
- **BUG-078 — coloration syntaxique des fichiers de code perdue.** Les feuilles de
style highlight.js étaient basculées à partir de la **clé de thème**
(`defaut-obsigate`, …) au lieu du **mode** (`dark`/`light`) : les deux feuilles se
retrouvaient désactivées et les blocs de code (`.py`, `.sh`, `.ps1`, `.yml`, JSON,
blocs Markdown…) s'affichaient en texte brut. Le basculement est désormais piloté
par le mode, de façon déterministe, dans le moteur de thème (`themes.js`) comme au
premier rendu (`ui.js`) ; sépia et contraste élevé réutilisent la palette claire.
---
## [2.24.0] — 2026-09-24
### Ajouté
- **BUG-077 — assistant IA : bouton « Stop ».** Pendant qu'une réponse se diffuse, le
bouton d'envoi du composeur devient un bouton **Stop** (icône carrée, couleur
d'alerte) : un clic interrompt immédiatement l'exécution de l'agent (abort de la
requête SSE, annulation de la tâche côté serveur à la déconnexion) et la réponse
partielle est conservée avec un marqueur « ⏹ Exécution arrêtée. ». Le bouton reste
actif (il n'est plus désactivé) tant que l'agent travaille, y compris pendant la
reprise d'une confirmation.
### Modifié
- **BUG-075 — assistant IA : approbation globale des actions.** Une même réponse du
modèle peut demander **plusieurs** mutations (créer un dossier et les fichiers
qu'il contient…). Elles sont désormais **regroupées en une seule confirmation** (au
lieu d'une action après l'autre) : la carte liste chaque action avec son libellé et
son aperçu de diff, et un unique bouton « **Tout approuver (N)** » envoie
`confirm_all` — les actions en attente sont appliquées d'un bloc, puis la suite de
l'exécution est autorisée sans nouvelle carte. Les appels en lecture du même lot
s'exécutent immédiatement (conversation valide). Côté backend, `pending.actions`
remplace le report `deferred` des mutations d'un lot (`backend/agent/loop.py`) et
`confirm_all` arme `ToolContext.confirmed` pour le reste du run
(`backend/bookslm_routes.py`).
### Corrigé
- **BUG-074 — assistant IA : bloc d'étapes sans titre et compteur figé à 1.** Le
résumé replié affiche désormais un **titre** (le libellé de la première action,
« N étapes — Fichier créé : notes/a.md ▶ »), le compteur ne compte plus les
**réflexions** (seules les actions) et la reprise d'une confirmation continue de
diffuser dans **le même message** au lieu de créer un nouveau message « 1 étape »
par approbation : le bloc d'étapes s'accumule sur tout l'échange.
- **BUG-076 — assistant IA : arborescence et document ouvert non rafraîchis.** Après
une action mutatrice de l'agent (création/suppression/renommage de fichier ou
dossier, écriture), l'arborescence est rafraîchie immédiatement (rafraîchissement
débouncé sur les événements `tool` mutateurs, sans attendre le watcher) et le
document affiché est rechargé depuis le disque ; les outils de création de documents
(xlsx/docx/csv/pdf) notifient désormais aussi la visionneuse.
---
## [2.23.0] — 2026-09-24
### Ajouté
- **#114 — Configuration, refonte mobile-responsive.** La page « Configurations »
est désormais pensée pour le tactile (≤ 768 px) : modale **plein écran**
(`100dvh`, safe-area), sommaire en **drawer coulissant** gauche
(`position: fixed`, largeur `min(320px, 88vw)`) avec fond assombri
(`#config-modal.config-toc-open::before`) et bouton de fermeture
`#config-toc-close` (tap sur le backdrop ou Échap ferme le drawer d'abord,
puis la modale) ; formulaires sur **une colonne** ; tous les boutons
(save/secondary/danger/add/sm/hamburger/fermer) et liens du sommaire en cibles
**≥ 44 px** ; champs et selects en **16 px + min-height 44 px** (anti-zoom
iOS) ; rangée « Sauvegarder / Réindexer / Réinitialiser » **sticky** en bas de
sa section avec safe-area ; MFA (codes de secours en 1 colonne, champ de code
full-width et wrap des rangées d'action) ; débordements webhook/token/share
corrigés. Dettes HTML/i18n de l'audit incluses : `.config-actions-row` replacée
dans `#cfg-backend-settings` (+ `</section>` orphelin supprimé), id dupliqué
`cfg-partages-publics` retiré du `<h2>`, conteneur mort
`#plugins-settings-container` supprimé, libellé `#mt-explorer` i18n
(`settings.explorer`), doublons `.config-btn-add` et règle morte
`.mfa-recovery-input` purgés ; i18n : clés mortes `settings.backend`,
`settings.backend_hint`, `settings.restart_badge`, `settings.save`,
`settings.plugins` supprimées, `config.toc_close` ajoutée, `settings.tabs` FR
corrigé (« Onglets »). Tests : `config-mobile.test.mjs` étendu (27, au CI) +
E2E `config-mobile.spec.js` (5, projet `chromium-mobile`). Fiche :
[docs/features/settings-mobile-114.md](./docs/features/settings-mobile-114.md).
---
## [2.22.1] — 2026-09-23
### Corrigé
- **BUG-073 — mobile, fin de contenu masquée.** La barre de navigation fixe du bas
(`#mobile-toolbar`, 64 px + safe-area) recouvrait le bas de tous les documents et
pages en vue mobile (≤ 768 px) : les dernières lignes restaient définitivement sous
la barre. Cause : la règle de clearance du bloc mobile ciblait `.main-layout`,
classe absente de `index.html` (le vrai conteneur est `.main-body`) — sélecteur
mort, aucun dégagement réservé. Correctif : cible `.main-body`
(`padding-bottom: calc(64px + env(safe-area-inset-bottom, 0))`), suppression de la
règle sœur morte `.editor-modal.active ~ .main-layout`, reset du dégagement en mode
lecture (barre masquée) et `body.np-active .content-area` ramené à `76 px` (le
dégagement dock, sans double comptage avec `.main-body`). Tests :
`tests/frontend/mobile-toolbar.test.mjs` (7, au CI) et E2E
`tests/e2e/mobile-toolbar.spec.js` (2, projet `chromium-mobile`).
---
## [2.22.0] — 2026-09-23
### Ajouté
- **#113 — avatar utilisateur.** La section « Profil » des Configurations permet de
**choisir / importer une image** (PNG, JPG, WEBP, 8 Mo max) : elle est recadrée en
carré 256 px côté client, validée côté serveur (data-URL + octets magiques, sans SVG)
et persistée sur le compte (`avatar` dans `data/users.json`, exposé par
`GET/PATCH /api/auth/me` et la réponse de login). L'image s'affiche dans le **cercle
du profil en bas de la sidebar** (initiales en repli) et peut être supprimée. UI :
aperçu circulaire avec overlay caméra au survol, boutons Choisir / Supprimer, erreurs
en ligne. Clés i18n FR/EN.
### Modifié
- **#113 — ordre des sections Configurations.** La TOC et la page sont réorganisées
dans un ordre naturel et **parfaitement synchronisées** : **Profil en premier**,
puis Sécurité, Thèmes, Recherche, Historique récent, Tags, Fichiers cachés,
Synchronisation, Backend, Diagnostics, IA, Sources connectées, Clés API & MCP,
Push, Webhooks, Partages publics, Plugins et **À propos en dernier**. Les ancres
`cfg-tags` et `cfg-partages-publics` sont désormais portées par leur `<section>`
(cible de défilement = haut de section). Garde-fous : test statique
`tests/frontend/settings-order-avatar.test.mjs` (ordre TOC = page, pas d'ancre morte).
---
## [2.21.0] — 2026-09-23
### Ajouté
- **#112 — section compte en bas de la sidebar.** L'identité (avatar avec
initiales, nom, rôle) et la **déconnexion** sont regroupées dans un bloc
épinglé au bas de la barre latérale, comme sur les sites professionnels ; un
clic sur l'identité ouvre le Profil. Nouvelle clef i18n FR/EN pour le rôle.
- **#112 — pellicule d'images défilable.** La molette de la souris fait défiler
horizontalement la bande de miniatures et deux **flèches translucides** sur les
côtés (révélées au survol) remplissent la même fonction.
### Modifié
- **#112 — en-tête allégé.** La **version** quitte le header pour le menu
**Options** (ligne « Version ») ; le **bouton de déconnexion** et le **nom de
l'utilisateur** sont retirés du header au profit de la section compte de la
sidebar. Le header droit ne conserve que l'indicateur hors-ligne, le contexte
vault et le bouton Options.
---
## [2.20.0] — 2026-09-23
### Ajouté
- **#111 — visionneuse d'images : flèches latérales translucides.** Deux boutons
superposés le long des bords du cadre passent à l'image précédente/suivante ;
quasi invisibles au repos, ils se révèlent au survol (et restent visibles sur
les appareils tactiles). Un compteur `n / total` complète la barre d'outils.
### Modifié
- **#111 — navigation d'images fluide.** Changer d'image se fait désormais **en
place** (`showSibling` remplace le `src` du `<img>`) : plus de rechargement de
la vue ni de nouvel appel `/api/browse` à chaque flèche, **même dans un dossier
contenant beaucoup d'images**. La liste du dossier est mise en cache (TTL court)
et les images voisines sont préchargées ; la **pellicule de miniatures reste
visible** et la vignette active est simplement re-marquée.
- **#111 — image toujours ajustée au cadre.** La visionneuse occupe tout l'espace
disponible (zone de contenu en flex, sans défilement de page) et l'image est
systématiquement redimensionnée dans son cadre, y compris panneau
« Métadonnées » ouvert.
---
## [2.19.2] — 2026-09-23
### Ajouté
- **Lanceur E2E Windows/PowerShell** : `scripts/run-e2e-local.ps1` (+ script npm
`test:e2e:ps`) reproduit localement le job CI `e2e` (uvicorn natif, auth
désactivée, fixtures TestVault/TestDir, port 2029, projet `chromium-desktop`)
sans dépendre de `bash` — indispensable sur les postes Windows où WSL ne
démarre pas et où git-bash est bloqué par une politique de contrôle
d'application. Le script `bash` reste la référence pour la CI/Linux.
### Corrigé
- **BUG-072 — visionneuse d'images** : le plein écran (lightbox) et le panneau
« Métadonnées » sont désormais **conservés lors de la navigation** entre images
(flèches ←/→ et clic sur la pellicule) ; auparavant chaque changement d'image
recréait la visionneuse et perdait ces états. Le panneau de métadonnées s'affiche
maintenant en **barre latérale à droite de l'image** (au lieu d'une bande sous la
pellicule) et reste visible en plein écran.
---
## [2.19.1] — 2026-09-23
### Corrigé
- **#110 — lisibilité et positionnement du lecteur média** : en mode mobile, la
barre audio passe en grille (barre de progression pleine largeur sur sa propre
ligne) et les actions secondaires (précédent/suivant/agrandir/volume) sont
masquées pour éviter tout chevauchement ; le volume est aussi masqué sur les
écrans intermédiaires en desktop, et `overflow: hidden` empêche le débordement
hors de la pilule. La mini-fenêtre vidéo n'est plus ré-aimantée sur les bords :
elle se déplace et se redimensionne **librement** (position absolue mémorisée,
centre autorisé), le glisser fonctionnant désormais depuis n'importe quel point
de la fenêtre (boutons/curseurs/poignée exclus, seuil de 4 px pour préserver
les contrôles natifs de la vidéo).
---
## [2.19.0] — 2026-09-23
### Ajouté
- **#110 — Lecteur média persistant « Now Playing »** : les fichiers audio et
vidéo continuent de jouer pendant la navigation grâce à un contrôleur global
`frontend/js/now-playing.js` qui possède **un seul** élément `<audio>`/`<video>`
téléporté entre la vue inline (onglet/panneau) et un dock flottant
(`<body>`), sans interruption de lecture. Dock desktop : pilule verre dépoli
(artwork, titre, voûte, play/pause, précédent/suivant, barre de progression,
volume, retour au média, agrandir, fermer). Mini-fenêtre vidéo flottante
déplaçable/redimensionnable avec aimantation aux coins et bouton
Picture-in-Picture natif. Mode mobile : mini-player fixé au-dessus de la barre
d'outils (safe-area `viewport-fit=cover`). Intégration **Media Session** (écran
verrouillé, touches matérielles, Windows SMTC), panneau étendu façon « Now
Playing », lecture auto du média suivant/précédent du dossier, reprise après
rechargement, notification quand l'onglet est fermé pendant la lecture. Fiche :
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md) (§ Now
Playing, #110).
---
## [2.18.0] — 2026-09-23
### Ajouté
- **#109 — Support audio & vidéo (lecteurs HTML5 intégrés)** : les fichiers audio
(`.mp3 .m4a .aac .wav .ogg .oga .opus .flac`) et vidéo (`.mp4 .webm .mov .m4v`)
apparaissent désormais dans l'arborescence et l'index (nom/taille/date
uniquement, contenu jamais lu, intégrés à `SUPPORTED_EXTENSIONS` via
`media_types.py`). Nouvel endpoint `GET /api/media/{vault}?path=…` avec support
HTTP `Range` / `206 Partial Content` (`Content-Range`, `Accept-Ranges`, `416`
sur plage invalide, `413` au-delà de `OBSIGATE_MEDIA_MAX_INLINE_MB`, défaut
500 Mo) — le helper Range de `pdf/stream` a été extrait en
`_stream_file_with_range()` et est partagé. `api_file_view()` expose
`is_audio`/`is_video`/`stream_url`/`media_mime` avant toute lecture texte.
Frontend : lecteur audio dédié (artwork, durée via `loadedmetadata`) et lecteur
vidéo (`playsinline`, scène noire letterboxée), icônes Lucide `audio-lines` /
`video`, pause à la réinitialisation de la vue, repli téléchargement si le codec
n'est pas lisible ou le fichier trop volumineux. Les médias sont exclus du
contexte textuel BooksLM et du cache hors-ligne du service worker. Fiche :
[docs/features/media-viewers-109.md](docs/features/media-viewers-109.md).
---
## [2.17.0] — 2026-09-23
### Ajouté
+15 -4
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.17.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.25.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/)
@@ -83,6 +83,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
- **🏷️ Tag cloud** : Filtrage par tags extraits des frontmatters YAML
- **🔗 Wikilinks** : Les `[[liens internes]]` Obsidian sont cliquables
- **🖼️ 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)
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
@@ -296,6 +297,7 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Autoriser les webhooks non HTTPS | `false` |
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Autoriser les webhooks vers des adresses privées/boucle | `false` |
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Taille max pour la lecture audio/vidéo intégrée (au-delà : téléchargement) | `500` |
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Fournisseurs de recherche web à clé (essayés avant SearXNG) | — |
| `OBSIGATE_WEB_PROVIDERS` | Ordre des fournisseurs de recherche (ex. `brave,searxng`) | — |
@@ -870,7 +872,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`.
| Validation des imports frontend | `node tests/frontend/validate-imports.mjs` | `lint` |
| Tests unitaires frontend | `node tests/frontend/unit.test.mjs` | `lint` |
| Tests backend | `pytest tests/ -q` | `test` |
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
#### Tests E2E locaux (`npm run test:e2e`)
@@ -893,6 +895,15 @@ bash scripts/run-e2e-local.sh --headed # navigateur visible
bash scripts/run-e2e-local.sh -g "reset panes" # filtre sur un test
```
Sous Windows, si `bash` n'est pas exploitable (WSL indisponible, git-bash
bloqué par une politique de contrôle d'application), utiliser le lanceur
PowerShell équivalent :
```powershell
npm run test:e2e:ps
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
```
La suite doit se terminer sur **tous les tests passant** (60 actuellement),
sans échec ni dépendance aux retries. En cas d'échec : corriger et relancer
localement jusqu'à 100 %, puis seulement commiter.
@@ -964,8 +975,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.17.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.25.0).
---
*Projet : ObsiGate | Version : 2.17.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.25.0 | Dernière mise à jour : Septembre 2026*
+14 -4
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.17.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.25.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/)
@@ -82,6 +82,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
- **🏷️ Tag Cloud** : Filtering by tags extracted from YAML frontmatters
- **🔗 Wikilinks** : `[[internal links]]` from Obsidian are clickable
- **🖼️ 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)
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
@@ -340,6 +341,7 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Allow non-HTTPS webhook targets | `false` |
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Allow webhooks to private/loopback addresses | `false` |
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
| `OBSIGATE_MEDIA_MAX_INLINE_MB` | Max size for inline audio/video playback (above: download) | `500` |
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
| `OBSIGATE_TAVILY_API_KEY` / `OBSIGATE_BRAVE_API_KEY` / `OBSIGATE_SERPAPI_API_KEY` / `OBSIGATE_EXA_API_KEY` | Keyed web-search providers (tried before SearXNG) | — |
| `OBSIGATE_WEB_PROVIDERS` | Search provider order (e.g. `brave,searxng`) | — |
@@ -1040,7 +1042,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf
| Frontend import validation | `node tests/frontend/validate-imports.mjs` | `lint` |
| Frontend unit tests | `node tests/frontend/unit.test.mjs` | `lint` |
| Backend tests | `pytest tests/ -q` | `test` |
| **E2E Playwright** | `npm run test:e2e` (~5 min) | `e2e` |
| **E2E Playwright** | `npm run test:e2e` (~10 min) | `e2e` |
#### Local E2E Tests (`npm run test:e2e`)
@@ -1063,6 +1065,14 @@ bash scripts/run-e2e-local.sh --headed # visible browser
bash scripts/run-e2e-local.sh -g "reset panes" # filter on a test
```
On Windows, when `bash` is unusable (WSL unavailable, git-bash blocked by an
Application Control policy), use the equivalent PowerShell launcher:
```powershell
npm run test:e2e:ps
pwsh -File scripts/run-e2e-local.ps1 -PlaywrightArgs @('-g','reset panes')
```
The suite must end with **all tests passing** (60 currently), with no failure
or reliance on retries. In case of failure: fix and re-run locally until 100 %,
then only commit.
@@ -1140,8 +1150,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.17.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.25.0).
---
*Project: ObsiGate | Version: 2.17.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.25.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.17.0
2.25.0
+121 -71
View File
@@ -28,6 +28,7 @@ from backend.tools.api import (
ToolError,
ToolScope,
call_tool,
get_tool,
get_tool_schemas,
)
from backend.tools.labels import thought_step_label, tool_step_label
@@ -121,12 +122,15 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
"""Answer a tool call that was not reached because the run stopped early.
A single LLM response may carry several tool calls. When one of them is
mutating and pauses the run for confirmation, the assistant message already
lists *all* of them, so every ``tool_call_id`` must get a tool result before
the next LLM call (the OpenAI tool protocol rejects dangling ids). The calls
that were not reached get a synthetic ``deferred`` result; the model
re-issues them once the confirmed call has been applied (BUG-050).
A single LLM response may carry several tool calls; when the run stops
before reaching some of them (tool-call quota), the assistant message still
lists *all* of them, so every ``tool_call_id`` must get a tool result
before the next LLM call (the OpenAI tool protocol rejects dangling ids).
The calls that were not reached get a synthetic ``deferred`` result.
Note: mutating calls that pause the run for confirmation are no longer
deferred — they are batched and applied together on resume (BUG-075); this
helper remains for budget stops (BUG-050/BUG-052).
"""
return {
"role": "tool",
@@ -135,13 +139,29 @@ def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, An
"content": json.dumps({
"status": "deferred",
"reason": reason or (
"Not executed: the run paused to confirm an earlier tool call. "
"Not executed: the run stopped before reaching this tool call. "
"Re-issue this call if it is still needed."
),
}, ensure_ascii=False),
}
def _action_descriptor(call: Any) -> dict[str, Any]:
"""Describe one paused mutating tool call for the confirmation payload.
A single LLM response may request several mutations (create a folder and
the files inside it…). They are batched into one confirmation so the user
approves the whole plan in one click (BUG-075). ``step`` reuses the
Notion-style label, so the confirmation card reads like the steps block.
"""
return {
"id": call.id,
"tool": call.name,
"arguments": call.arguments,
"step": tool_step_label(call.name, call.arguments),
}
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
"""Deterministic non-empty answer built from the gathered tool results.
@@ -212,53 +232,66 @@ def _execute_confirmed(
executed: list[ToolCallRecord],
on_tool_call: Callable[[ToolCallRecord], None] | None,
) -> None:
"""Apply a previously-paused mutating tool call and feed its result back.
"""Apply previously-paused mutating tool calls and feed their results back.
The pending payload is the ``error`` object emitted by a ``confirmation``
event. The assistant tool-call message is expected to already be in
event, optionally carrying an ``actions`` list with every mutating call of
the LLM turn (BUG-075). Each action is applied with a one-shot confirmation
and its ``tool_call_id`` answered, keeping the conversation valid for the
resumed turn. The assistant tool-call message is expected to already be in
``convo`` (it is part of the snapshot returned with the confirmation).
"""
from backend.ai_chat import ToolCall
error = confirm_pending.get("error", confirm_pending)
name = error.get("tool")
arguments = error.get("arguments") or {}
call_id = error.get("id") or "call_pending"
error = confirm_pending.get("error", confirm_pending) or {}
actions = confirm_pending.get("actions")
if not isinstance(actions, list) or not actions:
# Legacy single-action payload (no ``actions`` list).
actions = [{
"id": error.get("id") or "call_pending",
"tool": error.get("tool"),
"arguments": error.get("arguments") or {},
}]
if not name:
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
for action in actions:
name = action.get("tool")
arguments = action.get("arguments") or {}
call_id = action.get("id") or "call_pending"
# Make sure the assistant tool-call message is present in the snapshot.
if not any(
m.get("role") == "assistant" and any(
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
if not name:
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
# Make sure the assistant tool-call message is present in the snapshot.
if not any(
m.get("role") == "assistant" and any(
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
)
for m in convo
):
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
try:
result = call_tool(name, ctx, arguments, confirm=True)
payload = result.data
ok = True
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=name, arguments=arguments, ok=ok, result=payload,
step=tool_step_label(name, arguments),
)
for m in convo
):
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
executed.append(record)
if on_tool_call is not None:
on_tool_call(record)
try:
result = call_tool(name, ctx, arguments, confirm=True)
payload = result.data
ok = True
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=name, arguments=arguments, ok=ok, result=payload,
step=tool_step_label(name, arguments),
)
executed.append(record)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
convo.append({
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
async def run_agent(
@@ -315,6 +348,36 @@ async def run_agent(
convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
executed: list[ToolCallRecord] = []
def _run_call(call: Any) -> None:
"""Execute one tool call, record it and answer its ``tool_call_id``.
``ToolConfirmationRequired`` propagates to the caller so the loop can
pause and batch the mutating calls of the turn (BUG-075).
"""
try:
result = call_tool(call.name, ctx, call.arguments)
payload: Any = result.data
ok = True
except ToolConfirmationRequired:
raise
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=call.name, arguments=call.arguments, ok=ok, result=payload,
step=tool_step_label(call.name, call.arguments),
)
executed.append(record)
steps.append(record.step)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
if confirm_pending:
if quota is not None and len(executed) >= quota:
return AgentResult(
@@ -357,19 +420,25 @@ async def run_agent(
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
)
try:
result = call_tool(call.name, ctx, call.arguments)
payload = result.data
ok = True
_run_call(call)
except ToolConfirmationRequired as e:
logger.info(f"Agent paused: confirmation required for '{call.name}'")
pending = e.to_dict()
# Include the tool-call id so the client can echo it back.
pending["error"]["id"] = call.id
# BUG-050: the assistant message lists every tool call of this
# batch, so answer the ones we did not reach to keep the
# conversation valid for the resumed turn.
for skipped in response.tool_calls[index + 1:]:
convo.append(_deferred_tool_message(skipped))
# BUG-075: batch every mutating call of this LLM turn so the
# user approves the whole plan at once (one resume applies them
# all) instead of approving one action after another. Read-only
# calls of the batch run immediately and answer their
# ``tool_call_id`` so the resumed turn stays valid.
actions = [_action_descriptor(call)]
for after in response.tool_calls[index + 1:]:
spec = get_tool(after.name)
if spec is not None and spec.requires_confirmation:
actions.append(_action_descriptor(after))
else:
_run_call(after)
pending["actions"] = actions
return AgentResult(
content=response.content or "",
messages=convo,
@@ -379,25 +448,6 @@ async def run_agent(
stopped=STOP_CONFIRMATION_REQUIRED,
pending=pending,
)
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=call.name, arguments=call.arguments, ok=ok, result=payload,
step=tool_step_label(call.name, call.arguments),
)
executed.append(record)
steps.append(record.step)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
logger.warning(f"Agent reached max iterations ({max_iterations})")
return await _finalize_answer(
+48 -2
View File
@@ -2,6 +2,8 @@
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
# and admin user CRUD.
import base64
import binascii
import logging
import re
@@ -109,6 +111,43 @@ class UpdateUserRequest(BaseModel):
return validate_password_strength(v)
# ── Profile avatar (#113) ───────────────────────────────────────────
#: Avatar data-URL pattern — PNG/JPEG/WebP only (no SVG: XSS surface).
_AVATAR_DATA_URL_RE = re.compile(
r"^data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$"
)
#: ~300 KB of base64 payload (a 256px JPEG is ~15 KB; generous headroom).
_AVATAR_MAX_CHARS = 400_000
def _validate_avatar(data_url: str) -> str | None:
"""Validate an avatar data-URL for storage on the user profile.
Returns the normalized data-URL, or ``None`` when clearing the avatar
(empty string). Raises ``HTTPException(400)`` on anything else.
"""
if data_url == "":
return None
if len(data_url) > _AVATAR_MAX_CHARS:
raise HTTPException(400, "Avatar image too large")
if not _AVATAR_DATA_URL_RE.match(data_url):
raise HTTPException(400, "Avatar must be a PNG, JPEG or WebP data URL")
try:
raw = base64.b64decode(data_url.split(",", 1)[1], validate=True)
except (ValueError, binascii.Error) as exc: # pragma: no cover — regex guards
raise HTTPException(400, "Avatar payload is not valid base64") from exc
# Confirm the decoded bytes really are a supported image (magic numbers).
is_png = raw.startswith(b"\x89PNG\r\n\x1a\n")
is_jpeg = raw.startswith(b"\xff\xd8\xff")
is_webp = (
len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP"
)
if not (is_png or is_jpeg or is_webp):
raise HTTPException(400, "Avatar payload is not a PNG, JPEG or WebP image")
return data_url
# ── Public endpoints ──────────────────────────────────────────────────
@router.get("/status")
@@ -221,6 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
"display_name": user["display_name"],
"role": user["role"],
"vaults": user["vaults"],
"avatar": user.get("avatar"),
},
}
@@ -343,6 +383,7 @@ async def get_me(current_user=Depends(require_auth)):
"vaults": current_user["vaults"],
"language": current_user.get("language", "fr"),
"last_login": current_user.get("last_login"),
"avatar": current_user.get("avatar"),
}
@@ -350,19 +391,23 @@ class UpdateMeRequest(BaseModel):
"""Fields the user can update on their own profile."""
display_name: str | None = None
language: str | None = None
#: Image data-URL (PNG/JPEG/WebP), or ``""`` to remove the avatar (#113).
avatar: str | None = None
@router.patch("/me")
async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
"""Update current user's profile fields (display_name, language)."""
"""Update current user's profile fields (display_name, language, avatar)."""
from .user_store import update_user
updates = {}
updates: dict[str, object] = {}
if req.display_name is not None:
updates["display_name"] = req.display_name
if req.language is not None:
if req.language not in ("fr", "en"):
raise HTTPException(400, "language must be 'fr' or 'en'")
updates["language"] = req.language
if req.avatar is not None:
updates["avatar"] = _validate_avatar(req.avatar)
if not updates:
raise HTTPException(400, "No fields to update")
updated = update_user(current_user["username"], updates)
@@ -373,6 +418,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
"vaults": updated["vaults"],
"language": updated.get("language", "fr"),
"last_login": updated.get("last_login"),
"avatar": updated.get("avatar"),
}
+1
View File
@@ -96,6 +96,7 @@ def create_user(
"vaults": vaults or [],
"active": True,
"language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(timezone.utc).isoformat(),
"password_changed_at": datetime.now(timezone.utc).timestamp(),
"last_login": None,
+5
View File
@@ -15,6 +15,7 @@ import time
from pathlib import Path
from typing import Any
from backend.media_types import is_media
from backend.secret_redactor import redact_file_content
logger = logging.getLogger("obsigate.bookslm")
@@ -185,6 +186,10 @@ def collect_directory_context(vault_path: Path, directory: str) -> dict[str, Any
def _file_entry(target: Path, rel_path: str, remaining: int) -> dict[str, Any] | None:
"""Read, redact and truncate a single file into a context entry."""
suffix = target.suffix.lower()
# #109-D3 — audio/video (and images) carry no extractable text; never feed
# raw bytes to the model. Images are handled separately via vision data URLs.
if is_media(suffix):
return None
try:
if suffix == ".pdf":
from backend.pdf_reader import extract_pdf_text
+15 -3
View File
@@ -110,6 +110,12 @@ class BooksLMChatRequest(BaseModel):
description="Conversation snapshot returned alongside a ``confirmation`` event, "
"echoed back to resume the agent run.",
)
confirm_all: bool = Field(
default=False,
description="Global approval (BUG-075): apply every pending action of the batch "
"and auto-approve the remaining mutating calls of the same run, "
"so the run does not pause on each action.",
)
app_context: dict[str, Any] | None = Field(
default=None,
description="Live client UI state for the General assistant: open_documents, "
@@ -506,9 +512,11 @@ async def api_bookslm_agent(
Same context as ``/chat`` but the model may call tools (read/search the
vault) through the shared tool layer. Emits one ``tool`` event per executed
tool call, then a final ``message`` event. Mutating tools pause the run with
a ``confirmation`` event (two-step propose/apply) carrying the pending call
and the conversation snapshot; the client resumes by echoing them back in
``confirm`` / ``confirm_messages``.
a ``confirmation`` event (two-step propose/apply) carrying the pending
``actions`` (every mutating call of the turn) and the conversation snapshot;
the client resumes by echoing them back in ``confirm`` / ``confirm_messages``,
optionally with ``confirm_all`` to apply the whole batch and auto-approve the
rest of the run (BUG-075).
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user, agent=True)
@@ -523,6 +531,10 @@ async def api_bookslm_agent(
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
if req.confirm_all:
# BUG-075: a single global approval authorizes the whole plan, so the
# run no longer pauses on every subsequent mutating call.
ctx.confirmed = True
async def _llm(msgs, tool_schemas):
return await chat_completion(
+2 -2
View File
@@ -11,7 +11,7 @@ from typing import Any
import frontmatter
from backend.media_types import IMAGE_EXTENSIONS, is_media
from backend.media_types import AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, is_media
logger = logging.getLogger("obsigate.indexer")
@@ -71,7 +71,7 @@ SUPPORTED_EXTENSIONS = {
".dockerfile", ".makefile", ".cmake",
".excalidraw",
".excalidraw.md",
} | set(IMAGE_EXTENSIONS)
} | set(IMAGE_EXTENSIONS) | set(AUDIO_EXTENSIONS) | set(VIDEO_EXTENSIONS)
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
+160 -28
View File
@@ -53,7 +53,7 @@ from backend.indexer import (
update_single_file,
)
from backend.media_thumbs import generate_thumbnail, is_decodable
from backend.media_types import IMAGE_EXTENSIONS, is_image, media_mime_type
from backend.media_types import IMAGE_EXTENSIONS, is_audio, is_image, is_video, media_mime_type
from backend.openapi_docs import (
API_DESCRIPTION,
TAGS_METADATA,
@@ -205,6 +205,11 @@ class FileContentResponse(BaseModel):
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
is_pdf: bool | None = Field(default=None, description="True for PDF files")
is_image: bool | None = Field(default=None, description="True for image files")
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
is_csv: bool | None = Field(default=None, description="True for CSV files")
is_json: bool | None = Field(default=None, description="True for JSON files")
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
@@ -1037,6 +1042,27 @@ def _content_disposition(disposition: str, filename: str) -> str:
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
def _media_max_inline_bytes() -> int:
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
above the limit are not streamed in the viewer (the UI falls back to the
download button), which keeps a single uvicorn worker from being pinned by
multi-gigabyte media. Invalid or non-positive values fall back to default.
"""
default_mb = 500
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
if not raw:
return default_mb * 1024 * 1024
try:
mb = float(raw)
except ValueError:
return default_mb * 1024 * 1024
if mb <= 0:
return default_mb * 1024 * 1024
return int(mb * 1024 * 1024)
def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a relative path safely within the vault root.
@@ -2443,6 +2469,61 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"size_bytes": size,
}
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
if is_audio(ext) or is_video(ext):
size = file_path.stat().st_size
mime = media_mime_type(str(file_path))
media_kind = "audio" if is_audio(ext) else "video"
# #109-A3 — beyond the inline limit the viewer falls back to download
# (a single uvicorn worker must not be pinned by multi-GB media).
if size > _media_max_inline_bytes():
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"media_too_large": True,
"size_bytes": size,
}
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
if media_kind == "audio":
html = (
f'<div class="audio-viewer">'
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
f'</div>'
)
else:
html = (
f'<div class="video-viewer">'
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
f'</div>'
)
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_audio": media_kind == "audio",
"is_video": media_kind == "video",
"media_mime": mime,
"stream_url": stream_url,
"size_bytes": size,
}
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
@@ -2618,32 +2699,21 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
}
@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
async def api_pdf_stream(
request: Request,
vault_name: str,
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
def _stream_file_with_range(file_path: Path, request: Request, media_type: str):
"""Return a file response honouring the HTTP ``Range`` header (roadmap #109).
Supports HTTP Range requests (206 Partial Content) so browsers can
progressively render large PDFs in the native viewer.
Shared by ``pdf/stream`` and ``/api/media``: a plain :class:`FileResponse`
with ``Accept-Ranges: bytes`` when no range is requested, or a
:class:`StreamingResponse` (206 Partial Content, 64 KiB chunks) for a valid
single range. An unsatisfiable range yields ``416`` with a
``Content-Range: bytes */size`` header.
Reads are offloaded to threads so the event loop is never blocked
(ASYNC230), matching the previous inline implementation.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Not a PDF file")
file_size = file_path.stat().st_size
range_header = request.headers.get("range")
disposition = _content_disposition("inline", file_path.name)
if range_header:
# Parse "bytes=start-end" (single range only; multi-range is not used by viewers)
@@ -2671,7 +2741,6 @@ async def api_pdf_stream(
chunk_size = end - start + 1
async def _partial():
# Open + reads offloaded to threads (avoid blocking the event loop — ASYNC230)
f = await asyncio.to_thread(open, str(file_path), "rb")
try:
await asyncio.to_thread(f.seek, start)
@@ -2688,18 +2757,45 @@ async def api_pdf_stream(
return StreamingResponse(
_partial(),
status_code=206,
media_type="application/pdf",
media_type=media_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk_size),
"Content-Disposition": _content_disposition("inline", file_path.name),
"Content-Disposition": disposition,
},
)
return FileResponse(str(file_path), media_type="application/pdf", headers={
return FileResponse(str(file_path), media_type=media_type, headers={
"Accept-Ranges": "bytes",
"Content-Disposition": _content_disposition("inline", file_path.name)})
"Content-Disposition": disposition})
@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse)
async def api_pdf_stream(
request: Request,
vault_name: str,
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Stream a PDF file with Content-Type: application/pdf for inline browser viewing.
Supports HTTP Range requests (206 Partial Content) so browsers can
progressively render large PDFs in the native viewer.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Not a PDF file")
return _stream_file_with_range(file_path, request, "application/pdf")
@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse)
@@ -3207,6 +3303,42 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}")
@app.get("/api/media/{vault_name}", response_class=FileResponse)
async def api_media_stream(
request: Request,
vault_name: str,
path: str = Query(..., description="Relative path to audio/video file"),
current_user=Depends(require_auth),
):
"""Stream an audio/video file with HTTP Range support (roadmap #109-A2).
Serves the bytes with the correct MIME type and honours ``Range`` requests
(``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is
what enables scrubbing in ``<audio>``/``<video>`` and is required by Safari
for MP4. Files above ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB) are
refused with ``413`` — the viewer falls back to the download button.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = _resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"Media not found: {path}")
ext = file_path.suffix.lower()
if not (is_audio(ext) or is_video(ext)):
raise HTTPException(status_code=400, detail="Not an audio/video file")
if file_path.stat().st_size > _media_max_inline_bytes():
raise HTTPException(status_code=413, detail="Media too large for inline streaming")
return _stream_file_with_range(file_path, request, media_mime_type(str(file_path)))
@app.get("/api/media/{vault_name}/thumb", response_class=FileResponse)
async def api_media_thumb(
vault_name: str,
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.17.0"
version = "2.25.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.17.0"
version = "2.25.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.17.0",
"version": "2.25.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+4
View File
@@ -221,6 +221,10 @@ Gestion :
| `/api/auth/admin/users/{u}` | Modifier / supprimer | PATCH/DELETE | Admin |
| `/api/admin/stats` · `/audit` · `/backup-stats` · `/stream` | Monitoring admin | GET | Admin |
> `PATCH /api/auth/me` accepte `{"avatar": "<data-url>"}` (PNG/JPEG/WebP, 400 000
> caractères max, octets magiques contrôlés) ; `{"avatar": ""}` supprime la photo.
> La valeur est renvoyée par `GET /api/auth/me` et par le payload `user` du login.
### 3.10 Partage, webhooks, conflits, plugins, push
| Endpoint | Description | Méthode |
+5
View File
@@ -67,6 +67,11 @@ CHANGE THIS PASSWORD on first login!
Changez-le immédiatement (menu profil → *Changer le mot de passe*).
Vous pouvez aussi ajouter une **photo de profil** : *Configurations → Profil →
Choisir une image* (PNG, JPG ou WEBP, 8 Mo maximum — recadrée en carré 256 px).
Elle remplace les initiales dans le cercle du compte en bas de la sidebar et peut
être supprimée à tout moment depuis la même section.
---
## 3. Gestion des utilisateurs
+6
View File
@@ -142,6 +142,12 @@ Elle regroupe les vues principales via des icônes :
Un champ **« Filtrer fichiers… »** restreint l'arborescence en temps réel, et le
bouton **Aa** ajuste l'affichage des libellés.
En bas de la sidebar (si l'authentification est activée), la **section compte**
affiche votre avatar (ou vos initiales), votre nom et votre rôle ; un clic ouvre
le profil. La photo se choisit dans **Configurations → Profil** (*Choisir une
image* : PNG, JPG ou WEBP — recadrée en carré 256 px, affichée dans le cercle de
la sidebar) ; le bouton *Se déconnecter* est juste à côté.
### 5.3 La zone de contenu
Elle affiche l'onglet actif : tableau de bord **Statistiques**, **Bookmarks**,
+14 -1
View File
@@ -14,7 +14,7 @@
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
- **Dernière mise à jour** : 2026-09-17
- **Dernière mise à jour** : 2026-09-24
---
@@ -180,7 +180,14 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts |
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
| *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite |
| *BUG-073* | Mobile : la barre de navigation fixe du bas masque le bas de tous les documents et pages affichés (les dernières lignes restent définitivement sous la barre) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/style.css` | Mobile (≤768px) : ouvrir un document long, défiler jusqu'au bas → la fin du contenu passe sous la barre `#mobile-toolbar` et n'est jamais atteignable | Clearance mobile retargetée de `.main-layout` (sélecteur mort, absent de `index.html`) vers `.main-body` (`calc(64px + env(safe-area-inset-bottom, 0))`) ; règle sœur morte `.editor-modal.active ~ .main-layout` supprimée ; reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture) ; `body.np-active .content-area` ramené à `76px` (dégagement dock seul, géométrie totale inchangée). Tests : `tests/frontend/mobile-toolbar.test.mjs` (7, au CI), E2E `tests/e2e/mobile-toolbar.spec.js` (2, `chromium-mobile`, skip desktop) | La règle de clearance du bloc mobile cible `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, aucun dégagement réservé |
| | | | | | | | | | | |
| *BUG-074* | [🟡 IMPORTANT] Assistant IA : le bloc d'étapes affiche « 1 step » sans titre alors que l'agent réalise plusieurs actions (compteur toujours à 1) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/agent/loop.py` | Mode agent : demander une création multi-fichiers/dossiers → chaque message ne montre qu'« 1 étape ▶ » sans détail | `backend/agent/loop.py` + `frontend/js/bookslm.js` : compteur = actions (hors réflexions) + titre = 1re action dans le `<summary>` ; reprise de confirmation diffusée dans le **même** message (fusion des étapes). Tests : `tests/frontend/ai.test.mjs` (+3) | Le résumé `<summary>` ne porte aucun titre ; chaque reprise de confirmation crée un **nouveau** message assistant qui ne contient qu'une action ; les réflexions gonflent le compteur |
| *BUG-075* | [🟡 IMPORTANT] Assistant IA : chaque action mutatrice demande son propre « Appliquer » — aucun résumé des actions en attente ni approbation globale | 🟢 corrigé | P1 | ⚙️ backend + 📱 frontend | IA | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js` | Mode agent : demander une structure de répertoires multi-fichiers → valider une action après l'autre | `backend/agent/loop.py` (`pending.actions`, lot exécuté au resume) ; `backend/bookslm_routes.py` (`confirm_all` → `ctx.confirmed`) ; `frontend/js/bookslm.js` (carte multi-actions + « Tout approuver (N) »). Tests : `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs` | La pause de confirmation ne capture que le **premier** appel mutateur du lot (les suivants sont `deferred`) ; carte unique sans liste ; nouveau `confirm_all` à ajouter pour autoriser la suite de l'exécution en une approbation |
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -258,6 +265,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-073 | Correction | `frontend/style.css`, `tests/frontend/mobile-toolbar.test.mjs` (nouveau), `tests/e2e/mobile-toolbar.spec.js` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-073** : en mobile (≤768px), la barre fixe `#mobile-toolbar` (64px + safe-area) recouvrait le bas de tous les documents/pages — fin de contenu inaccessible. (1) **Cause** : la règle de clearance du bloc `@media (max-width: 768px)` ciblait `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, zéro dégagement ; règle sœur morte `.editor-modal.active ~ .main-layout { padding-bottom: 0 }` supprimée (overlay plein écran / nécessaire en édition inline). (2) **Correctif** : `.main-body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)) }`, reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture), `body.np-active .content-area` ramené de `calc(64px+safe+76px)` à `76px` (dégagement dock seul — géométrie totale identique, pas de double comptage avec `.main-body`). Tests : `mobile-toolbar.test.mjs` (7 statiques, ajouté au CI), E2E `mobile-toolbar.spec.js` (géométrie + scroll fin de `ANALYSE_REVIEW.md`, skip hors viewport ≤768). Vérifié : `mobile-toolbar` 7/7, JSDOM 14 suites 0 échec, **E2E `chromium-mobile` BUG-073 2/2 + régressions mobile-editor/config-mobile 6/6**, **suite `chromium-desktop` complète 106 passed / 9 skipped (11,4 min)**, pytest 1302 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`</section>` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `<h2>`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) |
| 2026-09-24 | BUG-074 → BUG-077 | Correction | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot assistant IA (mode agent)** : (BUG-075) confirmation par lot — `pending.actions` regroupe toutes les mutations d'un tour LLM, un unique bouton « Tout approuver (N) » envoie `confirm_all` (`ToolContext.confirmed`) et n'interrompt plus à chaque action ; les lectures du lot s'exécutent aussitôt. (BUG-074) résumé du bloc d'étapes avec titre de la 1re action + compteur limité aux actions, reprise diffusée dans le même message (fini le « 1 étape » fragmenté). (BUG-076) refresh de l'arborescence débouncé sur les events `tool` mutateurs + `_notifyFileWritten` étendu aux documents (xlsx/docx/csv/pdf). (BUG-077) le bouton d'envoi devient « Stop » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). Guide/i18n FR/EN + `ai.stop`/`ai.stopped`/`ai.confirm_actions`/`ai.action_apply_all` ; `SW_VERSION` v25. Vérifié : pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11, ai 100/100, editor-inline 44/44, mobile-editor 35/35, ai-sidebar 6/6, forge 32/32, pane-manager 9/9, sw 8/8. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
---
+12 -58
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.17.0 | **Dernière mise à jour :** 2026-09-23
> **Version :** 2.25.0 | **Dernière mise à jour :** 2026-09-24
> **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)**
@@ -80,60 +80,6 @@
---
## ⚪ Backlog — Média (images, audio, vidéo)
### 109. Support audio & vidéo — lecteurs intégrés HTML5
- **Effort :** 2-3 jours | **Impact :** 🟡 | **Zone :** backend (media streaming) + frontend (viewer)
- **Statut :** ⚪ Prévu — s'appuie sur le socle `media_types.py` livré par #108
- **Description :** prise en charge des fichiers audio (`.mp3 .m4a .aac .wav .ogg .oga .opus .flac`) et vidéo (`.mp4 .webm .mov .m4v`) avec la même parité que les autres fichiers : apparition dans l'arborescence, indexation du nom, lecture directe dans le viewer via les balises HTML5 `<audio>` / `<video>`. Actuellement ces fichiers tombent dans le chemin binaire « Ce fichier est binaire et ne peut pas être affiché » + bouton download (`frontend/js/viewer.js:622`).
- **Choix technique — lecteurs HTML5 natifs, pas de transcodage :**
- `<video>`/`<audio>` sont natifs partout (Chrome, Firefox, Edge, Safari, mobile) : play, scrub, volume, vitesse 0,25–2×, PiP, plein écran — sans une ligne de JS de contrôle.
- **Zéro dépendance lourde** : pas de ffmpeg dans l'image Docker, pas de file de transcodage. Les formats web-natifs (MP4/H.264, WebM, MP3, AAC, Opus, FLAC, WAV) couvrent l'immense majorité des notes vocales et screencasts.
- Le vrai travail serveur est le **support HTTP `Range` / 206 Partial Content** : sans lui, pas de scrub (le navigateur retélécharge tout pour se positionner) et Safari refuse le MP4. Bonne nouvelle : `pdf/stream` (`backend/main.py:2617`) **implémente déjà les Range** (206, `Content-Range`, 416) — extraire cette logique en helper `stream_file_with_range(file_path, request, media_type)` et s'en servir pour les trois médias.
- Métadonnées (durée, résolution) lues **côté client** via `loadedmetadata` (`duration`, `videoWidth/videoHeight`) — aucun ffprobe serveur nécessaire.
- **Sous-tâches :**
##### A. Backend — socle média partagé (1 j) — bénéficie aussi à #74/#108
- [ ] **A1.** Ajouter `AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS` dans `backend/media_types.py` (#108-A1) et les intégrer à `SUPPORTED_EXTENSIONS` avec la branche « contenu non parsé » (#108-A2).
- [ ] **A2.** Extraire le helper Range de `pdf/stream` en fonction réutilisable ; nouvel endpoint `GET /api/media/{vault}?path=…` : streaming par chunks 64 ko (`aiofiles`), 206 + `Content-Range` + `Accept-Ranges`, 416 sur Range invalide, MIME via `mimetypes` + map de secours (`.m4a→audio/mp4`, `.opus→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
- [ ] **A3.** Gardes-fous : `_resolve_safe_path`, `check_vault_access`, taille inline max `OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut 500 — au-delà, UI download).
- [ ] **A4.** `api_file_view()` : branches AVANT le `read_text()` → `is_audio: true` / `is_video: true` (+ `size_bytes`, `stream_url` vers `/api/media`). `pdf/stream` bascule sur le helper A2 (comportement inchangé, tests de régression).
##### B. Frontend — lecteur audio (0,5 j)
- [ ] **B1.** Dispatch `data.is_audio` dans `viewer.js` : `<audio controls src=stream_url>` pleine largeur, artwork placeholder (icône Lucide `music`), titre + voûte + taille dans la toolbar, durée formatée via `loadedmetadata`.
- [ ] **B2.** `EXT_ICONS` : audio → Lucide `audio-lines` ; vidéo → `video`.
- [ ] **B3.** Pause au changement d'onglet (comportement fichier standard, cohérence #75) — pas de lecture persistante en v1.
##### C. Frontend — lecteur vidéo (0,5 j)
- [ ] **C1.** Dispatch `data.is_video` : `<video controls playsinline>` centré, `max-height: calc(100vh - header)`, fond noir, letterboxing.
- [ ] **C2.** Vérifier le bouton pop-out avec médias (médias same-origin, `X-Frame-Options: SAMEORIGIN` compatible).
- [ ] **C3.** Codec non supporté (`.mkv`, `.avi`, HEVC…) : sur l'événement `error` de l'élément média → remplacer le lecteur par l'UI binaire existante + message « Ce format ne peut pas être lu dans votre navigateur » + Télécharger / Ouvrir dans un nouvel onglet (le player OS prend le relais).
##### D. Recherche & intégrations (0,5 j)
- [ ] **D1.** Filtres `ext:mp3`, `ext:mp4`, etc. (mécanisme #108-A4).
- [ ] **D2.** Fichiers récents (#35) / dashboards : exclure les médias des previews de contenu (aucun texte).
- [ ] **D3.** BooksLM (#76) : exclure les médias du contexte collecté (pas de texte extractible).
- [ ] **D4.** (Hors scope, noter la porte) Transcription audio via Whisper sur le provider AI existant.
##### E. Mobile & PWA (0,25 j)
- [ ] **E1.** Tester le scrub sur iOS Safari (exige un Range strictement correct) et Android Chrome.
- [ ] **E2.** Service worker (#59) : exclure les médias du cache offline (taille), stratégie Network First inchangée pour le reste.
##### F. Tests (0,5 j)
- [ ] **F1.** `test_media_stream.py` : `bytes=0-1023` → 206 + Content-Range + 1024 octets ; Range hors borne → 416 ; sans Range → 200 + Accept-Ranges ; path traversal refusé ; voûte sans accès refusée. Régression : `pdf/stream` inchangé après extraction du helper.
- [ ] **F2.** `test_media_indexing.py` : un `.mp3` apparaît dans l'arborescence, indexé sans contenu, watcher sans erreur.
- [ ] **F3.** Playwright : fixtures binaires minimaux commités dans `tests/fixtures/` (mini `.mp3` sine wave ~2 ko, mini `.mp4`) → `<audio>` et `<video>` rendus.
- **Points d'attention / risques**
- **Range est LE prérequis** (sous-tâche la plus testée, pas une option) : sans 206 correct, Safari casse et le scrub hache la bande passante.
- **Formats hors web-natifs** (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans transcodage (ffmpeg hors scope) — le fallback C3 les rend téléchargeables proprement au lieu de silencieusement cassés.
- **Poids disque** : une vidéo de 1 Go dans un vault ralentit les backups (#17, gzip) — envisager d'exclure les gros médias de la compression gzip.
- **Sous uvicorn (worker unique)** : lectures vidéo simultanées = file descriptors + bande passante ; surveiller via le health check enrichi (#68).
- **Hors scope v1** : HLS, sous-titres (`<track src=.vtt>`), thumbnails vidéo (première frame) — extensions naturelles documentées ici.
---
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
### 84. Consolidation & sécurité — revue statique 2026-09-13 (phase 1)
@@ -237,6 +183,15 @@
| 107 | Configuration — Gestion des clés API & MCP : création/révocation de jetons longue durée (1 j, 1 mois, 6 mois, 1 an, sans fin), une seule clé pour l'API REST et le serveur MCP, « dernière utilisation », store `data/api_tokens.json` sans secret persisté | 2.15.0 | [features/api-mcp-tokens-107.md](./features/api-mcp-tokens-107.md) |
| 86 | Optimisation globale des performances (phase 3) — scan différentiel, excalidraw différé, garde-fou `replace` (inverted index / PDF lazy / caps regex déjà livrés via BUG-033/040/025) | 2.16.0 | [features/perf-phase3-86.md](./features/perf-phase3-86.md) |
| 108 | Support complet des images — arborescence, visionneuse (zoom/pan/navigation/miniatures), indexation nom+métadonnées, `media_types.py`, filtre `ext:`, SVG sandbox | 2.17.0 | [features/image-support.md](./features/image-support.md) |
| 109 | Support audio & vidéo — lecteurs HTML5 intégrés, streaming HTTP Range (`/api/media`), fallback codec/taille | 2.18.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
| 110 | Lecteur média persistant « Now Playing » — élément partagé téléporté (inline ⇄ dock), Media Session, mini-vidéo PiP, mobile, reprise | 2.19.0 | [features/media-viewers-109.md](./features/media-viewers-109.md) |
| 111 | Visionneuse d'images — navigation fluide : image ajustée au cadre, navigation en place (cache annuaire + préchargement), pellicule persistante, flèches latérales au survol | 2.20.0 | [features/image-navigation-111.md](./features/image-navigation-111.md) |
| 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) |
| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) |
| 114 | Configuration — refonte mobile-responsive (modale plein écran 100dvh, sommaire en drawer coulissant, cibles tactiles ≥ 44 px, inputs 16 px anti-zoom, sauvegarde sticky, MFA 1 colonne, purge i18n/HTML) | 2.23.0 | [features/settings-mobile-114.md](./features/settings-mobile-114.md) |
| BUG-078 | Fichiers de code — coloration syntaxique restaurée (feuilles highlight.js basculées sur le mode de thème et non la clé) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
---
@@ -244,12 +199,11 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–108, #92 | ~123 jours réalisés |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–114, #92 | ~133 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ Média | #109 Audio/vidéo (lecteurs HTML5, Range) — s'appuie sur le socle média de #108 | 2-3 jours |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total restant** | **7 items + finitions** | **~25-40 jours** |
| **Total restant** | **6 items + finitions** | **~23-38 jours** |
---
+1
View File
@@ -20,6 +20,7 @@
- [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend **pour le chat classique uniquement** (BUG-053 : en mode agent, le prompt impose les outils natifs et interdit les blocs `obsigate-action`)
- [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM.
- [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.*
- **Complément (BUG-074 → BUG-077)** : la pause de confirmation **regroupe toutes les mutations** d'un même tour LLM (`pending.actions`, chacune avec son libellé `step` et son diff) et la carte n'offre plus qu'un seul bouton « **Tout approuver (N)** » ; la reprise envoie `confirm_all` et le backend arme `ToolContext.confirmed` pour le reste du run (plus d'approbation action par action). Le bloc d'étapes affiche un **titre** (1re action) et ne compte que les **actions** (hors réflexions) ; la reprise diffuse dans le **même message** (« N étapes » cumulées). L'arborescence et le document affiché sont **rafraîchis** dès une action mutatrice (refresh débouncé + `obsigate:file-written`, documents xlsx/docx/csv/pdf inclus). Le bouton d'envoi devient « **Stop** » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »).
- [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md))
- [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`)
+69
View File
@@ -0,0 +1,69 @@
# #112 — En-tête allégé & compte en sidebar
> **Version livrée :** 2.21.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`index.html`, `frontend/js/auth.js`, `frontend/js/viewer.js`,
> `frontend/style.css`, i18n FR/EN) + CI (`.gitea/workflows/ci.yml`).
## Contexte
Le header concentrait plusieurs éléments redondants ou peu lisibles :
- la **version** était affichée en permanence à côté du bouton Options ;
- un **bouton de déconnexion** et le **nom de l'utilisateur** occupaient la zone
droite du header, sans regroupement logique ;
- la pellicule de miniatures de la visionneuse d'images ne pouvait être
parcourue qu'au glisser (pas de molette horizontale ni de flèches dédiées).
## Ce qui a été livré
### A. Header allégé
- **Version déplacée** du header vers le menu **Options** : nouvelle ligne
non-interactive « Version » (`#version-badge` conservé pour `populateVersions`).
- **Suppression** du bloc `#user-menu` (nom + bouton « sign out ») et de la
ligne « Déconnexion » du menu Options. Le header droit ne garde que l'indicateur
hors-ligne, le contexte vault et le bouton Options.
### B. Section compte en bas de la sidebar
- Nouveau bloc `#sidebar-user` épinglé au bas de la sidebar (`flex-shrink: 0`,
bordure supérieure), à la manière d'un site web professionnel :
- initiales dans un **avatar** circulaire (dégradé d'accent) ;
- **nom** (`#sidebar-user-name`, classe `.user-display-name` conservée pour la
réutilisation par la collaboration) et **rôle** localisé
(Administrateur / Utilisateur) ;
- **bouton déconnexion** (`#sidebar-user-logout`) ;
- clic sur l'identité → **Profil** (ouvre la section `#cfg-profile`).
- `AuthManager.renderUserSection()` peuple le bloc ; il reste masqué quand
l'authentification est désactivée (`#sidebar-user[hidden]`). `renderUserMenu()`
est conservé comme alias rétro-compatible.
- La règle mobile qui masquait `.user-display-name` est limitée au header
(`.header-right .user-display-name`) pour que le nom reste visible dans la
sidebar mobile.
### C. Pellicule d'images défilable (#112, complément de #111)
- Nouveau conteneur `.image-nav` autour de la pellicule, avec deux **flèches
translucides** fixes (`.image-strip-arrow-prev/next`) révélées au survol.
- **Molette** au-dessus de la pellicule → défilement horizontal
(`strip.scrollLeft += deltaY`), conversion des deltas verticaux.
- Les flèches font défiler d'une « page » (`strip.scrollBy`, défilement doux).
## Tests
- `tests/frontend/unit.test.mjs` (+1) : header nettoyé (plus de `#user-menu`,
`#logout-btn`, ni version dans `.header-right`), version dans le menu Options,
présence de `#sidebar-user`, styles `.sidebar-user`/`.menu-list-version`, clés
i18n FR/EN.
- `tests/frontend/image-viewer.test.mjs` (+1) : wrapper `.image-nav`, flèches de
pellicule, molette et `scrollBy`.
- `tests/e2e/header-sidebar.spec.js` (nouveau) : test header/version exécuté
partout ; test compte en sidebar conditionné à l'auth (skip sinon).
- CI : `image-viewer.test.mjs` ajouté au job `lint` (il n'y était pas depuis #108).
## Limitations connues
- La section compte n'apparaît que si l'authentification est activée.
- `doLogoutFallback` (script inline supprimé avec la ligne du menu) reste appelé
par la section Profil uniquement en repli ; `window.handleLogout` est toujours
défini par `auth.js`, donc le repli n'est jamais atteint.
+80
View File
@@ -0,0 +1,80 @@
# #111 — Visionneuse d'images : navigation fluide
> **Version livrée :** 2.20.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/style.css`, i18n FR/EN).
> **Améliore :** [#108](./image-support.md) (visionneuse livrée en 2.17.0).
## Contexte
La visionneuse d'images de #108 fonctionnait mais restait perfectible sur l'usage
quotidien :
- certaines images s'affichaient **plus grandes que le cadre** de présentation ;
- changer d'image (←/→, flèches, vignette) appelait `openFile` → `renderFile` →
`renderImageViewer`, soit **un rechargement complet** de la vue **et** un
nouvel appel `/api/browse` à *chaque* image — sensible dès qu'un dossier en
contient beaucoup ;
- la **pellicule de miniatures disparaissait** le temps du re-rendu ;
- aucune zone de clic latérale ne permettait de changer d'image.
## Ce qui a été livré
### A. Ajustement au cadre
- La zone de contenu devient un conteneur **flex** dédié à la visionneuse
(`.content-area:has(> .image-viewer-container) { padding: 0; overflow: hidden }`) :
plus de défilement de page, la visionneuse occupe tout l'espace disponible.
- `.image-stage` gagne `min-height: 0`, un `padding` de 16 px (cadre) et
`box-sizing: border-box` ; `.image-main` conserve `max-width/height: 100%`
avec `width/height: auto`. L'image est donc **toujours redimensionnée dans
l'espace disponible**, y compris quand le panneau « Métadonnées » s'ouvre
(la scène se réduit, l'image suit).
### B. Navigation « en place » (performance)
- `renderImageViewer` ne se contente plus de rendre une image : il gère un état
mutable (`currentPath`, `currentTitle`, `imgUrl`, `currentMeta`) et expose
`showSibling(index)` qui **remplace le `src` du `<img>`** sans reconstruire le
DOM. Les flèches, le clavier ←/→ et les miniatures passent tous par là.
- Conséquences : plus de `openFile`, plus de re-rendu, **plus de refetch du
fichier ni de `/api/browse`** à chaque image.
- **Cache annuaire** `_imageDirCache` (`Map`, TTL 15 s) : la liste des images
d'un dossier n'est récupérée qu'une fois par courte fenêtre.
- **Préchargement** des images voisines (`new Image()`), avec un `Set` pour
éviter les doublons.
### C. Pellicule persistante
- La pellicule de miniatures n'est plus recréée à chaque navigation ; la
vignette active est simplement re-marquée (`.active`) et **amenée dans la vue
par défilement horizontal du film uniquement** (jamais la page).
- Miniatures en `loading="lazy"` + `decoding="async"`.
### D. Flèches latérales translucides
- Deux boutons superposés `.image-nav-arrow` (prev/next) longent les bords du
cadre, avec une icône `chevron` et une ombre portée pour rester lisibles sur
toute image.
- Opacité quasi nulle au repos, révélée au **survol du cadre** (`0.4`) puis du
bouton (`1`, avec dégradé sombre). Toujours visibles (opacité moyenne) sur les
appareils tactiles (`@media (hover: none)`).
- Le `pointerdown`/`dblclick` des flèches n'est pas propagé à la scène : le
pan (glisser) et le double-clic de réinitialisation du zoom restent intacts.
- Un **compteur** `n / total` est ajouté à la barre d'outils.
## Tests
- `tests/frontend/image-viewer.test.mjs` : helpers purs inchangés + vérifications
statiques de la navigation en place (`showSibling`, absence de
`_imageViewerNavPending`, cache annuaire), des flèches et du CSS
(`:has(> .image-viewer-container)`, `max-width/height`, `opacity`).
- `tests/e2e/image-viewer.spec.js` (+1) : image contenue dans le cadre, flèche
superposée révélée au survol, titre mis à jour **sans recréer le conteneur**
(marqueur `data-inplace`), pellicule toujours visible et compteur affiché.
## Limitations connues
- Le cache annuaire a un TTL court : une image ajoutée puis ouverte dans les
quelques secondes peut ne pas apparaître tout de suite dans la pellicule.
- Les flèches latérales n'apparaissent pas en mode lightbox plein écran
(navigation clavier ←/→ et pellicule masquée conservées).
+187
View File
@@ -0,0 +1,187 @@
# #109 — Support audio & vidéo (lecteurs HTML5 intégrés)
> **Version livrée :** 2.18.0 · **Statut :** ✅ · **Impact :** 🟡
> **Zone :** backend (`main`, `indexer`, `media_types`, `bookslm`) + frontend
> (`viewer.js`, `utils.js`, `style.css`, `sw.js`).
## Contexte
Le socle média de #108 (`backend/media_types.py`) exposait déjà
`AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS`, mais ces fichiers n'étaient ni indexés
ni affichables : ils tombaient dans le chemin binaire « Ce fichier est binaire
et ne peut pas être affiché » + bouton download.
## Ce qui a été livré
### A. Backend
- **Indexation** : `AUDIO_EXTENSIONS` et `VIDEO_EXTENSIONS` sont intégrées à
`SUPPORTED_EXTENSIONS` (`indexer.py`). La branche `is_media(ext)` existante
indexe **nom / taille / mtime** sans jamais lire les octets (`content: ""`),
donc le TF-IDF et les logs restent propres. Le watcher et le filtre `ext:`
suivent automatiquement.
- **Streaming** : le helper Range de `pdf/stream` a été extrait en
`_stream_file_with_range(file_path, request, media_type)` (206 +
`Content-Range` + `Accept-Ranges`, `416` sur plage invalide, `FileResponse`
simple sinon, lectures offloadées via `asyncio.to_thread`). `pdf/stream`
l'utilise désormais aussi (comportement inchangé, tests de régression).
- **Nouvel endpoint `GET /api/media/{vault}?path=…`** : sert audio/vidéo avec le
MIME `media_types.media_mime_type` (surcharges `.m4a→audio/mp4`,
`.opus/.oga→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
- **Gardes-fous** : `_resolve_safe_path`, `check_vault_access`, et
`OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut **500 Mo**) — au-delà, l'endpoint
renvoie `413` et la vue fichier bascule sur l'UI de téléchargement.
- **`api_file_view()`** renvoie, avant tout `read_text()`, `is_audio` /
`is_video` / `stream_url` / `media_mime` / `size_bytes`. Au-delà de la limite :
`unsupported: true` + `media_too_large: true`.
### B/C. Frontend — lecteurs
- `viewer.js` dispatche `data.is_audio` → `renderAudioViewer()` et
`data.is_video` → `renderVideoViewer()`.
- **Audio** : `<audio controls preload="metadata">` pleine largeur, artwork
placeholder (icône Lucide `audio-lines`), durée lue via `loadedmetadata`,
toolbar (titre, voûte + taille, badge durée, ouvrir l'original, télécharger).
- **Vidéo** : `<video controls playsinline preload="metadata">` centrée sur une
scène noire letterboxée (`max-height: calc(100vh - 180px)`), même toolbar
avec durée + résolution.
- `renderMediaFallback()` : sur l'événement `error` de l'élément média (codec
hors web-natif : `.mkv`, `.avi`, HEVC…) ou si le fichier est trop volumineux,
remplace le lecteur par l'UI binaire + message
`viewer.media_unsupported` / `viewer.media_too_large` + **Télécharger** /
**Ouvrir dans un nouvel onglet**.
- **Pause** au changement de vue : `_mediaViewerCleanup` met en pause et détache
la source quand `renderFile()` re-rend la zone (changement d'onglet, navigation)
— pas de lecture persistante en v1 (cohérence #75).
- `EXT_ICONS` (`utils.js`) : audio → `audio-lines`, vidéo → `video`.
- i18n FR/EN (`viewer.media_unsupported`, `viewer.media_too_large`).
### D. Recherche & intégrations
- Filtres `ext:mp3`, `ext:mp4`, etc. opérationnels (les médias entrent dans
l'index).
- Récents / dashboards : previews vides (aucun texte extrait) — comportement
naturel de la branche binaire.
- **BooksLM** : `_file_entry()` ignore désormais tout média (`is_media`) — les
octets ne sont jamais envoyés au modèle ; les images restent gérées à part via
`load_vault_image_data_url` (vision).
- **Hors scope v1** (porte notée) : transcription audio via Whisper.
### E. Mobile & PWA
- Le service worker ne met **jamais** en cache le flux média
(`/api/media/{vault}`), tout en conservant le cache des miniatures
(`/api/media/{vault}/thumb`) et la stratégie Network First pour le reste. Les
requêtes `Range` étaient déjà exclues.
### F. Tests
- `tests/test_media_stream.py` : 206 + `Content-Range` + 1024 octets, `416` hors
borne, `200` + `Accept-Ranges` sans Range, suffix range, `413` au-delà de la
limite, `403` path traversal, `403` vault sans accès, `400` non-média, MIME
`.mov`, régression `pdf/stream`.
- `tests/test_media_indexing.py` : `.mp3`/`.mp4`/`.flac` dans l'arborescence et
l'index, `content == ""`, watcher pertinent, filtre `ext:mp3`.
- `tests/frontend/media-viewer.test.mjs` : helpers purs (`formatMediaDuration`,
`buildMediaUrl`) + vérifications statiques (dispatch, `<audio>`/`<video>`,
fallback, CSS, icônes, i18n, service worker, endpoints backend).
- `tests/e2e/media-viewer.spec.js` : lecture `<audio>` et `<video>` via
`/api/media` + réponse `206` sur requête `Range`. Fixtures :
`test_vault/sample-audio.mp3` (sine 1 s) et `test_vault/sample-video.webm`
(VP8 64×64).
## Limitations connues
- Formats hors web-natifs (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans
transcodage (ffmpeg hors scope) → repli téléchargement / lecteur de l'OS.
- HLS, sous-titres `<track src=".vtt">` et vignettes vidéo : hors scope v1.
- Gros médias (> 500 Mo par défaut) : pas de lecture intégrée (protège le worker
uvicorn unique) ; ajustable via `OBSIGATE_MEDIA_MAX_INLINE_MB`.
---
## #110 — Lecteur média persistant « Now Playing »
> **Version livrée :** 2.19.0 · **Statut :** ✅ · **Impact :** 🟡
> **Zone :** frontend (`now-playing.js`, `viewer.js`, `ui.js`, `pane-manager.js`,
> `app.js`, `style.css`, `index.html`, locales).
### Principe : un seul média, téléporté
`frontend/js/now-playing.js` est un contrôleur singleton qui possède **l'unique
élément `<audio>`/`<video>`** de l'application. Il est déplacé par `appendChild`
(sans recréation, donc sans couper la lecture) entre :
- la **vue inline** — l'onglet/panneau du média, via la surface
`NowPlaying.attachInline(area, data)` (appelée par `renderAudioViewer` /
`renderVideoViewer` de `viewer.js`) ; et
- le **dock global** — un enfant direct de `<body>` (`#now-playing-host`), monté
hors de `.content-wrapper` pour survivre à `renderFile()`, aux onglets, aux
panneaux et à la reconstruction de la grille split.
`renderFile()` appelle `NowPlaying.handleRender(area, data)` : si la zone qui va
être réécrite contient l'élément média, celui-ci est renvoyé au dock. Les autres
points qui vident le contenu (dashboard `_showDashboard`, `showWelcome`,
`PaneManager._buildGrid` / `_collapseToSingle`) appellent le même hook via le
global `window.NowPlaying`.
### Surfaces et ergonomie
- **Dock audio (desktop)** : pilule flottante verre dépoli centrée en bas
(`.np-dock--audio`) — artwork, titre, voûte, durée, play/pause,
précédent/suivant, barre de progression (seek), volume, **revenir au média**,
agrandir, fermer.
- **Panneau étendu** : carte centrale (bottom-sheet sur mobile) avec artwork,
scrub large, volume, vitesse 0,5–2×, précédent/suivant et actions
ouvrir/télécharger/fermer.
- **Mini-vidéo flottante** (`.np-dock--video`) : déplaçable **librement** depuis
n'importe quel point de la fenêtre (position absolue mémorisée, centre autorisé
— aucune aimantation aux bords) et redimensionnable, géométrie persistée ; sur
mobile elle se fixe au-dessus de la barre d'outils.
- **Mobile** : mini-player au-dessus de la barre 64 px
(`bottom: calc(64px + env(safe-area-inset-bottom))`), `viewport-fit=cover`
ajouté, et `body.np-active` ajoute le décalage du contenu. La barre audio passe
en grille (progression sur sa propre ligne) et masque les actions secondaires
pour éviter tout chevauchement.
### Comportements
- Naviguer (onglet, panneau, dashboard, split) **ne coupe pas** la lecture ; le
dock apparaît.
- **Revenir au média** : `NowPlaying.focus()` rouvre/focalise l'onglet
`vault::path` (`window.getActiveTabManager().open`).
- **Fermer** : `NowPlaying.stop()` met en pause, libère l'élément et masque le
dock.
- **Fermer l'onglet** du média en cours : la lecture continue et un toast
`player.continues` le signale.
- **Media Session** : métadonnées (`MediaMetadata`) + actions
play/pause/stop/seek/nexttrack/previoustrack → écran verrouillé, casque
Bluetooth, touches média, **Windows SMTC** (WebView2).
- **Picture-in-Picture** natif pour la vidéo (`requestPictureInPicture`), bouton
masqué si non supporté.
- **Reprise après rechargement** : état (fichier, position, pause, préférences
volume/vitesse) persisté en `localStorage` (`obsigate-now-playing`,
`obsigate-player-prefs`, `obsigate-player-pos`).
### Fichiers modifiés / ajoutés
- Nouveau : `frontend/js/now-playing.js` (contrôleur, dock, session, PiP,
persistance), `tests/e2e/media-viewer.spec.js` (dock/retour/fermeture/mini-vidéo).
- Modifiés : `viewer.js` (délégation inline + `handleRender`), `ui.js`
(dashboard + toast de fermeture d'onglet), `pane-manager.js` (grille/collapse),
`app.js` (`initNowPlaying`), `style.css` (dock/étendu/vidéo/mobile, et
correction des variables `--surface1`/`--text-dim` non définies),
`index.html` (`viewport-fit=cover`), locales FR/EN (`player.*`).
### Correctifs annexes
- Les variables CSS `--surface1` et `--text-dim`, utilisées mais **jamais
définies** depuis #108/#109, sont remplacées par `--surface` et
`--text-secondary` (+ `--text-dim` dans toute la feuille).
### Hors scope (porte notée)
- Fenêtre vidéo détachée **native** Tauri (`WebviewWindowBuilder` +
`always_on_top`) : non implémentée, à faire dans une itération dédiée (Rust,
capabilities, route `/player`).
+172
View File
@@ -0,0 +1,172 @@
# #114 — Configuration — refonte mobile-responsive de la section Settings
> **Statut :** 🟢 · **Impact :** 🟡 · **Zone :** frontend (mobile, ≤ 768 px)
> **Fichiers :** `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`,
> `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`,
> `tests/e2e/config-mobile.spec.js`.
## Contexte
La page **Configurations** (`#config-modal`) était utilisable en mobile seulement
partiellement (correctifs BUG-071) : le sommaire s'ouvrait en bloc haut, la modale
n'était pas plein écran, les cibles tactiles étaient sous 44 px, le clavier virtuel
iOS zoomait les champs, la rangée « Sauvegarder » disparaissait au scroll et plusieurs
dettes HTML/i18n étaient restées en place.
## Ce qui a été livré
### A. Sommaire en drawer coulissant
- `#config-nav` devient un **panneau coulissant gauche** (`position: fixed`,
`width: min(320px, 88vw)`, `z-index: 40`) sous un fond assombri
(`#config-modal.config-toc-open::before`, `z-index: 35`).
- Ouverture/fermeture pilotée par la classe **`.config-toc-open`** sur `#config-modal`
(JS `_setConfigNav`) + un `display` inline conservé pour le contrat de reset.
- Bouton **`#config-toc-close`** (classe `.help-toc-close`, `aria-label`
`config.toc_close`) dans l'en-tête du drawer ; visible uniquement dans le drawer
de la config (masqué sur desktop où la nav est toujours visible).
- **Backdrop** : un tap hors du drawer ferme d'abord le drawer, pas la modale
(`e.target === modal` → `config-toc-open` présent → `_setConfigNav(false)`).
- **Échap** : ferme le drawer d'abord, puis la modale.
- Fermeture de la modale (`closeConfigModal`) nettoie toujours la classe
`config-toc-open` et le `display` inline (aucun fond ne subsiste).
- Animation `config-toc-slide-in` (translateX) à l'ouverture.
### B. Modale plein écran
- `#config-modal` : `padding: 0`, `.editor-container` en `100vw × 100dvh`
(`100dvh` = hauteur du viewport dynamique, tient compte de la barre du navigateur
mobile), `border-radius: 0`, `border: none`.
- Le contenu (`#config-scroll`) garde son scroll propre ; le sous-bloc
`.config-content` reçoit un `padding-bottom: 80 px` pour ne jamais passer sous la
rangée sticky.
### C. Cibles tactiles ≥ 44 px & anti-zoom iOS
- **Champs** : `.config-input`, `.config-select`, `.help-nav-search`,
`.profile-field .config-input/.config-select` → `min-height: 44px` +
`font-size: 16px` (**anti-zoom iOS** : un `font-size < 16px` déclenche le zoom
automatique au focus). La règle est **scoppée `#config-modal`** pour ne pas écraser
`.mfa-code-input` (qui a sa propre typographie).
- **Boutons** : `.config-btn-save`, `.config-btn-secondary`, `.config-btn-primary`,
`.config-btn-danger`, `.config-btn-add`, `.config-btn-sm`, `.mfa-link-btn`,
`.theme-action-btn`, `.profile-avatar-actions .config-btn-secondary`,
`.editor-btn` (fermer), `#config-hamburger`, `#config-toc-close`,
`.help-search-clear` → `min-height/min-width: 44px`.
- **Liens du sommaire** : `.help-nav-link` → `min-height: 44px` (ligne tactile
confortable).
- `.help-hamburger` passe de 36 px à **44 px** en mobile (règle partagée avec le
modal d'aide).
### D. Rangée « Sauvegarder » sticky
- `.config-actions-row` (dans `#cfg-backend-settings`) → `position: sticky;
bottom: 0`, empilée verticalement (`flex-direction: column`), boutons pleine
largeur 44 px, fond opaque + bordure, `padding-bottom` avec
`env(safe-area-inset-bottom)` (barre home iOS).
- La rangée reste visible pendant le scroll de la section backend ; le
`padding-bottom: 80px` de `.config-content` garantit qu'elle ne masque jamais les
derniers contrôles.
### E. Formulaires 1 colonne & grilles
- `.config-row` → 1 colonne (`grid-template-columns: 1fr`), `.config-input--num`
pleine largeur, `text-align: left`.
- `.ai-default-grid` / `.ai-provider-fields` → 1 colonne.
- Add-rows (`.config-add-row`, `.config-add-pattern`) → wrap + largeurs inline
(`180/140/100px`) neutralisées (`width: auto !important`).
- Items webhook/token/share → wrap ; URLs/méta sur leur propre ligne
(`overflow-wrap: anywhere`) ; boutons de suppression 44 px.
- `.hidden-files-add-row` → wrap, input pleine largeur.
- `.config-diag-row` → wrap.
- `.profile-avatar-row` → wrap ; `.profile-form` → `max-width: 100%`.
- `.webauthn-key-item` → wrap ; `.webauthn-key-label` → pleine largeur.
- `.theme-grid` reste en `auto-fill minmax(160px, 1fr)` (déjà responsive).
### F. MFA & sécurité
- `.mfa-recovery-list` → **1 colonne** en mobile (2 colonnes illisibles à 360 px).
- `.mfa-verify-section`, `.mfa-recovery-actions`, `.mfa-disable-actions` → wrap ;
champs/boutons enfants en pleine largeur.
- `.mfa-code-input` → `width: 100%`, `max-width: 320px`, `letter-spacing: 6px`
(au lieu de 12 px qui débordait), `min-height: 52px`.
- `.mfa-secret-code` → `word-break: break-all` (secret TOTP long).
- `.mfa-code-input-group` → wrap.
- Règle morte **`.mfa-recovery-input`** supprimée (auth.js utilise
`mfa-code-input recovery-input`).
### G. Dettes HTML corrigées
- `.config-actions-row` (Sauvegarder / Réindexer / Réinitialiser) déplacée
**dans** `#cfg-backend-settings` (elle était hors de toute section → le sticky
n'avait pas de conteneur de scroll fiable) ; `</section>` orphelin supprimé.
- Id dupliqué **`cfg-partages-publics`** retiré du `<h2>` (l'id reste sur la
`<section>`, cf. #113).
- Conteneur mort **`#plugins-settings-container`** supprimé (le rendu réel est
`#cfg-plugins` via `plugins.js`).
- Sections plugins/about ré-indentées.
- **`#mt-explorer`** : libellé brut `settings.search` remplacé par
`<span data-i18n="settings.explorer">` (i18n correct, FR « Explorateur » / EN
« Files »).
- Doublons CSS **`.config-btn-add`** (3 définitions) réduits à la définition de
référence.
### H. i18n FR/EN
- **Purge des clés mortes** (aucune référence HTML/JS/tests/backend) :
`settings.backend`, `settings.backend_hint`, `settings.restart_badge`,
`settings.save`, `settings.plugins`.
- **Ajouts** : `settings.explorer` (FR « Explorateur » / EN « Files »),
`config.toc_close` (FR « Fermer le sommaire » / EN « Close contents »).
- **Correctif** : `settings.tabs` en FR était le mot anglais « Tabs » →
**« Onglets »** (EN reste « Tabs »).
- Clés vivantes conservées : `settings.reindex`, `settings.no_restart_badge`,
`settings.backend_section`, `settings.security`, `settings.search`,
`settings.tabs`, `settings.search_placeholder`.
## Tests
### Statics — `tests/frontend/config-mobile.test.mjs` (27, au CI)
- BUG-071a–e (hamburger, toggle JS, grilles/wrap, ancres mortes,
`data-i18n-attr` multi-paires) — conservés et adaptés au drawer.
- **#114a** : `#config-toc-close` présent + i18n ; `_setConfigNav` bascule
`.config-toc-open` ; backdrop `::before` (z-index 35) ; `.help-toc-close`
masqué sur desktop / visible dans le drawer ; Échap et backdrop ferment le
drawer d'abord ; `closeConfigModal` nettoie la classe.
- **#114b** : modale `100dvh` + `padding: 0` ; inputs/selects `16px` +
`44px` ; boutons `44px` ; rangée sticky (`position: sticky` +
`flex-direction: column` + safe-area) ; MFA 1 colonne + wrap + code
full-width ; `.config-actions-row` bien dans `#cfg-backend-settings`.
- **#114i18n** : clés mortes purgées ; `settings.explorer` FR/EN ;
`settings.tabs` FR = « Onglets » ; `#mt-explorer` porte
`data-i18n="settings.explorer"` ; `#plugins-settings-container` absent.
### E2E — `tests/e2e/config-mobile.spec.js` (5, projet `chromium-mobile`)
1. Hamburger → drawer `position: fixed` + classe `config-toc-open` sur la modale.
2. Bouton `#config-toc-close` et tap backdrop ferment le drawer **sans** fermer la
modale.
3. Sélection d'une section → scroll doux + lien actif + repli du drawer.
4. Modale plein écran (largeur/hauteur ≈ viewport) + `#config-hamburger`,
`#config-close` et `#cfg-save-backend` ≥ 44 px.
5. Aucun débordement horizontal à 393 px (sections IA / tokens / webhooks /
partages).
## Détails d'implémentation notables
- **Spécificité CSS** : les règles `#config-modal #config-nav` (2 ids) priment sur
les règles génériques `.help-nav` / `body .help-nav` qui masquent la nav en
mobile — pas besoin de `!important`.
- **Backdrop = pseudo-élément** : les clics sur `::before` sont attribués à
l'élément or (`#config-modal`), donc le handler `e.target === modal` existant
fonctionne sans node supplémentaire.
- **Contrat de reset conservé** : `configNavOnOpen.style.display = ''` à
l'ouverture (test statique BUG-071b) — le CSS reprend la main (drawer masqué par
défaut sur mobile).
- **`100dvh` avec repli `100vh`** : les navigateurs sans support `dvh` gardent le
comportement précédent.
- La règle `.help-hamburger { display: inline-flex }` du bloc mobile du modal
d'aide est **partagée** (44 px) ; le drawer de la config double avec
`#config-modal .help-hamburger` pour rester robuste à un réordonnancement des
règles.
@@ -0,0 +1,94 @@
# #113 — Ordre naturel des sections Configurations & avatar utilisateur
> **Version livrée :** 2.22.0 · **Statut :** 🟢 · **Impact :** 🟡
> **Zone :** frontend (`index.html`, `frontend/js/config.js`, `frontend/js/auth.js`,
> `frontend/style.css`, i18n FR/EN) + backend (`backend/auth/router.py`,
> `backend/auth/user_store.py`) + CI (`.gitea/workflows/ci.yml`).
## Contexte
Deux irritants sur la page **Configurations** :
- l'ordre des sections était historique et peu naturel (Recherche en tête, Profil
noyé en position 11, À propos au milieu) ;
- la section **Profil** ne permettait pas de personnaliser l'image affichée dans le
cercle du compte en bas de la sidebar (initiales uniquement).
## Ce qui a été livré
### A. Ordre naturel des sections (TOC = page)
Nouvel ordre, appliqué **à la liste `<ul class="help-nav-list">` (TOC) et aux
`<section>` de la page**, dans le même ordre :
1. **Profil** (`#cfg-profile`) — en premier
2. Sécurité du compte (`#cfg-security`)
3. Thèmes (`#cfg-themes`)
4. Paramètres de recherche (`#cfg-search`)
5. Historique récent (`#cfg-recent`)
6. Filtrage de tags (`#cfg-tags`)
7. Fichiers cachés (`#cfg-hidden-files`)
8. Synchronisation (`#cfg-sync`)
9. Paramètres backend (`#cfg-backend-settings`)
10. Diagnostics (`#cfg-diags`)
11. Clés API IA (`#cfg-ai`)
12. Sources connectées (`#cfg-sources`)
13. Clés API & MCP (`#cfg-tokens`)
14. Notifications push (`#cfg-push`)
15. Webhooks (`#cfg-webhooks`)
16. Partages publics (`#cfg-partages-publics`)
17. Plugins (`#cfg-plugins`)
18. **À propos** (`#cfg-about`) — en dernier
Regroupement retenu : **Compte & apparence → Navigation & contenu → Système →
Intégrations & notifications → À propos**.
Les ancres `cfg-tags` et `cfg-partages-publics` étaient posées sur un `<h2>` à
l'intérieur d'une `<section>` sans id : elles sont désormais portées par la
`<section>` elle-même, pour que le scroll vise le haut de la section (comme toutes
les autres).
### B. Avatar utilisateur ( Profil )
- **Import d'image** : bouton « Choisir une image » + overlay caméra au survol de
l'aperçu circulaire (88 px) → `<input type="file" accept="image/png,image/jpeg,image/webp">`.
- **Traitement client** (`config.js`) : garde type (PNG/JPEG/WEBP) et taille brute
(8 Mo), **recadrage carré central** et redimensionnement à **256 px** via canvas,
export JPEG qualitée 0,85 (fond blanc pour les PNG transparents).
- **Persistance serveur** : `PATCH /api/auth/me` avec `{"avatar": "<data-url>"}` ;
`""` supprime. Validation stricte dans `backend/auth/router.py`
(`_validate_avatar`) : data-URL PNG/JPEG/WebP uniquement (**SVG refusé** — surface
XSS), plafond 400 000 caractères, base64 valide et **octets magiques** contrôlés.
Champ `avatar` ajouté à `data/users.json` (`create_user`), renvoyé par
`GET/PATCH /api/auth/me` et par le payload `user` de login.
- **Affichage sidebar** (`auth.js`) : `renderUserSection()` insère un
`<img class="sidebar-user-avatar-img">` dans `#sidebar-user-avatar` quand
`user.avatar` est défini, sinon les initiales (repli inchangé). Helpers
`AuthManager.updateCachedUser()` et `AuthManager.isAuthEnabled()`.
- **Suppression** : bouton « Supprimer la photo » (stylisté danger), revenu aux
initiales.
- **Auth désactivée** : le bloc avatar est masqué (pas de compte, sidebar masquée).
- Feedback : toasts `config.avatar_updated` / `config.avatar_removed`, erreurs en
ligne (`config.avatar_invalid_type`, `config.avatar_too_large`,
`config.avatar_upload_failed`).
## Tests
- `tests/test_auth_api.py` — classe `TestAvatar` (+8) : GET/PATCH exposent
l'avatar, data-URL PNG acceptée, `""` efface, SVG refusé, payload non-image refusé,
trop grand refusé, base64 invalide refusé, avatar présent dans le payload de login.
- `tests/frontend/settings-order-avatar.test.mjs` (nouveau, ajouté au job `lint`) —
9 tests : ordre TOC (Profil 1er, À propos dernier), ordre de page strictement
identique à la TOC, aucune ancre morte / section orpheline, présence de l'UI avatar
dans `#cfg-profile`, flux `config.js` (types, taille, resize, PATCH, rafraîchissement
sidebar), rendu `auth.js`, règles CSS, clés i18n FR/EN, validation backend.
- Vérifications locales : pytest 1302 passed, ruff/mypy 0 erreur, tests frontend
statiques + JSDOM verts.
## Limitations connues
- L'avatar est stocké en data-URL dans `data/users.json` (adéquat pour un usage
personnel ; un stockage fichier dédié restera possible si les comptes se multiplient).
- La conversion GIF/animé n'est pas prise en charge (types PNG/JPEG/WEBP uniquement).
- Le nom d'affichage du profil reste local (`localStorage`) et n'est pas poussé au
serveur (comportement antérieur conservé).
@@ -0,0 +1,122 @@
# #115 / BUG-078 / #117 — Barre d'outils épinglée, coloration syntaxique des fichiers de code & avatars prédéfinis
> **Statut :** 🟢 livré (en attente vérification utilisateur)
> **Impact :** 🟡 (#115, BUG-078) · 🟢 (#117)
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/js/themes.js`,
> `frontend/js/ui.js`, `frontend/js/config.js`, `frontend/index.html`,
> `frontend/style.css`, `frontend/popout.html`, `frontend/locales/fr.json`,
> `frontend/locales/en.json`, `frontend/icons/avatar/*`)
Trois demandes traitées dans la même livraison, toutes côté frontend.
## #115 — Barre d'outils de lecture toujours visible
### Problème
La barre d'actions d'un document (pop-out, bookmark, Editer, Source, Copier, PDF, Export,
Partager…) était rendue **dans** `.file-header`, un conteneur court placé en haut de la
zone de lecture. Or un élément `position: sticky` reste borné par son parent : dès que
`.file-header` sortait de l'écran au défilement d'un document long, la barre disparaissait.
### Correctif
- `frontend/js/viewer.js` et `frontend/popout.html` sortent la barre d'actions de
`.file-header` et la placent dans un nouveau conteneur `.file-toolbar`, **enfant direct
de `.content-area`** (le conteneur de défilement). La barre peut donc se coller au haut
de la zone de lecture pour toute la hauteur du document.
- `frontend/style.css` :
```css
.file-toolbar {
position: sticky;
top: 0;
z-index: 30;
margin: 0 0 16px;
padding: 8px 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
```
Le fond est opaque pour qu'aucun texte ne transparaisse dessous.
- `body.reading-mode .file-toolbar { display: none }` : la barre reste masquée en mode
lecture, comme les autres actions.
## BUG-078 — Coloration syntaxique des fichiers de code
### Problème
Les fichiers `.py`, `.sh`, `.ps1`, `.yml`, `.json`… (et les blocs de code Markdown)
s'affichaient en **texte brut**, sans couleurs. Le code était pourtant correctement
généré côté backend (`<pre><code class="language-python">…`) et `safeHighlight()` appelait
bien `hljs.highlightElement()`.
La cause était ailleurs : les deux feuilles de style de highlight.js (`#hljs-theme-dark`
et `#hljs-theme-light`, chargées depuis le CDN dans `index.html`) étaient basculées à
partir de la **clé de thème** persistée (`obsigate-theme` = `defaut-obsigate`, …) :
```js
darkSheet.disabled = theme !== "dark"; // "defaut-obsigate" !== "dark" → true
lightSheet.disabled = theme !== "light"; // "defaut-obsigate" !== "light" → true
```
Les deux feuilles finissaient donc désactivées, privant tous les tokens de leurs couleurs.
Le résultat dépendait de l'ordre de deux initialisations concurrentes — `UI.initTheme()`
(clé de thème) au démarrage et `themes.initThemes()` (mode) via `Sync.init()` — d'où un
comportement **non déterministe** (parfois coloré, le plus souvent non).
### Correctif
- `frontend/js/themes.js` — `applyTheme(themeKey, mode)` bascule désormais les feuilles
highlight.js selon le **mode** :
```js
var isDark = mode === 'dark';
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
```
(`sepia` et `high-contrast` réutilisent la palette claire.)
- `frontend/js/ui.js` — `initTheme()` lit le **mode** persisté (`obsigate-theme-mode`) et
`applyTheme()` résout un mode avant de fixer `data-theme` et de basculer les feuilles,
au lieu de comparer la clé de thème à `"dark"`/`"light"`.
Le basculement est ainsi **déterministe** dès le premier rendu et à chaque changement de
mode.
## #117 — Avatars prédéfinis dans le profil
### Ce qui a été livré
- **Galerie de 12 avatars** dans la section `Profil` (`#cfg-profile`), servis depuis
`frontend/icons/avatar/` (`/static/icons/avatar/<fichier>.jpg`) : Chat, chien, elephan,
hibou, koala, lapin, lion, ours, penda, pingouin, raton, tigre.
- Un clic charge l'image, la fait passer par le **même pipeline que l'import** (recadrage
carré central, redimensionnement 256 px, export JPEG 0,85) et l'enregistre via
`PATCH /api/auth/me` — aucune modification backend n'a été nécessaire, la validation
data-URL PNG/JPEG/WebP existante (BUG-113) s'applique telle quelle.
- L'avatar actif est **surligné** ; le choix est mémorisé dans `localStorage`
(`obsigate-avatar-preset`) et purgé dès qu'on importe une photo personnalisée ou qu'on
supprime l'avatar.
- L'import d'une photo et la suppression restent disponibles.
- i18n : nouvelle clé `config.avatar_presets_label` (FR/EN).
## Tests
- `tests/frontend/unit.test.mjs` — `syntax highlight theme` (BUG-078) : `themes.applyTheme`
bascule les feuilles selon le mode et `ui.js` ne compare plus la clé au mode.
- `tests/frontend/toolbar-order.test.mjs` — #115 : `.file-toolbar` dans `viewer.js` et
`popout.html`, règle CSS `position: sticky; top: 0`, masquage en mode lecture.
- `tests/frontend/settings-order-avatar.test.mjs` — #117 : 12 avatars présents dans
`#cfg-profile`, fichiers d'images existants, pipeline `config.js`, règles CSS, clé i18n
FR/EN.
- Vérification Playwright (instance locale, auth désactivée) : coloration déterministe sur
5 chargements successifs d'un `.py` ; `.file-toolbar` dont le `top` ne bouge plus après
défilement (épinglage effectif).
## Limitations
- L'avatar reste stocké en data-URL 256 px dans `data/users.json` (comportement #113
conservé).
- Le mode `high-contrast`/`sepia` utilise le thème highlight.js **clair** (pas de palette
dédiée).
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+413 -301
View File
@@ -2,7 +2,7 @@
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title data-i18n="header.logo">ObsiGate</title>
<!-- PWA Meta Tags -->
@@ -519,9 +519,6 @@
></i>
<span id="vault-context-text">All</span>
</div>
<!-- User menu (visible when logged in) -->
<div class="user-menu" id="user-menu"></div>
<div class="version-badge" id="version-badge" title="Version ObsiGate"></div>
<div class="header-menu">
<button
class="header-menu-btn"
@@ -770,33 +767,33 @@
>
</span>
</button>
<script>function doLogoutFallback(){var x=new XMLHttpRequest();x.open('POST','/api/auth/logout');x.withCredentials=true;x.onload=function(){var a=document.getElementById('app');var l=document.getElementById('login-screen');if(a)a.classList.add('hidden');if(l)l.classList.remove('hidden');try{localStorage.removeItem('obsigate-token')}catch(e){}};x.send()}</script>
<button
class="menu-list-row menu-list-button"
id="logout-btn"
type="button"
role="menuitem"
onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}"
<div
class="menu-list-row menu-list-version"
role="none"
>
<span
class="menu-list-icon"
aria-hidden="true"
>
<i
data-lucide="log-out"
style="width: 17px; height: 17px; color: var(--danger)"
data-lucide="tag"
style="width: 17px; height: 17px"
></i>
</span>
<span class="menu-list-content">
<span class="menu-list-title" data-i18n="header.menu_logout"
style="color:var(--danger)"
>Deconnexion</span
>
<span class="menu-list-subtitle" data-i18n="header.menu_logout_desc"
>Quitter la session</span
<span class="menu-list-title" data-i18n="header.menu_version"
>Version</span
>
<span class="menu-list-subtitle"
>ObsiGate
<span
class="version-badge"
id="version-badge"
title="Version ObsiGate"
></span
></span>
</span>
</button>
</div>
</div>
</div>
</div>
@@ -1052,6 +1049,43 @@
<span>Aucune conversation</span>
</div>
</div>
<!-- User section (bottom of the sidebar, #112) -->
<div class="sidebar-user" id="sidebar-user" hidden>
<button
class="sidebar-user-profile"
id="sidebar-user-profile"
type="button"
title="Profil"
>
<span
class="sidebar-user-avatar"
id="sidebar-user-avatar"
aria-hidden="true"
></span>
<span class="sidebar-user-meta">
<span
class="sidebar-user-name user-display-name"
id="sidebar-user-name"
></span>
<span
class="sidebar-user-role"
id="sidebar-user-role"
></span>
</span>
</button>
<button
class="sidebar-user-logout"
id="sidebar-user-logout"
type="button"
title="Déconnexion"
>
<i
data-lucide="log-out"
style="width: 16px; height: 16px"
></i>
</button>
</div>
</aside>
<!-- Sidebar resize handle -->
@@ -1517,6 +1551,17 @@
<nav class="help-nav" id="config-nav">
<div class="help-nav-header">
<div class="help-nav-title" data-i18n="config.title">Configuration</div>
<button
class="help-toc-close"
id="config-toc-close"
data-i18n-attr="aria-label:config.toc_close"
aria-label="Fermer le sommaire"
>
<i
data-lucide="x"
style="width: 16px; height: 16px"
></i>
</button>
</div>
<div class="help-nav-search-wrap">
<input
@@ -1547,28 +1592,152 @@
</button>
</div>
<ul class="help-nav-list">
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-search" class="help-nav-link" data-i18n="help.nav_search"></a></li>
<li><a href="#cfg-recent" class="help-nav-link" data-i18n="config.section_recent"></a></li>
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
<li><a href="#cfg-tags" class="help-nav-link" data-i18n="config.section_tags"></a></li>
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
<li><a href="#cfg-hidden-files" class="help-nav-link" data-i18n="config.section_hidden"></a></li>
<li><a href="#cfg-sync" class="help-nav-link" data-i18n="settings.sync"></a></li>
<li><a href="#cfg-backend-settings" class="help-nav-link" data-i18n="config.section_backend"></a></li>
<li><a href="#cfg-diags" class="help-nav-link" data-i18n="settings.diagnostics"></a></li>
<li><a href="#cfg-ai" class="help-nav-link" data-i18n="settings.ai"></a></li>
<li><a href="#cfg-sources" class="help-nav-link" data-i18n="config.section_sources">Sources connectées</a></li>
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-tokens" class="help-nav-link" data-i18n="config.nav_tokens">🔑 Clés API & MCP</a></li>
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
<li><a href="#cfg-webhooks" class="help-nav-link" data-i18n="config.section_webhooks"></a></li>
<li><a href="#cfg-partages-publics" class="help-nav-link" data-i18n="config.section_shares"></a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
</ul>
</nav>
<div class="help-content" id="config-scroll">
<div class="config-content">
<!-- Profil -->
<section
class="config-section help-section"
id="cfg-profile"
>
<h2 data-i18n="config.profile">👤 Profil utilisateur</h2>
<p class="config-description" data-i18n="config.profile_desc">
Préférences personnelles pour personnaliser
l'expérience ObsiGate.
</p>
<div class="profile-form" id="profile-form">
<div class="profile-field profile-avatar-field" id="profile-avatar-field">
<span class="profile-avatar-label" id="profile-avatar-label" data-i18n="config.avatar_label">Photo de profil</span>
<div class="profile-avatar-row">
<div
class="profile-avatar-preview"
id="profile-avatar-preview"
data-i18n-attr="title:config.avatar_choose;aria-label:config.avatar_choose"
title="Choisir une image"
aria-label="Choisir une image"
>
<img id="profile-avatar-img" alt="" hidden />
<span class="profile-avatar-initials" id="profile-avatar-initials" aria-hidden="true"></span>
<button
type="button"
class="profile-avatar-overlay"
id="profile-avatar-overlay"
data-i18n-attr="title:config.avatar_choose;aria-label:config.avatar_choose"
title="Choisir une image"
aria-label="Choisir une image"
>
<i data-lucide="camera" style="width: 18px; height: 18px"></i>
</button>
</div>
<div class="profile-avatar-actions">
<button type="button" class="config-btn-secondary" id="profile-avatar-choose" data-i18n="config.avatar_choose">Choisir une image</button>
<button type="button" class="config-btn-secondary profile-avatar-remove-btn" id="profile-avatar-remove" data-i18n="config.avatar_remove" hidden>Supprimer la photo</button>
<input
type="file"
id="profile-avatar-input"
accept="image/png,image/jpeg,image/webp"
hidden
/>
<span class="profile-hint" data-i18n="config.avatar_hint">PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px.</span>
</div>
</div>
<span class="profile-avatar-label" data-i18n="config.avatar_presets_label">Avatars prédéfinis</span>
<div
class="profile-avatar-presets"
id="profile-avatar-presets"
role="radiogroup"
data-i18n-attr="aria-label:config.avatar_presets_label"
aria-label="Avatars prédéfinis"
>
<button type="button" class="profile-avatar-preset" data-avatar="Chat.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/Chat.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="chien.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/chien.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="elephan.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/elephan.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="hibou.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/hibou.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="koala.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/koala.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="lapin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lapin.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="lion.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lion.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="ours.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/ours.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="penda.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/penda.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="pingouin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/pingouin.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="raton.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/raton.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="tigre.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/tigre.jpg" alt="" loading="lazy" /></button>
</div>
<p class="profile-avatar-error hidden" id="profile-avatar-error" role="alert"></p>
</div>
<div class="profile-field">
<label for="profile-lang" data-i18n="config.lang_label">Langue préférée</label>
<select id="profile-lang" class="config-select">
<option value="fr">Francais</option>
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="de">Deutsch</option>
</select>
<span class="profile-hint" data-i18n="config.lang_hint">Utilisée par l'autocomplétion et le ghost text.</span>
</div>
<div class="profile-field">
<label for="profile-name" data-i18n="config.display_name">Nom d'affichage</label>
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
</div>
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
</div>
</section>
<!-- Sécurité du compte (MFA) -->
<section
class="config-section help-section"
id="cfg-security"
>
<h2 data-i18n="settings.security">🔒 Sécurité du compte</h2>
<p class="config-description" data-i18n="settings.security_desc">
Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.
</p>
<div id="mfa-settings">
<div id="mfa-status" class="mfa-status-section">
<div class="mfa-status-row">
<span class="mfa-status-label" data-i18n="mfa.status_label">Authentification 2FA</span>
<span id="mfa-status-badge" class="mfa-badge mfa-badge-off" data-i18n="mfa.disabled">Désactivée</span>
</div>
<div id="mfa-setup-area"></div>
</div>
</div>
</section>
<!-- Themes -->
<section
class="config-section help-section"
id="cfg-themes"
>
<h2 data-i18n="auto.c14b5603">🎨 Thèmes</h2>
<p class="config-description" data-i18n="auto.caac40b9">
Choisissez un thème visuel. Chaque thème
offre un mode sombre, clair, contraste élevé et sépia.
</p>
<div class="theme-grid" id="theme-grid">
<div class="config-diag-loading" data-i18n="common.loading">Chargement...</div>
</div>
</section>
<!-- Performance Settings - Frontend -->
<section
class="config-section help-section"
@@ -1714,6 +1883,182 @@
</div>
</section>
<!-- Tag Filtering (existing) -->
<section class="config-section help-section" id="cfg-tags">
<h2 data-i18n="config.tag_filtering_label">Filtrage de tags</h2>
<p class="config-description" data-i18n="auto.e0b8a606">
Définissez les patterns de tags à masquer
dans la sidebar. Vous pouvez utiliser des
wildcards pour cibler les tags de template.
</p>
<div
class="config-filters-list"
id="config-filters-list"
></div>
<div class="config-add-pattern">
<input
type="text"
id="config-pattern-input"
placeholder="Ex: #&lt;% ... %&gt; ou #{{ ... }}"
class="config-input"
/>
<button id="config-add-btn"
class="config-btn-add" data-i18n="config.add_webhook">
Ajouter
</button>
</div>
<div
class="config-regex-preview"
id="config-regex-preview"
style="display: none"
>
<small
>Regex :
<code id="config-regex-code"></code
></small>
</div>
</section>
<!-- Hidden Files/Folders Configuration -->
<section id="cfg-hidden-files" class="config-section help-section">
<h2 data-i18n="auto.f8ba6127">🗂️ Fichiers cachés</h2>
<p class="config-description">
Contrôlez l'affichage des fichiers/dossiers
cachés (commençant par <code>.</code>) par
vault.
</p>
<p
class="config-hint"
style="
margin-bottom: 12px;
padding: 8px;
background: var(--background-secondary);
border-radius: 4px;
"
>
i️ <strong data-i18n="config.note">Note :</strong> Tous les fichiers
sont toujours indexés et cherchables. Ce
paramètre contrôle uniquement leur
visibilité dans l'interface.
</p>
<div id="hidden-files-vault-list">
<!-- Vault-specific settings will be injected here -->
</div>
<div
class="config-actions-row"
style="margin-top: 16px"
>
<button
class="config-btn-save"
id="cfg-save-hidden-files"
>
💾 Sauvegarder
</button>
</div>
</section>
<!-- Watcher / Synchronisation -->
<section
class="config-section help-section"
id="cfg-sync"
>
<h2 data-i18n="config.section_watcher">Synchronisation automatique</h2>
<p class="config-description" data-i18n="auto.d33601dc">
Surveillance des fichiers en temps réel via
watchdog. Les modifications sont détectées
automatiquement et l'index est mis à jour
sans redémarrage.
</p>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-enabled"
>Activer la surveillance</label
>
<label class="config-toggle">
<input
type="checkbox"
id="cfg-watcher-enabled"
checked
/>
<span
class="config-toggle-slider"
></span>
</label>
<span class="config-hint"
>Activer/désactiver la surveillance
automatique des fichiers</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-polling"
>Mode polling (fallback)</label
>
<label class="config-toggle">
<input
type="checkbox"
id="cfg-watcher-polling"
/>
<span
class="config-toggle-slider"
></span>
</label>
<span class="config-hint"
>Forcer le mode polling au lieu de
inotify natif (utile si le mode natif ne
fonctionne pas)</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-interval"
>Intervalle polling (s)</label
>
<input
type="number"
id="cfg-watcher-interval"
class="config-input config-input--num"
min="1"
max="30"
step="1"
value="5"
/>
<span class="config-hint"
>Intervalle de scrutation en mode
polling (1-30 secondes)</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-debounce"
>Debounce (s)</label
>
<input
type="number"
id="cfg-watcher-debounce"
class="config-input config-input--num"
min="0.5"
max="10"
step="0.5"
value="2"
/>
<span class="config-hint"
>Délai avant traitement des changements
groupés (0.5-10 secondes)</span
>
</div>
</section>
<!-- Performance Settings - Backend -->
<section id="cfg-backend-settings" class="config-section help-section">
<h2 data-i18n="config.section_backend">
@@ -1833,7 +2178,6 @@
>Nombre max de tokens élargis par préfixe (10-200)</span
>
</div>
</section>
<div class="config-actions-row">
<button class="config-btn-save"
id="cfg-save-backend" data-i18n="help.shortcut_save">
@@ -1850,182 +2194,6 @@
</div>
</section>
<!-- Tag Filtering (existing) -->
<section class="config-section help-section">
<h2 id="cfg-tags" data-i18n="config.tag_filtering_label">Filtrage de tags</h2>
<p class="config-description" data-i18n="auto.e0b8a606">
Définissez les patterns de tags à masquer
dans la sidebar. Vous pouvez utiliser des
wildcards pour cibler les tags de template.
</p>
<div
class="config-filters-list"
id="config-filters-list"
></div>
<div class="config-add-pattern">
<input
type="text"
id="config-pattern-input"
placeholder="Ex: #&lt;% ... %&gt; ou #{{ ... }}"
class="config-input"
/>
<button id="config-add-btn"
class="config-btn-add" data-i18n="config.add_webhook">
Ajouter
</button>
</div>
<div
class="config-regex-preview"
id="config-regex-preview"
style="display: none"
>
<small
>Regex :
<code id="config-regex-code"></code
></small>
</div>
</section>
<!-- Watcher / Synchronisation -->
<section
class="config-section help-section"
id="cfg-sync"
>
<h2 data-i18n="config.section_watcher">Synchronisation automatique</h2>
<p class="config-description" data-i18n="auto.d33601dc">
Surveillance des fichiers en temps réel via
watchdog. Les modifications sont détectées
automatiquement et l'index est mis à jour
sans redémarrage.
</p>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-enabled"
>Activer la surveillance</label
>
<label class="config-toggle">
<input
type="checkbox"
id="cfg-watcher-enabled"
checked
/>
<span
class="config-toggle-slider"
></span>
</label>
<span class="config-hint"
>Activer/désactiver la surveillance
automatique des fichiers</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-polling"
>Mode polling (fallback)</label
>
<label class="config-toggle">
<input
type="checkbox"
id="cfg-watcher-polling"
/>
<span
class="config-toggle-slider"
></span>
</label>
<span class="config-hint"
>Forcer le mode polling au lieu de
inotify natif (utile si le mode natif ne
fonctionne pas)</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-interval"
>Intervalle polling (s)</label
>
<input
type="number"
id="cfg-watcher-interval"
class="config-input config-input--num"
min="1"
max="30"
step="1"
value="5"
/>
<span class="config-hint"
>Intervalle de scrutation en mode
polling (1-30 secondes)</span
>
</div>
<div class="config-row">
<label
class="config-label"
for="cfg-watcher-debounce"
>Debounce (s)</label
>
<input
type="number"
id="cfg-watcher-debounce"
class="config-input config-input--num"
min="0.5"
max="10"
step="0.5"
value="2"
/>
<span class="config-hint"
>Délai avant traitement des changements
groupés (0.5-10 secondes)</span
>
</div>
</section>
<!-- Hidden Files/Folders Configuration -->
<section id="cfg-hidden-files" class="config-section help-section">
<h2 data-i18n="auto.f8ba6127">🗂️ Fichiers cachés</h2>
<p class="config-description">
Contrôlez l'affichage des fichiers/dossiers
cachés (commençant par <code>.</code>) par
vault.
</p>
<p
class="config-hint"
style="
margin-bottom: 12px;
padding: 8px;
background: var(--background-secondary);
border-radius: 4px;
"
>
i️ <strong data-i18n="config.note">Note :</strong> Tous les fichiers
sont toujours indexés et cherchables. Ce
paramètre contrôle uniquement leur
visibilité dans l'interface.
</p>
<div id="hidden-files-vault-list">
<!-- Vault-specific settings will be injected here -->
</div>
<div
class="config-actions-row"
style="margin-top: 16px"
>
<button
class="config-btn-save"
id="cfg-save-hidden-files"
>
💾 Sauvegarder
</button>
</div>
</section>
<!-- Diagnostics -->
<section
class="config-section help-section"
@@ -2282,72 +2450,6 @@
</div>
</section>
<!-- Themes -->
<section
class="config-section help-section"
id="cfg-themes"
>
<h2 data-i18n="auto.c14b5603">🎨 Thèmes</h2>
<p class="config-description" data-i18n="auto.caac40b9">
Choisissez un thème visuel. Chaque thème
offre un mode sombre, clair, contraste élevé et sépia.
</p>
<div class="theme-grid" id="theme-grid">
<div class="config-diag-loading" data-i18n="common.loading">Chargement...</div>
</div>
</section>
<!-- Profil -->
<section
class="config-section help-section"
id="cfg-profile"
>
<h2 data-i18n="config.profile">👤 Profil utilisateur</h2>
<p class="config-description" data-i18n="config.profile_desc">
Préférences personnelles pour personnaliser
l'expérience ObsiGate.
</p>
<div class="profile-form" id="profile-form">
<div class="profile-field">
<label for="profile-lang" data-i18n="config.lang_label">Langue préférée</label>
<select id="profile-lang" class="config-select">
<option value="fr">Francais</option>
<option value="en">English</option>
<option value="es">Espanol</option>
<option value="de">Deutsch</option>
</select>
<span class="profile-hint" data-i18n="config.lang_hint">Utilisée par l'autocomplétion et le ghost text.</span>
</div>
<div class="profile-field">
<label for="profile-name" data-i18n="config.display_name">Nom d'affichage</label>
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
</div>
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
</div>
</section>
<!-- Sécurité du compte (MFA) -->
<section
class="config-section help-section"
id="cfg-security"
>
<h2 data-i18n="settings.security">🔒 Sécurité du compte</h2>
<p class="config-description" data-i18n="settings.security_desc">
Activez l'authentification à deux facteurs (2FA) pour renforcer la sécurité de votre compte.
</p>
<div id="mfa-settings">
<div id="mfa-status" class="mfa-status-section">
<div class="mfa-status-row">
<span class="mfa-status-label" data-i18n="mfa.status_label">Authentification 2FA</span>
<span id="mfa-status-badge" class="mfa-badge mfa-badge-off" data-i18n="mfa.disabled">Désactivée</span>
</div>
<div id="mfa-setup-area"></div>
</div>
</div>
</section>
<!-- Clés API / MCP (#107) -->
<section id="cfg-tokens" class="config-section help-section">
<h2 data-i18n="config.section_tokens">🔑 Clés API &amp; MCP</h2>
@@ -2406,34 +2508,6 @@
</div>
</section>
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
<div id="plugins-settings-container"></div>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
<div
id="config-about"
class="config-diagnostics"
>
<div class="config-diag-loading" data-i18n="common.loading">
Chargement...
</div>
</div>
</section>
<!-- Webhooks -->
<section id="cfg-webhooks" class="config-section help-section">
<h2>🔔 Webhooks</h2>
@@ -2465,14 +2539,41 @@
</section>
<!-- Partages publics -->
<section class="config-section help-section">
<h2 id="cfg-partages-publics">📤 Partages publics</h2>
<section class="config-section help-section" id="cfg-partages-publics">
<h2>📤 Partages publics</h2>
<p class="config-description">
Liens de partage publics pour des documents
(lecture seule, sans authentification).
</p>
<div id="shares-list"></div>
</section>
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
<div
id="config-about"
class="config-diagnostics"
>
<div class="config-diag-loading" data-i18n="common.loading">
Chargement...
</div>
</div>
</section>
</div>
</div>
</div>
@@ -3293,6 +3394,10 @@
<strong data-i18n="sidebar.tab_tags">Tags</strong> <span data-i18n="help.desc_tags_tab">: Nuage de tags
cliquables</span>
</li>
<li>
<strong data-i18n="help.sidebar_user">Compte</strong> <span data-i18n="help.desc_sidebar_user">: Avatar, nom,
rôle et déconnexion en bas de la sidebar</span>
</li>
</ul>
<p data-i18n="help.sidebar_features">Fonctionnalités de la sidebar :</p>
<ul>
@@ -4560,6 +4665,13 @@
chercher) ; les actions de modification demandent une
confirmation avec aperçu des changements.
</li>
<li data-i18n="help.assistant_agent_run">
Les actions s'affichent dans le fil : l'assistant regroupe les
modifications en une seule approbation (« Tout approuver ») et
met à jour l'arborescence et le document ouvert dès qu'elles
sont appliquées. Le bouton d'envoi devient « Stop » pour
interrompre l'exécution à tout moment.
</li>
<li data-i18n="help.assistant_resize">
Le bord gauche du panneau est redimensionnable ; la largeur est
mémorisée.
@@ -5539,7 +5651,7 @@ curl -X POST https://votre-serveur.com/webhook \
data-lucide="folder-open"
style="width: 20px; height: 20px"
></i>
<span class="mt-label">settings.search</span>
<span class="mt-label" data-i18n="settings.explorer">Explorateur</span>
</button>
<button
class="mt-btn"
+2
View File
@@ -6,6 +6,7 @@ import * as UI from './ui.js';
import * as Utils from './utils.js';
import { initI18n, t } from './i18n.js';
import { initAIFab } from './ai-fab.js';
import { initNowPlaying } from './now-playing.js';
// Wire up AI toolbar toast (avoids circular import in utils.js)
window._obsigateShowToast = UI.showToast;
@@ -113,6 +114,7 @@ async function init() {
setupFocusMode();
Utils.safeCreateIcons();
initAIFab();
initNowPlaying();
}
document.addEventListener("DOMContentLoaded", async () => {
+69 -9
View File
@@ -1,7 +1,7 @@
/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
import { state } from './state.js';
import { safeCreateIcons, escapeHtml } from './utils.js';
import { showToast, closeHeaderMenu } from './ui.js';
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
import { t, getLocale, setLocale } from './i18n.js';
import { showWelcome } from './viewer.js';
@@ -13,6 +13,15 @@ window.handleLogout = () => {
AuthManager.logout();
};
/** Two-letter initials for the sidebar account avatar (#112). */
function userInitials(name) {
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
const first = parts[0][0] || "";
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase() || "?";
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
@@ -116,6 +125,18 @@ const AuthManager = {
return raw ? JSON.parse(raw) : null;
},
/** Merge fields into the cached user object (#113 — profile avatar). */
updateCachedUser(fields) {
const next = { ...(this.getUser() || {}), ...fields };
sessionStorage.setItem(this.USER_KEY, JSON.stringify(next));
return next;
},
/** Whether authentication is enabled on this instance (#113). */
isAuthEnabled() {
return !!this._authEnabled;
},
isTokenExpired() {
const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
if (!expiry) return true;
@@ -323,22 +344,58 @@ const AuthManager = {
const app = document.getElementById("app");
if (login) login.classList.add("hidden");
if (app) app.classList.remove("hidden");
this.renderUserMenu();
this.renderUserSection();
},
renderUserMenu() {
/**
* Render the account block pinned at the bottom of the sidebar (#112) —
* the user identity/logout no longer live in the header.
*/
renderUserSection() {
const user = this.getUser();
const userMenu = document.getElementById("user-menu");
if (!userMenu) return;
const section = document.getElementById("sidebar-user");
if (!user || !this._authEnabled) {
userMenu.innerHTML = "";
if (section) section.hidden = true;
return;
}
userMenu.innerHTML = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="' + t('auth.logout_title') + '" onclick="window.handleLogout()"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
if (!section) return;
const name = user.display_name || user.username || "";
const nameEl = document.getElementById("sidebar-user-name");
if (nameEl) nameEl.textContent = name;
const roleEl = document.getElementById("sidebar-user-role");
if (roleEl) roleEl.textContent = user.role === "admin" ? t("sidebar.user_role_admin") : t("sidebar.user_role_user");
const avatarEl = document.getElementById("sidebar-user-avatar");
if (avatarEl) {
// #113: custom avatar image when set, initials as the fallback.
if (user.avatar) {
avatarEl.textContent = "";
const img = document.createElement("img");
img.src = user.avatar;
img.alt = "";
img.className = "sidebar-user-avatar-img";
avatarEl.appendChild(img);
} else {
avatarEl.textContent = userInitials(name);
}
}
section.hidden = false;
safeCreateIcons();
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout());
const logoutBtn = document.getElementById("sidebar-user-logout");
if (logoutBtn && !logoutBtn._obsigateBound) {
logoutBtn._obsigateBound = true;
logoutBtn.addEventListener("click", () => this.logout());
}
const profileBtn = document.getElementById("sidebar-user-profile");
if (profileBtn && !profileBtn._obsigateBound) {
profileBtn._obsigateBound = true;
profileBtn.addEventListener("click", () => {
closeMobileSidebar();
const trigger = document.getElementById("profile-open-btn");
if (trigger) trigger.click();
});
}
const adminRow = document.getElementById("admin-menu-row");
if (adminRow) {
@@ -355,6 +412,9 @@ const AuthManager = {
}
},
// Backwards-compatible alias (older callers/tests may use this name).
renderUserMenu() { this.renderUserSection(); },
// ── Initialization ──────────────────────────────────────────────
async checkAuthStatus() {
+195 -41
View File
@@ -52,6 +52,22 @@ const PANEL_MIN_WIDTH = 320;
const PANEL_MAX_WIDTH = 1000;
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
// BUG-076 — Tools that mutate the vault: their execution must refresh the
// sidebar tree immediately (the watcher SSE is delayed and directory-only
// changes may not produce an index event).
const MUTATING_TOOLS = new Set([
'create_file', 'create_directory', 'edit_file', 'append_to_file',
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
'delete_file', 'delete_directory', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
]);
// Subset carrying a concrete `vault` + `path`: the displayed document is
// reloaded from disk so an open viewer/editor reflects the agent's write.
const FILE_WRITE_TOOLS = new Set([
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
]);
/**
* BUG-059 — Is this pointer press a scrollbar drag (and only that)?
*
@@ -895,15 +911,14 @@ class BooksLM {
}
/**
* #93 — A write tool just modified a vault file. Notify the UI so the
* displayed document is reloaded from disk: otherwise the read view keeps the
* stale content, and an open editor buffer autosaves the old text back over
* the assistant's change (see utils.reloadExternalWrite).
* #93 / BUG-076 — A write tool just modified a vault file. Notify the UI so
* the displayed document is reloaded from disk: otherwise the read view keeps
* the stale content, and an open editor buffer autosaves the old text back
* over the assistant's change (see utils.reloadExternalWrite).
*/
_notifyFileWritten(data) {
if (!data || data.ok === false) return;
const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup'];
if (WRITE_TOOLS.indexOf(data.name) === -1) return;
if (!FILE_WRITE_TOOLS.has(data.name)) return;
const args = data.arguments || {};
if (!args.vault || !args.path) return;
window.dispatchEvent(
@@ -913,6 +928,24 @@ class BooksLM {
);
}
/**
* BUG-076 — Refresh the sidebar tree right after an agent mutation, without
* waiting for the (debounced) watcher SSE. Debounced so a batch of tool
* events (e.g. a folder plus its files) triggers a single refresh.
*/
_scheduleTreeRefresh() {
if (this._treeRefreshTimer) clearTimeout(this._treeRefreshTimer);
this._treeRefreshTimer = setTimeout(async () => {
this._treeRefreshTimer = null;
try {
const m = await import('./sidebar.js');
if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
await m.refreshSidebarTreePreservingState();
}
} catch { /* tree refresh is best-effort */ }
}, 250);
}
// ── Rendering ───────────────────────────────────────────────────────
_render() {
@@ -1041,7 +1074,11 @@ class BooksLM {
}
});
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => {
// BUG-077: the same button stops the run while a response streams.
if (this._isLoading) this._stopGeneration();
else this._sendMessage();
});
// Resizable panel (drag the left edge).
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
@@ -2665,6 +2702,17 @@ class BooksLM {
return t('ai.tool_call', { name: call.name });
}
/**
* BUG-074 — number of *action* steps of a message (reasoning notes are not
* actions). Falls back to the total when the block holds only thoughts so a
* “0 étape” label can never appear.
*/
_actionStepCount(toolCalls) {
const list = toolCalls || [];
const actions = list.filter((c) => !(c.step && c.step.key === 'thought'));
return actions.length || list.length;
}
/** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */
_chevron() {
const c = document.createElement('span');
@@ -2698,13 +2746,32 @@ class BooksLM {
dots.classList.add('bookslm-steps-dots');
summary.appendChild(dots);
}
const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
// BUG-074: the counter reflects *actions*, not reasoning notes, and the
// collapsed header also previews the first action so an “N étapes ▶” line
// is no longer an opaque title.
const actionCalls = toolCalls.filter((c) => !(c.step && c.step.key === 'thought'));
const count = this._actionStepCount(toolCalls);
const countKey = count > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
const label = document.createElement('span');
label.className = 'bookslm-steps-label';
label.textContent = t(countKey, { count: toolCalls.length });
label.textContent = t(countKey, { count });
summary.appendChild(label);
const first = actionCalls[0];
let preview = '';
if (first) {
preview = this._stepText(first);
const title = document.createElement('span');
title.className = 'bookslm-steps-title';
title.textContent = `— ${preview}`;
summary.appendChild(title);
}
summary.appendChild(this._chevron());
if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`);
if (running) {
summary.setAttribute(
'aria-label',
`${label.textContent}${preview ? ` — ${preview}` : ''} — ${t('ai.steps_running')}`,
);
}
wrap.appendChild(summary);
const body = document.createElement('div');
@@ -2804,8 +2871,11 @@ class BooksLM {
const conf = msg.confirmation;
const pending = conf.pending || {};
const error = pending.error || pending;
const tool = error.tool || 'action';
const args = error.arguments || {};
// BUG-075: the run batches every mutating call of the LLM turn into
// `pending.actions`; fall back to the single legacy `error` shape.
const actions = (Array.isArray(pending.actions) && pending.actions.length)
? pending.actions
: [{ id: error.id, tool: error.tool, arguments: error.arguments || {}, step: null }];
const card = document.createElement('div');
card.className = 'bookslm-action bookslm-confirm';
@@ -2814,23 +2884,48 @@ class BooksLM {
meta.className = 'bookslm-action-meta';
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
const textEl = document.createElement('span');
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
if (actions.length > 1) {
textEl.textContent = t('ai.confirm_actions', { count: actions.length });
} else {
const tool = actions[0].tool || 'action';
const args = actions[0].arguments || {};
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
}
meta.appendChild(textEl);
card.appendChild(meta);
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (conf._diffHtml) {
diffHost.innerHTML = conf._diffHtml;
} else if (!conf._diffLoading) {
conf._diffLoading = true;
this._fillConfirmationDiff(args, conf).then(() => this._renderMessages());
// One row per action with a diff preview when it writes file content.
const hasContent = (a) => {
const args = a.arguments || {};
return typeof args.content === 'string' && args.vault && args.path;
};
if (actions.length > 1) {
const list = document.createElement('div');
list.className = 'bookslm-confirm-actions';
for (const action of actions) {
const args = action.arguments || {};
const row = document.createElement('details');
row.className = 'bookslm-confirm-action';
const summary = document.createElement('summary');
const text = document.createElement('span');
text.textContent = this._stepText({ step: action.step, name: action.tool })
+ (args.path ? ` — ${args.path}` : '');
summary.appendChild(text);
if (hasContent(action)) summary.appendChild(this._chevron());
row.appendChild(summary);
if (hasContent(action)) row.appendChild(this._diffHost(action, args));
list.appendChild(row);
}
card.appendChild(list);
} else if (hasContent(actions[0])) {
card.appendChild(this._diffHost(conf, actions[0].arguments || {}));
}
card.appendChild(diffHost);
const apply = document.createElement('button');
apply.className = 'bookslm-action-apply';
apply.textContent = t('ai.action_apply');
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
apply.addEventListener('click', async () => {
apply.disabled = true;
apply.textContent = t('ai.action_applying');
@@ -2839,7 +2934,9 @@ class BooksLM {
apply.textContent = t('ai.action_applied');
} catch (e) {
apply.disabled = false;
apply.textContent = t('ai.action_apply');
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
showToast(t('ai.action_failed', { error: e.message }), 'error');
}
});
@@ -2847,6 +2944,19 @@ class BooksLM {
return card;
}
/** Diff host for one confirmation action (lazy LCS diff, cached on the host). */
_diffHost(host, args) {
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (host._diffHtml) {
diffHost.innerHTML = host._diffHtml;
} else if (!host._diffLoading) {
host._diffLoading = true;
this._fillConfirmationDiff(args, host).then(() => this._renderMessages());
}
return diffHost;
}
async _fillConfirmationDiff(args, conf) {
const proposed = args.content;
if (typeof proposed !== 'string' || !args.vault || !args.path) return;
@@ -2935,22 +3045,20 @@ class BooksLM {
if (!conf || conf._applying) return;
conf._applying = true;
// BUG-075: one click applies the whole pending batch AND authorizes the
// remaining actions of the same run (no second confirmation card).
const payload = {
...(msg.payload || {}),
confirm: conf.pending,
confirm_messages: conf.messages,
confirm_all: true,
};
this._isLoading = true;
this._abortCtrl = new AbortController();
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._syncSendButton();
this._setActivity('working', t('ai.activity_streaming'));
// BUG-046: carry the original request payload over to the continuation.
// When an applied tool failed and the model re-proposed a confirmation,
// the continuation used to have `payload: null`: the second "Appliquer"
// then resumed without `message` → 422 → "[object Object]" toast.
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: msg.payload ? { ...msg.payload } : null };
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
@@ -2960,21 +3068,65 @@ class BooksLM {
if (!resp.ok) {
throw await this._responseError(resp);
}
// The confirmation is resolved: drop the card and show the continuation.
// The confirmation is resolved: drop the card and keep streaming into the
// SAME message, so the steps block accumulates the whole exchange
// instead of fragmenting into a new “1 étape” message per approval
// (BUG-074). BUG-046 payload carry-over is inherent: `msg.payload` stays.
msg.confirmation = null;
this._messages.push(continuation);
this._renderMessages({ anchor: true });
await this._streamResponse(resp, continuation, payload);
await this._streamResponse(resp, msg, payload);
} catch (e) {
if (e.name === 'AbortError') {
this._markStopped(msg);
} else {
throw e;
}
} finally {
conf._applying = false;
this._isLoading = false;
this._abortCtrl = null;
if (sendBtn) sendBtn.disabled = false;
this._syncSendButton();
this._renderMessages();
this._saveHistory();
}
}
/** BUG-077 — abort the in-flight agent/chat request. */
_stopGeneration() {
if (this._abortCtrl) {
try {
this._abortCtrl.abort();
} catch { /* already aborted */ }
}
}
/** Append a visible « stopped by the user » marker to an assistant message. */
_markStopped(msg) {
const marker = `⏹ ${t('ai.stopped')}`;
const content = String(msg.content || '');
if (!content.includes(marker)) {
msg.content = content ? `${content}\n\n${marker}` : marker;
}
this._setActivity('idle');
}
/**
* BUG-077 — the composer's round button doubles as a Stop control while a
* response streams: a new send is already blocked by `_isLoading`, so the
* button switches to a stop icon instead of being disabled.
*/
_syncSendButton() {
const btn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (!btn) return;
const loading = !!this._isLoading;
btn.classList.toggle('is-stopping', loading);
btn.title = loading ? t('ai.stop') : t('bookslm.send');
btn.setAttribute('aria-label', btn.title);
const icon = btn.querySelector('i');
if (icon) icon.setAttribute('data-lucide', loading ? 'square' : 'arrow-up');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
// ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() {
@@ -3053,10 +3205,8 @@ class BooksLM {
this._isLoading = true;
this._renderMessages({ anchor: true, instant: true });
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
this._syncSendButton();
this._setActivity('working', t('ai.activity_sending'));
try {
@@ -3085,17 +3235,17 @@ class BooksLM {
}
this._setActivity('done', t('ai.activity_done'));
} catch (e) {
if (e.name !== 'AbortError') {
if (e.name === 'AbortError') {
this._markStopped(assistantMsg);
} else {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('AI assistant chat error:', e);
this._setActivity('error', t('ai.activity_error'));
} else {
this._setActivity('idle');
}
}
this._isLoading = false;
if (sendBtn) sendBtn.disabled = false;
this._syncSendButton();
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
@@ -3155,6 +3305,10 @@ class BooksLM {
});
// #93 — a vault write must be reflected in the displayed document.
this._notifyFileWritten(data);
// BUG-076 — reflect tree changes (create/delete/rename…) right away.
if (data.ok !== false && MUTATING_TOOLS.has(data.name)) {
this._scheduleTreeRefresh();
}
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
return;
}
+283 -6
View File
@@ -767,10 +767,15 @@ function initConfigModal() {
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
closeHeaderMenu();
// BUG-071: reset the TOC to the CSS default (mobile: hidden, desktop:
// visible) like the help modal does on open.
// BUG-071/#114: reset the TOC to the CSS default (mobile: hidden drawer,
// desktop: visible sidebar) like the help modal does on open. Clear the
// stale inline display and the drawer-open class so a previous mobile
// session cannot leave the nav stuck open.
var configNavOnOpen = document.getElementById("config-nav");
if (configNavOnOpen) configNavOnOpen.style.display = '';
modal.classList.remove("config-toc-open");
var configHamburgerOnOpen = document.getElementById("config-hamburger");
if (configHamburgerOnOpen) configHamburgerOnOpen.classList.remove("active");
renderConfigFilters();
loadConfigFields();
loadDiagnostics();
@@ -786,6 +791,12 @@ function initConfigModal() {
closeBtn.addEventListener("click", closeConfigModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) {
// #114: a tap on the backdrop (or outside the drawer) first closes the
// TOC drawer; only a second tap closes the whole modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
@@ -890,17 +901,19 @@ function initConfigModal() {
});
}
// BUG-071: mobile table of contents. #config-nav shares the .help-nav
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
// rule that hides it below 768px, but — unlike the help modal — the config
// modal had no toggle to reveal it, leaving mobile users with no way to
// reach a section. The header hamburger opens it as a top block; picking
// a section smooth-scrolls inside the modal and collapses it on mobile.
// reach a section. The header hamburger opens it as a left slide-over
// drawer (backdrop via .config-toc-open on the modal); picking a section
// smooth-scrolls inside the modal and collapses it on mobile.
var configNav = document.getElementById("config-nav");
var configHamburger = document.getElementById("config-hamburger");
function _isConfigMobile() { return window.innerWidth <= 768; }
function _setConfigNav(open) {
if (!configNav) return;
configNav.style.display = open ? "flex" : "none";
modal.classList.toggle("config-toc-open", !!open && _isConfigMobile());
if (configHamburger) configHamburger.classList.toggle("active", !!open);
}
if (configHamburger) {
@@ -910,6 +923,14 @@ function initConfigModal() {
_setConfigNav(hidden);
});
}
// #114: close button inside the drawer header.
var configTocClose = document.getElementById("config-toc-close");
if (configTocClose) {
configTocClose.addEventListener("click", function(e) {
e.stopPropagation();
_setConfigNav(false);
});
}
if (configNav) {
configNav.querySelectorAll(".help-nav-link").forEach(function(a) {
a.addEventListener("click", function(e) {
@@ -930,6 +951,11 @@ function initConfigModal() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
// #114: Escape closes the TOC drawer first, then the modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
@@ -949,7 +975,13 @@ function initConfigModal() {
function closeConfigModal() {
const modal = document.getElementById("config-modal");
if (modal) modal.classList.remove("active");
if (modal) {
modal.classList.remove("active");
// #114: never leave the drawer-open class (backdrop) behind.
modal.classList.remove("config-toc-open");
const nav = document.getElementById("config-nav");
if (nav) nav.style.display = '';
}
}
// --- Config field helpers ---
@@ -2356,6 +2388,107 @@ function initAboutModal() {
}
// ── Profile ──────────────────────────────────────────────────────────
// #113 — profile picture: pick/import an image, center-crop it to a 256 px
// square JPEG, persist it on the account (PATCH /api/auth/me) and show it in
// the sidebar circle (#sidebar-user-avatar). Initials remain the fallback.
const _AVATAR_MAX_FILE_BYTES = 8 * 1024 * 1024;
const _AVATAR_TYPES = ["image/png", "image/jpeg", "image/webp"];
const _AVATAR_SIZE = 256;
function _profileInitials(name) {
var parts = String(name || "").trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
var first = parts[0][0] || "";
var last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (first + last).toUpperCase() || "?";
}
/** Center-crop the picked image to a square and export it as a JPEG data-URL. */
function _resizeAvatarFile(file) {
return new Promise(function (resolve, reject) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () {
URL.revokeObjectURL(url);
try {
var canvas = document.createElement("canvas");
canvas.width = _AVATAR_SIZE;
canvas.height = _AVATAR_SIZE;
var ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, _AVATAR_SIZE, _AVATAR_SIZE);
var side = Math.min(img.naturalWidth, img.naturalHeight);
var sx = (img.naturalWidth - side) / 2;
var sy = (img.naturalHeight - side) / 2;
ctx.drawImage(img, sx, sy, side, side, 0, 0, _AVATAR_SIZE, _AVATAR_SIZE);
resolve(canvas.toDataURL("image/jpeg", 0.85));
} catch (err) {
reject(err);
}
};
img.onerror = function () {
URL.revokeObjectURL(url);
reject(new Error("image decode failed"));
};
img.src = url;
});
}
function _profileAvatarError(key) {
var err = document.getElementById("profile-avatar-error");
if (!err) return;
if (!key) {
err.textContent = "";
err.classList.add("hidden");
return;
}
err.textContent = t(key);
err.classList.remove("hidden");
}
/** Paint the settings preview (image or initials) for the given avatar. */
function _renderProfileAvatar(dataUrl) {
var img = document.getElementById("profile-avatar-img");
var initialsEl = document.getElementById("profile-avatar-initials");
var removeBtn = document.getElementById("profile-avatar-remove");
if (!img || !initialsEl) return;
if (dataUrl) {
img.src = dataUrl;
img.hidden = false;
initialsEl.textContent = "";
if (removeBtn) removeBtn.hidden = false;
} else {
img.removeAttribute("src");
img.hidden = true;
var user = AuthManager.getUser() || {};
var name = localStorage.getItem("obsigate-name") || user.display_name || user.username || "";
initialsEl.textContent = _profileInitials(name);
if (removeBtn) removeBtn.hidden = true;
}
}
/** PATCH /api/auth/me with an avatar value (data-URL, or "" to clear). */
async function _patchProfileAvatar(value) {
var headers = { "Content-Type": "application/json" };
var authHeaders = AuthManager.getAuthHeaders ? AuthManager.getAuthHeaders() : null;
if (authHeaders) headers = Object.assign(headers, authHeaders);
var res = await fetch("/api/auth/me", {
method: "PATCH",
headers: headers,
credentials: "include",
body: JSON.stringify({ avatar: value }),
});
if (!res.ok) {
var detail = "";
try {
detail = (await res.json()).detail || "";
} catch (e) { /* no json body */ }
throw new Error(detail || "HTTP " + res.status);
}
return res.json();
}
function initProfile() {
var langSel = document.getElementById('profile-lang');
var nameInp = document.getElementById('profile-name');
@@ -2393,4 +2526,148 @@ function initProfile() {
});
// Logout button — handled inline in index.html (onclick)
// ── Avatar (#113) ────────────────────────────────────────────────
var avatarField = document.getElementById('profile-avatar-field');
var avatarInput = document.getElementById('profile-avatar-input');
var chooseBtn = document.getElementById('profile-avatar-choose');
var overlayBtn = document.getElementById('profile-avatar-overlay');
var previewBox = document.getElementById('profile-avatar-preview');
var removeBtn = document.getElementById('profile-avatar-remove');
var presetsBox = document.getElementById('profile-avatar-presets');
if (!avatarField || !avatarInput || !chooseBtn) return;
// The avatar only exists on a real account — hide it when auth is off.
if (!AuthManager.isAuthEnabled || !AuthManager.isAuthEnabled()) {
avatarField.hidden = true;
return;
}
_renderProfileAvatar((AuthManager.getUser() || {}).avatar || null);
// Refresh from the server so the preview survives a page reload.
fetch('/api/auth/me', { credentials: 'include' })
.then(function (res) { return res.ok ? res.json() : null; })
.then(function (user) {
if (!user) return;
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || null);
})
.catch(function () { /* non-bloquant */ });
// ── Preset avatars (#117) ────────────────────────────────────────
var PRESET_STORAGE_KEY = 'obsigate-avatar-preset';
function markActivePreset(name) {
if (!presetsBox) return;
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
var on = !!name && btn.getAttribute('data-avatar') === name;
btn.classList.toggle('active', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
}
function clearActivePreset() {
try { localStorage.removeItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(null);
}
function rememberActivePreset(name) {
try { localStorage.setItem(PRESET_STORAGE_KEY, name); } catch (e) { /* ignore */ }
markActivePreset(name);
}
var savedPreset = null;
try { savedPreset = localStorage.getItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(savedPreset);
if (presetsBox) {
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
btn.addEventListener('click', async function () {
var preset = btn.getAttribute('data-avatar');
if (!preset) return;
_profileAvatarError(null);
var all = presetsBox.querySelectorAll('.profile-avatar-preset');
all.forEach(function (b) { b.disabled = true; });
try {
// Load the bundled image, then reuse the upload pipeline (center-crop
// + 256 px JPEG) so it stores exactly like an imported picture.
var res = await fetch('/static/icons/avatar/' + encodeURIComponent(preset), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
var blob = await res.blob();
var dataUrl = await _resizeAvatarFile(blob);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
rememberActivePreset(preset);
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar preset failed:', err);
} finally {
all.forEach(function (b) { b.disabled = false; });
}
});
});
}
function openPicker() {
_profileAvatarError(null);
avatarInput.click();
}
chooseBtn.addEventListener('click', openPicker);
if (overlayBtn) overlayBtn.addEventListener('click', openPicker);
if (previewBox) {
previewBox.addEventListener('click', function (e) {
if (e.target.closest('button')) return;
openPicker();
});
}
avatarInput.addEventListener('change', async function () {
var file = avatarInput.files && avatarInput.files[0];
avatarInput.value = ""; // allow re-picking the same file
if (!file) return;
_profileAvatarError(null);
if (_AVATAR_TYPES.indexOf(file.type) === -1) {
_profileAvatarError('config.avatar_invalid_type');
return;
}
if (file.size > _AVATAR_MAX_FILE_BYTES) {
_profileAvatarError('config.avatar_too_large');
return;
}
chooseBtn.disabled = true;
try {
var dataUrl = await _resizeAvatarFile(file);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
clearActivePreset(); // an imported photo is not a preset
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar upload failed:', err);
} finally {
chooseBtn.disabled = false;
}
});
if (removeBtn) {
removeBtn.addEventListener('click', async function () {
_profileAvatarError(null);
removeBtn.disabled = true;
try {
var user = await _patchProfileAvatar("");
AuthManager.updateCachedUser(user);
_renderProfileAvatar(null);
AuthManager.renderUserSection();
clearActivePreset();
showToast(t('config.avatar_removed'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar removal failed:', err);
} finally {
removeBtn.disabled = false;
}
});
}
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -197,9 +197,13 @@ function createPaneTabManager(paneId) {
close(tabId) {
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
if (this._tabs.length === 0) {
this._activeTabId = null;
// If this pane has no more tabs and isn't the last pane, close it
@@ -826,6 +830,8 @@ const PaneManager = {
grid.className = 'pane-grid';
this._applyGridTemplate(grid, n);
// #110 — keep playing media alive across a pane-grid rebuild.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
wrapper.appendChild(grid);
this.panes = [];
@@ -1216,6 +1222,8 @@ const PaneManager = {
if (!grid) return;
const wrapper = grid.parentElement;
const pane0 = this.panes[0];
// #110 — keep playing media alive across a pane collapse.
if (window.NowPlaying) window.NowPlaying.handleRender(wrapper, null);
wrapper.innerHTML = '';
let tabBar = null, content = null;
if (pane0 && pane0.element) {
+11
View File
@@ -598,6 +598,17 @@ function applyTheme(themeKey, mode) {
// Notify other components of theme change
try {
root.setAttribute('data-theme', mode);
// Syntax highlighting follows the light/dark mode (BUG-078): the theme
// *key* is not a mode, so toggling the sheets by key used to disable both
// and strip every code block of its colours. Sepia/high-contrast reuse the
// light palette.
var darkSheet = document.getElementById('hljs-theme-dark');
var lightSheet = document.getElementById('hljs-theme-light');
if (darkSheet && lightSheet) {
var isDark = mode === 'dark';
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
}
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } }));
} catch(e) {}
}
+29 -10
View File
@@ -137,14 +137,26 @@ export const RightSidebarManager = {
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
const _THEME_MODES = ["dark", "light", "high-contrast", "sepia"];
export function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
// The theme engine (themes.js) owns the CSS variables and the theme *key*;
// the <html data-theme> attribute must carry the *mode* (dark/light/…). Read
// the persisted mode here so the first paint and the highlight.js stylesheet
// are right before the async theme init runs (BUG-078).
let mode = "dark";
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { /* ignore */ }
applyTheme(mode);
}
export function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
let mode = theme;
if (_THEME_MODES.indexOf(mode) === -1) {
// Callers may pass a theme key (legacy): fall back to the persisted mode.
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { mode = "dark"; }
}
const isDark = mode === "dark";
document.documentElement.setAttribute("data-theme", mode);
// Update theme button icon and label
const themeBtn = document.getElementById("theme-toggle");
@@ -152,23 +164,23 @@ export function applyTheme(theme) {
if (themeBtn && themeLabel) {
const icon = themeBtn.querySelector("i");
if (icon) {
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
icon.setAttribute("data-lucide", isDark ? "moon" : "sun");
}
themeLabel.textContent = theme === "dark" ? t('theme.dark') : t('theme.light');
themeLabel.textContent = isDark ? t('theme.dark') : t('theme.light');
safeCreateIcons();
}
// Swap highlight.js theme
// Swap highlight.js theme — keyed on the mode, not the theme key (BUG-078).
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
}
// Update Mermaid theme for newly rendered diagrams
import('./mermaid-viewer.js').then(function(m) {
m.updateMermaidTheme(theme === 'dark');
m.updateMermaidTheme(isDark);
}).catch(function() {});
}
@@ -2080,11 +2092,16 @@ export const TabManager = {
}
const idx = this._tabs.findIndex(t => t.id === tabId);
if (idx === -1) return;
const closingTab = this._tabs[idx];
this._tabs.splice(idx, 1);
delete this._tabCache[tabId];
this._dirtyTabs.delete(tabId);
if (window.NowPlaying && closingTab) {
window.NowPlaying.notifyTabClosed(closingTab.vault, closingTab.path);
}
if (this._tabs.length === 0) {
this._activeTabId = null;
this._showDashboard();
@@ -2207,6 +2224,8 @@ export const TabManager = {
_showDashboard() {
const area = document.getElementById("content-area");
// #110 — move any playing media to the persistent dock before wiping.
if (window.NowPlaying && area) window.NowPlaying.handleRender(area, null);
// Save dashboard DOM before clearing (it may have been removed from DOM by renderFile)
let dashboard = document.getElementById("dashboard-home");
if (!dashboard) {
+20 -18
View File
@@ -213,25 +213,27 @@ const EXT_ICONS = {
".tiff": "image",
".tif": "image",
// Audio files
".mp3": "file-music",
".wav": "file-music",
".flac": "file-music",
".aac": "file-music",
".ogg": "file-music",
".m4a": "file-music",
".wma": "file-music",
// Audio files (roadmap #109-B2)
".mp3": "audio-lines",
".wav": "audio-lines",
".flac": "audio-lines",
".aac": "audio-lines",
".ogg": "audio-lines",
".oga": "audio-lines",
".opus": "audio-lines",
".m4a": "audio-lines",
".wma": "audio-lines",
// Video files
".mp4": "play",
".avi": "play",
".mov": "play",
".wmv": "play",
".flv": "play",
".webm": "play",
".mkv": "play",
".m4v": "play",
".3gp": "play",
// Video files (roadmap #109-B2)
".mp4": "video",
".avi": "video",
".mov": "video",
".wmv": "video",
".flv": "video",
".webm": "video",
".mkv": "video",
".m4v": "video",
".3gp": "video",
// Archive files
".zip": "file-archive",
+287 -47
View File
@@ -14,6 +14,7 @@ import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
import { t } from './i18n.js';
import { onFileRender } from './plugins.js';
import { NowPlaying } from './now-playing.js';
// ── Multi-format export ────────────────────────────────────────────────────
// Downloads a file export (HTML / MD bundle / ePub) via the authenticated
@@ -547,6 +548,10 @@ const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".
const IMAGE_ZOOM_MIN = 0.1;
const IMAGE_ZOOM_MAX = 8;
let _imageViewerCleanup = null;
// BUG-072 — lightbox / metadata-panel state, reset as soon as a non-image file
// is rendered. #111 — image→image navigation now happens **in place**, so the
// viewer (and these states) is no longer destroyed between siblings.
const _imageViewerState = { lightbox: false, meta: false };
/** Clamp a zoom factor into the supported [0.1, 8] range. */
export function clampImageZoom(value) {
@@ -570,6 +575,40 @@ function buildThumbUrl(vault, path, size) {
return `/api/media/${encodeURIComponent(vault)}/thumb?path=${encodeURIComponent(path)}&size=${size}`;
}
// #111 — directory listing cache. Opening / switching to another image must not
// hammer `/api/browse` (the filmstrip and the sibling list share one listing).
const _imageDirCache = new Map();
const IMAGE_DIR_CACHE_TTL = 15000;
/** List the viewable image siblings of *dir*, memoised for a short TTL. */
async function listImageSiblings(vault, dir) {
const key = `${vault}\u0000${dir}`;
const cached = _imageDirCache.get(key);
if (cached && Date.now() - cached.ts < IMAGE_DIR_CACHE_TTL) return cached.items;
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
const items = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
_imageDirCache.set(key, { items, ts: Date.now() });
return items;
}
const IMAGE_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".webp": "image/webp",
".bmp": "image/bmp",
".ico": "image/x-icon",
};
/** Best-effort MIME type for an image path (metadata panel). */
function imageMimeFor(path) {
const lower = (path || "").toLowerCase();
const dot = lower.lastIndexOf(".");
return dot === -1 ? "" : IMAGE_MIME[lower.slice(dot)] || "";
}
function formatBytes(n) {
if (!n && n !== 0) return "";
if (n < 1024) return `${n} o`;
@@ -584,20 +623,30 @@ function formatBytes(n) {
*/
export function renderImageViewer(area, data) {
const vault = data.vault;
const path = data.path;
const fileName = (path || "").split("/").pop();
const imgUrl = buildImageUrl(vault, path);
// #111 — mutable current-image state: navigating swaps these in place instead
// of re-rendering the whole viewer (which used to refetch the file + listing).
let currentPath = data.path;
let currentTitle = data.title || (currentPath || "").split("/").pop();
let imgUrl = buildImageUrl(vault, currentPath);
let currentMeta = {
image_mime: data.image_mime || imageMimeFor(currentPath),
size_bytes: data.size_bytes,
modified: data.modified,
};
area.innerHTML = "";
const container = el("div", { class: "image-viewer-container", tabindex: "0" });
// ── Toolbar ────────────────────────────────────────────────────────────
const toolbar = el("div", { class: "image-toolbar" });
toolbar.appendChild(el("span", { class: "image-title", title: path }, [
document.createTextNode(data.title || fileName),
]));
const titleEl = el("span", { class: "image-title", title: currentPath }, [
document.createTextNode(currentTitle),
]);
toolbar.appendChild(titleEl);
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
const counter = el("span", { class: "image-counter", hidden: true }, [document.createTextNode("")]);
toolbar.appendChild(counter);
const zoomBadge = el("span", { class: "image-zoom-badge" }, [document.createTextNode("100%")]);
toolbar.appendChild(zoomBadge);
@@ -613,26 +662,64 @@ export function renderImageViewer(area, data) {
const nextBtn = mkBtn("chevron-right", t("viewer.image_next"));
const originalBtn = mkBtn("external-link", t("viewer.image_open_original"));
const downloadBtn = mkBtn("download", t("viewer.download"));
const metaBtn = mkBtn("info", t("viewer.image_metadata"));
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"));
const metaBtn = mkBtn("info", t("viewer.image_metadata"), "image-btn-metadata");
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"), "image-btn-lightbox");
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
.forEach((b) => toolbar.appendChild(b));
container.appendChild(toolbar);
// ── Stage (image + pan/zoom) ───────────────────────────────────────────
// ── Body: image stage + metadata sidebar (right) ───────────────────────
const body = el("div", { class: "image-viewer-body" });
const stage = el("div", { class: "image-stage" });
const img = el("img", { class: "image-main", src: imgUrl, alt: data.title || fileName, draggable: "false" });
const img = el("img", { class: "image-main", src: imgUrl, alt: currentTitle, draggable: "false" });
stage.appendChild(img);
container.appendChild(stage);
// ── Overlay arrows along the frame edges (#111) ────────────────────────
const prevArrow = el("button", {
class: "image-nav-arrow image-nav-arrow-prev",
type: "button",
title: t("viewer.image_prev"),
"aria-label": t("viewer.image_prev"),
hidden: true,
}, [icon("chevron-left", 32)]);
const nextArrow = el("button", {
class: "image-nav-arrow image-nav-arrow-next",
type: "button",
title: t("viewer.image_next"),
"aria-label": t("viewer.image_next"),
hidden: true,
}, [icon("chevron-right", 32)]);
stage.appendChild(prevArrow);
stage.appendChild(nextArrow);
body.appendChild(stage);
// ── Metadata sidebar (kept open across ←/→ navigation) ─────────────────
const metaPanel = el("div", { class: "image-meta-panel" });
metaPanel.hidden = !_imageViewerState.meta;
body.appendChild(metaPanel);
container.appendChild(body);
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
const strip = el("div", { class: "image-nav-strip", hidden: true });
container.appendChild(strip);
// ── Metadata panel ─────────────────────────────────────────────────────
const metaPanel = el("div", { class: "image-meta-panel" });
metaPanel.hidden = true;
container.appendChild(metaPanel);
// #112 — the film scrolls with the mouse wheel and two translucent edge
// arrows; the scrollable strip is wrapped so the arrows stay fixed.
const navWrap = el("div", { class: "image-nav", hidden: true });
const strip = el("div", { class: "image-nav-strip" });
const stripPrev = el("button", {
class: "image-strip-arrow image-strip-arrow-prev",
type: "button",
title: t("viewer.image_strip_prev"),
"aria-label": t("viewer.image_strip_prev"),
}, [icon("chevron-left", 20)]);
const stripNext = el("button", {
class: "image-strip-arrow image-strip-arrow-next",
type: "button",
title: t("viewer.image_strip_next"),
"aria-label": t("viewer.image_strip_next"),
}, [icon("chevron-right", 20)]);
navWrap.appendChild(strip);
navWrap.appendChild(stripPrev);
navWrap.appendChild(stripNext);
container.appendChild(navWrap);
let scale = 1;
let tx = 0;
@@ -651,12 +738,12 @@ export function renderImageViewer(area, data) {
const renderMeta = () => {
const rows = [
[t("viewer.image_type"), data.image_mime || ""],
[t("viewer.image_type"), currentMeta.image_mime || ""],
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
[t("viewer.metadata_size"), formatBytes(data.size_bytes)],
[t("viewer.metadata_path"), path],
[t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)],
[t("viewer.metadata_path"), currentPath],
];
if (data.modified) rows.push([t("viewer.metadata_modified"), data.modified]);
if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.modified]);
metaPanel.innerHTML = "";
const dl = el("dl", {});
rows.forEach(([k, v]) => {
@@ -666,41 +753,120 @@ export function renderImageViewer(area, data) {
metaPanel.appendChild(dl);
};
// ── Sibling navigation ─────────────────────────────────────────────────
// ── Sibling navigation (in place, #111) ────────────────────────────────
let siblings = [];
let currentIndex = -1;
let thumbs = [];
const preloaded = new Set();
const preload = (i) => {
if (!siblings.length) return;
const it = siblings[(i % siblings.length + siblings.length) % siblings.length];
if (!it || preloaded.has(it.path)) return;
preloaded.add(it.path);
const pre = new Image();
pre.src = buildImageUrl(vault, it.path);
};
const updateStripActive = () => {
thumbs.forEach((thumb, i) => thumb.classList.toggle("active", i === currentIndex));
const active = thumbs[currentIndex];
if (!active) return;
// Scroll the filmstrip only (never the document) to reveal the active thumb.
const left = active.offsetLeft;
const right = left + active.offsetWidth;
if (left < strip.scrollLeft) strip.scrollLeft = Math.max(0, left - 8);
else if (right > strip.scrollLeft + strip.clientWidth) strip.scrollLeft = right - strip.clientWidth + 8;
};
const updateArrows = () => {
const multi = siblings.length > 1;
prevArrow.hidden = nextArrow.hidden = prevBtn.disabled = nextBtn.disabled = !multi;
prevArrow.disabled = nextArrow.disabled = !multi;
counter.hidden = !multi;
counter.textContent = multi ? `${currentIndex + 1} / ${siblings.length}` : "";
};
/** Swap to the sibling at *index* without rebuilding the viewer. */
const showSibling = (index) => {
const item = siblings[index];
if (!item || item.path === currentPath) return;
currentIndex = index;
currentPath = item.path;
currentTitle = item.name;
imgUrl = buildImageUrl(vault, currentPath);
currentMeta = {
image_mime: imageMimeFor(currentPath),
size_bytes: item.size,
modified: null,
};
scale = 1; tx = 0; ty = 0;
applyTransform();
img.style.display = "";
const errEl = stage.querySelector(".image-error");
if (errEl) errEl.remove();
img.alt = currentTitle;
img.src = imgUrl;
titleEl.textContent = currentTitle;
titleEl.title = currentPath;
updateStripActive();
updateArrows();
state.currentVault = vault;
state.currentPath = currentPath;
try { syncActiveFileTreeItem(vault, currentPath); } catch (_) { /* best-effort */ }
preload(index - 1);
preload(index + 1);
if (!metaPanel.hidden) renderMeta();
};
const go = (delta) => {
if (siblings.length < 2 || currentIndex < 0) return;
const next = (currentIndex + delta + siblings.length) % siblings.length;
openFile(vault, siblings[next].path);
showSibling((currentIndex + delta + siblings.length) % siblings.length);
};
const renderStrip = () => {
if (siblings.length < 2) { strip.hidden = true; return; }
strip.hidden = false;
if (siblings.length < 2) { navWrap.hidden = true; return; }
navWrap.hidden = false;
strip.innerHTML = "";
siblings.forEach((s, i) => {
thumbs = siblings.map((s, i) => {
const thumb = el("img", {
class: `image-thumb${i === currentIndex ? " active" : ""}`,
src: buildThumbUrl(vault, s.path, 96),
alt: s.name,
title: s.name,
loading: "lazy",
decoding: "async",
});
thumb.addEventListener("click", () => { if (s.path !== path) openFile(vault, s.path); });
thumb.addEventListener("click", () => showSibling(i));
strip.appendChild(thumb);
return thumb;
});
updateStripActive();
};
// #112 — scroll the film with the wheel (vertical → horizontal) and the
// two overlay arrows; the active image never changes, only the strip view.
const scrollStrip = (dir) => {
strip.scrollBy({ left: dir * Math.max(140, strip.clientWidth * 0.8), behavior: "smooth" });
};
stripPrev.addEventListener("click", () => scrollStrip(-1));
stripNext.addEventListener("click", () => scrollStrip(1));
strip.addEventListener("wheel", (e) => {
if (strip.scrollWidth <= strip.clientWidth) return;
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
strip.scrollLeft += delta;
e.preventDefault();
}, { passive: false });
(async () => {
try {
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
siblings = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
currentIndex = siblings.findIndex((it) => it.path === path);
prevBtn.disabled = nextBtn.disabled = siblings.length < 2;
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
siblings = await listImageSiblings(vault, dir);
currentIndex = siblings.findIndex((it) => it.path === currentPath);
renderStrip();
updateArrows();
preload(currentIndex - 1);
preload(currentIndex + 1);
} catch (_) { /* navigation is best-effort */ }
})();
@@ -711,13 +877,15 @@ export function renderImageViewer(area, data) {
setZoom(scale * factor);
}, { passive: false });
const isArrow = (e) => !!(e.target && e.target.closest && e.target.closest(".image-nav-arrow"));
let dragging = false;
let startX = 0;
let startY = 0;
let startTx = 0;
let startTy = 0;
stage.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
if (e.button !== 0 || isArrow(e)) return;
dragging = true;
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
stage.classList.add("panning");
@@ -738,7 +906,15 @@ export function renderImageViewer(area, data) {
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
stage.addEventListener("dblclick", resetView);
stage.addEventListener("dblclick", (e) => { if (!isArrow(e)) resetView(); });
const stopArrowPointer = (e) => e.stopPropagation();
prevArrow.addEventListener("pointerdown", stopArrowPointer);
nextArrow.addEventListener("pointerdown", stopArrowPointer);
prevArrow.addEventListener("dblclick", stopArrowPointer);
nextArrow.addEventListener("dblclick", stopArrowPointer);
prevArrow.addEventListener("click", (e) => { e.stopPropagation(); go(-1); });
nextArrow.addEventListener("click", (e) => { e.stopPropagation(); go(1); });
zoomInBtn.addEventListener("click", () => setZoom(scale * 1.25));
zoomOutBtn.addEventListener("click", () => setZoom(scale / 1.25));
@@ -747,22 +923,37 @@ export function renderImageViewer(area, data) {
nextBtn.addEventListener("click", () => go(1));
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
downloadBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
window.open(dlUrl, "_blank");
});
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
metaBtn.addEventListener("click", () => {
metaPanel.hidden = !metaPanel.hidden;
if (!metaPanel.hidden) renderMeta();
_imageViewerState.meta = !_imageViewerState.meta;
metaPanel.hidden = !_imageViewerState.meta;
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
if (_imageViewerState.meta) renderMeta();
});
const syncLightbox = () => {
container.classList.toggle("lightbox", _imageViewerState.lightbox);
lightboxBtn.setAttribute("aria-pressed", _imageViewerState.lightbox ? "true" : "false");
};
syncLightbox();
lightboxBtn.addEventListener("click", () => {
container.classList.toggle("lightbox");
lightboxBtn.setAttribute("aria-pressed", container.classList.contains("lightbox") ? "true" : "false");
_imageViewerState.lightbox = !_imageViewerState.lightbox;
syncLightbox();
});
img.addEventListener("load", () => { if (!metaPanel.hidden) renderMeta(); });
img.addEventListener("load", () => {
img.style.display = "";
const errEl = stage.querySelector(".image-error");
if (errEl) errEl.remove();
if (!metaPanel.hidden) renderMeta();
});
img.addEventListener("error", () => {
stage.innerHTML = "";
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
img.style.display = "none";
if (!stage.querySelector(".image-error")) {
stage.appendChild(el("div", { class: "image-error" }, [document.createTextNode(t("viewer.image_error"))]));
}
});
const onKey = (e) => {
@@ -771,7 +962,10 @@ export function renderImageViewer(area, data) {
else if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom(scale * 1.25); }
else if (e.key === "-") { e.preventDefault(); setZoom(scale / 1.25); }
else if (e.key === "0") { e.preventDefault(); resetView(); }
else if (e.key === "Escape") { container.classList.remove("lightbox"); }
else if (e.key === "Escape") {
_imageViewerState.lightbox = false;
syncLightbox();
}
};
document.addEventListener("keydown", onKey);
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
@@ -779,9 +973,28 @@ export function renderImageViewer(area, data) {
area.appendChild(container);
safeCreateIcons();
applyTransform();
if (_imageViewerState.meta) renderMeta();
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
}
// #110 — Audio/video are handled by the global Now Playing controller
// (frontend/js/now-playing.js): a single shared media element is teleported
// between this inline view and a body-level dock, so playback survives tab,
// pane and page navigation. The inline branches below just hand over the
// surface; the legacy "stop on re-render" cleanup no longer applies.
export { formatMediaDuration, buildMediaUrl, renderFallback as renderMediaFallback } from "./now-playing.js";
/** Audio viewer (roadmap #109-B, made persistent by #110). */
export function renderAudioViewer(area, data) {
NowPlaying.attachInline(area, data);
}
/** Video viewer (roadmap #109-C, made persistent by #110). */
export function renderVideoViewer(area, data) {
NowPlaying.attachInline(area, data);
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
@@ -791,7 +1004,17 @@ export function renderFile(data) {
// #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; }
// BUG-072 / #111 — any render that is not an image viewer starts from a clean
// viewer. Image→image navigation now happens in place (see renderImageViewer),
// so the viewer is never rebuilt between siblings.
if (!data.is_image) {
_imageViewerState.lightbox = false;
_imageViewerState.meta = false;
}
const area = getContentArea();
// #110 — if this render is about to replace the surface that currently hosts
// the shared media element, hand it back to the persistent dock first.
NowPlaying.handleRender(area, data);
// Handle PDF files — render in iframe with TOC sidebar
if (data.is_pdf) {
@@ -847,6 +1070,16 @@ export function renderFile(data) {
return;
}
// Handle audio / video — native HTML5 players (roadmap #109)
if (data.is_audio) {
renderAudioViewer(area, data);
return;
}
if (data.is_video) {
renderVideoViewer(area, data);
return;
}
// Handle unsupported (binary) files
if (data.unsupported) {
const sizeStr = data.size_bytes
@@ -858,7 +1091,7 @@ export function renderFile(data) {
<div class="unsupported-file">
<i data-lucide="file" style="width:48px;height:48px"></i>
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
<div>Ce fichier est binaire et ne peut pas être affiché.</div>
<div>${data.media_too_large ? escapeHtml(t("viewer.media_too_large")) : "Ce fichier est binaire et ne peut pas être affiché."}</div>
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
<button class="btn-action" id="unsupported-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
@@ -1185,7 +1418,12 @@ export function renderFile(data) {
// Assemble
area.innerHTML = "";
area.appendChild(breadcrumb);
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, fileActions)]));
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv]));
// #115 — the action bar is a direct child of the scroll container (wrapped in
// `.file-toolbar`) so it can stay pinned while long documents scroll; nested
// inside `.file-header` it would stop sticking as soon as the header left the
// viewport.
area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
area.appendChild(rawDiv);
@@ -1523,6 +1761,8 @@ export function showWelcome() {
// Restore or rebuild the dashboard with tabbed sections
const area = getContentArea();
// #110 — keep playing media alive if the dashboard replaces its surface.
NowPlaying.handleRender(area, null);
const home = document.getElementById("dashboard-home");
if (area && !home) {
+46 -8
View File
@@ -80,12 +80,16 @@
"admin.users_title": "User management",
"ai.action_applied": "Applied ✓",
"ai.action_apply": "Apply",
"ai.action_apply_all": "Approve all ({count})",
"ai.action_applying": "Applying…",
"ai.action_create_dir": "Create folder {path}",
"ai.action_create_file": "Create file {path}",
"ai.action_created_dir": "Folder created: {path}",
"ai.action_created_file": "File created: {path}",
"ai.action_failed": "Action failed: {error}",
"ai.confirm_actions": "{count} actions to approve",
"ai.stop": "Stop the assistant",
"ai.stopped": "Run stopped.",
"ai.agent_mode_off": "Agent mode off (read/search + actions)",
"ai.agent_mode_on": "Agent mode on (read/search tools + actions)",
"ai.casual": "Casual tone",
@@ -380,6 +384,16 @@
"config.delete_key": "Delete",
"config.delete_key_confirm": "Delete key",
"config.key_deleted": "Key deleted:",
"config.avatar_label": "Profile picture",
"config.avatar_hint": "PNG, JPG or WEBP — square image cropped and resized to 256 px. Shown in the sidebar.",
"config.avatar_presets_label": "Or pick a preset avatar",
"config.avatar_choose": "Choose an image",
"config.avatar_remove": "Remove picture",
"config.avatar_updated": "Profile picture updated",
"config.avatar_removed": "Profile picture removed",
"config.avatar_too_large": "Image too large (8 MB max)",
"config.avatar_invalid_type": "Unsupported format — use PNG, JPG or WEBP",
"config.avatar_upload_failed": "Failed to save the profile picture",
"config.backups": "Backups",
"config.backups_desc": "Manage automatic file backups.",
"config.client_config": "Client config",
@@ -569,6 +583,7 @@
"config.test": "Test",
"config.timeout_label": "Search timeout (ms)",
"config.title": "Settings",
"config.toc_close": "Close contents",
"config.toc_toggle": "Show contents",
"config.title_boost": "Title boost",
"config.title_boost_hint": "Relevance multiplier for title matches",
@@ -777,6 +792,7 @@
"header.menu_theme": "Theme",
"header.menu_theme_light": "Light",
"header.menu_toggle": "Menu",
"header.menu_version": "Version",
"header.profile": "Profile",
"header.quick_help": "Quick Help",
"header.refresh": "Refresh",
@@ -1106,7 +1122,7 @@
"help.desc_markdown_all": ": Full markdown rendering",
"help.desc_md_render": "Markdown files are rendered with:",
"help.desc_menu_header": ": \"Vault\" dropdown in the Options menu",
"help.desc_menu_options": ": Access to settings, theme, help",
"help.desc_menu_options": ": Access to settings, theme, help and version",
"help.desc_op_ext": ": Filter by file type",
"help.desc_op_path": ": Filter by path",
"help.desc_op_phrase": ": Exact phrase search",
@@ -1309,6 +1325,8 @@
"help.sidebar_filtering": "Sidebar filtering",
"help.sidebar_section": "Sidebar",
"help.sidebar_tabs": "The sidebar has two tabs:",
"help.sidebar_user": "Account",
"help.desc_sidebar_user": ": Avatar, name, role and sign-out at the bottom of the sidebar",
"help.sidebar_toggle_btn": "Sidebar toggle button",
"help.simple": "Simple",
"help.simple_search": "Simple search",
@@ -1339,6 +1357,7 @@
"help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.",
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
"help.assistant_agent_run": "Actions appear in the thread: the assistant groups modifications into a single approval (\"Approve all\") and refreshes the file tree and the open document as soon as they are applied. The send button becomes \"Stop\" to interrupt the run at any time.",
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
"help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).",
@@ -1619,25 +1638,21 @@
"search.whole_word": "Whole word",
"settings.about": "📦 About",
"settings.ai": "🤖 AI",
"settings.backend": "⚙️ Backend",
"settings.backend_hint": "These settings are saved on the server. Some require a restart or reindexing.",
"settings.backend_section": "Backend Settings",
"settings.client_label": "These settings apply immediately on the client side.",
"settings.diagnostics": "🩺 Diagnostics",
"settings.explorer": "Files",
"settings.history_count_desc": "Between 5 and 100 files (saved on server)",
"settings.history_count_label": "Files in history",
"settings.history_section": "Recent History",
"settings.no_restart_badge": "No restart needed",
"settings.profile": "👤 Profile",
"settings.reindex": "Force reindex",
"settings.restart_badge": "Restart required",
"settings.save": "Save",
"settings.search": "Search",
"settings.tabs": "Tabs",
"settings.search_placeholder": "Search...",
"settings.sync": "🔄 Sync",
"settings.tabs": "Tabs",
"settings.themes": "🎨 Themes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Link copied!",
"share.copy_link": "Copy link",
"share.create": "Create share link",
@@ -1682,6 +1697,8 @@
"sidebar.tab_saved": "Searches",
"sidebar.tab_tags": "Tags",
"sidebar.tab_vaults": "Vaults",
"sidebar.user_role_admin": "Administrator",
"sidebar.user_role_user": "User",
"sidebar.vault_context_all": "All vaults",
"sidebar.vault_specific": "Specific vault",
"sync.autocomplete": "Autocomplete",
@@ -1830,6 +1847,8 @@
"viewer.image_next": "Next image",
"viewer.image_open_original": "Open original",
"viewer.image_prev": "Previous image",
"viewer.image_strip_next": "Scroll thumbnails right",
"viewer.image_strip_prev": "Scroll thumbnails left",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom in",
"viewer.image_zoom_out": "Zoom out",
@@ -1839,6 +1858,8 @@
"viewer.loaded_from_cache": "File loaded from offline cache",
"viewer.loading": "Loading file...",
"viewer.markdown": "Markdown",
"viewer.media_too_large": "File too large for inline playback. Download it to watch.",
"viewer.media_unsupported": "This format cannot be played in your browser.",
"viewer.metadata_created": "Created",
"viewer.metadata_modified": "Modified",
"viewer.metadata_path": "Path",
@@ -2026,6 +2047,23 @@
"mfa.webauthn_btn": "Verify with my key",
"mfa.webauthn_cancelled": "WebAuthn ceremony cancelled.",
"mfa.webauthn_no_key": "No security key registered for this account.",
"player.now_playing": "Now playing",
"player.play": "Play",
"player.pause": "Pause",
"player.previous": "Previous",
"player.next": "Next",
"player.seek": "Seek",
"player.volume": "Volume",
"player.speed": "Playback speed",
"player.expand": "Expand player",
"player.minimize": "Minimize player",
"player.close": "Stop and close player",
"player.continues": "Playback continues — use the player to stop it.",
"player.reopen_tab": "Return to media",
"player.pip": "Picture in picture",
"player.move": "Move",
"player.resize": "Resize",
"player.resume": "Resuming last playback",
"plugins.title": "🧩 Plugins",
"plugins.description": "Extend ObsiGate with custom renderers, search filters, and editor actions.",
"plugins.install": "Install Plugin",
@@ -2127,7 +2165,7 @@
"guide105.lib_h3_conflicts": "Sync conflicts",
"guide105.lib_conflicts": "If you sync the vault with Syncthing, ObsiGate detects conflict files (\"sync-conflict\" copies) and offers to compare then resolve them from a dedicated page in the Options menu.",
"guide105.lib_h3_attach": "Attachments & media",
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, navigation between images in the folder, thumbnails, metadata, lightbox); the \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
"guide105.lib_attach": "Inline <code>![[image.png]]</code> images, attachments and media (audio, video, embedded PDFs) are rendered in the viewer and indexed for search. Images also appear in the file tree and open in a dedicated viewer (wheel zoom, pan, smooth navigation between images in the folder — image resized to the frame, hover side arrows, persistent thumbnail filmstrip —, metadata, lightbox); audio (.mp3, .wav, .flac…) and video (.mp4, .webm…) files open in a built-in HTML5 player (play, seek, speed, fullscreen), falling back to download when the format is not playable in the browser; playback continues while you navigate thanks to a floating mini-player (audio) or a mini video window, letting you return to the media or stop it at any time. The \"Rescan attachments\" button in Configuration rebuilds the attachment index.",
"guide105.off_pwa": "ObsiGate is a PWA: install it (install icon in the address bar) to open it like an app. The service worker caches the UI and your recently viewed documents.",
"guide105.off_edit": "Offline you can read cached documents and even edit them: changes are queued in IndexedDB.",
"guide105.off_sync": "When back online the queue replays automatically (sync badge in the header). If the server version diverged meanwhile, the file is flagged as conflict and the server copy is kept as a backup.",
+46 -8
View File
@@ -80,12 +80,16 @@
"admin.users_title": "Gestion utilisateurs",
"ai.action_applied": "Appliqué ✓",
"ai.action_apply": "Appliquer",
"ai.action_apply_all": "Tout approuver ({count})",
"ai.action_applying": "Application…",
"ai.action_create_dir": "Créer le dossier {path}",
"ai.action_create_file": "Créer le fichier {path}",
"ai.action_created_dir": "Dossier créé : {path}",
"ai.action_created_file": "Fichier créé : {path}",
"ai.action_failed": "Échec de l'action : {error}",
"ai.confirm_actions": "{count} actions à approuver",
"ai.stop": "Arrêter l'assistant",
"ai.stopped": "Exécution arrêtée.",
"ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)",
"ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)",
"ai.casual": "Ton décontracté",
@@ -380,6 +384,16 @@
"config.delete_key": "Supprimer",
"config.delete_key_confirm": "Supprimer la clé",
"config.key_deleted": "Clé supprimée :",
"config.avatar_label": "Photo de profil",
"config.avatar_hint": "PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px. Apparaît dans la barre latérale.",
"config.avatar_presets_label": "Ou choisissez un avatar prédéfini",
"config.avatar_choose": "Choisir une image",
"config.avatar_remove": "Supprimer la photo",
"config.avatar_updated": "Photo de profil mise à jour",
"config.avatar_removed": "Photo de profil supprimée",
"config.avatar_too_large": "Image trop lourde (8 Mo maximum)",
"config.avatar_invalid_type": "Format non pris en charge — utilisez PNG, JPG ou WEBP",
"config.avatar_upload_failed": "Échec de l'enregistrement de la photo de profil",
"config.backups": "Sauvegardes",
"config.backups_desc": "Gérez les sauvegardes automatiques de vos fichiers.",
"config.client_config": "Configuration client",
@@ -569,6 +583,7 @@
"config.test": "Tester",
"config.timeout_label": "Timeout recherche (ms)",
"config.title": "Configuration",
"config.toc_close": "Fermer le sommaire",
"config.toc_toggle": "Afficher le sommaire",
"config.title_boost": "Boost titre",
"config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre",
@@ -777,6 +792,7 @@
"header.menu_theme": "Thème",
"header.menu_theme_light": "Clair",
"header.menu_toggle": "Menu",
"header.menu_version": "Version",
"header.profile": "Profil",
"header.quick_help": "Raccourcis & Astuces",
"header.refresh": "Rafraîchir",
@@ -1106,7 +1122,7 @@
"help.desc_markdown_all": ":\n Rendu complet du markdown",
"help.desc_md_render": "Les fichiers markdown sont rendus avec :",
"help.desc_menu_header": ": Dropdown \"Vault\" dans le menu Options",
"help.desc_menu_options": ": Accès aux\n paramètres, thème, aide",
"help.desc_menu_options": ": Accès aux\n paramètres, thème, aide et version",
"help.desc_op_ext": ": Filtrer par type de fichier",
"help.desc_op_path": ": Filtrer par chemin",
"help.desc_op_phrase": ": Recherche de phrase entre guillemets",
@@ -1309,6 +1325,8 @@
"help.sidebar_filtering": "Filtrage de la sidebar",
"help.sidebar_section": "Sidebar (barre latérale)",
"help.sidebar_tabs": "La sidebar est divisée en deux onglets :",
"help.sidebar_user": "Compte",
"help.desc_sidebar_user": ": Avatar, nom, rôle et déconnexion en bas de la sidebar",
"help.sidebar_toggle_btn": "Bouton toggle sidebar",
"help.simple": "Simple",
"help.simple_search": "Recherche simple",
@@ -1339,6 +1357,7 @@
"help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.",
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
"help.assistant_agent_run": "Les actions s'affichent dans le fil : l'assistant regroupe les modifications en une seule approbation (« Tout approuver ») et met à jour l'arborescence et le document ouvert dès qu'elles sont appliquées. Le bouton d'envoi devient « Stop » pour interrompre l'exécution à tout moment.",
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
"help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).",
@@ -1619,25 +1638,21 @@
"search.whole_word": "Mot entier",
"settings.about": "📦 À propos",
"settings.ai": "🤖 IA",
"settings.backend": "⚙️ Backend",
"settings.backend_hint": "Ces paramètres sont sauvegardés sur le serveur. Certains nécessitent un redémarrage ou une réindexation.",
"settings.backend_section": "Paramètres backend",
"settings.client_label": "Ces paramètres s'appliquent immédiatement côté client.",
"settings.diagnostics": "🩺 Diagnostics",
"settings.explorer": "Explorateur",
"settings.history_count_desc": "Entre 5 et 100 fichiers (sauvegarde sur le serveur)",
"settings.history_count_label": "Nombre de fichiers dans l'historique",
"settings.history_section": "Historique récent",
"settings.no_restart_badge": "Redémarrage non requis",
"settings.profile": "👤 Profil",
"settings.reindex": "Forcer réindexation",
"settings.restart_badge": "Redémarrage requis",
"settings.save": "Sauvegarder",
"settings.search": "Recherche",
"settings.tabs": "Tabs",
"settings.search_placeholder": "Rechercher...",
"settings.sync": "🔄 Synchronisation",
"settings.tabs": "Onglets",
"settings.themes": "🎨 Thèmes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Lien copié !",
"share.copy_link": "Copier le lien",
"share.create": "Créer un lien de partage",
@@ -1682,6 +1697,8 @@
"sidebar.tab_saved": "Recherches",
"sidebar.tab_tags": "Tags",
"sidebar.tab_vaults": "Vaults",
"sidebar.user_role_admin": "Administrateur",
"sidebar.user_role_user": "Utilisateur",
"sidebar.vault_context_all": "Toutes les vaults",
"sidebar.vault_specific": "Vault spécifique",
"sync.autocomplete": "Auto-complétion",
@@ -1830,6 +1847,8 @@
"viewer.image_next": "Image suivante",
"viewer.image_open_original": "Ouvrir l'original",
"viewer.image_prev": "Image précédente",
"viewer.image_strip_next": "Faire défiler les miniatures vers la droite",
"viewer.image_strip_prev": "Faire défiler les miniatures vers la gauche",
"viewer.image_type": "Type",
"viewer.image_zoom_in": "Zoom avant",
"viewer.image_zoom_out": "Zoom arrière",
@@ -1839,6 +1858,8 @@
"viewer.loaded_from_cache": "Fichier chargé depuis le cache hors-ligne",
"viewer.loading": "Chargement du fichier...",
"viewer.markdown": "Markdown",
"viewer.media_too_large": "Fichier trop volumineux pour la lecture intégrée. Téléchargez-le pour le lire.",
"viewer.media_unsupported": "Ce format ne peut pas être lu dans votre navigateur.",
"viewer.metadata_created": "Créé",
"viewer.metadata_modified": "Modifié",
"viewer.metadata_path": "Chemin",
@@ -2026,6 +2047,23 @@
"mfa.webauthn_btn": "Valider avec ma clé",
"mfa.webauthn_cancelled": "Cérémonie WebAuthn annulée.",
"mfa.webauthn_no_key": "Aucune clé de sécurité enregistrée pour ce compte.",
"player.now_playing": "Lecture en cours",
"player.play": "Lecture",
"player.pause": "Pause",
"player.previous": "Précédent",
"player.next": "Suivant",
"player.seek": "Position de lecture",
"player.volume": "Volume",
"player.speed": "Vitesse de lecture",
"player.expand": "Agrandir le lecteur",
"player.minimize": "Réduire le lecteur",
"player.close": "Arrêter et fermer le lecteur",
"player.continues": "Lecture en cours — utilisez le lecteur pour l'arrêter.",
"player.reopen_tab": "Revenir au média",
"player.pip": "Image dans l'image",
"player.move": "Déplacer",
"player.resize": "Redimensionner",
"player.resume": "Reprise de la dernière lecture",
"plugins.title": "🧩 Plugins",
"plugins.description": "Étendez ObsiGate avec des renderers personnalisés, filtres de recherche et actions d'éditeur.",
"plugins.install": "Installer le plugin",
@@ -2127,7 +2165,7 @@
"guide105.lib_h3_conflicts": "Conflits de synchronisation",
"guide105.lib_conflicts": "Si vous synchronisez le vault avec Syncthing, ObsiGate détecte les fichiers de conflit (copies « sync-conflict ») et propose de les comparer puis résoudre depuis la page dédiée du menu Options.",
"guide105.lib_h3_attach": "Fichiers joints & médias",
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation entre images du dossier, miniatures, métadonnées, lightbox) ; le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
"guide105.lib_attach": "Les images <code>![[image.png]]</code>, pièces jointes et médias (audio, vidéo, PDF intégrés) dans les notes sont rendus dans le viewer et indexés pour la recherche. Les images apparaissent aussi dans l'arborescence et s'ouvrent dans une visionneuse dédiée (zoom molette, pan, navigation fluide entre les images du dossier — image redimensionnée au cadre, flèches latérales au survol, pellicule de miniatures persistante —, métadonnées, lightbox) ; les fichiers audio (.mp3, .wav, .flac…) et vidéo (.mp4, .webm…) s'ouvrent dans un lecteur HTML5 intégré (lecture, déplacement, vitesse, plein écran), avec repli sur le téléchargement si le format n'est pas lisible par le navigateur ; la lecture continue pendant la navigation grâce à un mini-lecteur flottant (audio) ou une mini-fenêtre vidéo, qui permet à tout moment de revenir au média ou de l'arrêter. Le bouton « Rescan attachments » de la configuration recrée l'index des pièces jointes.",
"guide105.off_pwa": "ObsiGate est une PWA : installez-la (icône d'installation de la barre d'adresse) pour l'ouvrir comme une application. Le service worker met en cache l'interface et vos derniers documents consultés.",
"guide105.off_edit": "Hors-ligne, vous pouvez lire les documents en cache et même les éditer : les modifications sont mises en file d'attente dans IndexedDB.",
"guide105.off_sync": "Au retour en ligne, la file se rejoue automatiquement (badge de synchronisation dans l'en-tête). Si la version serveur a divergé entre-temps, le fichier est marqué en conflit et la version serveur est préservée en backup.",
+7 -2
View File
@@ -38,7 +38,7 @@
}
})();
</script>
<link rel="stylesheet" href="/static/style.css?v=2">
<link rel="stylesheet" href="/static/style.css?v=3">
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-theme-dark">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
@@ -871,8 +871,13 @@ const RightSidebarManager = {
group.forEach(function (b) { actionsDiv.appendChild(b); });
});
header.appendChild(actionsDiv);
area.appendChild(header);
// #115 — pinned action bar (direct child of the scroll container) so it
// stays visible while long documents scroll, as in the main viewer.
const toolbar = document.createElement("div");
toolbar.className = "file-toolbar";
toolbar.appendChild(actionsDiv);
area.appendChild(toolbar);
// Frontmatter — Accent Card
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
+1180 -68
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v24';
const SW_VERSION = 'v26';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
@@ -109,6 +109,13 @@ self.addEventListener('fetch', (event) => {
// Let the browser handle range requests (PDF/streamed media) directly.
if (request.headers.has('range')) return;
// Streamed audio/video is large and range-driven — never cache it
// (roadmap #109-E2). Image thumbnails (/api/media/{vault}/thumb) stay cached.
if (url.pathname.startsWith('/api/media/') && !url.pathname.endsWith('/thumb')) {
event.respondWith(fetch(request));
return;
}
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, API_CACHE, () =>
new Response(JSON.stringify({ error: 'Offline' }), {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.17.0",
"version": "2.25.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": {
@@ -9,7 +9,8 @@
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test:e2e": "bash scripts/run-e2e-local.sh"
"test:e2e": "bash scripts/run-e2e-local.sh",
"test:e2e:ps": "pwsh -NoProfile -File scripts/run-e2e-local.ps1"
},
"repository": {
"type": "git",
+120
View File
@@ -0,0 +1,120 @@
<#
.SYNOPSIS
ObsiGate — E2E locaux (Playwright) sous Windows/PowerShell.
.DESCRIPTION
Équivalent PowerShell de `scripts/run-e2e-local.sh`, pour les postes Windows
où `bash` n'est pas utilisable (WSL indisponible, git-bash bloqué par une
politique de contrôle d'application). Démarre le backend nativement via
uvicorn (auth désactivée, fixtures TestVault/TestDir, port 2029 — mêmes
conditions que le job CI `e2e`), lance la suite Playwright puis nettoie.
.PARAMETER PlaywrightArgs
Arguments transmis à `npx playwright test`, ex. `-g "image viewer"`,
`--headed`.
.EXAMPLE
./scripts/run-e2e-local.ps1
./scripts/run-e2e-local.ps1 -g "BUG-072"
./scripts/run-e2e-local.ps1 --headed
#>
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$PlaywrightArgs
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $Root
$Port = if ($env:E2E_PORT) { $env:E2E_PORT } else { "2029" }
$BaseUrl = "http://127.0.0.1:$Port"
$ServerLog = "data/e2e-server.log"
$ServerErrLog = "data/e2e-server.err.log"
function Assert-Command([string]$Name, [string]$Hint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "[ERR] $Name introuvable. $Hint"
}
}
Assert-Command "uv" "Installez-le : https://docs.astral.sh/uv/"
Assert-Command "npx" "Installez Node.js (>= 20)."
# ----- Venv Python 3.11 (créé une seule fois) -----
$Python = ".venv-e2e/Scripts/python.exe"
if (-not (Test-Path -LiteralPath $Python)) {
Write-Host "[INFO] Création du venv .venv-e2e (Python 3.11)..."
uv venv .venv-e2e --python 3.11
uv pip install --python $Python -r backend/requirements.txt
}
# ----- Port déjà occupé ? -----
try {
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
Write-Host "[ERR] Quelque chose répond déjà sur $BaseUrl."
Write-Host " Arrêtez-le ou choisissez un autre port : `$env:E2E_PORT=2030; ./scripts/run-e2e-local.ps1"
exit 1
} catch {
# port libre
}
# ----- Démarrage du serveur (mêmes conditions que le CI e2e) -----
Write-Host "[INFO] Démarrage d'ObsiGate sur $BaseUrl (auth désactivée)..."
New-Item -ItemType Directory -Force -Path "data" | Out-Null
$env:OBSIGATE_AUTH_ENABLED = "false"
$env:VAULT_1_NAME = "TestVault"
$env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
$env:DIR_1_NAME = "TestDir"
$env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
$server = Start-Process -FilePath $Python `
-ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
-RedirectStandardOutput $ServerLog -RedirectStandardError $ServerErrLog `
-PassThru -WindowStyle Hidden
$exitCode = 1
try {
# ----- Attente du health check (30 s max, comme le CI) -----
$ready = $false
for ($i = 0; $i -lt 30; $i++) {
try {
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
$ready = $true
break
} catch {
if ($server.HasExited) {
Write-Host "[ERR] Le serveur a quitté prématurément. Log :"
Get-Content -LiteralPath $ServerErrLog -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
Start-Sleep -Seconds 1
}
}
if (-not $ready) {
Write-Host "[ERR] Serveur injoignable sur $BaseUrl. Log :"
Get-Content -LiteralPath $ServerErrLog -Tail 30 -ErrorAction SilentlyContinue
exit 1
}
Write-Host "[OK] Serveur prêt."
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
npx playwright install chromium
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
$env:BASE_URL = $BaseUrl
& npx playwright test --project=chromium-desktop @PlaywrightArgs
$exitCode = $LASTEXITCODE
} finally {
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
# uvicorn (via uv) peut lancer un interpréteur enfant : tuer le groupe resté sur le port.
Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }
}
exit $exitCode
Binary file not shown.
Binary file not shown.
+10
View File
@@ -106,6 +106,16 @@ def test_vault_dir(tmp_path: Path) -> str:
encoding="utf-8",
)
# Audio/video media (roadmap #109) — indexed as metadata-only binaries,
# streamed via /api/media. Prefer the committed E2E fixture when present.
repo_media = Path(__file__).resolve().parent.parent / "test_vault" / "sample-audio.mp3"
if repo_media.exists():
(vault / "sample-audio.mp3").write_bytes(repo_media.read_bytes())
else:
(vault / "sample-audio.mp3").write_bytes(
b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
)
# File with accents in title
(vault / "café_crème.md").write_text(
"---\ntitle: Café Crème\n---\n# Café Crème\nUn bon café.\n",
+73 -8
View File
@@ -1,15 +1,18 @@
/**
* E2E tests for the Configurations modal on mobile (BUG-071).
* E2E tests for the Configurations modal on mobile (BUG-071 + #114).
*
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
* skipped on the desktop project that the CI job executes — same convention
* as mobile-editor.spec.js.
*
* Covered:
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav,
* which is hidden by default on mobile;
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav
* as a left slide-over drawer (fixed positioning), hidden by default;
* - the drawer has a close button (#config-toc-close) and a dimming backdrop
* (tap outside closes the drawer, not the whole modal);
* - picking a TOC entry scrolls to the section, marks the link active and
* collapses the nav;
* collapses the drawer;
* - the modal is full-screen (100dvh) and interactive controls are ≥44px;
* - the modal content does not overflow horizontally at 393px.
*
* Run:
@@ -20,6 +23,7 @@
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
const MIN_TOUCH_TARGET = 44;
async function boot(page) {
await page.goto('/');
@@ -33,8 +37,8 @@ async function openConfigModal(page) {
await expect(page.locator('#config-modal.active')).toBeVisible();
}
test.describe('Configurations modal on mobile (BUG-071)', () => {
test('hamburger reveals the table of contents', async ({ page, viewport }) => {
test.describe('Configurations modal on mobile (BUG-071 + #114)', () => {
test('hamburger reveals the TOC as a fixed drawer', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
@@ -45,9 +49,39 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
// #114: the drawer is a fixed slide-over, not an inline top block.
const position = await page.locator('#config-nav').evaluate(
(el) => window.getComputedStyle(el).position,
);
expect(position).toBe('fixed');
// The modal carries the drawer-open class (backdrop contract).
await expect(page.locator('#config-modal')).toHaveClass(/config-toc-open/);
});
test('picking a section scrolls to it and collapses the nav', async ({ page, viewport }) => {
test('close button and backdrop dismiss the drawer, not the modal', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// Close button inside the drawer header.
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-toc-close')).toBeVisible();
await page.locator('#config-toc-close').click();
await expect(page.locator('#config-nav')).toBeHidden();
await expect(page.locator('#config-modal.active')).toBeVisible();
// Backdrop tap closes the drawer first; the modal stays open.
// Click near the right edge of the modal (outside the left drawer).
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
await page.locator('#config-modal').click({ position: { x: (viewport?.width ?? 393) - 8, y: 200 } });
await expect(page.locator('#config-nav')).toBeHidden();
await expect(page.locator('#config-modal.active')).toBeVisible();
});
test('picking a section scrolls to it and collapses the drawer', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
@@ -57,7 +91,7 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
await expect(link).toBeVisible();
await link.click();
// Nav collapses on mobile after selection…
// Drawer collapses on mobile after selection…
await expect(page.locator('#config-nav')).toBeHidden();
// …the link is marked active…
await expect(link).toHaveClass(/active/);
@@ -76,6 +110,37 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
.toBeLessThanOrEqual(0);
});
test('modal is full-screen and key controls meet the 44px touch target', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// Full-screen: the modal container fills the viewport.
const container = page.locator('#config-modal .editor-container');
const box = await container.boundingBox();
expect(box).not.toBeNull();
expect(box.width).toBeGreaterThanOrEqual((viewport?.width ?? 0) - 2);
expect(box.height).toBeGreaterThanOrEqual((viewport?.height ?? 0) - 2);
// Header controls (hamburger + close) are ≥44px.
for (const sel of ['#config-hamburger', '#config-close']) {
const b = await page.locator(sel).boundingBox();
expect(b, `${sel} has no bounding box`).not.toBeNull();
expect(b.height, `${sel} height`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
expect(b.width, `${sel} width`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
}
// Backend Save row: buttons are ≥44px tall.
await page.locator('#config-hamburger').click();
await page.locator('#config-nav a[href="#cfg-backend-settings"]').click();
await expect(page.locator('#config-nav')).toBeHidden();
const saveBtn = page.locator('#cfg-save-backend');
await expect(saveBtn).toBeVisible();
const saveBox = await saveBtn.boundingBox();
expect(saveBox).not.toBeNull();
expect(saveBox.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
});
test('no horizontal overflow at 393px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
+67
View File
@@ -0,0 +1,67 @@
/**
* E2E — Header allégé + section compte en bas de la sidebar (#112).
*
* Le premier test s'exécute partout ; le second (section compte) nécessite
* l'authentification activée et se marque `skip` sinon (job CI e2e, serveur
* local `run-e2e-local` en auth désactivée). Pour le valider manuellement,
* lancer Playwright contre l'instance Docker de test (auth active) :
*
* BASE_URL=http://localhost:2020 OBSIGATE_PASS=test123 \
* npx playwright test tests/e2e/header-sidebar.spec.js --project=chromium-desktop
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function authEnabled(page) {
return page.evaluate(async () => {
try {
const r = await fetch('/api/auth/status');
const d = await r.json();
return !!d.auth_enabled;
} catch {
return false;
}
});
}
test.describe('Header allégé + compte en sidebar (#112)', () => {
test('le header ne porte plus utilisateur/version/déconnexion ; la version est dans le menu Options', async ({ page }) => {
await login(page);
await expect(page.locator('.header-right #user-menu')).toHaveCount(0);
await expect(page.locator('#logout-btn')).toHaveCount(0);
await expect(page.locator('.header-right > .version-badge')).toHaveCount(0);
await page.locator('#header-menu-btn').click();
await expect(page.locator('#header-menu-dropdown #version-badge')).toBeVisible();
});
test('le compte est affiché en bas de la sidebar (auth active)', async ({ page }) => {
await login(page);
test.skip(!(await authEnabled(page)), 'auth disabled in this environment');
await expect(page.locator('#sidebar-user')).toBeVisible();
await expect(page.locator('#sidebar-user-name')).not.toBeEmpty();
await expect(page.locator('#sidebar-user-role')).not.toBeEmpty();
await expect(page.locator('#sidebar-user-logout')).toBeVisible();
});
});
+91
View File
@@ -95,4 +95,95 @@ test.describe('Image viewer — zoom / pan / navigation (#108)', () => {
await page.locator('#content-area .image-nav-strip img.image-thumb').first().click();
await expect(page.locator('#content-area .image-title')).toBeVisible();
});
test('conserve le plein écran et le panneau métadonnées à la navigation (#BUG-072)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const container = page.locator('#content-area .image-viewer-container');
const metaPanel = page.locator('#content-area .image-meta-panel');
const title = page.locator('#content-area .image-title');
await expect(container).toBeVisible({ timeout: 10000 });
// Métadonnées : barre latérale à droite de l'image (pas sous la pellicule).
await page.locator('#content-area .image-btn-metadata').click();
await expect(metaPanel).toBeVisible();
const stageBox = await page.locator('#content-area .image-stage').boundingBox();
const metaBox = await metaPanel.boundingBox();
expect(metaBox.x).toBeGreaterThanOrEqual(stageBox.x + stageBox.width - 1);
// Plein écran activé.
await page.locator('#content-area .image-btn-lightbox').click();
await expect(container).toHaveClass(/lightbox/);
// Naviguer (flèche droite) : les deux états doivent survivre au re-render.
const titleBefore = await title.innerText();
await page.keyboard.press('ArrowRight');
await expect(container).toHaveClass(/lightbox/);
await expect(metaPanel).toBeVisible();
await expect(title).not.toHaveText(titleBefore);
});
test('navigation en place : flèches latérales, pellicule persistante et cadre ajusté (#111)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const container = page.locator('#content-area .image-viewer-container');
const strip = page.locator('#content-area .image-nav-strip');
const main = page.locator('#content-area img.image-main');
await expect(strip).toBeVisible({ timeout: 10000 });
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
// Marqueur : la navigation en place ne doit PAS recréer le conteneur.
await container.evaluate((el) => { el.dataset.inplace = '1'; });
// L'image reste contenue dans le cadre (jamais plus grande).
const stageBox = await page.locator('#content-area .image-stage').boundingBox();
const imgBox = await main.boundingBox();
expect(imgBox.width).toBeLessThanOrEqual(stageBox.width + 1);
expect(imgBox.height).toBeLessThanOrEqual(stageBox.height + 1);
// Flèche superposée révélée au survol.
const nextArrow = page.locator('#content-area .image-nav-arrow-next');
await expect(nextArrow).toBeAttached();
await nextArrow.hover();
await expect.poll(() => nextArrow.evaluate((el) => getComputedStyle(el).opacity)).toBe('1');
const title = page.locator('#content-area .image-title');
const before = await title.innerText();
await nextArrow.click();
await expect(title).not.toHaveText(before);
await expect(strip).toBeVisible();
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
await expect(container).toHaveAttribute('data-inplace', '1');
await expect(page.locator('#content-area .image-counter')).toContainText('/');
});
test('la pellicule est défilable (molette + flèches latérales) sans changer d\'image (#112)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-image.png');
const nav = page.locator('#content-area .image-nav');
const strip = page.locator('#content-area .image-nav-strip');
await expect(strip).toBeVisible({ timeout: 10000 });
await expect(nav).toBeAttached();
// Les deux flèches de défilement existent (translucides, révélées au survol).
const prev = page.locator('#content-area .image-strip-arrow-prev');
const next = page.locator('#content-area .image-strip-arrow-next');
await expect(prev).toBeAttached();
await expect(next).toBeAttached();
await expect
.poll(() => next.evaluate((el) => getComputedStyle(el).opacity))
.not.toBe('1'); // au repos, la flèche n'est pas pleinement visible
// Faire défiler la pellicule ne change PAS l'image courante.
const title = page.locator('#content-area .image-title');
const before = await title.innerText();
await next.hover();
await next.click();
await prev.click();
await expect(title).toHaveText(before);
});
});
+163
View File
@@ -0,0 +1,163 @@
/**
* E2E tests for the ObsiGate media viewers & persistent player (roadmap #109/#110).
*
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine) + `test_vault/sample-video.webm`.
*
* Run (local):
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/media-viewer.spec.js
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
}
await treeItem.dblclick({ timeout: 5000 });
}
test.describe('Media viewers — HTML5 audio/video (#109)', () => {
test('rend un lecteur audio natif branché sur /api/media', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
const audio = page.locator('#content-area .audio-viewer-container audio.np-media--audio');
await expect(audio).toBeVisible({ timeout: 10000 });
await expect(audio).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
await expect(audio).toHaveAttribute('controls', '');
await expect(page.locator('#content-area .media-duration-badge')).not.toHaveText('--:--', { timeout: 10000 });
});
test('rend un lecteur vidéo natif branché sur /api/media', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-video.webm');
const video = page.locator('#content-area .video-viewer-container video.np-media--video');
await expect(video).toBeVisible({ timeout: 10000 });
await expect(video).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
await expect(video).toHaveAttribute('playsinline', '');
});
test('le streaming média honore les requêtes Range (206)', async ({ page }) => {
await login(page);
const resp = await page.request.get(
`${BASE}/api/media/TestVault?path=${encodeURIComponent('sample-video.webm')}`,
{ headers: { Range: 'bytes=0-99' } },
);
expect(resp.status()).toBe(206);
expect(resp.headers()['content-range']).toMatch(/^bytes 0-99\/\d+$/);
expect(resp.headers()['accept-ranges']).toBe('bytes');
});
});
test.describe('Now Playing — lecture persistante (#110)', () => {
test('affiche le dock et continue la lecture quand on navigue ailleurs', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
// Naviguer vers une note : le média doit passer dans le dock.
await openFile(page, 'TestVault', 'note1.md');
const dock = page.locator('#now-playing-host .np-dock--audio');
await expect(dock).toBeVisible({ timeout: 10000 });
await expect(dock.locator('.np-dock-title')).toHaveText('sample-audio.mp3');
// S'assurer de la lecture (l'autoplay peut être bloqué sans geste).
const media = page.locator('#now-playing-host audio.np-media--audio');
if (await media.evaluate((el) => el.paused)) {
await dock.locator('[data-np="play"]').click();
}
await expect.poll(() => media.evaluate((el) => !el.paused), { timeout: 5000 }).toBe(true);
// Naviguer encore : toujours en lecture.
await openFile(page, 'TestVault', 'Accueil.md');
await expect(dock).toBeVisible();
expect(await media.evaluate((el) => !el.paused)).toBe(true);
});
test('revient sur le média depuis le dock', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
await openFile(page, 'TestVault', 'note1.md');
const dock = page.locator('#now-playing-host .np-dock--audio');
await expect(dock).toBeVisible({ timeout: 10000 });
await dock.locator('[data-np="reopen"]').click();
await expect(page.locator('#content-area .audio-viewer-container audio.np-media--audio')).toBeVisible({ timeout: 10000 });
await expect(dock).toBeHidden();
});
test('ferme la lecture depuis le dock', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
await expect(page.locator('#content-area audio.np-media--audio')).toBeVisible({ timeout: 10000 });
await openFile(page, 'TestVault', 'note1.md');
const dock = page.locator('#now-playing-host .np-dock--audio');
await expect(dock).toBeVisible({ timeout: 10000 });
await dock.locator('[data-np="close"]').click();
await expect(page.locator('#now-playing-host .np-dock--audio')).toBeHidden();
});
test('le mini-player vidéo flotte et reste visible en naviguant', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-video.webm');
await expect(page.locator('#content-area video.np-media--video')).toBeVisible({ timeout: 10000 });
await openFile(page, 'TestVault', 'note1.md');
const mini = page.locator('#now-playing-host .np-dock--video');
await expect(mini).toBeVisible({ timeout: 10000 });
await expect(mini.locator('video.np-media--video')).toBeVisible();
});
test('la mini-fenêtre vidéo peut être déplacée librement (centre)', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-video.webm');
await expect(page.locator('#content-area video.np-media--video')).toBeVisible({ timeout: 10000 });
await openFile(page, 'TestVault', 'note1.md');
const mini = page.locator('#now-playing-host .np-dock--video');
await expect(mini).toBeVisible({ timeout: 10000 });
const before = await mini.evaluate((el) => ({ left: parseFloat(el.style.left), top: parseFloat(el.style.top) }));
const box = await mini.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + 12);
await page.mouse.down();
await page.mouse.move(640, 360, { steps: 12 });
await page.mouse.up();
const after = await mini.evaluate((el) => ({ left: parseFloat(el.style.left), top: parseFloat(el.style.top) }));
expect(after.left).not.toBe(before.left);
expect(after.top).not.toBe(before.top);
// Doit pouvoir rester au centre (pas de re-aimantation sur les bords).
expect(after.left).toBeGreaterThan(50);
expect(after.left).toBeLessThan(900);
expect(after.top).toBeGreaterThan(20);
expect(after.top).toBeLessThan(600);
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* E2E tests for the mobile bottom toolbar clearance (BUG-073).
*
* The fixed bottom bar (#mobile-toolbar, 64px + safe-area) used to cover the
* last pixels of every document/page: the clearance rule targeted the dead
* `.main-layout` selector instead of `.main-body`, so #content-area reached
* behind the bar and the end of the content was unreachable.
*
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
* skipped on the desktop project that the CI job executes — same convention
* as mobile-editor.spec.js / config-mobile.spec.js.
*
* Run:
* npx playwright test tests/e2e/mobile-toolbar.spec.js --project=chromium-mobile
* pwsh scripts/run-e2e-local.ps1 --project=chromium-mobile -g "BUG-073"
* (ObsiGate listening on http://localhost:2029, auth disabled)
*/
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
const LONG_DOC = 'ANALYSE_REVIEW.md';
async function boot(page) {
await page.goto('/');
await page
.waitForSelector('#app:not(.hidden)', { timeout: 15000 })
.catch(() => {});
}
/** Wait for the async markdown render, then confirm the area actually scrolls. */
async function openLongDocument(page) {
await page.evaluate((p) => window.TabManager.open('TestVault', p), LONG_DOC);
await expect(page.locator('#content-area .md-content')).toBeAttached({ timeout: 15000 });
await expect
.poll(
() =>
page.evaluate(() => {
const area = document.getElementById('content-area');
return area.scrollHeight > area.clientHeight;
}),
{ timeout: 15000 },
)
.toBe(true);
}
/** Geometry of #content-area vs the fixed #mobile-toolbar bar. */
async function measure(page) {
return page.evaluate(() => {
const area = document.getElementById('content-area').getBoundingClientRect();
const bar = document.getElementById('mobile-toolbar').getBoundingClientRect();
return { areaBottom: area.bottom, barTop: bar.top, barVisible: bar.height > 0 };
});
}
test.describe('Mobile bottom toolbar clearance (BUG-073)', () => {
test('content area ends above the fixed bottom toolbar', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
const toolbar = page.locator('#mobile-toolbar');
await expect(toolbar).toBeVisible();
// Open a long document so the layout is in its real document-view state.
await openLongDocument(page);
await expect(page.locator('#content-area')).toBeVisible();
// The scroll container itself must stop at (or above) the bar — 1px
// tolerance for subpixel rounding.
await expect
.poll(async () => {
const m = await measure(page);
return m.areaBottom - m.barTop;
})
.toBeLessThanOrEqual(1);
});
test('the end of a long document scrolls clear of the toolbar', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openLongDocument(page);
// Scroll to the very end: the last rendered element must sit above the bar.
await page.evaluate(() => {
const area = document.getElementById('content-area');
area.scrollTop = area.scrollHeight;
});
const result = await page.evaluate(() => {
const area = document.getElementById('content-area');
const barTop = document.getElementById('mobile-toolbar').getBoundingClientRect().top;
// Last element of the rendered document (markdown root or content child).
const root = area.querySelector('.md-content') || area.lastElementChild;
let last = root;
if (root && root.lastElementChild) last = root.lastElementChild;
const lastBottom = last ? last.getBoundingClientRect().bottom : area.getBoundingClientRect().bottom;
return { lastBottom, barTop };
});
expect(result.lastBottom).toBeLessThanOrEqual(result.barTop + 1);
});
});
+158 -2
View File
@@ -104,6 +104,7 @@ async function main() {
const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href);
const { BooksLM, MODE } = bookslmMod;
const { collectOpenDocuments, documentsSignature } = bookslmMod;
const { t } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
const { state } = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
// ── 1. Rewrite modal resolves with the typed instruction ──
@@ -213,8 +214,14 @@ async function main() {
const { readFileSync } = await import("node:fs");
const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8");
const apply = src.slice(src.indexOf("async _applyConfirmation"));
assert.match(apply, /payload:\s*msg\.payload\s*\?\s*\{[^\n]*\.\.\.msg\.payload[^\n]*\}\s*:\s*null/,
"continuation must inherit the original request payload (no null payload)");
// BUG-075: the whole batch is approved at once and the continuation keeps
// streaming into the SAME message (its `payload` stays available, so the
// BUG-046 null-payload regression cannot reappear).
assert.match(apply, /confirm_all:\s*true/, "global approval flag sent");
assert.match(apply, /await this\._streamResponse\(resp, msg, payload\)/,
"resume streams into the original message (payload preserved)");
assert.doesNotMatch(apply, /this\._messages\.push\(continuation\)/,
"no fragmented continuation message per approval");
assert.match(apply, /await this\._responseError\(resp\)/,
"resume path must format non-OK responses via _responseError");
});
@@ -550,6 +557,50 @@ async function main() {
assert.ok(summary.getAttribute("aria-label"), "the state is announced to screen readers");
});
await test("the steps header previews the first action and counts actions only (BUG-074)", () => {
const b = new BooksLM();
// Two reasoning notes + one action → the header counts 1 action and
// previews it; the notes must not inflate the counter.
const calls = [
{ name: "", ok: true, step: { key: "thought", params: { value: "je réfléchis" } } },
{ name: "create_file", ok: true, step: { key: "file_create", params: { value: "a.md" } } },
{ name: "", ok: true, step: { key: "thought", params: { value: "encore" } } },
];
const trace = b._renderToolActivity(calls, {}, false);
const summary = trace.querySelector("summary");
assert.equal(
summary.querySelector(".bookslm-steps-label").textContent,
t("ai.steps_count", { count: 1 }),
"only actions are counted",
);
const title = summary.querySelector(".bookslm-steps-title");
assert.ok(title, "the first action is previewed in the collapsed header");
assert.ok(title.textContent.startsWith("— "), "preview is prefixed with a dash");
assert.ok(title.textContent.includes(b._stepText(calls[1])),
"preview shows the first action's label");
// Several actions → plural label.
const two = b._renderToolActivity([
{ name: "create_file", ok: true, step: { key: "file_create", params: { value: "a.md" } } },
{ name: "create_directory", ok: true, step: { key: "dir_create", params: { value: "d" } } },
], {}, false);
assert.equal(
two.querySelector(".bookslm-steps-label").textContent,
t("ai.steps_count_plural", { count: 2 }),
"plural for several actions",
);
});
await test("_actionStepCount ignores reasoning notes (BUG-074)", () => {
const b = new BooksLM();
assert.equal(b._actionStepCount([
{ step: { key: "thought" } },
{ step: { key: "file_create" } },
]), 1, "only the action is counted");
assert.equal(b._actionStepCount([{ step: { key: "thought" } }]), 1,
"a thoughts-only block still shows 1, never 0");
assert.equal(b._actionStepCount([]), 0);
});
await test("reasoning notes become « Réflexion » sub-sections, open state kept", () => {
const b = new BooksLM();
const msg = {};
@@ -1206,14 +1257,119 @@ async function main() {
assert.equal(msg.confirmation, null, "confirmation resolved");
assert.ok(posted, "resumed via /agent");
assert.equal(posted.confirm.error.tool, "edit_file");
assert.equal(posted.confirm_all, true, "BUG-075: global approval flag sent");
assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]);
// BUG-074: the continuation stays in the same message (cumulative steps).
assert.equal(b._messages.length, 1, "no fragmented continuation message");
const cont = b._messages[b._messages.length - 1];
assert.equal(cont, msg, "streams into the original message");
assert.equal(cont.content, "Fait.");
assert.equal(cont.toolCalls.length, 1, "tool trace captured");
b._panel.remove();
localStorage.clear();
});
await test("a batch confirmation lists every action and offers one approval (BUG-075)", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._fillConfirmationDiff = async () => {};
const msg = {
role: "assistant",
content: "",
confirmation: {
pending: {
error: { tool: "create_file", arguments: { vault: "V", path: "a.md" }, id: "1" },
actions: [
{ id: "1", tool: "create_directory", step: { key: "dir_create", params: { value: "proj" } }, arguments: { vault: "V", path: "proj" } },
{ id: "2", tool: "create_file", step: { key: "file_create", params: { value: "proj/a.md" } }, arguments: { vault: "V", path: "proj/a.md", content: "x" } },
],
},
messages: [],
},
};
const card = b._renderConfirmationCard(msg);
const rows = card.querySelectorAll(".bookslm-confirm-action");
assert.equal(rows.length, 2, "one row per pending action");
assert.ok(
rows[0].querySelector("summary").textContent.includes(
b._stepText({ step: { key: "dir_create", params: { value: "proj" } }, name: "create_directory" }),
),
"each row shows the action's human label",
);
const apply = card.querySelector(".bookslm-action-apply");
assert.equal(apply.textContent, t("ai.action_apply_all", { count: 2 }),
"a single global approval button");
b._panel.remove();
});
await test("single-action confirmation keeps the legacy Apply label (BUG-075)", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._fillConfirmationDiff = async () => {};
const msg = {
role: "assistant",
content: "",
confirmation: {
pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "x" }, id: "1" } },
messages: [],
},
};
const card = b._renderConfirmationCard(msg);
assert.equal(card.querySelectorAll(".bookslm-confirm-action").length, 0,
"no batch list for a single action");
assert.equal(card.querySelector(".bookslm-action-apply").textContent, t("ai.action_apply"));
b._panel.remove();
});
await test("the send button doubles as a Stop control while running (BUG-077)", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const btn = b._panel.querySelector(".bookslm-btn-send");
b._syncSendButton();
assert.equal(btn.querySelector("i").getAttribute("data-lucide"), "arrow-up");
assert.ok(!btn.classList.contains("is-stopping"), "idle: normal send button");
let aborted = false;
b._isLoading = true;
b._abortCtrl = { abort: () => { aborted = true; } };
b._syncSendButton();
assert.ok(btn.classList.contains("is-stopping"), "stop state applied while running");
assert.equal(btn.querySelector("i").getAttribute("data-lucide"), "square");
btn.click();
assert.ok(aborted, "clicking while running aborts the request");
b._isLoading = false;
b._panel.remove();
});
await test("_markStopped appends a visible stop marker (BUG-077)", () => {
const b = new BooksLM();
const msg = { content: "Début de réponse" };
b._markStopped(msg);
assert.ok(msg.content.includes("⏹"), "marker present");
assert.ok(msg.content.startsWith("Début de réponse"), "partial answer kept");
assert.ok(!msg.content.includes("⏹\n"), "marker separated from the answer");
const empty = { content: "" };
b._markStopped(empty);
assert.ok(empty.content.includes("⏹"));
assert.ok(!empty.content.startsWith("\n"), "no leading blank line for an empty answer");
});
await test("document writes notify the viewer; path-less tools do not (BUG-076)", () => {
const b = new BooksLM();
const seen = [];
const handler = (e) => seen.push(e.detail);
window.addEventListener("obsigate:file-written", handler);
b._notifyFileWritten({ name: "create_xlsx", ok: true, arguments: { vault: "V", path: "a.xlsx" } });
b._notifyFileWritten({ name: "edit_file", ok: false, arguments: { vault: "V", path: "b.md" } });
b._notifyFileWritten({ name: "replace_in_files", ok: true, arguments: { vault: "V" } });
window.removeEventListener("obsigate:file-written", handler);
assert.equal(seen.length, 1, "only the successful path-bearing write notifies");
assert.equal(seen[0].path, "a.xlsx");
});
// ── 29. Resizable panel bounds and persistence (#81) ──
await test("panel width is clamped and persisted", async () => {
localStorage.clear();
+147 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* ObsiGate — Configurations modal mobile usability non-regression tests (BUG-071).
* ObsiGate — Configurations modal mobile usability tests (BUG-071 + #114).
*
* Static checks (no jsdom needed — runs in the "Frontend unit tests" CI step):
* - BUG-071a: #config-nav shared the .help-nav rule that hides it below
@@ -18,8 +18,16 @@
* style.css must stack/wrap them below 768px with 44px touch targets.
* - BUG-071d: every #config-nav link target must exist (dead-anchor guard,
* same class of bug as BUG-067 for the help modal).
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"-
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"
* separated) so the toggle carries translated title AND aria-label.
* - #114a: the TOC is a left slide-over drawer (position: fixed) opened by
* .config-toc-open on #config-modal, with a backdrop (::before) and a
* close button (#config-toc-close).
* - #114b: the modal is full-screen (100dvh), inputs are ≥16px anti-zoom
* with 44px min-height, buttons are 44px touch targets, the backend Save
* row is sticky, and the MFA recovery list is single-column on mobile.
* - #114i18n: dead settings.* keys purged, settings.explorer and
* config.toc_close present in FR and EN, settings.tabs translated in FR.
*
* Usage: node tests/frontend/config-mobile.test.mjs
*/
@@ -82,12 +90,17 @@ test("config.js — TOC links smooth-scroll, mark active, collapse on mobile", (
test("config.js — TOC display reset when the modal opens", () => {
assert.match(configJs, /configNavOnOpen[\s\S]{0,120}?style\.display = ''/, "stale inline display would stick across sessions");
assert.match(configJs, /modal\.classList\.remove\("config-toc-open"\)/,
"the drawer-open class (backdrop) must be cleared on open");
});
// ── BUG-071c: mobile CSS ────────────────────────────────────────────────────
test("style.css — config TOC becomes a capped top block on mobile", () => {
test("style.css — config TOC becomes a fixed drawer on mobile", () => {
assert.match(css, /#config-modal #config-nav/, "no mobile rule scoped to #config-modal #config-nav");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/, "the opened TOC must be height-capped so content stays reachable");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?position: fixed/,
"the TOC must be a fixed slide-over drawer on mobile");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/,
"the drawer must be height-capped so content stays reachable");
});
test("style.css — two-column config grids stack on mobile", () => {
@@ -124,6 +137,136 @@ test("i18n.js — data-i18n-attr supports several attr:key pairs", () => {
assert.match(i18nJs, /el\.setAttribute\(attr, t\(key\)\)/, "each pair must set its attribute");
});
// ── #114a: drawer, backdrop, close button ───────────────────────────────────
test("index.html — #config-toc-close exists inside the TOC header", () => {
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
assert.ok(nav, "#config-nav not found");
assert.match(nav[1], /id="config-toc-close"/, "no #config-toc-close in the TOC — no way to dismiss the drawer");
assert.match(nav[1], /config\.toc_close/, "the close button label must use the i18n key config.toc_close");
});
test("i18n — config.toc_close exists in FR and EN", () => {
assert.ok(fr["config.toc_close"], "fr.json missing config.toc_close");
assert.ok(en["config.toc_close"], "en.json missing config.toc_close");
assert.notEqual(fr["config.toc_close"], "config.toc_close", "FR value must be translated");
assert.notEqual(en["config.toc_close"], "config.toc_close", "EN value must be translated");
});
test("config.js — drawer open class + close button + backdrop + Escape", () => {
assert.match(configJs, /classList\.toggle\("config-toc-open"/,
"_setConfigNav must toggle .config-toc-open on the modal (backdrop + drawer CSS)");
assert.match(configJs, /getElementById\("config-toc-close"\)/,
"no binding on #config-toc-close");
assert.match(configJs, /config-toc-open[\s\S]{0,200}?_setConfigNav\(false\)/,
"a backdrop tap must close the drawer first, not the whole modal");
assert.match(configJs, /Escape[\s\S]{0,200}?config-toc-open[\s\S]{0,120}?_setConfigNav\(false\)/,
"Escape must close the drawer first, then the modal");
assert.match(configJs, /closeConfigModal[\s\S]{0,200}?classList\.remove\("config-toc-open"\)/,
"closeConfigModal must never leave the backdrop class behind");
});
test("style.css — drawer backdrop via .config-toc-open::before", () => {
assert.match(css, /#config-modal\.config-toc-open::before/,
"no backdrop pseudo-element for the open drawer");
assert.match(css, /#config-modal\.config-toc-open::before[\s\S]{0,200}?z-index: 35/,
"the backdrop must sit above the content and below the drawer (z-index 40)");
assert.match(css, /#config-modal\.config-toc-open #config-nav/,
"the open drawer must be forced visible via the modal class");
});
test("style.css — .help-toc-close hidden on desktop, visible in the config drawer", () => {
assert.match(css, /\.help-toc-close\s*\{[^}]*display:\s*none/,
".help-toc-close must be hidden by default (desktop nav is always visible)");
assert.match(css, /#config-modal \.help-toc-close\s*\{[^}]*display:\s*inline-flex/,
"#config-modal .help-toc-close must show on mobile");
});
// ── #114b: full-screen, anti-zoom, touch targets, sticky save ───────────────
test("style.css — config modal goes full-screen with 100dvh on mobile", () => {
assert.match(css, /#config-modal \.editor-container[\s\S]{0,300}?100dvh/,
"the modal must fill the dynamic viewport (mobile browser chrome)");
assert.match(css, /#config-modal[\s\S]{0,200}?padding:\s*0/,
"the modal must drop its outer padding on mobile");
});
test("style.css — inputs are ≥16px with 44px min-height (anti-zoom iOS)", () => {
assert.match(css, /#config-modal \.config-input[\s\S]{0,200}?font-size:\s*16px/,
"config inputs must be ≥16px to stop iOS auto-zoom");
assert.match(css, /#config-modal \.config-input[\s\S]{0,200}?min-height:\s*44px/,
"config inputs need a 44px touch target");
assert.match(css, /#config-modal \.config-select[\s\S]{0,200}?font-size:\s*16px/,
"config selects must be ≥16px to stop iOS auto-zoom");
});
test("style.css — primary buttons are 44px touch targets", () => {
// The shared button block lists many selectors; match from the block start
// (first #config-modal .config-btn-save) to the min-height declaration.
const buttonBlock = css.match(/#config-modal \.config-btn-save,[\s\S]{0,600}?\{[^}]*min-height:\s*44px/);
assert.ok(buttonBlock, ".config-btn-save needs a 44px touch target");
for (const cls of ["config-btn-secondary", "config-btn-danger", "config-btn-sm"]) {
assert.match(css, new RegExp(`#config-modal \\.${cls}`), `#${cls} has no #114 mobile rule`);
}
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,400}?min-height:\s*44px/,
"buttons inside the sticky Save row need a 44px touch target");
});
test("style.css — backend Save row is sticky at the bottom of its section", () => {
assert.match(css, /#config-modal \.config-actions-row\s*\{[^}]*position:\s*sticky/,
"the Save row must stick to the bottom while scrolling the backend section");
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,300}?flex-direction:\s*column/,
"the Save row must stack vertically on mobile");
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,300}?env\(safe-area-inset-bottom/,
"the sticky Save row must clear the home indicator");
});
test("style.css — MFA recovery list single-column + verify section wraps", () => {
assert.match(css, /#config-modal \.mfa-recovery-list\s*\{[^}]*grid-template-columns:\s*1fr/,
"recovery codes must be a single column on mobile");
assert.match(css, /#config-modal \.mfa-verify-section[\s\S]{0,200}?flex-wrap:\s*wrap/,
"the MFA verify row must wrap instead of overflowing");
assert.match(css, /#config-modal \.mfa-code-input[\s\S]{0,300}?width:\s*100%/,
"the MFA code input must be full-width on mobile");
});
test("index.html — .config-actions-row lives inside #cfg-backend-settings", () => {
const section = indexHtml.match(/<section[^>]*id="cfg-backend-settings"[\s\S]*?<\/section>/);
assert.ok(section, "#cfg-backend-settings section not found");
assert.match(section[0], /class="config-actions-row"/,
".config-actions-row must be inside the backend section (sticky footer contract)");
});
// ── #114i18n: dead keys purged, new keys added ──────────────────────────────
test("i18n — dead settings.* keys are purged from FR and EN", () => {
const dead = ["settings.backend", "settings.backend_hint", "settings.restart_badge",
"settings.save", "settings.plugins"];
for (const key of dead) {
assert.equal(fr[key], undefined, `fr.json still has dead key ${key}`);
assert.equal(en[key], undefined, `en.json still has dead key ${key}`);
}
});
test("i18n — settings.explorer exists in FR and EN", () => {
assert.ok(fr["settings.explorer"], "fr.json missing settings.explorer");
assert.ok(en["settings.explorer"], "en.json missing settings.explorer");
assert.notEqual(fr["settings.explorer"], "settings.explorer", "FR value must be translated");
assert.notEqual(en["settings.explorer"], "settings.explorer", "EN value must be translated");
});
test("i18n — settings.tabs is translated in FR (was the English word \"Tabs\")", () => {
assert.equal(fr["settings.tabs"], "Onglets", "fr.json settings.tabs must be French");
assert.equal(en["settings.tabs"], "Tabs", "en.json settings.tabs must stay English");
});
test("index.html — #mt-explorer label carries data-i18n=settings.explorer", () => {
assert.match(indexHtml, /id="mt-explorer"[\s\S]{0,400}?data-i18n="settings\.explorer"/,
"the explorer toolbar button must be translated via settings.explorer");
});
test("index.html — no dead container #plugins-settings-container", () => {
assert.ok(!indexHtml.includes("plugins-settings-container"),
"#plugins-settings-container was a dead div — plugins render into #cfg-plugins");
});
if (process.exitCode) {
console.error("\nConfig mobile tests FAILED");
} else {
+7 -2
View File
@@ -333,8 +333,13 @@ test("bookslm.js reports the edited document and the assistant writes", () => {
assert.match(bookslmSrc, /this\._notifyFileWritten\(data\);/);
const notify = bookslmSrc.match(/_notifyFileWritten\(data\) \{([\s\S]*?)\n \}/);
assert.ok(notify, "_notifyFileWritten not found");
for (const tool of ["edit_file", "append_to_file", "create_file", "restore_backup"]) {
assert.ok(notify[1].includes(`'${tool}'`), `${tool} missing from the write tools`);
// BUG-076: the write-tool list is a module-level set (documents included).
assert.match(notify[1], /FILE_WRITE_TOOLS\.has\(data\.name\)/);
const writeSet = bookslmSrc.match(/const FILE_WRITE_TOOLS = new Set\(\[([\s\S]*?)\]\);/);
assert.ok(writeSet, "FILE_WRITE_TOOLS not found");
for (const tool of ["edit_file", "append_to_file", "create_file", "restore_backup",
"create_xlsx", "create_docx", "create_csv", "create_pdf"]) {
assert.ok(writeSet[1].includes(`'${tool}'`), `${tool} missing from the write tools`);
}
assert.match(notify[1], /new CustomEvent\('obsigate:file-written'/);
});
+81 -3
View File
@@ -77,20 +77,64 @@ test("renderImageViewer never uses the JSON raw endpoint as an image src", () =>
assert.ok(start !== -1 && stop > start, "renderImageViewer block not found");
const block = viewer.slice(start, stop);
assert.doesNotMatch(block, /\/raw\?path=/, "images must be served by /api/image, not /raw");
assert.match(block, /buildImageUrl\(vault, path\)/);
assert.match(block, /buildImageUrl\(vault, currentPath\)/);
});
test("image viewer supports wheel zoom, drag pan and double-click reset", () => {
assert.match(viewer, /stage\.addEventListener\("wheel"/);
assert.match(viewer, /stage\.addEventListener\("pointerdown"/);
assert.match(viewer, /stage\.addEventListener\("dblclick", resetView\)/);
assert.match(viewer, /stage\.addEventListener\("dblclick", \(e\) => \{ if \(!isArrow\(e\)\) resetView\(\); \}\)/);
assert.match(viewer, /renderImageViewer/);
});
test("image viewer exposes thumbnail navigation via /api/media thumb", () => {
assert.match(viewer, /\/api\/media\/\$\{encodeURIComponent\(vault\)\}\/thumb\?path=/);
assert.match(viewer, /let siblings = \[\]/);
assert.match(viewer, /siblings = \(res\.items \|\| \[\]\)/);
// #111 — the sibling list comes from the memoised directory listing.
assert.match(viewer, /listImageSiblings/);
});
// BUG-072 — lightbox + metadata panel keep their state; #111 — navigation is
// in place, so the viewer (and its filmstrip) is no longer rebuilt.
test("image viewer persists lightbox + metadata state across navigation", () => {
assert.match(viewer, /const _imageViewerState = \{ lightbox: false, meta: false \}/);
// renderFile only resets the viewer state when the next render is not an image.
assert.match(viewer, /if \(!data\.is_image\) \{\s*\n\s*_imageViewerState\.lightbox = false;/);
// The persisted state drives the container class and the panel visibility.
assert.match(viewer, /classList\.toggle\("lightbox", _imageViewerState\.lightbox\)/);
assert.match(viewer, /metaPanel\.hidden = !_imageViewerState\.meta/);
});
// #111 — switching siblings swaps the <img> in place instead of re-rendering
// (no openFile round-trip, filmstrip stays in the DOM, neighbours preloaded).
test("image viewer navigates between siblings in place (#111)", () => {
assert.doesNotMatch(viewer, /_imageViewerNavPending/);
assert.match(viewer, /const showSibling = \(index\) =>/);
assert.match(viewer, /const go = \(delta\) => \{\s*\n\s*if \(siblings\.length < 2[^)]*\) return;\s*\n\s*showSibling\(/);
// Thumbnail clicks and the overlay arrows both call showSibling/go in place.
assert.match(viewer, /thumb\.addEventListener\("click", \(\) => showSibling\(i\)\)/);
assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-prev"/);
assert.match(viewer, /class: "image-nav-arrow image-nav-arrow-next"/);
// The active thumbnail is only re-highlighted / scrolled, never rebuilt.
assert.match(viewer, /updateStripActive/);
assert.match(viewer, /const preload = \(i\) =>/);
// Directory listing is cached for a short TTL.
assert.match(viewer, /const _imageDirCache = new Map\(\)/);
});
// #112 — the filmstrip scrolls horizontally (mouse wheel + two edge arrows).
test("image viewer filmstrip scrolls with the wheel and edge arrows (#112)", () => {
assert.match(viewer, /const navWrap = el\("div", \{ class: "image-nav", hidden: true \}\)/);
assert.match(viewer, /image-strip-arrow-prev/);
assert.match(viewer, /image-strip-arrow-next/);
assert.match(viewer, /strip\.addEventListener\("wheel"/);
assert.match(viewer, /strip\.scrollBy\(\{ left: dir/);
assert.match(viewer, /navWrap\.hidden = true/);
assert.match(viewer, /navWrap\.hidden = false/);
assert.match(css, /\.image-nav\s*\{[\s\S]*?position:\s*relative/);
assert.match(css, /\.image-strip-arrow\s*\{/);
assert.match(css, /\.image-strip-arrow-prev\s*\{\s*left:\s*0/);
assert.match(css, /\.image-strip-arrow-next\s*\{\s*right:\s*0/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
@@ -101,6 +145,40 @@ test("style.css defines the image viewer layout + lightbox", () => {
assert.match(css, /\.image-viewer-container\.lightbox\s*\{/);
});
// #111 — the viewer fills the content box and the image is capped to the frame.
test("style.css resizes the image to the available frame (#111)", () => {
assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*padding:\s*0/);
assert.match(css, /\.content-area:has\(> \.image-viewer-container\)\s*\{[^}]*overflow:\s*hidden/);
const main = /\.image-main\s*\{([^}]*)\}/.exec(css);
assert.ok(main, ".image-main rule not found");
assert.match(main[1], /max-width:\s*100%/);
assert.match(main[1], /max-height:\s*100%/);
});
// #111 — translucent overlay arrows on both frame edges, revealed on hover.
test("style.css styles the overlay navigation arrows (#111)", () => {
assert.match(css, /\.image-nav-arrow\s*\{/);
assert.match(css, /\.image-nav-arrow-prev\s*\{\s*left:\s*0/);
assert.match(css, /\.image-nav-arrow-next\s*\{\s*right:\s*0/);
const arrow = /\.image-nav-arrow\s*\{([^}]*)\}/.exec(css);
assert.ok(arrow, ".image-nav-arrow rule not found");
assert.match(arrow[1], /opacity:\s*0/);
assert.match(css, /\.image-nav-arrow:hover[\s\S]*?opacity:\s*1/);
});
// BUG-072 — metadata is a right-hand sidebar, not a strip under the filmstrip,
// and it stays visible in lightbox mode.
test("style.css lays the metadata panel out as a right sidebar", () => {
assert.match(css, /\.image-viewer-body\s*\{[\s\S]*?display:\s*flex/);
const meta = /\.image-meta-panel\s*\{([^}]*)\}/.exec(css);
assert.ok(meta, ".image-meta-panel rule not found");
assert.match(meta[1], /border-left:/);
assert.doesNotMatch(meta[1], /border-top:/);
// The lightbox hide rule targets the filmstrip only — not the metadata sidebar.
assert.match(css, /\.image-viewer-container\.lightbox \.image-nav-strip\s*\{/);
assert.doesNotMatch(css, /\.image-viewer-container\.lightbox[^{]*\.image-meta-panel/);
});
// ── Static checks: icon mapping + backend ──────────────────────────────────
test("utils.js maps image extensions to the Lucide 'image' icon", () => {
assert.match(utils, /"\.png": "image"/);
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* ObsiGate — Media viewers tests (roadmap #109 + #110).
*
* Pure helpers are evaluated in a VM sandbox to avoid importing the
* browser-only module chain. The rest are static checks on the source, CSS,
* i18n and backend wiring.
*
* Usage: node tests/frontend/media-viewer.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8");
const np = readFileSync(path.join(ROOT, "frontend", "js", "now-playing.js"), "utf8");
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8");
const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8");
function test(label, fn) {
try {
fn();
console.log(" \u2713 " + label);
} catch (err) {
console.error(" \u2717 " + label + "\n " + String(err.message).slice(0, 300));
process.exitCode = 1;
}
}
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
const begin = np.indexOf("const AUDIO_EXTS");
const end = np.indexOf("function downloadUrl");
assert.ok(begin !== -1 && end > begin, "now-playing helper block not found");
const sandbox = {};
vm.createContext(sandbox);
vm.runInContext(
`${np.slice(begin, end).replace(/\bexport /g, "")}\n` +
`globalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;` +
`globalThis.isAudioPath = isAudioPath; globalThis.isVideoPath = isVideoPath;`,
sandbox,
);
const { formatMediaDuration, buildMediaUrl, isAudioPath, isVideoPath } = sandbox;
test("formatMediaDuration renders m:ss and h:mm:ss", () => {
assert.equal(formatMediaDuration(0), "0:00");
assert.equal(formatMediaDuration(65), "1:05");
assert.equal(formatMediaDuration(3725), "1:02:05");
assert.equal(formatMediaDuration(NaN), "");
assert.equal(formatMediaDuration(-1), "");
});
test("buildMediaUrl encodes vault and path", () => {
const url = buildMediaUrl("My Vault", "café clip.mp4");
assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4");
});
test("isAudioPath / isVideoPath recognise media extensions", () => {
for (const p of ["a.mp3", "a.M4A", "dir/b.flac", "c.opus", "d.oga"]) assert.equal(isAudioPath(p), true, p);
for (const p of ["a.mp4", "a.WEBM", "dir/b.mov", "c.m4v"]) assert.equal(isVideoPath(p), true, p);
for (const p of ["a.md", "a.png", "noext", ""]) {
assert.equal(isAudioPath(p), false, p);
assert.equal(isVideoPath(p), false, p);
}
});
// ── Static checks: viewer delegates to the persistent controller ──────────
test("viewer.js dispatches is_audio / is_video to the media viewers", () => {
assert.match(viewer, /if \(data\.is_audio\) \{\s*renderAudioViewer\(area, data\);/);
assert.match(viewer, /if \(data\.is_video\) \{\s*renderVideoViewer\(area, data\);/);
});
test("viewer delegates inline rendering to NowPlaying.attachInline", () => {
const start = viewer.indexOf("export function renderAudioViewer");
const stop = viewer.indexOf("export function renderFile");
assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found");
const block = viewer.slice(start, stop);
assert.match(block, /NowPlaying\.attachInline\(area, data\)/);
});
test("renderFile hands the shared element to the dock before a re-render", () => {
assert.match(viewer, /NowPlaying\.handleRender\(area, data\)/);
assert.doesNotMatch(viewer, /_mediaViewerCleanup/);
});
// ── Static checks: global Now Playing controller (#110) ───────────────────
test("now-playing owns ONE media element reused across surfaces", () => {
assert.match(np, /media\.dataset\.npKey === `\$\{data\.vault\}::\$\{data\.path\}`/);
assert.match(np, /function ensureMedia\(kind\)/);
assert.match(np, /slot\.appendChild\(media\)/);
assert.match(np, /dock\.appendChild\(S\.media\)/);
});
test("now-playing shows an unsupported fallback on media error", () => {
assert.match(np, /media\.addEventListener\('error',\s*\(\)\s*=>\s*onMediaError\(\)\)/);
assert.match(np, /export function renderFallback/);
});
test("now-playing wires Media Session, PiP and persistence", () => {
assert.match(np, /navigator\.mediaSession\.metadata/);
assert.match(np, /setActionHandler\('nexttrack'/);
assert.match(np, /requestPictureInPicture/);
assert.match(np, /const LS_KEY = 'obsigate-now-playing'/);
assert.match(np, /function restore\(\)/);
});
test("now-playing exposes the public controller API and is initialised at boot", () => {
assert.match(np, /export const NowPlaying = \{/);
assert.match(np, /attachInline/);
assert.match(np, /notifyTabClosed/);
const app = readFileSync(path.join(ROOT, "frontend", "js", "app.js"), "utf8");
assert.match(app, /initNowPlaying\(\)/);
});
test("video mini-window is freely positioned (no corner snapping)", () => {
assert.doesNotMatch(np, /nearestCorner/);
assert.match(np, /function clampToViewport/);
assert.match(np, /left: Math\.round\(r\.left\),\s*\n\s*top: Math\.round\(r\.top\)/);
assert.match(np, /bindVideoDrag\(dock\);/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the media viewers and the Now Playing dock", () => {
assert.match(css, /\.audio-viewer-container/);
assert.match(css, /\.video-stage\s*\{/);
assert.match(css, /\.np-dock--audio\s*\{/);
assert.match(css, /\.np-dock--video\s*\{/);
assert.match(css, /\.np-expanded\s*\{/);
assert.match(css, /body\.np-active \.content-area/);
});
test("mobile dock uses a grid so actions never overlap the progress bar", () => {
assert.match(css, /grid-template-areas:\s*\n\s*"progress progress progress progress"/);
assert.match(css, /\.np-dock--audio \[data-np="expand"\]/);
});
test("style.css no longer references undefined --surface1 / --text-dim vars", () => {
assert.doesNotMatch(css, /var\(--surface1\)/);
assert.doesNotMatch(css, /var\(--text-dim\)/);
});
// ── Static checks: icons, i18n, service worker ─────────────────────────────
test("utils.js maps media extensions to audio-lines / video icons", () => {
assert.match(utils, /"\.mp3": "audio-lines"/);
assert.match(utils, /"\.flac": "audio-lines"/);
assert.match(utils, /"\.mp4": "video"/);
assert.match(utils, /"\.webm": "video"/);
});
test("player/viewer media strings exist in FR and EN", () => {
for (const key of [
"viewer.media_unsupported",
"viewer.media_too_large",
"player.now_playing",
"player.close",
"player.reopen_tab",
"player.continues",
]) {
assert.match(fr, new RegExp(`"${key}"`));
assert.match(en, new RegExp(`"${key}"`));
}
});
test("service worker never caches streamed media", () => {
assert.match(sw, /startsWith\('\/api\/media\/'\)/);
assert.match(sw, /endsWith\('\/thumb'\)/);
});
// ── Static checks: backend ─────────────────────────────────────────────────
test("backend exposes /api/media and the shared Range helper", () => {
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(main, /def _stream_file_with_range\(/);
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
});
if (process.exitCode) {
console.error("\nMedia viewer tests FAILED");
} else {
console.log("\nAll media viewer tests passed.");
}
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* ObsiGate — Mobile bottom toolbar clearance non-regression tests (BUG-073).
*
* Static checks (no jsdom — runs in the "Frontend unit tests" CI step):
* - BUG-073: the fixed bottom toolbar (#mobile-toolbar, 64px + safe-area)
* covered the last pixels of every document/page because the clearance
* rule in the ≤768px media block targeted `.main-layout` — a class that
* does not exist in index.html (the real layout container is
* `.main-body`). The dead selector reserved no space, so #content-area
* extended behind the bar and the end of the content could never be
* scrolled clear of it.
* - The clearance must live on `.main-body` inside the mobile media block
* (desktop keeps no padding), the dead `.main-layout` rules must be gone,
* reading mode (toolbar hidden) must reset the padding, and the
* now-playing rule must not double-count the toolbar zone.
*
* Usage: node tests/frontend/mobile-toolbar.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
function test(label, fn) {
try {
fn();
console.log(" \u2713 " + label);
} catch (err) {
console.error(" \u2717 " + label + "\n " + err.message);
process.exitCode = 1;
}
}
/** Body of the first @media (max-width: 768px) block whose body contains `needle`. */
function mobileBlockContaining(needle) {
const marker = "@media (max-width: 768px)";
let idx = 0;
while ((idx = css.indexOf(marker, idx)) !== -1) {
const open = css.indexOf("{", idx);
let depth = 0;
let end = open;
for (; end < css.length; end++) {
if (css[end] === "{") depth++;
else if (css[end] === "}") {
depth--;
if (depth === 0) break;
}
}
const body = css.slice(open + 1, end);
if (body.includes(needle)) return body;
idx = end + 1;
}
return null;
}
// ── The layout container the clearance must target exists in the DOM ───────
test("index.html — layout container .main-body and #mobile-toolbar both exist", () => {
assert.match(indexHtml, /class="main-body"/, "index.html must define the .main-body layout container");
assert.match(indexHtml, /class="mobile-toolbar"/, "index.html must define the .mobile-toolbar bar");
});
// ── BUG-073: clearance inside the mobile media block ────────────────────────
const toolbarBlock = mobileBlockContaining(".mobile-toolbar");
test("style.css — a ≤768px media block styles .mobile-toolbar", () => {
assert.ok(toolbarBlock, "no @media (max-width: 768px) block contains .mobile-toolbar");
});
test("style.css — mobile clearance targets .main-body (64px + safe-area)", () => {
assert.ok(toolbarBlock, "toolbar media block not found");
assert.match(
toolbarBlock,
/\.main-body \{[^}]*padding-bottom:[^}]*calc\(64px \+ env\(safe-area-inset-bottom/,
"the toolbar clearance must pad .main-body inside the mobile block (BUG-073: .main-layout does not exist in index.html)",
);
});
test("style.css — dead .main-layout clearance rules are gone", () => {
assert.ok(toolbarBlock, "toolbar media block not found");
assert.doesNotMatch(
toolbarBlock,
/\.main-layout \{[^}]*padding-bottom/,
".main-layout clearance is a dead selector in index.html (BUG-073)",
);
assert.doesNotMatch(
toolbarBlock,
/\.editor-modal\.active ~ \.main-layout/,
"the sibling clearance reset targeted a selector that never matches (BUG-073)",
);
});
test("style.css — desktop .main-body carries no toolbar padding", () => {
const base = css.match(/\.main-body \{[^}]*\}/);
assert.ok(base, ".main-body base rule not found");
assert.doesNotMatch(base[0], /padding-bottom/, "toolbar clearance must stay mobile-only");
});
// ── Companion rules ─────────────────────────────────────────────────────────
test("style.css — reading mode resets the clearance (toolbar hidden)", () => {
assert.match(
css,
/body\.reading-mode \.main-body \{[^}]*padding-bottom: 0/,
"reading mode hides #mobile-toolbar: .main-body must release the padding",
);
});
test("style.css — np-active keeps only the dock zone (no toolbar double-count)", () => {
assert.match(
css,
/body\.np-active \.content-area \{[^}]*padding-bottom: 76px/,
"toolbar clearance now comes from .main-body: np-active must pad the dock zone only (76px)",
);
assert.doesNotMatch(
css,
/body\.np-active \.content-area \{[^}]*calc\(64px \+ env\(safe-area-inset-bottom[^}]*\+ 76px/,
"the old formula double-counted the toolbar zone already cleared by .main-body",
);
});
if (process.exitCode) {
console.error("\nMobile toolbar tests FAILED");
} else {
console.log("\nAll mobile toolbar tests passed.");
}
@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* ObsiGate — Settings section order + profile avatar (#113).
*
* Static checks (no jsdom — runs in the "Frontend unit tests" CI step):
* - Order: the #config-nav TOC and the page sections must list the same
* anchors in the same order, with #cfg-profile first and #cfg-about last
* (regression guard for the reorganization).
* - Avatar UI: the Profil section must carry the picker (preview, file
* input, choose/remove buttons), config.js the resize/PATCH flow, auth.js
* the sidebar image rendering, style.css the preview/img rules, and the
* i18n keys must exist in FR and EN.
* - Backend: backend/auth/router.py must validate the avatar data-URL.
*
* Usage: node tests/frontend/settings-order-avatar.test.mjs
*/
import { strict as assert } from "node:assert";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8");
const authJs = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const routerPy = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8");
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
function test(label, fn) {
try {
fn();
console.log(" ✓ " + label);
} catch (err) {
console.error(" ✗ " + label + "\n " + err.message);
process.exitCode = 1;
}
}
function tocHrefs() {
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
assert.ok(nav, "#config-nav not found");
return [...nav[1].matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
}
// ── #113a: order — TOC and page in sync ─────────────────────────────────────
test("index.html — #cfg-profile is the first TOC entry, #cfg-about the last", () => {
const hrefs = tocHrefs();
assert.ok(hrefs.length >= 10, "TOC must list the settings sections");
assert.equal(hrefs[0], "cfg-profile", "Profile must come first");
assert.equal(hrefs[hrefs.length - 1], "cfg-about", "About must come last");
});
test("index.html — page sections appear in the exact TOC order", () => {
const hrefs = tocHrefs();
const start = indexHtml.indexOf('id="config-scroll"');
assert.ok(start > 0, "#config-scroll not found");
const region = indexHtml.slice(start);
let prev = -1;
for (const id of hrefs) {
const pos = region.indexOf(`id="${id}"`);
assert.ok(pos > 0, `section #${id} missing from the page`);
assert.ok(pos > prev, `section #${id} is out of TOC order`);
prev = pos;
}
});
test("index.html — TOC has no dead anchors and page has no orphan cfg sections", () => {
const hrefs = tocHrefs();
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
assert.deepEqual(missing, [], `dead TOC anchors: ${missing.join(", ")}`);
// Every cfg-* id declared on a <section> must be reachable from the TOC
// (form-control ids like #cfg-debounce are not sections).
const start = indexHtml.indexOf('id="config-scroll"');
const region = indexHtml.slice(start, indexHtml.indexOf("Graph View Modal"));
const pageIds = [...region.matchAll(/<section[^>]*id="(cfg-[^"]+)"/g)].map((m) => m[1]);
const orphans = [...new Set(pageIds)].filter((id) => !hrefs.includes(id));
assert.deepEqual(orphans, [], `page sections missing from the TOC: ${orphans.join(", ")}`);
});
// ── #113b: avatar UI in the Profil section ──────────────────────────────────
test("index.html — Profil section carries the avatar picker", () => {
const section = indexHtml.match(
/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/,
);
assert.ok(section, "#cfg-profile section not found");
const html = section[0];
for (const id of [
"profile-avatar-field",
"profile-avatar-preview",
"profile-avatar-img",
"profile-avatar-initials",
"profile-avatar-input",
"profile-avatar-choose",
"profile-avatar-remove",
"profile-avatar-error",
]) {
assert.match(html, new RegExp(`id="${id}"`), `#${id} missing from #cfg-profile`);
}
assert.match(html, /accept="image\/png,image\/jpeg,image\/webp"/,
"the file input must restrict accepted image types");
});
test("config.js — avatar flow: type/size guards, resize, PATCH, sidebar refresh", () => {
assert.match(configJs, /_AVATAR_TYPES/, "accepted MIME types must be declared");
assert.match(configJs, /image\/png[\s\S]{0,80}image\/jpeg[\s\S]{0,80}image\/webp/,
"PNG/JPEG/WebP must be the accepted types");
assert.match(configJs, /_AVATAR_MAX_FILE_BYTES/, "raw file size guard missing");
assert.match(configJs, /_resizeAvatarFile/, "client-side resize helper missing");
assert.match(configJs, /toDataURL\(["']image\/jpeg["']/, "resize must export a JPEG data-URL");
assert.match(configJs, /method:\s*["']PATCH["'][\s\S]{0,200}avatar/,
"avatar must be persisted via PATCH /api/auth/me");
assert.match(configJs, /avatar:\s*value|JSON\.stringify\(\{\s*avatar/,
"the PATCH body must carry the avatar field");
assert.match(configJs, /renderUserSection\(\)/,
"the sidebar must be re-rendered after an avatar change");
assert.match(configJs, /config\.avatar_invalid_type/, "invalid-type error missing");
assert.match(configJs, /config\.avatar_too_large/, "too-large error missing");
});
test("auth.js — sidebar circle renders the avatar image, initials as fallback", () => {
assert.match(authJs, /user\.avatar/, "renderUserSection must read user.avatar");
assert.match(authJs, /sidebar-user-avatar-img/, "the <img> must carry its CSS class");
assert.match(authJs, /userInitials\(name\)/, "initials fallback removed");
assert.match(authJs, /updateCachedUser/, "cached-user merge helper missing");
assert.match(authJs, /isAuthEnabled\(/, "public auth-status helper missing");
});
test("style.css — preview circle and sidebar image rules exist", () => {
assert.match(css, /\.profile-avatar-preview\s*\{/, "no .profile-avatar-preview rule");
assert.match(css, /\.profile-avatar-overlay\s*\{/, "no hover overlay rule");
assert.match(css, /\.sidebar-user-avatar-img\s*\{/, "no .sidebar-user-avatar-img rule");
});
// ── #117: preset avatar gallery ─────────────────────────────────────────────
test("index.html — #cfg-profile exposes 12 bundled preset avatars", () => {
const section = indexHtml.match(/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/);
assert.ok(section, "#cfg-profile section not found");
const html = section[0];
assert.match(html, /id="profile-avatar-presets"/, "preset gallery container missing");
const buttons = [...html.matchAll(/class="profile-avatar-preset"[^>]*data-avatar="([^"]+)"/g)];
assert.equal(buttons.length, 12, `expected 12 preset avatars, found ${buttons.length}`);
const names = buttons.map((m) => m[1]);
assert.equal(new Set(names).size, 12, "preset avatar names must be unique");
for (const name of names) {
const rel = path.join(ROOT, "frontend", "icons", "avatar", name);
assert.ok(existsSync(rel), `missing avatar file: ${rel}`);
assert.ok(html.includes(`/static/icons/avatar/${name}`),
`#${name} must be served from /static/icons/avatar/`);
}
});
test("config.js — preset avatars reuse the upload pipeline", () => {
assert.match(configJs, /profile-avatar-presets/, "preset container lookup missing");
assert.match(configJs, /\.profile-avatar-preset/, "preset button selector missing");
assert.match(configJs, /\/static\/icons\/avatar\//, "preset image base URL missing");
assert.match(configJs, /_resizeAvatarFile\(blob\)/, "preset must go through the resize pipeline");
assert.match(configJs, /_patchProfileAvatar\(dataUrl\)/, "preset must be persisted via PATCH");
assert.match(configJs, /obsigate-avatar-preset/, "active preset marker missing");
assert.match(configJs, /markActivePreset/, "active preset highlight missing");
});
test("style.css — preset avatar rules exist", () => {
assert.match(css, /\.profile-avatar-presets\s*\{/, "no .profile-avatar-presets rule");
assert.match(css, /\.profile-avatar-preset\s*\{/, "no .profile-avatar-preset rule");
assert.match(css, /\.profile-avatar-preset\.active\s*\{/, "no active preset rule");
});
// ── #113c: i18n + backend ───────────────────────────────────────────────────
test("i18n — avatar keys exist in FR and EN", () => {
const keys = [
"config.avatar_label",
"config.avatar_hint",
"config.avatar_presets_label",
"config.avatar_choose",
"config.avatar_remove",
"config.avatar_updated",
"config.avatar_removed",
"config.avatar_too_large",
"config.avatar_invalid_type",
"config.avatar_upload_failed",
];
for (const key of keys) {
assert.ok(fr[key], `fr.json missing ${key}`);
assert.ok(en[key], `en.json missing ${key}`);
assert.notEqual(fr[key], key, `fr.json ${key} is untranslated`);
assert.notEqual(en[key], key, `en.json ${key} is untranslated`);
}
});
test("backend — /api/auth/me validates the avatar data-URL", () => {
assert.match(routerPy, /_AVATAR_DATA_URL_RE/, "data-URL regex missing");
assert.match(routerPy, /def _validate_avatar/, "validation helper missing");
assert.match(routerPy, /image\/\(\?:png\|jpeg\|webp\)/, "only PNG/JPEG/WebP allowed (no SVG)");
assert.match(routerPy, /_AVATAR_MAX_CHARS/, "size cap missing");
assert.match(routerPy, /"avatar": current_user\.get\("avatar"\)/, "GET /me must return the avatar");
assert.match(routerPy, /avatar: str \| None = None/, "UpdateMeRequest must expose avatar");
assert.match(routerPy, /updates\["avatar"\] = _validate_avatar/, "PATCH /me must validate before store");
});
if (process.exitCode) {
console.error("\nSettings order & avatar tests FAILED");
} else {
console.log("\nAll settings order & avatar tests passed.");
}
+24
View File
@@ -111,6 +111,30 @@ test("style.css — .action-sep rule exists", () => {
assert.match(css, /\.file-actions \.action-sep\s*\{[^}]*background:[^;]*;/);
});
// ── #115 : barre d'outils collante au défilement ─────────────────────────
test("viewer.js — action bar wrapped in a sticky .file-toolbar", () => {
assert.match(viewer, /class:\s*"file-toolbar"[\s\S]{0,120}file-actions/,
"the document action bar must live in a .file-toolbar wrapper");
});
test("popout.html — action bar wrapped in a .file-toolbar", () => {
assert.match(popout, /className\s*=\s*"file-toolbar"/,
"popout action bar must live in a .file-toolbar wrapper");
});
test("style.css — .file-toolbar is pinned to the top while scrolling", () => {
const m = css.match(/\.file-toolbar\s*\{([\s\S]*?)\}/);
assert.ok(m, ".file-toolbar rule missing");
assert.match(m[1], /position:\s*sticky/, ".file-toolbar must be sticky");
assert.match(m[1], /top:\s*0/, ".file-toolbar must pin to the top");
assert.match(m[1], /background:/, ".file-toolbar needs an opaque background");
});
test("style.css — reading mode hides the sticky toolbar", () => {
assert.match(css, /body\.reading-mode\s+\.file-toolbar\s*[,{]/,
"reading mode must hide .file-toolbar");
});
if (process.exitCode) {
console.error("\nToolbar order tests FAILED");
} else {
+68
View File
@@ -240,6 +240,72 @@ function testAIFabModuleExports() {
console.log(' ✓ ai-fab.js exports initAIFab');
}
// ── Test header trimming + sidebar account section (#112) ──────────────────
const AUTH_PATH = join(JS_DIR, 'auth.js');
const STYLE_PATH = join(FRONTEND_DIR, 'style.css');
function testSidebarUserSection() {
const html = readFileSync(INDEX_HTML, 'utf-8');
const auth = readFileSync(AUTH_PATH, 'utf-8');
const css = readFileSync(STYLE_PATH, 'utf-8');
// The header no longer carries the user block / version badge…
assert.doesNotMatch(html, /id="user-menu"/, 'header #user-menu must be removed');
assert.doesNotMatch(html, /id="logout-btn"/, 'header sign-out button must be removed');
const headerRight = html.slice(
html.indexOf('class="header-right"'),
html.indexOf('class="header-menu"'),
);
assert.doesNotMatch(headerRight, /version-badge/, 'version badge must leave the header-right');
assert.doesNotMatch(headerRight, /user-menu/, 'user block must leave the header-right');
// …the version lives in the options dropdown…
assert.match(html, /class="menu-list-row menu-list-version"[\s\S]*?id="version-badge"/);
// …and the account section sits at the bottom of the sidebar.
assert.match(html, /id="sidebar-user"/);
assert.match(html, /id="sidebar-user-logout"/);
assert.match(html, /id="sidebar-user-name"/);
assert.match(html, /id="sidebar-user-avatar"/);
// auth.js drives it.
assert.match(auth, /renderUserSection\(\)/);
assert.match(auth, /getElementById\("sidebar-user"\)/);
// styles + i18n exist in FR and EN.
assert.match(css, /\.sidebar-user\s*\{/);
assert.match(css, /\.menu-list-version\s*\{/);
for (const lang of ['fr', 'en']) {
const locale = JSON.parse(readFileSync(join(FRONTEND_DIR, 'locales', `${lang}.json`), 'utf-8'));
for (const key of ['header.menu_version', 'sidebar.user_role_admin', 'sidebar.user_role_user']) {
assert.ok(locale[key], `${lang}: missing i18n key ${key}`);
}
}
console.log(' ✓ header trimmed + sidebar account section (#112)');
}
// ── Test syntax highlighting follows the theme mode (BUG-078) ──────────────
function testSyntaxHighlightTheme() {
const themes = readFileSync(join(JS_DIR, 'themes.js'), 'utf-8');
const ui = readFileSync(join(JS_DIR, 'ui.js'), 'utf-8');
// themes.js must swap the highlight.js stylesheets based on the *mode*.
const applyTheme = themes.match(/function applyTheme\(themeKey, mode\)\s*\{([\s\S]*?)\n\}/);
assert.ok(applyTheme, 'themes.js applyTheme not found');
assert.match(applyTheme[1], /hljs-theme-dark/, 'applyTheme must toggle the dark hljs sheet');
assert.match(applyTheme[1], /hljs-theme-light/, 'applyTheme must toggle the light hljs sheet');
assert.match(applyTheme[1], /mode === 'dark'|mode === "dark"/, 'sheet choice must key on the mode');
// ui.js must resolve the persisted *mode*, never treat the theme key as one.
assert.match(ui, /obsigate-theme-mode/, 'ui.js must read the persisted theme mode');
assert.doesNotMatch(
ui,
/darkSheet\.disabled\s*=\s*theme !== "dark"[\s\S]{0,120}lightSheet\.disabled\s*=\s*theme !== "light"/,
'ui.js must not compare the theme key to "dark"/"light" (BUG-078 regression)',
);
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
}
// ── Run all tests ──────────────────────────────────────────────────────────
async function main() {
let passed = 0, failed = 0;
@@ -255,6 +321,8 @@ async function main() {
['ai-fab module exists', testAIFabModuleExists],
['ai-fab module exports', testAIFabModuleExports],
['config TOC icons', testConfigTocIcons],
['sidebar user section', testSidebarUserSection],
['syntax highlight theme', testSyntaxHighlightTheme],
];
for (const [name, fn] of tests) {
+34 -13
View File
@@ -281,12 +281,13 @@ class TestConfirmationResume:
assert assistant_tool_msgs[0]["tool_calls"][0]["id"] == "call_9"
@pytest.mark.asyncio
async def test_confirmation_with_parallel_calls_keeps_conversation_valid(self, monkeypatch):
"""BUG-050: pausing on one tool call of a batch must answer the others.
async def test_confirmation_batches_all_writes_and_keeps_conversation_valid(self, monkeypatch):
"""BUG-075 (was BUG-050): one pause batches every mutating call.
The assistant message lists every tool call of the response, so the
provider rejects the resumed turn when a ``tool_call_id`` has no tool
result (the "create a folder and a file inside" scenario).
result. Mutating calls of the batch are now all pending (one approval
applies them together); no dangling id remains.
"""
_register(monkeypatch, "_write", lambda ctx, params: {"done": params}, risk=ToolRisk.WRITE)
@@ -297,14 +298,15 @@ class TestConfirmationResume:
paused = await run_agent([{"role": "user", "content": "write both"}], ctx=_ctx(), llm=llm1)
assert paused.stopped == STOP_CONFIRMATION_REQUIRED
assert paused.pending["error"]["id"] == "1"
# The call that was not reached is answered right away; the pending one
# gets its result on resume, when the user applies it.
# Both mutating calls are batched into the single confirmation.
actions = paused.pending["actions"]
assert [a["id"] for a in actions] == ["1", "2"]
assert actions[0]["step"]["key"] == "generic"
# Nothing is executed nor deferred while waiting for the approval.
answered = {m["tool_call_id"] for m in paused.messages if m.get("role") == "tool"}
assert "2" in answered
assert "1" not in answered
assert answered == set()
# Resume: the pending call is applied, the next turn stays valid.
# Resume: both pending calls are applied, the next turn stays valid.
llm2 = ScriptedLLM([LLMResponse(content="ok")])
resumed = await run_agent(
[{"role": "user", "content": "write both"}],
@@ -315,8 +317,8 @@ class TestConfirmationResume:
)
assert resumed.stopped == STOP_DONE
assert resumed.content == "ok"
assert len(resumed.tool_calls) == 1
assert resumed.tool_calls[0].ok is True
assert [r.name for r in resumed.tool_calls] == ["_write", "_write"]
assert all(r.ok for r in resumed.tool_calls)
# Before the resumed LLM call, every announced tool_call_id is answered.
resumed_messages = llm2.calls[0]["messages"]
assistant = next(
@@ -326,12 +328,31 @@ class TestConfirmationResume:
announced = {tc["id"] for tc in assistant["tool_calls"]}
answered = {m["tool_call_id"] for m in resumed_messages if m.get("role") == "tool"}
assert announced <= answered
# The skipped call is flagged "deferred" so the model can re-issue it.
# No deferred result: the batched calls were approved, not skipped.
deferred = [
m for m in resumed_messages
if m.get("role") == "tool" and json.loads(m["content"]).get("status") == "deferred"
]
assert [m["tool_call_id"] for m in deferred] == ["2"]
assert deferred == []
@pytest.mark.asyncio
async def test_confirmation_runs_read_calls_of_the_batch_immediately(self, monkeypatch):
"""Only mutating calls are batched; read-only calls of the turn run now."""
_register(monkeypatch, "_write", lambda ctx, params: {"done": params}, risk=ToolRisk.WRITE)
_register(monkeypatch, "_read", lambda ctx, params: {"value": 1})
llm1 = ScriptedLLM([LLMResponse(tool_calls=[
ToolCall(id="1", name="_write", arguments={"x": 1}),
ToolCall(id="2", name="_read", arguments={}),
ToolCall(id="3", name="_write", arguments={"x": 3}),
])])
paused = await run_agent([{"role": "user", "content": "write and read"}], ctx=_ctx(), llm=llm1)
assert paused.stopped == STOP_CONFIRMATION_REQUIRED
# The read ran while the two writes are pending.
assert [r.name for r in paused.tool_calls] == ["_read"]
assert [a["id"] for a in paused.pending["actions"]] == ["1", "3"]
answered = {m["tool_call_id"] for m in paused.messages if m.get("role") == "tool"}
assert answered == {"2"}
class TestAgentPermissions:
+95 -1
View File
@@ -308,4 +308,98 @@ class TestAdmin:
resp = auth_client.post("/api/auth/logout", headers={
"Authorization": f"Bearer {token}",
})
assert resp.status_code == 200
assert resp.status_code == 200
# ═══════════════════════════════════════════════════════════════════
# Profile avatar (#113)
# ═══════════════════════════════════════════════════════════════════
# 1×1 transparent PNG.
TINY_PNG = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8"
"z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
class TestAvatar:
def _login(self, client):
resp = client.post("/api/auth/login", json={
"username": "admin", "password": "chab30",
})
return resp.json()["access_token"]
def _headers(self, token):
return {"Authorization": f"Bearer {token}"}
def test_me_returns_avatar_field(self, auth_client):
"""GET /api/auth/me exposes the avatar key (None by default)."""
token = self._login(auth_client)
resp = auth_client.get("/api/auth/me", headers=self._headers(token))
assert resp.status_code == 200
assert "avatar" in resp.json()
assert resp.json()["avatar"] is None
def test_patch_me_sets_avatar(self, auth_client):
"""A valid PNG data-URL is persisted and returned by GET /me."""
token = self._login(auth_client)
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": TINY_PNG})
assert resp.status_code == 200
assert resp.json()["avatar"] == TINY_PNG
me = auth_client.get("/api/auth/me", headers=self._headers(token)).json()
assert me["avatar"] == TINY_PNG
def test_patch_me_avatar_empty_string_clears(self, auth_client):
"""Sending an empty string removes the avatar."""
token = self._login(auth_client)
auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": TINY_PNG})
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": ""})
assert resp.status_code == 200
assert resp.json()["avatar"] is None
def test_patch_me_avatar_rejects_svg(self, auth_client):
"""SVG data-URLs are refused (XSS surface)."""
token = self._login(auth_client)
svg = "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": svg})
assert resp.status_code == 400
def test_patch_me_avatar_rejects_non_image_payload(self, auth_client):
"""A PNG data-URL header over non-image bytes is refused."""
token = self._login(auth_client)
fake = "data:image/png;base64,aGVsbG8gd29ybGQ=" # "hello world"
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": fake})
assert resp.status_code == 400
def test_patch_me_avatar_rejects_too_large(self, auth_client):
"""Payloads above the size cap are refused before any decode."""
token = self._login(auth_client)
huge = "data:image/png;base64," + "A" * 500_000
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": huge})
assert resp.status_code == 400
def test_patch_me_avatar_rejects_invalid_base64(self, auth_client):
"""Malformed base64 inside a well-formed data-URL is refused."""
token = self._login(auth_client)
resp = auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": "data:image/png;base64,@@not-base64@@"})
assert resp.status_code == 400
def test_login_user_payload_includes_avatar(self, auth_client):
"""The login response user object carries the avatar for the sidebar."""
token = self._login(auth_client)
auth_client.patch("/api/auth/me", headers=self._headers(token),
json={"avatar": TINY_PNG})
# Re-login to get a fresh user payload.
resp = auth_client.post("/api/auth/login", json={
"username": "admin", "password": "chab30",
})
assert resp.status_code == 200
assert resp.json()["user"]["avatar"] == TINY_PNG
+75
View File
@@ -1065,6 +1065,81 @@ class TestBooksLMAgentEndpoint:
assert "C'est fait." in resp2.text
assert "event: message" in resp2.text
def test_agent_confirmation_batches_actions_and_confirm_all(self, bookslm_client, monkeypatch):
"""BUG-075: one confirmation lists every mutating call of the turn and
``confirm_all`` authorizes the rest of the run without pausing again."""
import re
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall
from backend.tools import registry
from backend.tools.api import ToolRisk
from backend.tools.registry import ToolSpec
from backend.tools.schemas import ListVaultsInput
calls = []
def handler(ctx, params):
calls.append(params)
return {"ok": True}
spec = ToolSpec(
name="_agent_write_batch",
description="write for tests",
input_model=ListVaultsInput,
handler=handler,
risk=ToolRisk.WRITE,
)
monkeypatch.setitem(registry._REGISTRY, "_agent_write_batch", spec)
responses = [LLMResponse(tool_calls=[
ToolCall(id="c1", name="_agent_write_batch", arguments={}),
ToolCall(id="c2", name="_agent_write_batch", arguments={}),
])]
async def fake_chat_completion(messages, **kwargs):
return responses.pop(0)
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
payload = {"vault": "TestVault", "directory": "", "message": "crée tout", "mode": "directory"}
resp = bookslm_client.post(
"/api/ai/bookslm/agent",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert "event: confirmation" in resp.text
match = re.search(r"event: confirmation\ndata: (.*)", resp.text)
assert match, resp.text
confirmation = json.loads(match.group(1))
# Both mutating calls are pending, not one.
assert [a["tool"] for a in confirmation["pending"]["actions"]] == [
"_agent_write_batch", "_agent_write_batch",
]
assert calls == [], "nothing runs before the approval"
# Resume with a global approval: the two pending calls run, and a third
# mutating call of the same run runs without a second confirmation.
responses.append(LLMResponse(tool_calls=[ToolCall(id="c3", name="_agent_write_batch", arguments={})]))
responses.append(LLMResponse(content="Terminé."))
resume_payload = dict(
payload,
confirm=confirmation["pending"],
confirm_messages=confirmation["messages"],
confirm_all=True,
)
resp2 = bookslm_client.post(
"/api/ai/bookslm/agent",
json=resume_payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp2.status_code == 200
assert "event: confirmation" not in resp2.text, "confirm_all must not pause again"
assert len(calls) == 3, "the whole plan ran in one approval"
assert "Terminé." in resp2.text
def test_agent_message_reports_effective_model(self, bookslm_client, monkeypatch):
"""The SSE payload carries the model really used, not the raw request.
+91
View File
@@ -0,0 +1,91 @@
# tests/test_media_indexing.py — Audio/video in the tree & index (roadmap #109-A/F2)
import asyncio
from pathlib import Path
from backend.indexer import _index_single_file_sync, _scan_vault
from backend.services.vaults import browse_directory
from backend.watcher import VaultEventHandler
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
def _make_vault(tmp_path: Path) -> Path:
vault = tmp_path / "Vault"
vault.mkdir()
(vault / "note.md").write_text("# Note\n", encoding="utf-8")
(vault / "note-vocale.mp3").write_bytes(MEDIA_BYTES)
(vault / "clip.mp4").write_bytes(MEDIA_BYTES)
assets = vault / "assets"
assets.mkdir()
(assets / "ambiance.flac").write_bytes(MEDIA_BYTES)
return vault
class TestScanVaultMedia:
def test_audio_video_indexed_without_content(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
by_path = {f["path"]: f for f in result["files"]}
for path in ("note-vocale.mp3", "clip.mp4", "assets/ambiance.flac"):
assert path in by_path, path
entry = by_path[path]
assert entry["content"] == "" # never read the bytes (#109-A1)
assert entry["content_preview"] == ""
assert entry["size"] > 0
def test_media_present_in_paths(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
files = {p["path"] for p in result["paths"] if p["type"] == "file"}
assert "note-vocale.mp3" in files
assert "clip.mp4" in files
assert "assets/ambiance.flac" in files
def test_no_binary_content_leaks(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
for f in result["files"]:
if f["extension"] in (".mp3", ".mp4", ".flac"):
assert f["content"] == ""
class TestSingleFileMedia:
def test_metadata_only(self, tmp_path):
vault = _make_vault(tmp_path)
info = _index_single_file_sync("V", str(vault), str(vault / "note-vocale.mp3"))
assert info is not None
assert info["content"] == ""
assert info["extension"] == ".mp3"
assert info["path"] == "note-vocale.mp3"
class TestWatcherRelevance:
def test_media_extensions_relevant(self, tmp_path):
handler = VaultEventHandler("V", asyncio.Queue(), asyncio.new_event_loop())
for name in ("a.mp3", "a.flac", "a.opus", "a.mp4", "a.webm", "a.mov"):
assert handler._is_relevant(str(tmp_path / name)) is True, name
assert handler._is_relevant(str(tmp_path / "a.mkv")) is False
class TestBrowseAndSearch:
def test_browse_lists_media(self, client):
resp = client.get("/api/browse/TestVault")
assert resp.status_code == 200
items = resp.json()["items"]
names = {i["name"]: i for i in items}
assert "sample-audio.mp3" in names
assert names["sample-audio.mp3"]["type"] == "file"
assert names["sample-audio.mp3"]["extension"] == ".mp3"
def test_ext_filter_returns_audio(self, client):
resp = client.get("/api/search/advanced", params={"q": "ext:mp3"})
assert resp.status_code == 200
paths = [r["path"] for r in resp.json()["results"]]
assert any(p.endswith(".mp3") for p in paths)
class TestBrowseService:
def test_browse_directory_service(self, app_with_vault, test_vault_dir):
data = browse_directory("TestVault", "")
names = [i["name"] for i in data["items"]]
assert "sample-audio.mp3" in names
+168
View File
@@ -0,0 +1,168 @@
# tests/test_media_stream.py — Audio/video streaming & HTTP Range (roadmap #109-A/F1)
import base64
from pathlib import Path
import pytest
# 4 KiB of pseudo-ID3 bytes — the endpoint never decodes the payload, it only
# has to serve bytes with the right MIME and honour Range.
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
PDF_BYTES = b"%PDF-1.4\n" + b"x" * 2048 + b"\n%%EOF"
def _write(vault_dir: str, name: str, content: bytes) -> None:
(Path(vault_dir) / name).write_bytes(content)
# ═══════════════════════════════════════════════════════════════════
# /api/file — audio/video metadata returned before read_text (#109-A4)
# ═══════════════════════════════════════════════════════════════════
class TestMediaFileView:
def test_audio_metadata(self, client, test_vault_dir):
_write(test_vault_dir, "note-vocale.mp3", MEDIA_BYTES)
resp = client.get("/api/file/TestVault", params={"path": "note-vocale.mp3"})
assert resp.status_code == 200
data = resp.json()
assert data["is_audio"] is True
assert data["is_video"] is not True
assert data["media_mime"] == "audio/mpeg"
assert data["stream_url"] == "/api/media/TestVault?path=note-vocale.mp3"
assert data["size_bytes"] == len(MEDIA_BYTES)
assert data["unsupported"] is not True
def test_video_metadata(self, client, test_vault_dir):
_write(test_vault_dir, "clip.mp4", MEDIA_BYTES)
resp = client.get("/api/file/TestVault", params={"path": "clip.mp4"})
data = resp.json()
assert data["is_video"] is True
assert data["is_audio"] is not True
assert data["media_mime"] == "video/mp4"
assert data["stream_url"] == "/api/media/TestVault?path=clip.mp4"
def test_large_media_falls_back_to_download(self, client, test_vault_dir, monkeypatch):
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
resp = client.get("/api/file/TestVault", params={"path": "huge.mp3"})
data = resp.json()
assert data["unsupported"] is True
assert data["media_too_large"] is True
assert not data.get("is_audio")
# ═══════════════════════════════════════════════════════════════════
# /api/media — byte-range streaming (#109-A2/F1)
# ═══════════════════════════════════════════════════════════════════
class TestMediaStreamRange:
def test_no_range_returns_200_and_accept_ranges(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "song.mp3"})
assert resp.status_code == 200
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-type"].startswith("audio/mpeg")
assert resp.content == MEDIA_BYTES
def test_first_kibibyte_returns_206(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": "bytes=0-1023"},
)
assert resp.status_code == 206
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-range"] == f"bytes 0-1023/{len(MEDIA_BYTES)}"
assert len(resp.content) == 1024
assert resp.content == MEDIA_BYTES[:1024]
def test_suffix_range(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": "bytes=-100"},
)
assert resp.status_code == 206
assert resp.content == MEDIA_BYTES[-100:]
def test_unsatisfiable_range_returns_416(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": f"bytes={len(MEDIA_BYTES) + 10}-"},
)
assert resp.status_code == 416
assert resp.headers["content-range"] == f"bytes */{len(MEDIA_BYTES)}"
def test_mov_mime_override(self, client, test_vault_dir):
_write(test_vault_dir, "clip.mov", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "clip.mov"})
assert resp.headers["content-type"].startswith("video/quicktime")
def test_rejects_non_media(self, client, test_vault_dir):
resp = client.get("/api/media/TestVault", params={"path": "note1.md"})
assert resp.status_code == 400
def test_missing_media_404(self, client):
resp = client.get("/api/media/TestVault", params={"path": "nope.mp3"})
assert resp.status_code == 404
def test_unknown_vault_404(self, client):
resp = client.get("/api/media/NoSuchVault", params={"path": "song.mp3"})
assert resp.status_code == 404
def test_path_traversal_rejected(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "../../../etc/passwd.mp3"})
assert resp.status_code == 403
def test_media_too_large_returns_413(self, client, test_vault_dir, monkeypatch):
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
resp = client.get("/api/media/TestVault", params={"path": "huge.mp3"})
assert resp.status_code == 413
# ═══════════════════════════════════════════════════════════════════
# Vault access control (#109-A3)
# ═══════════════════════════════════════════════════════════════════
class TestMediaAccess:
def test_normaluser_cannot_stream_other_vault(self, admin_client):
resp = admin_client.post(
"/api/auth/login", json={"username": "normaluser", "password": "normal123"}
)
assert resp.status_code == 200, resp.text
token = resp.json()["access_token"]
resp = admin_client.get(
"/api/media/OtherVault",
params={"path": "song.mp3"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
# ═══════════════════════════════════════════════════════════════════
# Regression: pdf/stream unchanged after extracting the shared helper (#109-A2)
# ═══════════════════════════════════════════════════════════════════
class TestPdfStreamRegression:
def test_pdf_range_still_works(self, client, test_vault_dir):
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
resp = client.get(
"/api/file/TestVault/pdf/stream",
params={"path": "doc.pdf"},
headers={"Range": "bytes=0-9"},
)
assert resp.status_code == 206
assert resp.headers["content-type"].startswith("application/pdf")
assert resp.headers["content-range"] == f"bytes 0-9/{len(PDF_BYTES)}"
assert resp.content == PDF_BYTES[:10]
def test_pdf_without_range_returns_200(self, client, test_vault_dir):
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": "doc.pdf"})
assert resp.status_code == 200
assert resp.headers["accept-ranges"] == "bytes"