diff --git a/CHANGELOG.md b/CHANGELOG.md
index 851b93d..ba052ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.17.0**.
---
@@ -14,6 +14,38 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [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 `` 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é
diff --git a/README.fr.md b/README.fr.md
index 577bf5d..19e8b5c 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -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/)
@@ -406,6 +406,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 +649,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 +964,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.17.0).
---
-*Projet : ObsiGate | Version : 2.16.6 | Dernière mise à jour : Septembre 2026*
+*Projet : ObsiGate | Version : 2.17.0 | Dernière mise à jour : Septembre 2026*
diff --git a/README.md b/README.md
index af62fae..dff25a9 100644
--- a/README.md
+++ b/README.md
@@ -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/)
@@ -516,6 +516,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 +767,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 +1140,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.17.0).
---
-*Project: ObsiGate | Version: 2.16.6 | Last updated: September 2026*
+*Project: ObsiGate | Version: 2.17.0 | Last updated: September 2026*
diff --git a/VERSION b/VERSION
index 3c5d010..d76bd2b 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.16.6
+2.17.0
diff --git a/backend/attachment_indexer.py b/backend/attachment_indexer.py
index 6c32c72..2503352 100644
--- a/backend/attachment_indexer.py
+++ b/backend/attachment_indexer.py
@@ -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]]] = {}
diff --git a/backend/indexer.py b/backend/indexer.py
index 21111cf..79a4c0e 100644
--- a/backend/indexer.py
+++ b/backend/indexer.py
@@ -11,6 +11,8 @@ from typing import Any
import frontmatter
+from backend.media_types import IMAGE_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)
# 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()
diff --git a/backend/main.py b/backend/main.py
index 93ce8cc..0053f02 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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_image, media_mime_type
from backend.openapi_docs import (
API_DESCRIPTION,
TAGS_METADATA,
@@ -699,20 +701,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).
@@ -2409,19 +2414,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 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'
'
- f''
+ f''
f'
'
)
return {
@@ -3183,16 +3187,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 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 +3207,55 @@ 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}/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 +4148,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 +4157,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,
+ }
# ---------------------------------------------------------------------------
diff --git a/backend/media_thumbs.py b/backend/media_thumbs.py
new file mode 100644
index 0000000..1e6808e
--- /dev/null
+++ b/backend/media_thumbs.py
@@ -0,0 +1,74 @@
+"""Image thumbnail generation and disk cache (roadmap #108-C).
+
+Thumbnails are generated on demand with Pillow and cached under
+``/.obsigate-cache/thumbs/.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
diff --git a/backend/media_types.py b/backend/media_types.py
new file mode 100644
index 0000000..ecf0ff5
--- /dev/null
+++ b/backend/media_types.py
@@ -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"
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7cbdc04..c2dd631 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -24,3 +24,4 @@ sse-starlette==2.1.3
openpyxl>=3.1
python-docx>=1.1
reportlab>=4.0
+pillow>=10.0
diff --git a/backend/schemas.py b/backend/schemas.py
index 1566220..07341ed 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index bd4e201..7c113ff 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
-version = "2.16.6"
+version = "2.17.0"
dependencies = [
"chrono",
"env_logger",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index 1bb7f8f..4438832 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
-version = "2.16.6"
+version = "2.17.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json
index 5caef42..897ba98 100644
--- a/desktop/tauri.conf.json
+++ b/desktop/tauri.conf.json
@@ -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.17.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 111dfe7..f973e12 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
-> **Version :** 2.16.6 | **Dernière mise à jour :** 2026-09-22
+> **Version :** 2.17.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)**
@@ -82,60 +82,10 @@
## ⚪ Backlog — Média (images, audio, vidéo)
-### 108. Support complet des images — arborescence, visionneuse, indexation
-
-- **Effort :** 3-4 jours | **Impact :** 🟡 | **Zone :** backend (indexer, main) + frontend (viewer, utils)
-- **Statut :** ⚪ Prévu
-- **Description :** parité fonctionnelle des images avec les autres fichiers du vault : apparition dans l'arborescence, ouverture directe dans une visionneuse dédiée (zoom molette, pan, navigation dossier), indexation (nom + métadonnées), miniatures. Actuellement seul l'affichage *inline* dans un document markdown (`![[image.png]]`) fonctionne ; l'image isolée est invisible dans l'arborescence et son affichage standalone est cassé.
-- **Architecture actuelle (vérifiée sur `main` @ v2.16.6) :**
- - `SUPPORTED_EXTENSIONS` (`backend/indexer.py:61`) : aucune extension image → images filtrées de l'arborescence et de l'index.
- - `api_file_view()` (`backend/main.py:~2411`) : branche `IMAGE_EXTENSIONS` (`.png .jpg .jpeg .gif .svg .webp .bmp .ico`) qui retourne `is_image: true`, MAIS le `` généré pointe vers `/api/file/{vault}/raw?path=…` — un endpoint qui retourne du **JSON** (`FileRawResponse`, `main.py:1569`), pas des octets d'image. Affichage standalone cassé. Le frontend (`frontend/js/viewer.js:600`, branche `data.is_image`) reproduit la même URL cassée pour le bouton « Plein écran ».
- - `/api/image/{vault}` (`backend/main.py:3160`) : endpoint existant et fonctionnel qui sert les octets avec le bon MIME type. C'est LUI que doivent utiliser le `` backend et le frontend. L'auth navigateur fonctionne (middleware JWT avec fallback cookie `access_token`).
- - `image_processor.py` + `attachment_indexer.py` : réécriture des 4 syntaxes Obsidian (`![[img]]`, ``…) vers `/api/image` avec résolution multi-stratégie → **l'inline markdown fonctionne déjà**, aucun changement requis.
- - CSP : `img-src 'self' data: blob:` — compatible, rien à assouplir.
-- **Sous-tâches :**
-
-##### A. Backend — arborescence & indexation (1 j)
- - [ ] **A1.** Créer `backend/media_types.py` : constantes partagées `IMAGE_EXTENSIONS`, `AUDIO_EXTENSIONS`, `VIDEO_EXTENSIONS` (réutilisées par #109) ; remplacer le set local de `api_file_view()`.
- - [ ] **A2.** Intégrer les extensions images à `SUPPORTED_EXTENSIONS` (`indexer.py:61`) **avec branche binaire** : `index_document()` ne doit JAMAIS `read_text()`/`read_bytes()` une image — indexer nom/taille/mtime uniquement, `content: ""` pour que le TF-IDF reste propre.
- - [ ] **A3.** Vérifier le reindex watchdog sur création/suppression d'image (même filtre d'extensions, couvert par A2).
- - [ ] **A4.** Recherche : le nom de fichier est déjà indexé (`chatScreenshot.png` trouvable par « screenshot ») ; ajouter le filtre `ext:png`/`ext:jpg` (mécanisme des filtres `ext:` existants) ; comptabiliser les images à part dans les compteurs du dashboard (#11).
-
-##### B. Backend — correction affichage standalone (0,5 j)
- - [ ] **B1.** Fix `` cassé : `api_file_view()` → `src="/api/image/{vault}?path=…"` (URL encodée) ; idem `viewer.js:600` (bouton Plein écran).
- - [ ] **B2.** Métadonnées : dimensions via `` frontend (`naturalWidth/naturalHeight` — gratuit, recommandé ; Pillow seulement si C1 est retenu).
- - [ ] **B3.** Sécurité SVG : sur `/api/image`, ajouter `Content-Security-Policy: sandbox` (ou `Content-Disposition: attachment`) quand `ext == .svg` — un SVG ouvert directement dans un onglet exécute son JS (XSS same-origin) ; dans une balise `` il est inoffensif.
-
-##### C. Backend — miniatures (1 j, phase 2 tolérable)
- - [ ] **C1.** Dépendance `pillow>=10.0` (wheels précompilés, rien de système dans `python:3.11-slim`).
- - [ ] **C2.** `GET /api/media/{vault}/thumb` : miniature 256 px WebP, cache disque `/data/.obsigate-cache/thumbs/{sha1(path+mtime)}.webp` (invalidation naturelle par mtime). SVG : servir l'original. GIF animé : première frame.
- - [ ] **C3.** Génération dans un thread pool (`run_in_executor`), fallback original si timeout 2 s.
-
-##### D. Frontend — visionneuse (1 j)
- - [ ] **D1.** `EXT_ICONS` (`frontend/js/utils.js`) : extensions images → icône Lucide `image`.
- - [ ] **D2.** Visionneuse digne de ce nom dans `viewer.js` : image centrée `object-fit:contain`, **zoom molette (0,1×–8×), pan au drag, double-clic reset**, boutons +/−/reset, badge de zoom.
- - [ ] **D3.** Navigation ←/→ entre images du même dossier (données `list_directory` déjà disponibles) ; bouton « Ouvrir original » (nouvel onglet, `/api/image`).
- - [ ] **D4.** Barre d'outils : Télécharger (déjà fonctionnel), lightbox plein viewport (fond `rgba(0,0,0,.9)`), panneau métadonnées repliable (dimensions, taille, type, date).
- - [ ] **D5.** (Optionnel 🟢) Vue galerie vignettes pour les dossiers d'images via `/api/media/thumb`, `loading="lazy"`.
- - [ ] **D6.** Split View (#75) : rendu dans `getContentArea()` du panneau actif ; vérifier le cache d'onglets.
-
-##### E. Tests (0,5 j)
- - [ ] **E1.** `test_image_api.py` : `/api/image` octets + MIME ; SVG avec header sandbox ; `api_file_view` → `is_image: true` avec URL `/api/image` dans le html.
- - [ ] **E2.** `test_image_indexing.py` : image visible dans `list_directory`, indexée avec contenu vide, watcher OK, rien de binaire dans le TF-IDF.
- - [ ] **E3.** Playwright (#58) : clic sur une image de l'arborescence → visionneuse rendue ; zoom molette applique la transform.
-
-- **Points d'attention / risques**
- - **Ne jamais lire les octets dans l'indexeur** — dérive n°1 d'un A2 bâclé (UnicodeDecodeError de masse dans les logs, TF-IDF pollué).
- - **`_attachments/`** : les vaults Obsidian réels contiennent des milliers d'images → vérifier la fluidité de l'arborescence et le coût du scan d'index.
- - **HEIC (iPhone)** : non décodable par les navigateurs → hors scope v1, documenter la limitation ; `pillow-heif` en v2 si demande.
- - **Parité Obsidian Desktop** : la visionneuse D2 doit atteindre le même confort zoom/pan, sinon l'utilisateur repart sur le desktop.
-
----
-
### 109. Support audio & vidéo — lecteurs intégrés HTML5
- **Effort :** 2-3 jours | **Impact :** 🟡 | **Zone :** backend (media streaming) + frontend (viewer)
-- **Statut :** ⚪ Prévu — dépend du socle `media_types.py` de #108-A1
+- **Statut :** ⚪ Prévu — s'appuie sur le socle `media_types.py` livré par #108
- **Description :** prise en charge des fichiers audio (`.mp3 .m4a .aac .wav .ogg .oga .opus .flac`) et vidéo (`.mp4 .webm .mov .m4v`) avec la même parité que les autres fichiers : apparition dans l'arborescence, indexation du nom, lecture directe dans le viewer via les balises HTML5 `