From eccbf7474e6e5e43755c94d01a55405beed94d27 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 23 Sep 2026 07:47:01 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20support=20complet=20des=20images=20?= =?UTF-8?q?=E2=80=94=20arborescence,=20visionneuse,=20indexation=20#108?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 34 +++- README.fr.md | 19 +- README.md | 18 +- VERSION | 2 +- backend/attachment_indexer.py | 5 +- backend/indexer.py | 15 +- backend/main.py | 136 ++++++++++---- backend/media_thumbs.py | 74 ++++++++ backend/media_types.py | 76 ++++++++ backend/requirements.txt | 1 + backend/schemas.py | 2 + desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 61 +----- docs/features/image-support.md | 96 ++++++++++ frontend/js/utils.js | 22 +-- frontend/js/viewer.js | 265 +++++++++++++++++++++++++-- frontend/locales/en.json | 13 +- frontend/locales/fr.json | 13 +- frontend/style.css | 127 +++++++++++++ package.json | 2 +- test_vault/sample-image.png | Bin 0 -> 488 bytes test_vault/sample-vector.svg | 4 + tests/conftest.py | 12 ++ tests/e2e/image-viewer.spec.js | 98 ++++++++++ tests/frontend/image-viewer.test.mjs | 119 ++++++++++++ tests/test_image_api.py | 109 +++++++++++ tests/test_image_indexing.py | 105 +++++++++++ tests/test_indexer.py | 8 +- tests/test_indexer_advanced.py | 13 +- 31 files changed, 1317 insertions(+), 138 deletions(-) create mode 100644 backend/media_thumbs.py create mode 100644 backend/media_types.py create mode 100644 docs/features/image-support.md create mode 100644 test_vault/sample-image.png create mode 100644 test_vault/sample-vector.svg create mode 100644 tests/e2e/image-viewer.spec.js create mode 100644 tests/frontend/image-viewer.test.mjs create mode 100644 tests/test_image_api.py create mode 100644 tests/test_image_indexing.py 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. -[![Version](https://img.shields.io/badge/Version-2.16.6-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.17.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -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. -[![Version](https://img.shields.io/badge/Version-2.16.6-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.17.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -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]]`, `![alt](path)`…) 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 `