Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d142049216 | ||
|
|
33fe1a3439 | ||
|
|
a726ad8511 | ||
|
|
b926f01b85 | ||
|
|
8264e7ffae | ||
|
|
80852374a8 | ||
|
|
e3c6789776 | ||
|
|
e2417cb5ab | ||
|
|
eccbf7474e | ||
|
|
69cee4d93a |
@@ -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: |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+208
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.16.6**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.22.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,213 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [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é
|
||||
|
||||
- **#108 — Support complet des images (arborescence, visionneuse, indexation)** :
|
||||
les images (`.png .jpg .jpeg .gif .svg .webp .bmp .ico`) apparaissent désormais
|
||||
dans l'arborescence et l'index comme les autres fichiers — nom/taille/date
|
||||
uniquement, jamais les octets (contenu indexé vide, TF-IDF préservé). Nouveau
|
||||
module partagé `backend/media_types.py` (extensions + MIME, socle réutilisé par
|
||||
#109). Visionneuse dédiée : zoom molette 0,1×–8×, pan au glisser, double-clic
|
||||
pour réinitialiser, boutons +/−/reset et badge de zoom, navigation ←/→ entre
|
||||
les images du dossier avec pellicule de miniatures, panneau métadonnées
|
||||
(dimensions, taille, type, chemin, date), lightbox plein écran, « Ouvrir
|
||||
l'original » et téléchargement. Endpoint `GET /api/media/{vault}/thumb`
|
||||
(miniature WebP 256 px, cache disque invalidé par mtime, repli sur l'original
|
||||
pour le SVG, `pillow>=10.0`). Filtre `ext:png`/`ext:jpg` opérationnel ;
|
||||
compteurs d'images séparés dans `/api/dashboard` (`image_count`/`total_images`).
|
||||
Fiche : [docs/features/image-support.md](docs/features/image-support.md).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#108-B1 — Affichage isolé d'une image** : le `<img>` généré par
|
||||
`api_file_view()` pointait vers `/api/file/{vault}/raw` (qui renvoie du JSON)
|
||||
au lieu de `/api/image/{vault}` (octets + MIME correct). Corrigé côté backend
|
||||
et dans `viewer.js` (bouton Plein écran), avec encodage d'URL des chemins.
|
||||
- **#108-B3 — XSS via SVG** : `/api/image` (et le repli miniatures) pose
|
||||
`Content-Security-Policy: sandbox` sur les SVG ouverts directement, pour
|
||||
empêcher l'exécution du JavaScript embarqué ; le middleware n'écrase plus une
|
||||
politique stricte posée par une route.
|
||||
|
||||
---
|
||||
|
||||
## [2.16.6] — 2026-09-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
+28
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -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`) | — |
|
||||
@@ -406,6 +408,18 @@ ObsiGate supporte **toutes les syntaxes d'images Obsidian** avec résolution int
|
||||
6. Index de démarrage (match le plus proche)
|
||||
7. Fallback : placeholder stylisé `[image not found: filename.ext]`
|
||||
|
||||
### Visionneuse & arborescence
|
||||
|
||||
Les images sont de plein droit des fichiers du vault : elles apparaissent dans
|
||||
l'arborescence, sont indexées (nom + métadonnées, **jamais les octets**) et
|
||||
s'ouvrent dans une **visionneuse dédiée** — zoom molette 0,1×–8×, pan au
|
||||
glisser, double-clic pour réinitialiser, navigation ←/→ entre les images du
|
||||
dossier (avec pellicule de miniatures WebP), panneau de métadonnées, lightbox
|
||||
plein écran, ouverture de l'original et téléchargement. Le filtre de recherche
|
||||
`ext:png`/`ext:jpg` est disponible. Formats décodables : PNG, JPEG, GIF, WebP,
|
||||
BMP, ICO, SVG (SVG servi avec une politique CSP `sandbox`). **HEIC/HEIF**
|
||||
(iPhone) n'est pas décodable par les navigateurs et n'est pas pris en charge.
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
@@ -637,6 +651,7 @@ ObsiGate expose une API REST complète :
|
||||
| `/api/events` | Flux SSE temps réel | GET | Oui |
|
||||
| `/api/vaults/add` / `/api/vaults/{name}` | Gestion dynamique des vaults | POST/DELETE | Admin |
|
||||
| `/api/image/{vault}?path=` | Servir une image | GET | Oui |
|
||||
| `/api/media/{vault}/thumb?path=&size=` | Miniature WebP (cache disque) | GET | Oui |
|
||||
| `/api/config` | Lire / écrire la configuration | GET/POST | Oui/Admin |
|
||||
| `/api/diagnostics` | Statistiques index et mémoire | GET | Admin |
|
||||
|
||||
@@ -857,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`)
|
||||
|
||||
@@ -880,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.
|
||||
@@ -951,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.16.6).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.22.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.16.6 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.22.1 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -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`) | — |
|
||||
@@ -516,6 +518,17 @@ ObsiGate uses 7 resolution strategies in order of priority:
|
||||
6. **Startup index (closest match)** : If multiple files have the same name
|
||||
7. **Fallback** : Display a styled placeholder `[image not found: filename.ext]`
|
||||
|
||||
### Viewer & file tree
|
||||
|
||||
Images are first-class vault files: they appear in the tree, are indexed (name +
|
||||
metadata, **never the bytes**) and open in a **dedicated viewer** — wheel zoom
|
||||
0.1×–8×, drag pan, double-click to reset, ←/→ navigation between images in the
|
||||
same folder (WebP thumbnail filmstrip), metadata panel, full-screen lightbox,
|
||||
open original and download. The `ext:png`/`ext:jpg` search filter is available.
|
||||
Decodable formats: PNG, JPEG, GIF, WebP, BMP, ICO, SVG (SVG served with a
|
||||
`sandbox` CSP). **HEIC/HEIF** (iPhone) is not decodable by browsers and is not
|
||||
supported.
|
||||
|
||||
### Configuration
|
||||
|
||||
To optimize resolution, configure the attachments folder for each vault:
|
||||
@@ -756,6 +769,7 @@ ObsiGate exposes a complete REST API :
|
||||
| `/api/events` | Real-time SSE stream | GET | Yes |
|
||||
| `/api/vaults/add` / `/api/vaults/{name}` | Dynamic vault management | POST/DELETE | Admin |
|
||||
| `/api/image/{vault}?path=` | Serve an image | GET | Yes |
|
||||
| `/api/media/{vault}/thumb?path=&size=` | WebP thumbnail (disk cache) | GET | Yes |
|
||||
| `/api/config` | Read / write configuration | GET/POST | Yes/Admin |
|
||||
| `/api/diagnostics` | Index and memory statistics | GET | Admin |
|
||||
|
||||
@@ -1028,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`)
|
||||
|
||||
@@ -1051,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.
|
||||
@@ -1128,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.16.6).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.22.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.16.6 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.22.1 | Last updated: September 2026*
|
||||
|
||||
@@ -4,10 +4,9 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.attachment_indexer")
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
|
||||
# Image file extensions to index
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
|
||||
logger = logging.getLogger("obsigate.attachment_indexer")
|
||||
|
||||
# Global attachment index: {vault_name: {filename_lower: [absolute_path, ...]}}
|
||||
attachment_index: dict[str, dict[str, list[Path]]] = {}
|
||||
|
||||
+48
-2
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-1
@@ -11,6 +11,8 @@ from typing import Any
|
||||
|
||||
import frontmatter
|
||||
|
||||
from backend.media_types import AUDIO_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, is_media
|
||||
|
||||
logger = logging.getLogger("obsigate.indexer")
|
||||
|
||||
# Global in-memory index
|
||||
@@ -69,7 +71,7 @@ SUPPORTED_EXTENSIONS = {
|
||||
".dockerfile", ".makefile", ".cmake",
|
||||
".excalidraw",
|
||||
".excalidraw.md",
|
||||
}
|
||||
} | set(IMAGE_EXTENSIONS) | set(AUDIO_EXTENSIONS) | set(VIDEO_EXTENSIONS)
|
||||
|
||||
|
||||
# Ignored directories (configurable via OBSIGATE_IGNORED_DIRS env var)
|
||||
@@ -550,6 +552,13 @@ def _scan_vault(
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
excalidraw_text_pending = True
|
||||
elif is_media(ext):
|
||||
# #108 — images (and future media, #109) are binary: index
|
||||
# name/size/mtime only and never read the bytes. ``content``
|
||||
# stays empty so the TF-IDF index remains clean.
|
||||
raw = ""
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
content_preview = ""
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||
@@ -934,6 +943,10 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
raw = extract_excalidraw_indexable(raw)
|
||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||
content_preview = raw[:200].strip()
|
||||
elif is_media(ext):
|
||||
# #108 — binary media: metadata only, never read the bytes.
|
||||
raw = ""
|
||||
content_preview = ""
|
||||
else:
|
||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
content_preview = raw[:200].strip()
|
||||
|
||||
+262
-60
@@ -2,7 +2,6 @@ import asyncio
|
||||
import html as html_mod
|
||||
import json as _json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
@@ -16,6 +15,7 @@ from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
@@ -52,6 +52,8 @@ from backend.indexer import (
|
||||
remove_vault_from_index,
|
||||
update_single_file,
|
||||
)
|
||||
from backend.media_thumbs import generate_thumbnail, is_decodable
|
||||
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,
|
||||
@@ -203,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")
|
||||
@@ -699,20 +706,23 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self' blob:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
# A route may set a stricter per-response policy (e.g. ``sandbox`` for
|
||||
# standalone SVG, #108-B3); keep it instead of overwriting it.
|
||||
if "Content-Security-Policy" not in response.headers:
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self' blob:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
# Static assets are NOT content-hashed, so they must revalidate:
|
||||
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
|
||||
# a stale build for a year (the service worker cache compounded it).
|
||||
@@ -1032,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.
|
||||
|
||||
@@ -2409,19 +2440,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
|
||||
|
||||
# === Images: return as viewable image ===
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"}
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
if is_image(ext):
|
||||
size = file_path.stat().st_size
|
||||
mime_map = {
|
||||
".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",
|
||||
}
|
||||
mime = mime_map.get(ext, "application/octet-stream")
|
||||
mime = media_mime_type(str(file_path))
|
||||
# #108-B1 — the raw endpoint returns JSON (FileRawResponse), so the
|
||||
# standalone <img> must point to /api/image, which serves the bytes
|
||||
# with the right MIME type. Paths are URL-encoded (accents, spaces).
|
||||
img_url = f"/api/image/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
|
||||
html = (
|
||||
f'<div class="image-viewer">'
|
||||
f'<img src="/api/file/{vault_name}/raw?path={path}" '
|
||||
f'alt="{file_path.name}" style="max-width:100%;max-height:80vh;object-fit:contain" />'
|
||||
f'<img src="{img_url}" '
|
||||
f'alt="{html_mod.escape(file_path.name, quote=True)}" '
|
||||
f'style="max-width:100%;max-height:80vh;object-fit:contain" />'
|
||||
f'</div>'
|
||||
)
|
||||
return {
|
||||
@@ -2439,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:
|
||||
@@ -2614,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)
|
||||
@@ -2667,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)
|
||||
@@ -2684,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)
|
||||
@@ -3183,16 +3283,19 @@ async def api_image(vault_name: str, path: str = Query(..., description="Relativ
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Image not found: {path}")
|
||||
|
||||
# Determine MIME type
|
||||
mime_type, _ = mimetypes.guess_type(str(file_path))
|
||||
if not mime_type:
|
||||
# Default to octet-stream if unknown
|
||||
mime_type = "application/octet-stream"
|
||||
mime_type = media_mime_type(str(file_path))
|
||||
|
||||
# #108-B3 — a standalone SVG opened in a tab executes its embedded JS
|
||||
# (same-origin XSS). ``sandbox`` forces a unique opaque origin with no
|
||||
# script execution; inside an <img> tag the header is irrelevant.
|
||||
headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if file_path.suffix.lower() == ".svg":
|
||||
headers["Content-Security-Policy"] = "sandbox"
|
||||
|
||||
try:
|
||||
# Read and return the image file
|
||||
content = file_path.read_bytes()
|
||||
return Response(content=content, media_type=mime_type)
|
||||
return Response(content=content, media_type=mime_type, headers=headers)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Permission denied")
|
||||
except Exception as e:
|
||||
@@ -3200,6 +3303,91 @@ 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,
|
||||
path: str = Query(..., description="Relative path to image"),
|
||||
size: int = Query(256, ge=32, le=1024, description="Max thumbnail edge in pixels"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Serve a cached WebP thumbnail of an image (roadmap #108-C).
|
||||
|
||||
SVG (and any format Pillow cannot decode) falls back to the original
|
||||
bytes. Generation runs in a thread and is capped at 2 s; on timeout or
|
||||
failure the original is served so the UI never breaks.
|
||||
"""
|
||||
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"Image not found: {path}")
|
||||
if not is_image(file_path.suffix.lower()):
|
||||
raise HTTPException(status_code=400, detail="Not an image file")
|
||||
|
||||
mime_type = media_mime_type(str(file_path))
|
||||
if not is_decodable(file_path):
|
||||
# SVG: never let a standalone navigation execute embedded JS (#108-B3).
|
||||
svg_headers = {"X-Content-Type-Options": "nosniff"}
|
||||
if file_path.suffix.lower() == ".svg":
|
||||
svg_headers["Content-Security-Policy"] = "sandbox"
|
||||
return FileResponse(str(file_path), media_type=mime_type, headers=svg_headers)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
thumb: Path | None = None
|
||||
try:
|
||||
thumb = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, generate_thumbnail, file_path, size),
|
||||
timeout=2.0,
|
||||
)
|
||||
except Exception:
|
||||
thumb = None
|
||||
|
||||
if thumb is not None and thumb.exists():
|
||||
return FileResponse(str(thumb), media_type="image/webp")
|
||||
return FileResponse(str(file_path), media_type=mime_type)
|
||||
|
||||
|
||||
@app.post("/api/attachments/rescan/{vault_name}", response_model=AttachmentRescanResponse)
|
||||
async def api_rescan_attachments(vault_name: str, current_user=Depends(require_admin)):
|
||||
"""Rescan attachments for a specific vault.
|
||||
@@ -4092,6 +4280,7 @@ async def api_dashboard(current_user=Depends(require_auth)):
|
||||
total_files = 0
|
||||
total_tags = set()
|
||||
total_size = 0
|
||||
total_images = 0
|
||||
for vname, vdata in index.items():
|
||||
if "*" not in user_vaults and vname not in user_vaults:
|
||||
continue
|
||||
@@ -4100,13 +4289,26 @@ async def api_dashboard(current_user=Depends(require_auth)):
|
||||
total_files += fc
|
||||
vtags = set()
|
||||
vsize = 0
|
||||
vimages = 0
|
||||
for f in files:
|
||||
vtags.update(f.get("tags", []))
|
||||
vsize += f.get("size", 0)
|
||||
if (f.get("extension") or "").lower() in IMAGE_EXTENSIONS:
|
||||
vimages += 1
|
||||
total_tags.update(vtags)
|
||||
total_size += vsize
|
||||
vault_stats.append({"name": vname, "file_count": fc, "tag_count": len(vtags), "total_size_bytes": vsize})
|
||||
return {"vaults": vault_stats, "total_files": total_files, "total_tags": len(total_tags), "total_size_bytes": total_size}
|
||||
total_images += vimages
|
||||
vault_stats.append({
|
||||
"name": vname, "file_count": fc, "tag_count": len(vtags),
|
||||
"total_size_bytes": vsize, "image_count": vimages,
|
||||
})
|
||||
return {
|
||||
"vaults": vault_stats,
|
||||
"total_files": total_files,
|
||||
"total_tags": len(total_tags),
|
||||
"total_size_bytes": total_size,
|
||||
"total_images": total_images,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Image thumbnail generation and disk cache (roadmap #108-C).
|
||||
|
||||
Thumbnails are generated on demand with Pillow and cached under
|
||||
``<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/<sha1>.webp``. The cache key
|
||||
embeds the source path, mtime (ns) and size, so an edited image naturally
|
||||
invalidates its stale thumbnail without any explicit cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_THUMB_SIZE = 256
|
||||
|
||||
# Extensions Pillow cannot decode without extra native libraries: served as-is.
|
||||
_UNDECODABLE = {".svg"}
|
||||
|
||||
|
||||
def thumbs_cache_dir() -> Path:
|
||||
"""Return (and create) the thumbnail cache directory."""
|
||||
base = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / ".obsigate-cache" / "thumbs"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def thumb_cache_path(file_path: Path, size: int) -> Path:
|
||||
"""Compute the deterministic cache path for *file_path* at *size*."""
|
||||
try:
|
||||
st = file_path.stat()
|
||||
stamp = f"{st.st_mtime_ns}:{st.st_size}"
|
||||
except OSError:
|
||||
stamp = "0:0"
|
||||
key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest()
|
||||
return thumbs_cache_dir() / f"{key}.webp"
|
||||
|
||||
|
||||
def is_decodable(file_path: Path) -> bool:
|
||||
"""True when Pillow can be expected to decode *file_path*."""
|
||||
return file_path.suffix.lower() not in _UNDECODABLE
|
||||
|
||||
|
||||
def generate_thumbnail(file_path: Path, size: int = DEFAULT_THUMB_SIZE) -> Path | None:
|
||||
"""Generate (or reuse) a WebP thumbnail and return its path.
|
||||
|
||||
Returns ``None`` when the file cannot be decoded (e.g. SVG) or Pillow is
|
||||
unavailable, so the caller can fall back to serving the original.
|
||||
"""
|
||||
cache_path = thumb_cache_path(file_path, size)
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
except Exception: # pragma: no cover - Pillow is an optional runtime dep
|
||||
return None
|
||||
|
||||
try:
|
||||
with Image.open(file_path) as opened:
|
||||
# Animated formats: keep only the first frame.
|
||||
if getattr(opened, "is_animated", False):
|
||||
opened.seek(0)
|
||||
img = ImageOps.exif_transpose(opened) or opened
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA")
|
||||
img.thumbnail((size, size))
|
||||
|
||||
tmp = cache_path.with_suffix(".tmp")
|
||||
img.save(tmp, "WEBP", quality=80)
|
||||
os.replace(tmp, cache_path)
|
||||
return cache_path
|
||||
except Exception:
|
||||
return None
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Shared media type constants and helpers.
|
||||
|
||||
Single source of truth for the file extensions and MIME types handled by the
|
||||
image support (roadmap #108) and reused by the audio/video players (#109).
|
||||
Keeping these sets here avoids the previous duplication (``indexer.py``,
|
||||
``attachment_indexer.py`` and ``main.py`` each carried their own copy).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
|
||||
# Image extensions viewable in the browser (HEIC/HEIF deliberately excluded —
|
||||
# no browser decodes them natively; see roadmap #108).
|
||||
IMAGE_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico",
|
||||
})
|
||||
|
||||
# Audio extensions (socle for #109, not wired into the index yet).
|
||||
AUDIO_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".mp3", ".m4a", ".aac", ".wav", ".ogg", ".oga", ".opus", ".flac",
|
||||
})
|
||||
|
||||
# Video extensions (socle for #109, not wired into the index yet).
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({
|
||||
".mp4", ".webm", ".mov", ".m4v",
|
||||
})
|
||||
|
||||
MEDIA_EXTENSIONS: frozenset[str] = IMAGE_EXTENSIONS | AUDIO_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
|
||||
# Explicit MIME types for extensions ``mimetypes`` gets wrong or does not know.
|
||||
_MIME_OVERRIDES: dict[str, str] = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".webp": "image/webp",
|
||||
".m4a": "audio/mp4",
|
||||
".oga": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/mp4",
|
||||
}
|
||||
|
||||
|
||||
def is_image(ext: str) -> bool:
|
||||
"""Return True when *ext* (with leading dot, any case) is an image."""
|
||||
return ext.lower() in IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def is_audio(ext: str) -> bool:
|
||||
"""Return True when *ext* is an audio extension."""
|
||||
return ext.lower() in AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
def is_video(ext: str) -> bool:
|
||||
"""Return True when *ext* is a video extension."""
|
||||
return ext.lower() in VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
def is_media(ext: str) -> bool:
|
||||
"""Return True when *ext* is any supported image/audio/video extension."""
|
||||
return ext.lower() in MEDIA_EXTENSIONS
|
||||
|
||||
|
||||
def media_mime_type(path: str) -> str:
|
||||
"""Return the best MIME type for *path* (extension based).
|
||||
|
||||
Falls back to ``application/octet-stream`` when the type is unknown.
|
||||
"""
|
||||
lower = path.lower()
|
||||
for ext, mime in _MIME_OVERRIDES.items():
|
||||
if lower.endswith(ext):
|
||||
return mime
|
||||
guessed, _ = mimetypes.guess_type(path)
|
||||
return guessed or "application/octet-stream"
|
||||
@@ -24,3 +24,4 @@ sse-starlette==2.1.3
|
||||
openpyxl>=3.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
|
||||
@@ -395,6 +395,7 @@ class DashboardVaultStat(BaseModel):
|
||||
file_count: int
|
||||
tag_count: int
|
||||
total_size_bytes: int
|
||||
image_count: int = 0
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
@@ -404,6 +405,7 @@ class DashboardResponse(BaseModel):
|
||||
total_files: int
|
||||
total_tags: int
|
||||
total_size_bytes: int
|
||||
total_images: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.16.6"
|
||||
version = "2.22.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.16.6"
|
||||
version = "2.22.1"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.16.6",
|
||||
"version": "2.22.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**,
|
||||
|
||||
@@ -180,6 +180,8 @@ 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é |
|
||||
| | | | | | | | | | | |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
@@ -258,6 +260,8 @@ 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+9
-5
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.16.6 | **Dernière mise à jour :** 2026-09-22
|
||||
> **Version :** 2.22.1 | **Dernière mise à jour :** 2026-09-23
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -40,8 +40,6 @@
|
||||
- [ ] **Signature de code Windows** : non retenue (pas de certificat) — alternatives : livrer non signé, SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
|
||||
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -182,6 +180,12 @@
|
||||
| 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -189,11 +193,11 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–107, #92 | ~120 jours réalisés |
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–113, #92 | ~131 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 |
|
||||
| ⚪ 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** | **6 items + finitions** | **~23-36 jours** |
|
||||
| **Total restant** | **6 items + finitions** | **~23-38 jours** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,96 @@
|
||||
# #108 — Support complet des images (arborescence, visionneuse, indexation)
|
||||
|
||||
> **Version livrée :** 2.17.0 · **Statut :** ✅ · **Impact :** 🟡
|
||||
> **Zone :** backend (`indexer`, `main`, `media_types`, `media_thumbs`) + frontend
|
||||
> (`viewer.js`, `utils.js`, `style.css`).
|
||||
|
||||
## Contexte
|
||||
|
||||
Seul l'affichage *inline* dans un document markdown (`![[image.png]]`) fonctionnait.
|
||||
L'image isolée était **invisible dans l'arborescence** (filtrée par
|
||||
`SUPPORTED_EXTENSIONS`) et son **affichage standalone était cassé** : le `<img>`
|
||||
généré par `api_file_view()` pointait vers `/api/file/{vault}/raw`, un endpoint qui
|
||||
renvoie du **JSON** (`FileRawResponse`) et non des octets d'image.
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### A. Arborescence & indexation
|
||||
|
||||
- **`backend/media_types.py`** (nouveau) : source unique des extensions
|
||||
`IMAGE_EXTENSIONS`, `AUDIO_EXTENSIONS`, `VIDEO_EXTENSIONS` (+ helpers
|
||||
`is_image`/`is_audio`/`is_video`/`is_media`/`media_mime_type`). Socle réutilisé
|
||||
par #109. `attachment_indexer.py` et `api_file_view()` ne dupliquent plus la
|
||||
liste.
|
||||
- Les extensions image sont intégrées à `SUPPORTED_EXTENSIONS`
|
||||
(`indexer.py`) **avec une branche binaire** : `_scan_vault` et
|
||||
`_index_single_file_sync` indexent **nom / taille / mtime** et ne lisent
|
||||
**jamais** les octets (`content: ""`, `content_preview: ""`). Le TF-IDF reste
|
||||
donc propre et aucune `UnicodeDecodeError` ne pollue les logs.
|
||||
- Le reindex **watchdog** suit automatiquement (même filtre d'extensions).
|
||||
- Le filtre `ext:png` / `ext:jpg` de la recherche avancée est opérationnel dès
|
||||
lors que les images entrent dans l'index.
|
||||
- `/api/dashboard` expose `image_count` (par vault) et `total_images` (global),
|
||||
séparés du `file_count` général.
|
||||
|
||||
### B. Affichage standalone (correctif)
|
||||
|
||||
- `api_file_view()` génère désormais
|
||||
`src="/api/image/{vault}?path=…"` (chemin URL-encodé) au lieu de `/raw`.
|
||||
- `viewer.js` utilise le même endpoint (plus de bouton « Plein écran » cassé).
|
||||
- **Sécurité SVG** : `/api/image` (et le repli de `/api/media/.../thumb`) ajoute
|
||||
`Content-Security-Policy: sandbox` pour les `.svg`, ce qui empêche
|
||||
l'exécution du JavaScript embarqué quand le fichier est ouvert directement
|
||||
dans un onglet (XSS same-origin). Dans une balise `<img>`, l'en-tête est sans
|
||||
effet. Le middleware de sécurité ne remplace plus une politique stricte posée
|
||||
par une route.
|
||||
|
||||
### C. Miniatures
|
||||
|
||||
- `GET /api/media/{vault}/thumb?path=…&size=…` : miniature **WebP** générée
|
||||
avec `pillow>=10.0`, mise en cache sous
|
||||
`<OBSIGATE_DATA_DIR>/.obsigate-cache/thumbs/{sha1}.webp`. La clé de cache
|
||||
embarque **mtime + taille**, donc toute édition invalide naturellement la
|
||||
vignette.
|
||||
- Génération dans un thread (`run_in_executor`) avec **timeout 2 s** ; repli sur
|
||||
l'original en cas d'échec. SVG : l'original est servi tel quel (Pillow ne
|
||||
décode pas le SVG) ; GIF/WebP animés : première frame.
|
||||
|
||||
### D. Visionneuse
|
||||
|
||||
`renderImageViewer()` (`frontend/js/viewer.js`) remplace l'ancien rendu minimal :
|
||||
|
||||
- image centrée `object-fit: contain` ; **zoom molette 0,1×–8×**, **pan au
|
||||
glisser** (Pointer Events), **double-clic = réinitialisation**, raccourcis
|
||||
`+` / `-` / `0` ;
|
||||
- boutons +/−/reset et **badge de zoom** ;
|
||||
- **navigation ←/→** entre les images du même dossier (via `/api/browse`) et
|
||||
**pellicule de miniatures** (`/api/media/.../thumb`, `loading="lazy"`) ;
|
||||
- barre d'outils : « Ouvrir l'original » (nouvel onglet `/api/image`),
|
||||
téléchargement, **panneau métadonnées** repliable (dimensions via
|
||||
`naturalWidth/Height`, taille, type MIME, chemin, date), **lightbox** plein
|
||||
écran (fond `rgba(0,0,0,.9)`, `Échap` pour quitter) ;
|
||||
- `EXT_ICONS` : extensions image → icône Lucide `image` ;
|
||||
- compatible Split View (#75) : rendu dans `getContentArea()` du panneau actif.
|
||||
|
||||
### E. Tests
|
||||
|
||||
- `tests/test_image_api.py` : octets + MIME sur `/api/image`, en-tête `sandbox`
|
||||
des SVG, URL `/api/image` dans le HTML de `api_file_view`, encodage des
|
||||
chemins accentués, miniatures WebP + repli SVG + refus non-image.
|
||||
- `tests/test_image_indexing.py` : image présente dans `list_directory` et
|
||||
`path_index`, indexée avec `content == ""`, pertinence watchdog, compteurs
|
||||
dashboard, filtre `ext:png`.
|
||||
- `tests/frontend/image-viewer.test.mjs` : helpers purs (`clampImageZoom`,
|
||||
`isImagePath`, `buildImageUrl`) + vérifications statiques (zoom/pan/nav,
|
||||
absence de `/raw` dans la visionneuse, CSS, icônes).
|
||||
- `tests/e2e/image-viewer.spec.js` : ouverture d'une image depuis
|
||||
l'arborescence, réponse `/api/image` en `image/png`, zoom molette, navigation
|
||||
par la pellicule (fixtures `test_vault/sample-image.png` +
|
||||
`sample-vector.svg`).
|
||||
|
||||
## Limitations connues
|
||||
|
||||
- **HEIC/HEIF** (iPhone) : non décodables par les navigateurs → hors scope ;
|
||||
`pillow-heif` envisagé en v2.
|
||||
- Le SVG passe par l'original (pas de rendu bitmap côté serveur) : les
|
||||
miniatures de dossiers SVG ne sont pas générées.
|
||||
@@ -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`).
|
||||
|
||||
@@ -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é).
|
||||
+373
-298
@@ -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 -->
|
||||
@@ -1547,28 +1581,131 @@
|
||||
</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>
|
||||
<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 +1851,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: #<% ... %> 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">
|
||||
@@ -1850,182 +2163,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: #<% ... %> 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 +2419,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 & MCP</h2>
|
||||
@@ -2406,34 +2477,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,13 +2508,41 @@
|
||||
</section>
|
||||
|
||||
<!-- Partages publics -->
|
||||
<section class="config-section help-section">
|
||||
<section class="config-section help-section" id="cfg-partages-publics">
|
||||
<h2 id="cfg-partages-publics">📤 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3293,6 +3364,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>
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -2356,6 +2356,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 +2494,92 @@ 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');
|
||||
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 */ });
|
||||
|
||||
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();
|
||||
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();
|
||||
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
@@ -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) {
|
||||
|
||||
@@ -2080,11 +2080,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 +2212,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) {
|
||||
|
||||
+31
-29
@@ -201,37 +201,39 @@ const EXT_ICONS = {
|
||||
".tex": "file-text",
|
||||
".latex": "file-text",
|
||||
|
||||
// Image files
|
||||
".png": "file-image",
|
||||
".jpg": "file-image",
|
||||
".jpeg": "file-image",
|
||||
".gif": "file-image",
|
||||
".svg": "file-image",
|
||||
".webp": "file-image",
|
||||
".bmp": "file-image",
|
||||
".ico": "file-image",
|
||||
".tiff": "file-image",
|
||||
".tif": "file-image",
|
||||
// Image files (roadmap #108-D1)
|
||||
".png": "image",
|
||||
".jpg": "image",
|
||||
".jpeg": "image",
|
||||
".gif": "image",
|
||||
".svg": "image",
|
||||
".webp": "image",
|
||||
".bmp": "image",
|
||||
".ico": "image",
|
||||
".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",
|
||||
|
||||
+483
-19
@@ -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
|
||||
@@ -540,13 +541,480 @@ export function navigatePdfToPage(area, page) {
|
||||
iframe.src = `${base}${sep}_pdfpage=${Date.now()}#page=${page}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image viewer (roadmap #108-D)
|
||||
// ---------------------------------------------------------------------------
|
||||
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]);
|
||||
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) {
|
||||
if (!Number.isFinite(value)) return 1;
|
||||
return Math.min(IMAGE_ZOOM_MAX, Math.max(IMAGE_ZOOM_MIN, value));
|
||||
}
|
||||
|
||||
/** True when *p* has a viewable image extension. */
|
||||
export function isImagePath(p) {
|
||||
const lower = (p || "").toLowerCase();
|
||||
const dot = lower.lastIndexOf(".");
|
||||
return dot !== -1 && IMAGE_EXTS.has(lower.slice(dot));
|
||||
}
|
||||
|
||||
/** Build the byte-serving URL used by <img> / thumbnails. */
|
||||
export function buildImageUrl(vault, path) {
|
||||
return `/api/image/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
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`;
|
||||
if (n < 1048576) return `${(n / 1024).toFixed(1)} Ko`;
|
||||
return `${(n / 1048576).toFixed(1)} Mo`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a full image viewer: centered image, wheel zoom (0.1×–8×), drag pan,
|
||||
* double-click reset, ←/→ navigation between siblings, thumbnail filmstrip,
|
||||
* collapsible metadata panel and a full-viewport lightbox.
|
||||
*/
|
||||
export function renderImageViewer(area, data) {
|
||||
const vault = data.vault;
|
||||
// #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" });
|
||||
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);
|
||||
|
||||
const mkBtn = (iconName, label, cls) => {
|
||||
const b = el("button", { class: `btn-action${cls ? " " + cls : ""}`, type: "button", title: label, "aria-label": label }, [icon(iconName, 14)]);
|
||||
return b;
|
||||
};
|
||||
|
||||
const zoomOutBtn = mkBtn("zoom-out", t("viewer.image_zoom_out"));
|
||||
const zoomInBtn = mkBtn("zoom-in", t("viewer.image_zoom_in"));
|
||||
const zoomResetBtn = mkBtn("rotate-ccw", t("viewer.image_zoom_reset"));
|
||||
const prevBtn = mkBtn("chevron-left", t("viewer.image_prev"));
|
||||
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"), "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);
|
||||
|
||||
// ── 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: currentTitle, draggable: "false" });
|
||||
stage.appendChild(img);
|
||||
|
||||
// ── 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) ───────────────────────────────────
|
||||
// #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;
|
||||
let ty = 0;
|
||||
|
||||
const applyTransform = () => {
|
||||
img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
|
||||
zoomBadge.textContent = `${Math.round(scale * 100)}%`;
|
||||
};
|
||||
const resetView = () => { scale = 1; tx = 0; ty = 0; applyTransform(); };
|
||||
const setZoom = (next) => {
|
||||
scale = clampImageZoom(next);
|
||||
if (scale === 1) { tx = 0; ty = 0; }
|
||||
applyTransform();
|
||||
};
|
||||
|
||||
const renderMeta = () => {
|
||||
const rows = [
|
||||
[t("viewer.image_type"), currentMeta.image_mime || ""],
|
||||
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
|
||||
[t("viewer.metadata_size"), formatBytes(currentMeta.size_bytes)],
|
||||
[t("viewer.metadata_path"), currentPath],
|
||||
];
|
||||
if (currentMeta.modified) rows.push([t("viewer.metadata_modified"), currentMeta.modified]);
|
||||
metaPanel.innerHTML = "";
|
||||
const dl = el("dl", {});
|
||||
rows.forEach(([k, v]) => {
|
||||
dl.appendChild(el("dt", {}, [document.createTextNode(k)]));
|
||||
dl.appendChild(el("dd", {}, [document.createTextNode(v || "—")]));
|
||||
});
|
||||
metaPanel.appendChild(dl);
|
||||
};
|
||||
|
||||
// ── 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;
|
||||
showSibling((currentIndex + delta + siblings.length) % siblings.length);
|
||||
};
|
||||
|
||||
const renderStrip = () => {
|
||||
if (siblings.length < 2) { navWrap.hidden = true; return; }
|
||||
navWrap.hidden = false;
|
||||
strip.innerHTML = "";
|
||||
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", () => 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 = 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 */ }
|
||||
})();
|
||||
|
||||
// ── Interactions ───────────────────────────────────────────────────────
|
||||
stage.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
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 || isArrow(e)) return;
|
||||
dragging = true;
|
||||
startX = e.clientX; startY = e.clientY; startTx = tx; startTy = ty;
|
||||
stage.classList.add("panning");
|
||||
if (stage.setPointerCapture) stage.setPointerCapture(e.pointerId);
|
||||
});
|
||||
stage.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
tx = startTx + (e.clientX - startX);
|
||||
ty = startTy + (e.clientY - startY);
|
||||
applyTransform();
|
||||
});
|
||||
const endDrag = (e) => {
|
||||
dragging = false;
|
||||
stage.classList.remove("panning");
|
||||
if (stage.releasePointerCapture && e.pointerId != null) {
|
||||
try { stage.releasePointerCapture(e.pointerId); } catch (_) { /* already released */ }
|
||||
}
|
||||
};
|
||||
stage.addEventListener("pointerup", endDrag);
|
||||
stage.addEventListener("pointercancel", endDrag);
|
||||
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));
|
||||
zoomResetBtn.addEventListener("click", resetView);
|
||||
prevBtn.addEventListener("click", () => go(-1));
|
||||
nextBtn.addEventListener("click", () => go(1));
|
||||
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
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", () => {
|
||||
_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", () => {
|
||||
_imageViewerState.lightbox = !_imageViewerState.lightbox;
|
||||
syncLightbox();
|
||||
});
|
||||
|
||||
img.addEventListener("load", () => {
|
||||
img.style.display = "";
|
||||
const errEl = stage.querySelector(".image-error");
|
||||
if (errEl) errEl.remove();
|
||||
if (!metaPanel.hidden) renderMeta();
|
||||
});
|
||||
img.addEventListener("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) => {
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); go(-1); }
|
||||
else if (e.key === "ArrowRight") { e.preventDefault(); go(1); }
|
||||
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") {
|
||||
_imageViewerState.lightbox = false;
|
||||
syncLightbox();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
|
||||
|
||||
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
|
||||
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
|
||||
// instead of being wiped mid-session by a tab switch / sidebar click.
|
||||
if (isInlineEditorActive()) detachInlineEditor();
|
||||
// #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) {
|
||||
@@ -596,25 +1064,19 @@ export function renderFile(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle images
|
||||
// Handle images — dedicated zoom/pan viewer (roadmap #108-D)
|
||||
if (data.is_image) {
|
||||
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
|
||||
area.innerHTML = `
|
||||
<div class="image-viewer-container">
|
||||
<div class="file-toolbar">
|
||||
<span class="file-info">${escapeHtml(data.title)}</span>
|
||||
<button class="btn-action" onclick="window.open('${imgUrl}', '_blank')">
|
||||
<i data-lucide="maximize" style="width:14px;height:14px"></i> Plein écran
|
||||
</button>
|
||||
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
<div class="image-viewer-body">
|
||||
${data.html}
|
||||
</div>
|
||||
</div>`;
|
||||
lucide.createIcons();
|
||||
renderImageViewer(area, 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;
|
||||
}
|
||||
|
||||
@@ -629,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
|
||||
@@ -1294,6 +1756,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) {
|
||||
|
||||
@@ -380,6 +380,15 @@
|
||||
"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_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",
|
||||
@@ -777,6 +786,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 +1116,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 +1319,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",
|
||||
@@ -1682,6 +1694,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",
|
||||
@@ -1823,11 +1837,26 @@
|
||||
"viewer.export_title": "Export document",
|
||||
"viewer.forge_brand": "Forge",
|
||||
"viewer.forge_title": "Forge (new editor)",
|
||||
"viewer.image_dimensions": "Dimensions",
|
||||
"viewer.image_error": "Unable to load the image",
|
||||
"viewer.image_fullscreen": "Full screen (lightbox)",
|
||||
"viewer.image_metadata": "Metadata",
|
||||
"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",
|
||||
"viewer.image_zoom_reset": "Reset zoom",
|
||||
"viewer.index_start": "Starting index...",
|
||||
"viewer.index_updated": "Updated",
|
||||
"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",
|
||||
@@ -2015,6 +2044,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",
|
||||
@@ -2116,7 +2162,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; 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.",
|
||||
|
||||
@@ -380,6 +380,15 @@
|
||||
"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_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",
|
||||
@@ -777,6 +786,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 +1116,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 +1319,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",
|
||||
@@ -1682,6 +1694,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",
|
||||
@@ -1823,11 +1837,26 @@
|
||||
"viewer.export_title": "Exporter le document",
|
||||
"viewer.forge_brand": "Forge",
|
||||
"viewer.forge_title": "Forge (nouvel éditeur)",
|
||||
"viewer.image_dimensions": "Dimensions",
|
||||
"viewer.image_error": "Impossible de charger l'image",
|
||||
"viewer.image_fullscreen": "Plein écran (lightbox)",
|
||||
"viewer.image_metadata": "Métadonnées",
|
||||
"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",
|
||||
"viewer.image_zoom_reset": "Réinitialiser le zoom",
|
||||
"viewer.index_start": "Démarrage index.",
|
||||
"viewer.index_updated": "Mise à jour",
|
||||
"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",
|
||||
@@ -2015,6 +2044,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",
|
||||
@@ -2116,7 +2162,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 ; 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.",
|
||||
|
||||
+959
-16
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.16.6",
|
||||
"version": "2.22.1",
|
||||
"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",
|
||||
|
||||
@@ -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.
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="64" viewBox="0 0 96 64">
|
||||
<rect width="96" height="64" fill="#2a7de1" />
|
||||
<circle cx="48" cy="32" r="20" fill="#ffc828" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 191 B |
Binary file not shown.
@@ -94,6 +94,28 @@ def test_vault_dir(tmp_path: Path) -> str:
|
||||
# Non-markdown file
|
||||
(vault / "config.json").write_text('{"key": "value"}', encoding="utf-8")
|
||||
|
||||
# Image attachments (roadmap #108) — indexed as metadata-only binaries and
|
||||
# listed in the tree / browse endpoint.
|
||||
import base64
|
||||
|
||||
(vault / "chatScreenshot.png").write_bytes(base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
|
||||
))
|
||||
(vault / "vector-icon.svg").write_text(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"></svg>',
|
||||
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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* E2E tests for the ObsiGate image viewer (roadmap #108).
|
||||
*
|
||||
* Fixtures : `test_vault/sample-image.png` (96x64) + `test_vault/sample-vector.svg`.
|
||||
*
|
||||
* Run (local):
|
||||
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/image-viewer.spec.js
|
||||
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/image-viewer.spec.js --headed
|
||||
*/
|
||||
|
||||
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('Image viewer — zoom / pan / navigation (#108)', () => {
|
||||
|
||||
test('affiche l\'image dans la visionneuse dédiée (URL /api/image)', async ({ page }) => {
|
||||
// #108-B1 — l'image isolée doit pointer vers /api/image (octets), pas /raw (JSON).
|
||||
const imageResponsePromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/image/') && r.status() === 200,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-image.png');
|
||||
|
||||
const main = page.locator('#content-area .image-viewer-container img.image-main');
|
||||
await expect(main).toBeVisible({ timeout: 10000 });
|
||||
await expect(main).toHaveAttribute('src', /\/api\/image\/TestVault\?path=/);
|
||||
|
||||
const resp = await imageResponsePromise;
|
||||
expect(resp.headers()['content-type']).toContain('image/png');
|
||||
|
||||
// Le badge de zoom démarre à 100 %.
|
||||
await expect(page.locator('#content-area .image-zoom-badge')).toHaveText('100%');
|
||||
});
|
||||
|
||||
test('le zoom molette et le reset modifient la transform', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-image.png');
|
||||
await expect(page.locator('#content-area .image-stage')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const badge = page.locator('#content-area .image-zoom-badge');
|
||||
await expect(badge).toHaveText('100%');
|
||||
|
||||
await page.locator('#content-area .image-stage').hover();
|
||||
await page.mouse.wheel(0, -240);
|
||||
await expect(badge).not.toHaveText('100%', { timeout: 5000 });
|
||||
|
||||
const transform = await page.locator('#content-area img.image-main').evaluate(
|
||||
(el) => getComputedStyle(el).transform,
|
||||
);
|
||||
expect(transform).not.toBe('none');
|
||||
|
||||
// Double-clic = réinitialisation.
|
||||
await page.locator('#content-area .image-stage').dblclick();
|
||||
await expect(badge).toHaveText('100%');
|
||||
});
|
||||
|
||||
test('navigue entre les images du dossier via la pellicule', async ({ page }) => {
|
||||
await login(page);
|
||||
await openFile(page, 'TestVault', 'sample-image.png');
|
||||
|
||||
const strip = page.locator('#content-area .image-nav-strip');
|
||||
await expect(strip).toBeVisible({ timeout: 10000 });
|
||||
// sample-image.png et sample-vector.svg partagent le dossier racine.
|
||||
await expect(strip.locator('img.image-thumb')).toHaveCount(2);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Image viewer tests (roadmap #108-D).
|
||||
*
|
||||
* Pure helpers (clampImageZoom / isImagePath / buildImageUrl) are evaluated in
|
||||
* a VM sandbox to avoid importing the full browser-only viewer chain. The rest
|
||||
* are static checks on the source and CSS.
|
||||
*
|
||||
* Usage: node tests/frontend/image-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 utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
|
||||
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
|
||||
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "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 = viewer.indexOf("const IMAGE_EXTS");
|
||||
const end = viewer.indexOf("function formatBytes");
|
||||
assert.ok(begin !== -1 && end !== -1, "image viewer helper block not found");
|
||||
const sandbox = {};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.clampImageZoom = clampImageZoom; globalThis.isImagePath = isImagePath; globalThis.buildImageUrl = buildImageUrl;`,
|
||||
sandbox,
|
||||
);
|
||||
const { clampImageZoom, isImagePath, buildImageUrl } = sandbox;
|
||||
|
||||
test("clampImageZoom bounds to [0.1, 8]", () => {
|
||||
assert.equal(clampImageZoom(0.01), 0.1);
|
||||
assert.equal(clampImageZoom(100), 8);
|
||||
assert.equal(clampImageZoom(2), 2);
|
||||
assert.equal(clampImageZoom(NaN), 1);
|
||||
});
|
||||
|
||||
test("isImagePath recognises image extensions (case-insensitive)", () => {
|
||||
for (const p of ["a.png", "a.JPG", "dir/b.jpeg", "x/y/z.svg", "p.webp", "q.bmp", "r.ico", "g.gif"]) {
|
||||
assert.equal(isImagePath(p), true, p);
|
||||
}
|
||||
for (const p of ["a.md", "a.txt", "noext", "a.PDF", ""]) {
|
||||
assert.equal(isImagePath(p), false, p);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildImageUrl encodes vault and path", () => {
|
||||
const url = buildImageUrl("My Vault", "café image.png");
|
||||
assert.equal(url, "/api/image/My%20Vault?path=caf%C3%A9%20image.png");
|
||||
});
|
||||
|
||||
// ── Static checks: viewer wiring ───────────────────────────────────────────
|
||||
test("viewer.js dispatches is_image to renderImageViewer", () => {
|
||||
assert.match(viewer, /if \(data\.is_image\) \{\s*renderImageViewer\(area, data\);/);
|
||||
});
|
||||
|
||||
test("renderImageViewer never uses the JSON raw endpoint as an image src", () => {
|
||||
const start = viewer.indexOf("export function renderImageViewer");
|
||||
const stop = viewer.indexOf("export function renderFile");
|
||||
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, 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", \(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 = \[\]/);
|
||||
// #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 ─────────────────────────────────────────────────────
|
||||
test("style.css defines the image viewer layout + lightbox", () => {
|
||||
assert.match(css, /\.image-stage\s*\{/);
|
||||
assert.match(css, /\.image-nav-strip\s*\{/);
|
||||
assert.match(css, /\.image-meta-panel\s*\{/);
|
||||
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"/);
|
||||
assert.match(utils, /"\.svg": "image"/);
|
||||
});
|
||||
|
||||
test("backend api_file_view points <img> at /api/image", () => {
|
||||
assert.match(main, /img_url = f"\/api\/image\//);
|
||||
assert.match(main, /f'<img src="\{img_url\}"/);
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error("\nImage viewer tests FAILED");
|
||||
} else {
|
||||
console.log("\nAll image viewer tests passed.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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,174 @@
|
||||
#!/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 { 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");
|
||||
});
|
||||
|
||||
// ── #113c: i18n + backend ───────────────────────────────────────────────────
|
||||
test("i18n — avatar keys exist in FR and EN", () => {
|
||||
const keys = [
|
||||
"config.avatar_label",
|
||||
"config.avatar_hint",
|
||||
"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.");
|
||||
}
|
||||
@@ -240,6 +240,50 @@ 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)');
|
||||
}
|
||||
|
||||
// ── Run all tests ──────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
let passed = 0, failed = 0;
|
||||
@@ -255,6 +299,7 @@ async function main() {
|
||||
['ai-fab module exists', testAIFabModuleExists],
|
||||
['ai-fab module exports', testAIFabModuleExports],
|
||||
['config TOC icons', testConfigTocIcons],
|
||||
['sidebar user section', testSidebarUserSection],
|
||||
];
|
||||
|
||||
for (const [name, fn] of tests) {
|
||||
|
||||
+95
-1
@@ -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
|
||||
@@ -0,0 +1,109 @@
|
||||
# tests/test_image_api.py — Image serving & viewer API (roadmap #108-B/C)
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PNG_1x1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
|
||||
)
|
||||
SVG_DOC = b'<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><script>alert(1)</script></svg>'
|
||||
|
||||
|
||||
def _write_image(vault_dir: str, name: str, content: bytes) -> None:
|
||||
(Path(vault_dir) / name).write_bytes(content)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/image — byte serving (BUG fixed in #108-B1)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestImageEndpoint:
|
||||
def test_serves_bytes_and_mime(self, client, test_vault_dir):
|
||||
_write_image(test_vault_dir, "pic.png", PNG_1x1)
|
||||
resp = client.get("/api/image/TestVault", params={"path": "pic.png"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("image/png")
|
||||
assert resp.content == PNG_1x1
|
||||
|
||||
def test_missing_image_404(self, client):
|
||||
resp = client.get("/api/image/TestVault", params={"path": "nope.png"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_svg_gets_sandbox_header(self, client, test_vault_dir):
|
||||
_write_image(test_vault_dir, "vector.svg", SVG_DOC)
|
||||
resp = client.get("/api/image/TestVault", params={"path": "vector.svg"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers.get("content-security-policy") == "sandbox"
|
||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||
assert resp.content == SVG_DOC
|
||||
|
||||
def test_png_keeps_the_global_csp(self, client, test_vault_dir):
|
||||
_write_image(test_vault_dir, "pic2.png", PNG_1x1)
|
||||
resp = client.get("/api/image/TestVault", params={"path": "pic2.png"})
|
||||
csp = resp.headers.get("content-security-policy", "")
|
||||
assert csp != "sandbox"
|
||||
assert "default-src" in csp
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/file — standalone image view points at /api/image, not /raw
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestImageViewMetadata:
|
||||
def test_file_view_uses_image_endpoint(self, client):
|
||||
resp = client.get("/api/file/TestVault", params={"path": "chatScreenshot.png"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_image"] is True
|
||||
assert data["image_mime"] == "image/png"
|
||||
assert data["size_bytes"] > 0
|
||||
assert "/api/image/TestVault?path=chatScreenshot.png" in data["html"]
|
||||
# The JSON raw endpoint must NOT be used as an <img> source.
|
||||
assert "/raw?path=" not in data["html"]
|
||||
|
||||
def test_file_view_url_encoded(self, client, test_vault_dir):
|
||||
_write_image(test_vault_dir, "café image.png", PNG_1x1)
|
||||
resp = client.get("/api/file/TestVault", params={"path": "café image.png"})
|
||||
assert resp.status_code == 200
|
||||
html = resp.json()["html"]
|
||||
assert "/api/image/TestVault?path=caf%C3%A9%20image.png" in html
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# /api/media/{vault}/thumb — thumbnails (roadmap #108-C)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestThumbnailEndpoint:
|
||||
def test_png_thumbnail_is_webp(self, client, tmp_path, monkeypatch):
|
||||
pytest.importorskip("PIL")
|
||||
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path / "data"))
|
||||
resp = client.get(
|
||||
"/api/media/TestVault/thumb",
|
||||
params={"path": "chatScreenshot.png", "size": 64},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("image/webp")
|
||||
|
||||
def test_svg_thumbnail_falls_back_to_original(self, client, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path / "data"))
|
||||
resp = client.get(
|
||||
"/api/media/TestVault/thumb",
|
||||
params={"path": "vector-icon.svg"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "svg" in resp.headers["content-type"]
|
||||
|
||||
def test_thumb_rejects_non_image(self, client):
|
||||
resp = client.get(
|
||||
"/api/media/TestVault/thumb",
|
||||
params={"path": "config.json"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_missing_thumb_404(self, client):
|
||||
resp = client.get(
|
||||
"/api/media/TestVault/thumb",
|
||||
params={"path": "nope.png"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,105 @@
|
||||
# tests/test_image_indexing.py — Images in the tree & index (roadmap #108-A)
|
||||
import asyncio
|
||||
import base64
|
||||
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
|
||||
|
||||
PNG_1x1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen63NgAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
def _make_vault(tmp_path: Path) -> Path:
|
||||
vault = tmp_path / "Vault"
|
||||
vault.mkdir()
|
||||
(vault / "note.md").write_text("# Note\n", encoding="utf-8")
|
||||
(vault / "chatScreenshot.png").write_bytes(PNG_1x1)
|
||||
assets = vault / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "photo.jpg").write_bytes(PNG_1x1)
|
||||
return vault
|
||||
|
||||
|
||||
class TestScanVaultImages:
|
||||
def test_image_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"]}
|
||||
assert "chatScreenshot.png" in by_path
|
||||
image = by_path["chatScreenshot.png"]
|
||||
assert image["content"] == "" # never read the bytes (#108-A2)
|
||||
assert image["content_preview"] == ""
|
||||
assert image["extension"] == ".png"
|
||||
assert image["size"] > 0
|
||||
assert "assets/photo.jpg" in by_path
|
||||
|
||||
def test_images_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 "chatScreenshot.png" in files
|
||||
assert "assets/photo.jpg" 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 (".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"):
|
||||
assert f["content"] == ""
|
||||
|
||||
|
||||
class TestSingleFileImage:
|
||||
def test_metadata_only(self, tmp_path):
|
||||
vault = _make_vault(tmp_path)
|
||||
info = _index_single_file_sync(
|
||||
"V", str(vault), str(vault / "chatScreenshot.png")
|
||||
)
|
||||
assert info is not None
|
||||
assert info["content"] == ""
|
||||
assert info["extension"] == ".png"
|
||||
assert info["path"] == "chatScreenshot.png"
|
||||
|
||||
|
||||
class TestWatcherRelevance:
|
||||
def test_image_extensions_relevant(self, tmp_path):
|
||||
handler = VaultEventHandler("V", asyncio.Queue(), asyncio.new_event_loop())
|
||||
assert handler._is_relevant(str(tmp_path / "a.png")) is True
|
||||
assert handler._is_relevant(str(tmp_path / "a.jpg")) is True
|
||||
assert handler._is_relevant(str(tmp_path / "a.svg")) is True
|
||||
assert handler._is_relevant(str(tmp_path / "a.exe")) is False
|
||||
|
||||
|
||||
class TestBrowseAndDashboard:
|
||||
def test_browse_lists_image(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 "chatScreenshot.png" in names
|
||||
assert names["chatScreenshot.png"]["type"] == "file"
|
||||
assert names["chatScreenshot.png"]["extension"] == ".png"
|
||||
|
||||
def test_dashboard_counts_images(self, client):
|
||||
resp = client.get("/api/dashboard")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total_images"] >= 2
|
||||
vault = next(v for v in data["vaults"] if v["name"] == "TestVault")
|
||||
assert vault["image_count"] >= 2
|
||||
|
||||
def test_ext_filter_returns_images(self, client):
|
||||
resp = client.get("/api/search/advanced", params={"q": "ext:png"})
|
||||
assert resp.status_code == 200
|
||||
paths = [r["path"] for r in resp.json()["results"]]
|
||||
assert any(p.endswith(".png") for p in paths)
|
||||
|
||||
|
||||
class TestBrowseService:
|
||||
def test_browse_directory_service(self, app_with_vault, test_vault_dir):
|
||||
# The shared index is populated by the app_with_vault fixture.
|
||||
data = browse_directory("TestVault", "")
|
||||
names = [i["name"] for i in data["items"]]
|
||||
assert "chatScreenshot.png" in names
|
||||
@@ -160,9 +160,15 @@ class TestSupportedExtensions:
|
||||
for ext in [".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf"]:
|
||||
assert ext in SUPPORTED_EXTENSIONS, f"{ext} should be supported"
|
||||
|
||||
def test_image_extensions_supported(self):
|
||||
# #108 — images are indexed (name/size/mtime, empty content) so they
|
||||
# show up in the tree and in file listings.
|
||||
for ext in [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".ico"]:
|
||||
assert ext in SUPPORTED_EXTENSIONS, f"{ext} should be supported"
|
||||
|
||||
def test_binary_not_supported(self):
|
||||
assert ".png" not in SUPPORTED_EXTENSIONS
|
||||
assert ".exe" not in SUPPORTED_EXTENSIONS
|
||||
assert ".zip" not in SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -70,11 +70,20 @@ class TestFileCRUD:
|
||||
assert info is None
|
||||
|
||||
def test_index_single_file_sync_unsupported(self, test_vault_dir):
|
||||
bin_file = Path(test_vault_dir) / "test_image.png"
|
||||
bin_file.write_bytes(b"fake png data")
|
||||
bin_file = Path(test_vault_dir) / "test_binary.exe"
|
||||
bin_file.write_bytes(b"fake exe data")
|
||||
info = _index_single_file_sync("TestVault", test_vault_dir, str(bin_file))
|
||||
assert info is None
|
||||
|
||||
def test_index_single_file_sync_image_metadata_only(self, test_vault_dir):
|
||||
# #108-A2 — images are indexed (name/size/mtime) but never read.
|
||||
img_file = Path(test_vault_dir) / "test_image.png"
|
||||
img_file.write_bytes(b"fake png data")
|
||||
info = _index_single_file_sync("TestVault", test_vault_dir, str(img_file))
|
||||
assert info is not None
|
||||
assert info["extension"] == ".png"
|
||||
assert info["content"] == ""
|
||||
|
||||
def test_add_and_remove_file(self, client):
|
||||
path = "crud_test_unique_12345.md"
|
||||
file_info = {
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user