Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2417cb5ab | ||
|
|
eccbf7474e | ||
|
|
69cee4d93a |
+56
-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.18.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,61 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [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é
|
||||
|
||||
+18
-3
@@ -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), 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 |
|
||||
|
||||
@@ -951,8 +966,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.18.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.16.6 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.18.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](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), 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 |
|
||||
|
||||
@@ -1128,8 +1142,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.18.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.16.6 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.18.0 | 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]]] = {}
|
||||
|
||||
@@ -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.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.16.6"
|
||||
version = "2.18.0"
|
||||
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.18.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.16.6 | **Dernière mise à jour :** 2026-09-22
|
||||
> **Version :** 2.18.0 | **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)**
|
||||
@@ -182,6 +182,8 @@
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -189,11 +191,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–109, #92 | ~125 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,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,99 @@
|
||||
# #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`.
|
||||
+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",
|
||||
|
||||
+394
-19
@@ -540,12 +540,393 @@ 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;
|
||||
|
||||
/** 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}`;
|
||||
}
|
||||
|
||||
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;
|
||||
const path = data.path;
|
||||
const fileName = (path || "").split("/").pop();
|
||||
const imgUrl = buildImageUrl(vault, path);
|
||||
|
||||
area.innerHTML = "";
|
||||
const container = el("div", { class: "image-viewer-container", tabindex: "0" });
|
||||
|
||||
// ── Toolbar ────────────────────────────────────────────────────────────
|
||||
const toolbar = el("div", { class: "image-toolbar" });
|
||||
toolbar.appendChild(el("span", { class: "image-title", title: path }, [
|
||||
document.createTextNode(data.title || fileName),
|
||||
]));
|
||||
toolbar.appendChild(el("div", { class: "image-toolbar-spacer" }));
|
||||
|
||||
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"));
|
||||
const lightboxBtn = mkBtn("maximize", t("viewer.image_fullscreen"));
|
||||
[prevBtn, nextBtn, zoomOutBtn, zoomInBtn, zoomResetBtn, metaBtn, originalBtn, downloadBtn, lightboxBtn]
|
||||
.forEach((b) => toolbar.appendChild(b));
|
||||
container.appendChild(toolbar);
|
||||
|
||||
// ── Stage (image + pan/zoom) ───────────────────────────────────────────
|
||||
const stage = el("div", { class: "image-stage" });
|
||||
const img = el("img", { class: "image-main", src: imgUrl, alt: data.title || fileName, draggable: "false" });
|
||||
stage.appendChild(img);
|
||||
container.appendChild(stage);
|
||||
|
||||
// ── Thumbnail filmstrip (navigation) ───────────────────────────────────
|
||||
const strip = el("div", { class: "image-nav-strip", hidden: true });
|
||||
container.appendChild(strip);
|
||||
|
||||
// ── Metadata panel ─────────────────────────────────────────────────────
|
||||
const metaPanel = el("div", { class: "image-meta-panel" });
|
||||
metaPanel.hidden = true;
|
||||
container.appendChild(metaPanel);
|
||||
|
||||
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"), data.image_mime || ""],
|
||||
[t("viewer.image_dimensions"), img.naturalWidth ? `${img.naturalWidth} × ${img.naturalHeight}` : ""],
|
||||
[t("viewer.metadata_size"), formatBytes(data.size_bytes)],
|
||||
[t("viewer.metadata_path"), path],
|
||||
];
|
||||
if (data.modified) rows.push([t("viewer.metadata_modified"), data.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 ─────────────────────────────────────────────────
|
||||
let siblings = [];
|
||||
let currentIndex = -1;
|
||||
|
||||
const go = (delta) => {
|
||||
if (siblings.length < 2 || currentIndex < 0) return;
|
||||
const next = (currentIndex + delta + siblings.length) % siblings.length;
|
||||
openFile(vault, siblings[next].path);
|
||||
};
|
||||
|
||||
const renderStrip = () => {
|
||||
if (siblings.length < 2) { strip.hidden = true; return; }
|
||||
strip.hidden = false;
|
||||
strip.innerHTML = "";
|
||||
siblings.forEach((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",
|
||||
});
|
||||
thumb.addEventListener("click", () => { if (s.path !== path) openFile(vault, s.path); });
|
||||
strip.appendChild(thumb);
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
||||
const res = await api(`/api/browse/${encodeURIComponent(vault)}?path=${encodeURIComponent(dir)}`);
|
||||
siblings = (res.items || []).filter((it) => it.type === "file" && isImagePath(it.path));
|
||||
currentIndex = siblings.findIndex((it) => it.path === path);
|
||||
prevBtn.disabled = nextBtn.disabled = siblings.length < 2;
|
||||
renderStrip();
|
||||
} 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 });
|
||||
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startTx = 0;
|
||||
let startTy = 0;
|
||||
stage.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) 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", resetView);
|
||||
|
||||
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(path)}`;
|
||||
window.open(dlUrl, "_blank");
|
||||
});
|
||||
metaBtn.addEventListener("click", () => {
|
||||
metaPanel.hidden = !metaPanel.hidden;
|
||||
if (!metaPanel.hidden) renderMeta();
|
||||
});
|
||||
lightboxBtn.addEventListener("click", () => {
|
||||
container.classList.toggle("lightbox");
|
||||
lightboxBtn.setAttribute("aria-pressed", container.classList.contains("lightbox") ? "true" : "false");
|
||||
});
|
||||
|
||||
img.addEventListener("load", () => { if (!metaPanel.hidden) renderMeta(); });
|
||||
img.addEventListener("error", () => {
|
||||
stage.innerHTML = "";
|
||||
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") { container.classList.remove("lightbox"); }
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
_imageViewerCleanup = () => document.removeEventListener("keydown", onKey);
|
||||
|
||||
area.appendChild(container);
|
||||
safeCreateIcons();
|
||||
applyTransform();
|
||||
try { container.focus({ preventScroll: true }); } catch (_) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
let _mediaViewerCleanup = null;
|
||||
|
||||
/** Format a duration in seconds as ``m:ss`` (or ``h:mm:ss``). */
|
||||
export function formatMediaDuration(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "";
|
||||
const total = Math.floor(seconds);
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||
}
|
||||
|
||||
/** Build the byte-range streaming URL used by the HTML5 media players (#109). */
|
||||
export function buildMediaUrl(vault, path) {
|
||||
return `/api/media/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
function buildDownloadUrl(vault, path) {
|
||||
return `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback shown when a media codec is not playable in the browser (roadmap
|
||||
* #109-C3) or when the file is too large to stream inline.
|
||||
*/
|
||||
export function renderMediaFallback(area, data, message) {
|
||||
const fileName = (data.path || "").split("/").pop();
|
||||
const sizeStr = formatBytes(data.size_bytes);
|
||||
area.innerHTML = "";
|
||||
const box = el("div", { class: "unsupported-file" }, [
|
||||
icon("file", 48),
|
||||
el("div", { class: "filename" }, [document.createTextNode(fileName)]),
|
||||
el("div", {}, [document.createTextNode(message)]),
|
||||
sizeStr ? el("div", { style: "font-size:0.85rem;margin-top:4px" }, [document.createTextNode(t("viewer.metadata_size") + " : " + sizeStr)]) : null,
|
||||
]);
|
||||
const dlBtn = el("button", { class: "btn-action", type: "button" }, [
|
||||
icon("download", 14), document.createTextNode(" " + t("viewer.download")),
|
||||
]);
|
||||
dlBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank"));
|
||||
const openBtn = el("button", { class: "btn-action", type: "button" }, [
|
||||
icon("external-link", 14), document.createTextNode(" " + t("viewer.image_open_original")),
|
||||
]);
|
||||
openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank"));
|
||||
box.appendChild(el("div", { class: "media-fallback-actions" }, [dlBtn, openBtn]));
|
||||
area.appendChild(box);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
function mediaToolbar(data, extraButtons) {
|
||||
const toolbar = el("div", { class: "media-toolbar" });
|
||||
toolbar.appendChild(el("span", { class: "media-title", title: data.path }, [
|
||||
document.createTextNode(data.title || (data.path || "").split("/").pop()),
|
||||
]));
|
||||
toolbar.appendChild(el("div", { class: "media-toolbar-spacer" }));
|
||||
(extraButtons || []).forEach((b) => toolbar.appendChild(b));
|
||||
const openBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.image_open_original"), "aria-label": t("viewer.image_open_original") }, [icon("external-link", 14)]);
|
||||
openBtn.addEventListener("click", () => window.open(data.stream_url || buildMediaUrl(data.vault, data.path), "_blank"));
|
||||
const downloadBtn = el("button", { class: "btn-action", type: "button", title: t("viewer.download"), "aria-label": t("viewer.download") }, [icon("download", 14)]);
|
||||
downloadBtn.addEventListener("click", () => window.open(buildDownloadUrl(data.vault, data.path), "_blank"));
|
||||
toolbar.appendChild(openBtn);
|
||||
toolbar.appendChild(downloadBtn);
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an audio player (roadmap #109-B): full-width native ``<audio>``,
|
||||
* artwork placeholder, duration read from ``loadedmetadata`` and a toolbar
|
||||
* with the vault, size, open-original and download actions.
|
||||
*/
|
||||
export function renderAudioViewer(area, data) {
|
||||
const streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
|
||||
area.innerHTML = "";
|
||||
const container = el("div", { class: "audio-viewer-container" });
|
||||
|
||||
const badge = el("span", { class: "media-duration-badge" }, [document.createTextNode("--:--")]);
|
||||
const vaultLabel = el("span", { class: "media-meta" }, [document.createTextNode(`${data.vault} · ${formatBytes(data.size_bytes)}`)]);
|
||||
container.appendChild(mediaToolbar(data, [vaultLabel, badge]));
|
||||
|
||||
const stage = el("div", { class: "audio-stage" });
|
||||
const artwork = el("div", { class: "audio-artwork" }, [icon("audio-lines", 64)]);
|
||||
const audio = el("audio", { class: "audio-player", controls: "", preload: "metadata", src: streamUrl });
|
||||
stage.appendChild(artwork);
|
||||
stage.appendChild(audio);
|
||||
container.appendChild(stage);
|
||||
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
badge.textContent = formatMediaDuration(audio.duration) || "--:--";
|
||||
});
|
||||
audio.addEventListener("error", () => {
|
||||
renderMediaFallback(area, data, t("viewer.media_unsupported"));
|
||||
});
|
||||
|
||||
_mediaViewerCleanup = () => { try { audio.pause(); audio.removeAttribute("src"); } catch (_) { /* detached */ } };
|
||||
|
||||
area.appendChild(container);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a video player (roadmap #109-C): centered native ``<video>`` with a
|
||||
* black letterboxed stage and a toolbar. Playing is stopped when the content
|
||||
* area is re-rendered (tab switch / navigation).
|
||||
*/
|
||||
export function renderVideoViewer(area, data) {
|
||||
const streamUrl = data.stream_url || buildMediaUrl(data.vault, data.path);
|
||||
area.innerHTML = "";
|
||||
const container = el("div", { class: "video-viewer-container" });
|
||||
|
||||
const badge = el("span", { class: "media-duration-badge" }, [document.createTextNode("--:--")]);
|
||||
const vaultLabel = el("span", { class: "media-meta" }, [document.createTextNode(`${data.vault} · ${formatBytes(data.size_bytes)}`)]);
|
||||
container.appendChild(mediaToolbar(data, [vaultLabel, badge]));
|
||||
|
||||
const stage = el("div", { class: "video-stage" });
|
||||
const video = el("video", { class: "video-player", controls: "", playsinline: "", preload: "metadata", src: streamUrl });
|
||||
stage.appendChild(video);
|
||||
container.appendChild(stage);
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
const parts = [formatMediaDuration(video.duration) || "--:--"];
|
||||
if (video.videoWidth && video.videoHeight) parts.push(`${video.videoWidth} × ${video.videoHeight}`);
|
||||
badge.textContent = parts.join(" · ");
|
||||
});
|
||||
video.addEventListener("error", () => {
|
||||
renderMediaFallback(area, data, t("viewer.media_unsupported"));
|
||||
});
|
||||
|
||||
_mediaViewerCleanup = () => { try { video.pause(); video.removeAttribute("src"); } catch (_) { /* detached */ } };
|
||||
|
||||
area.appendChild(container);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
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; }
|
||||
// #109 — stop any playing audio/video when the file view is replaced
|
||||
// (tab switch, navigation): no persistent playback in v1.
|
||||
if (_mediaViewerCleanup) { _mediaViewerCleanup(); _mediaViewerCleanup = null; }
|
||||
const area = getContentArea();
|
||||
|
||||
// Handle PDF files — render in iframe with TOC sidebar
|
||||
@@ -596,25 +977,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 +1004,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
|
||||
|
||||
@@ -1823,11 +1823,24 @@
|
||||
"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_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",
|
||||
@@ -2116,7 +2129,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, navigation between images in the folder, thumbnails, 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. 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.",
|
||||
|
||||
@@ -1823,11 +1823,24 @@
|
||||
"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_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",
|
||||
@@ -2116,7 +2129,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 entre images du dossier, miniatures, 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. 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.",
|
||||
|
||||
@@ -9639,6 +9639,216 @@ body.desktop-mode .editor-container {
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* #108-D — dedicated image viewer: zoom, pan, filmstrip, metadata, lightbox */
|
||||
.image-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.image-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.image-toolbar .image-title {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 40%;
|
||||
}
|
||||
.image-toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.image-zoom-badge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 46px;
|
||||
text-align: center;
|
||||
}
|
||||
.image-stage {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
background: var(--surface2);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.image-stage.panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.image-main {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
will-change: transform;
|
||||
}
|
||||
.image-error {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.image-nav-strip {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
background: var(--surface1);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.image-nav-strip[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.image-thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.image-thumb:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.image-thumb.active {
|
||||
border-color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.image-meta-panel {
|
||||
padding: 10px 16px;
|
||||
background: var(--surface1);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.image-meta-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.image-meta-panel dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.image-meta-panel dt {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.image-meta-panel dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.image-viewer-container.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.image-viewer-container.lightbox .image-nav-strip,
|
||||
.image-viewer-container.lightbox .image-meta-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Audio / Video viewers (roadmap #109) ── */
|
||||
.audio-viewer-container,
|
||||
.video-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.media-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.media-toolbar .media-title {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 40%;
|
||||
}
|
||||
.media-toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.media-duration-badge {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 46px;
|
||||
text-align: center;
|
||||
}
|
||||
.media-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.audio-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 32px 24px;
|
||||
min-height: 50vh;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.audio-artwork {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface1);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
}
|
||||
.audio-player {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
.video-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
min-height: 60vh;
|
||||
}
|
||||
.video-player {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 180px);
|
||||
object-fit: contain;
|
||||
}
|
||||
.media-fallback-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Text / CSV Viewer ── */
|
||||
.text-viewer-container {
|
||||
display: flex;
|
||||
|
||||
@@ -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' }), {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.16.6",
|
||||
"version": "2.18.0",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
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,98 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* E2E tests for the ObsiGate audio/video viewers (roadmap #109).
|
||||
*
|
||||
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine, ~5 ko) +
|
||||
* `test_vault/sample-video.webm` (64x64 VP8, ~1 ko).
|
||||
*
|
||||
* 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.audio-player');
|
||||
await expect(audio).toBeVisible({ timeout: 10000 });
|
||||
await expect(audio).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
|
||||
await expect(audio).toHaveAttribute('controls', '');
|
||||
// La durée est lue côté client via loadedmetadata.
|
||||
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.video-player');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/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, path\)/);
|
||||
});
|
||||
|
||||
test("image viewer supports wheel zoom, drag pan and double-click reset", () => {
|
||||
assert.match(viewer, /stage\.addEventListener\("wheel"/);
|
||||
assert.match(viewer, /stage\.addEventListener\("pointerdown"/);
|
||||
assert.match(viewer, /stage\.addEventListener\("dblclick", resetView\)/);
|
||||
assert.match(viewer, /renderImageViewer/);
|
||||
});
|
||||
|
||||
test("image viewer exposes thumbnail navigation via /api/media thumb", () => {
|
||||
assert.match(viewer, /\/api\/media\/\$\{encodeURIComponent\(vault\)\}\/thumb\?path=/);
|
||||
assert.match(viewer, /let siblings = \[\]/);
|
||||
assert.match(viewer, /siblings = \(res\.items \|\| \[\]\)/);
|
||||
});
|
||||
|
||||
// ── 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*\{/);
|
||||
});
|
||||
|
||||
// ── 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,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Audio/video viewer tests (roadmap #109).
|
||||
*
|
||||
* Pure helpers (formatMediaDuration / buildMediaUrl) are evaluated in a VM
|
||||
* sandbox to avoid importing the full browser-only viewer chain. The rest are
|
||||
* static checks on the source, CSS 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 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 = viewer.indexOf("export function formatMediaDuration");
|
||||
const end = viewer.indexOf("function buildDownloadUrl");
|
||||
assert.ok(begin !== -1 && end > begin, "media viewer helper block not found");
|
||||
const sandbox = {};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;`,
|
||||
sandbox,
|
||||
);
|
||||
const { formatMediaDuration, buildMediaUrl } = 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");
|
||||
});
|
||||
|
||||
// ── Static checks: viewer wiring ───────────────────────────────────────────
|
||||
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("audio viewer uses a native <audio> element and stream URL", () => {
|
||||
const start = viewer.indexOf("export function renderAudioViewer");
|
||||
const stop = viewer.indexOf("export function renderVideoViewer");
|
||||
assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found");
|
||||
const block = viewer.slice(start, stop);
|
||||
assert.match(block, /el\("audio"/);
|
||||
assert.match(block, /controls: ""/);
|
||||
assert.match(block, /data\.stream_url \|\| buildMediaUrl/);
|
||||
});
|
||||
|
||||
test("video viewer uses a native <video> element with playsinline", () => {
|
||||
const start = viewer.indexOf("export function renderVideoViewer");
|
||||
const stop = viewer.indexOf("export function renderFile");
|
||||
assert.ok(start !== -1 && stop > start, "renderVideoViewer block not found");
|
||||
const block = viewer.slice(start, stop);
|
||||
assert.match(block, /el\("video"/);
|
||||
assert.match(block, /playsinline: ""/);
|
||||
});
|
||||
|
||||
test("unsupported codec triggers the binary fallback UI", () => {
|
||||
assert.match(viewer, /audio\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
|
||||
assert.match(viewer, /video\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
|
||||
assert.match(viewer, /export function renderMediaFallback/);
|
||||
});
|
||||
|
||||
test("playback is stopped when the content area is re-rendered", () => {
|
||||
assert.match(viewer, /_mediaViewerCleanup/);
|
||||
assert.match(viewer, /_mediaViewerCleanup\(\); _mediaViewerCleanup = null;/);
|
||||
});
|
||||
|
||||
// ── Static checks: CSS ─────────────────────────────────────────────────────
|
||||
test("style.css defines the audio/video viewer layout", () => {
|
||||
assert.match(css, /\.audio-viewer-container/);
|
||||
assert.match(css, /\.audio-stage\s*\{/);
|
||||
assert.match(css, /\.audio-artwork\s*\{/);
|
||||
assert.match(css, /\.video-stage\s*\{/);
|
||||
assert.match(css, /\.video-player\s*\{/);
|
||||
});
|
||||
|
||||
// ── 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("media viewer strings exist in FR and EN", () => {
|
||||
for (const key of ["viewer.media_unsupported", "viewer.media_too_large"]) {
|
||||
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, /AUDIO_EXTENSIONS|is_audio/);
|
||||
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,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