refactor: #85 T6a extrait lecture fichiers vers backend/routers (comportement inchange)

This commit is contained in:
2026-09-26 13:43:53 -04:00
parent dadc055429
commit b83d8dacdf
13 changed files with 680 additions and 592 deletions
+13 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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 > **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.27.6**. > [Unreleased](#unreleased). La dernière version livrée est **2.27.7**.
--- ---
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.27.7] — 2026-09-26
---
## [2.27.6] — 2026-09-26 ## [2.27.6] — 2026-09-26
--- ---
@@ -34,6 +38,14 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Modifié ### Modifié
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
nouveau `backend/routers/files_read.py` ; modèles dans `schemas.py`,
`_content_disposition`/`_media_max_inline_bytes` dans
`backend/routers/helpers.py` (partagés avec les tranches suivantes).
Correctif au passage : décorateur orphelin `/s/{token}` resté en T3 et
double-enregistrement de `/api/conflicts` supprimés.
- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.** - **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.**
Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`, Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`,
`/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`, `/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`,
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. **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.27.6-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.27.7-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog ## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.6). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.7).
--- ---
*Projet : ObsiGate | Version : 2.27.6 | Dernière mise à jour : Septembre 2026* *Projet : ObsiGate | Version : 2.27.7 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. **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.27.6-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.27.7-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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/) [![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/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog ## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.6). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.7).
--- ---
*Project: ObsiGate | Version: 2.27.6 | Last updated: September 2026* *Project: ObsiGate | Version: 2.27.7 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.27.6 2.27.7
+10 -577
View File
@@ -11,7 +11,6 @@ import urllib.request
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote
import frontmatter import frontmatter
import mistune import mistune
@@ -33,11 +32,9 @@ from backend.history import (
) )
from backend.image_processor import preprocess_images from backend.image_processor import preprocess_images
from backend.indexer import ( from backend.indexer import (
_extract_tags,
add_vault_to_index, add_vault_to_index,
build_index, build_index,
find_file_in_index, find_file_in_index,
get_backlinks,
get_conflicts, get_conflicts,
get_vault_data, get_vault_data,
handle_file_move, handle_file_move,
@@ -64,7 +61,6 @@ from backend.schemas import (
AppConfigResponse, AppConfigResponse,
AttachmentRescanResponse, AttachmentRescanResponse,
AttachmentStatsResponse, AttachmentStatsResponse,
BacklinksResponse,
BookmarksResponse, BookmarksResponse,
BookmarkToggleResponse, BookmarkToggleResponse,
ConflictResolveResponse, ConflictResolveResponse,
@@ -87,7 +83,6 @@ from backend.search import (
from backend.semantic_search import init_semantic_index from backend.semantic_search import init_semantic_index
from backend.services.backups import get_backup_dir as service_get_backup_dir from backend.services.backups import get_backup_dir as service_get_backup_dir
from backend.services.errors import ServiceError from backend.services.errors import ServiceError
from backend.services.files import read_raw_file
from backend.services.mutations import ( from backend.services.mutations import (
batch_upload_files as service_batch_upload_files, batch_upload_files as service_batch_upload_files,
) )
@@ -121,7 +116,6 @@ from backend.services.mutations import (
from backend.services.recent import humanize_mtime, list_recent from backend.services.recent import humanize_mtime, list_recent
from backend.services.sanitizer import sanitize_html from backend.services.sanitizer import sanitize_html
from backend.services.vaults import ( from backend.services.vaults import (
browse_directory,
list_accessible_vaults, list_accessible_vaults,
list_all_files, list_all_files,
) )
@@ -149,64 +143,6 @@ class VaultInfo(BaseModel):
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)") type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
class BrowseItem(BaseModel):
"""A single entry (file or directory) returned by the browse endpoint."""
name: str = Field(description="File or directory name")
path: str = Field(description="Relative path within vault")
type: str = Field(description="'file' or 'directory'")
children_count: int | None = Field(default=None, description="Number of children (directories only)")
size: int | None = Field(default=None, description="File size in bytes")
extension: str | None = Field(default=None, description="File extension")
class BrowseResponse(BaseModel):
"""Paginated directory listing for a vault."""
vault: str
path: str
items: list[BrowseItem]
class FileContentResponse(BaseModel):
"""Rendered file content with metadata."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
title: str = Field(description="File title (from frontmatter or filename)")
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
html: str = Field(description="Rendered HTML content")
raw_length: int = Field(description="Length of raw file content in characters")
extension: str = Field(description="File extension (e.g. .md, .txt)")
is_markdown: bool = Field(description="Whether the file is markdown")
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
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_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
xlsx_sheets: list[dict[str, Any]] | None = Field(
default=None, description="Rendered xlsx sheets [{name, html}]"
)
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")
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
image_mime: str | None = Field(default=None, description="MIME type for image files")
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
raw: str = Field(description="Raw file content as text")
class FileSaveResponse(BaseModel): class FileSaveResponse(BaseModel):
"""Confirmation after saving a file.""" """Confirmation after saving a file."""
status: str = Field(description="Always 'ok'") status: str = Field(description="Always 'ok'")
@@ -717,6 +653,7 @@ from backend.ai_routes import router as ai_router
from backend.bookslm_routes import router as bookslm_router from backend.bookslm_routes import router as bookslm_router
from backend.export import ExportError, export_epub, export_html, export_md_bundle from backend.export import ExportError, export_epub, export_html, export_md_bundle
from backend.routers.backups import router as backups_router from backend.routers.backups import router as backups_router
from backend.routers.files_read import router as files_read_router
from backend.routers.health import router as health_router from backend.routers.health import router as health_router
from backend.routers.search import router as search_router from backend.routers.search import router as search_router
from backend.routers.sharing import router as sharing_router from backend.routers.sharing import router as sharing_router
@@ -733,6 +670,7 @@ app.include_router(skills_router)
app.include_router(health_router) # ROADMAP #85 T1 — System / health app.include_router(health_router) # ROADMAP #85 T1 — System / health
app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks
app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing
@@ -784,44 +722,11 @@ async def api_docs_landing():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Path safety helper # Path safety helper : voir backend.routers.helpers (#85 T6a)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _content_disposition(disposition: str, filename: str) -> str: from backend.routers.helpers import content_disposition as _content_disposition
"""Build a header-safe Content-Disposition value. from backend.routers.helpers import media_max_inline_bytes as _media_max_inline_bytes
HTTP header values must be ASCII. Unicode filenames are sent per
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
the filename contains accented characters (e.g. 'Bière blonde…pdf').
"""
from urllib.parse import quote
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
ext = Path(filename).suffix
if ext and not Path(ascii_name).suffix:
ascii_name = ascii_name + ext
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: def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
@@ -1199,97 +1104,9 @@ async def api_delete_saved_search(search_id: str, current_user=Depends(require_a
return {"status": "deleted"} return {"status": "deleted"}
@app.get("/api/browse/{vault_name}", response_model=BrowseResponse) # ---------------------------------------------------------------------------
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)): # File browse & read endpoints : voir backend.routers.files_read (#85 T6a)
"""Browse directories and files in a vault at a given path level. # ---------------------------------------------------------------------------
Returns sorted entries (directories first, then files) with metadata.
Hidden files/directories (starting with ``"."`` ) are excluded.
Args:
vault_name: Name of the vault to browse.
path: Relative directory path within the vault (empty = root).
Returns:
``BrowseResponse`` with vault name, path, and item list.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return browse_directory(vault_name, path)
# Map file extensions to highlight.js language hints
EXT_TO_LANG = {
".py": "python", ".js": "javascript", ".ts": "typescript",
".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash",
".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch",
".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
".toml": "toml", ".xml": "xml", ".csv": "plaintext",
".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash",
".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby",
".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift",
".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext",
".lua": "lua", ".pl": "perl", ".pm": "perl", ".ex": "elixir", ".exs": "elixir",
".dart": "dart", ".tf": "haskell", ".gradle": "groovy", ".groovy": "groovy",
".graphql": "graphql", ".gql": "graphql", ".prisma": "sql", ".proto": "c",
".vb": "basic", ".asm": "x86asm", ".s": "armasm",
".vue": "xml", ".svelte": "xml", ".astro": "xml",
".properties": "ini", ".service": "ini", ".hosts": "ini",
".ksh": "bash", ".dockerfile": "dockerfile",
".makefile": "makefile", ".cmake": "cmake",
}
@app.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return raw file content as plain text.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return read_raw_file(vault_name, path)
@app.get("/api/file/{vault_name}/download", response_class=FileResponse)
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a file as an attachment.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
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}")
# Record history
record_open(current_user.get("username"), vault_name, path)
return FileResponse(
path=str(file_path),
filename=file_path.name,
media_type="application/octet-stream",
)
@app.get( @app.get(
"/api/file/{vault_name}/pdf", "/api/file/{vault_name}/pdf",
@@ -1906,389 +1723,7 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
# File-level backup endpoints : voir backend.routers.backups (#85 T4) # File-level backup endpoints : voir backend.routers.backups (#85 T4)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse) # File backlinks + view endpoints : voir backend.routers.files_read (#85 T6a)
async def api_file_backlinks(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""Get backlinks (files linking to this file via wikilinks).
Returns a list of files that contain `[[wikilinks]]` pointing
to the requested file, across all accessible vaults.
Args:
vault_name: Name of the vault containing the target file.
path: Relative path of the target file within the vault.
Returns:
``{"vault": str, "path": str, "backlinks": [...]}``
"""
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")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
backlinks = get_backlinks(vault_name, path)
# Filter by user-accessible vaults
if "*" not in user_vaults:
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
return {
"vault": vault_name,
"path": path,
"backlinks": backlinks,
"total": len(backlinks),
}
@app.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
Markdown files are parsed for frontmatter, rendered with wikilink
support, and returned with extracted tags. Other supported file
types are syntax-highlighted as code blocks.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
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}")
# Record history
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
ext = file_path.suffix.lower()
# === PDF: special handling before read_text (binary file) ===
if ext == ".pdf":
try:
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc
pdf_text = extract_pdf_text(file_path, max_chars=100000)
pdf_meta = extract_pdf_metadata(file_path)
pdf_toc = extract_pdf_toc(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": pdf_meta.get("title") or file_path.name,
"tags": [],
"frontmatter": {},
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_pdf": True,
"unsupported": False,
"pdf_metadata": pdf_meta,
"pdf_toc": pdf_toc,
"size_bytes": size,
}
except Exception as e:
logger.error(f"PDF read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
if ext == ".xlsx":
try:
from backend.xlsx_reader import render_sheets
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": sheets[0]["html"] if sheets else "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
"unsupported": False,
"size_bytes": size,
}
except Exception as e:
logger.error(f"XLSX read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
# === Images: return as viewable image ===
if is_image(ext):
size = file_path.stat().st_size
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="{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 {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_image": True,
"image_mime": mime,
"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:
logger.error(f"Permission denied reading file {path}: {e}")
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
except UnicodeDecodeError:
# Binary / unsupported file — return structured info with download option
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"size_bytes": size,
}
except Exception as e:
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
# === CSV: render as HTML table ===
if ext == ".csv":
import csv
import io as csv_io
reader = csv.reader(csv_io.StringIO(raw))
rows = list(reader)
if not rows:
html = "<p><em>Fichier CSV vide</em></p>"
else:
headers = rows[0]
data_rows = rows[1:]
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
for h in headers:
html += f"<th>{h}</th>"
html += "</tr></thead><tbody>"
for row in data_rows:
html += "<tr>"
for cell in row:
html += f"<td>{cell}</td>"
html += "</tr>"
html += "</tbody></table></div>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_csv": True,
}
# === JSON: syntax-highlighted display ===
if ext == ".json":
import json as json_mod
try:
parsed = json_mod.loads(raw)
formatted = json_mod.dumps(parsed, indent=2, ensure_ascii=False)
except json_mod.JSONDecodeError:
formatted = raw
html = f"<pre class='json-viewer'><code>{html_mod.escape(formatted)}</code></pre>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_json": True,
}
# === Excalidraw .excalidraw.md (Obsidian plugin format) ===
if path.lower().endswith(".excalidraw.md"):
import re as re_mod
raw_lower = file_path.read_text(encoding="utf-8", errors="replace")
# Check for excalidraw-plugin in frontmatter or body
if "excalidraw-plugin:" in raw_lower:
# Extract compressed JSON block
match = re_mod.search(r'```compressed-json\n(.*?)\n```', raw_lower, re_mod.DOTALL)
if match:
compressed = match.group(1).strip()
return {
"vault": vault_name, "path": path,
"title": file_path.name.replace(".excalidraw.md", ""),
"tags": [], "frontmatter": {},
"html": "", "raw_length": len(raw_lower),
"extension": ".excalidraw.md",
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data_compressed": compressed,
}
# Fallback: treat as regular markdown
raw = raw_lower
if ext == ".excalidraw":
import json as json_mod
try:
parsed = json_mod.loads(raw)
except json_mod.JSONDecodeError:
parsed = None
if parsed and parsed.get("type") == "excalidraw":
return {
"vault": vault_name,
"path": path,
"title": parsed.get("appState", {}).get("name") or file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data": {
"elements": parsed.get("elements", []),
"appState": parsed.get("appState", {}),
"files": parsed.get("files", {}),
},
}
else:
# Not a valid Excalidraw file — fall through to text viewer
pass
# === Plain text / other readable files ===
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
".xml", ".rst", ".tex", ".sql", ".conf", ".env"}
if ext in TEXT_EXTENSIONS or ext == ".md":
pass # handled below or by markdown section
if ext == ".md":
post = parse_markdown_file(raw)
# Extract metadata using shared indexer logic
tags = _extract_tags(post)
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
html_content = _render_markdown(post.content, vault_name, file_path)
return {
"vault": vault_name,
"path": path,
"title": str(title),
"tags": tags,
"frontmatter": dict(post.metadata) if post.metadata else {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": True,
}
else:
# Non-markdown: wrap in syntax-highlighted code block
lang = EXT_TO_LANG.get(ext, "")
if not lang:
# Fichiers sans extension usuels (Dockerfile, Makefile, etc.)
NAME_TO_LANG = {
"dockerfile": "dockerfile", "makefile": "makefile",
"cmakelists.txt": "cmake", "jenkinsfile": "groovy",
"vagrantfile": "ruby", "rakefile": "ruby", "gemfile": "ruby",
"procfile": "plaintext", "bashrc": "bash", "bash_profile": "bash",
"zshrc": "bash", "profile": "bash", "gitignore": "plaintext",
}
lang = NAME_TO_LANG.get(file_path.name.lower(), "plaintext")
escaped = html_mod.escape(raw)
html_content = f'<pre><code class="language-{lang}">{escaped}</code></pre>'
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
}
def _stream_file_with_range(file_path: Path, request: Request, media_type: str): def _stream_file_with_range(file_path: Path, request: Request, media_type: str):
@@ -3379,9 +2814,7 @@ async def api_dashboard(current_user=Depends(require_auth)):
# Share (public document) endpoints : voir backend.routers.sharing (#85 T3) # Share (public document) endpoints : voir backend.routers.sharing (#85 T3)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.get("/s/{token}", response_class=HTMLResponse)
# ---------------------------------------------------------------------------
# Syncthing conflict endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Syncthing conflict endpoints # Syncthing conflict endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+525
View File
@@ -0,0 +1,525 @@
"""File browsing & reading endpoints (ROADMAP #85, tranche 6a).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/browse/*``, ``/api/file/*`` en
lecture), mêmes modèles de réponse (déménagés dans
:mod:`backend.schemas`), mêmes dépendances d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
- ``_render_markdown`` reste dans ``main`` (import différé, extraction
prévue dans une tranche ultérieure).
- ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG``
ont déménagé : helpers partagés dans :mod:`backend.routers.helpers`
(``EXT_TO_LANG`` n'était utilisé que par la vue fichier).
"""
import html as html_mod
import logging
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import record_open
from backend.indexer import (
_extract_tags,
get_backlinks,
get_vault_data,
parse_markdown_file,
)
from backend.media_types import is_audio, is_image, is_video, media_mime_type
from backend.routers.helpers import media_max_inline_bytes
from backend.schemas import (
BacklinksResponse,
BrowseResponse,
FileContentResponse,
FileRawResponse,
)
from backend.services.files import read_raw_file
from backend.services.paths import resolve_safe_path
from backend.services.vaults import browse_directory
logger = logging.getLogger("obsigate")
# Map file extensions to highlight.js language hints
EXT_TO_LANG = {
".py": "python", ".js": "javascript", ".ts": "typescript",
".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash",
".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch",
".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
".toml": "toml", ".xml": "xml", ".csv": "plaintext",
".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash",
".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby",
".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift",
".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext",
".lua": "lua", ".pl": "perl", ".pm": "perl", ".ex": "elixir", ".exs": "elixir",
".dart": "dart", ".tf": "haskell", ".gradle": "groovy", ".groovy": "groovy",
".graphql": "graphql", ".gql": "graphql", ".prisma": "sql", ".proto": "c",
".vb": "basic", ".asm": "x86asm", ".s": "armasm",
".vue": "xml", ".svelte": "xml", ".astro": "xml",
".properties": "ini", ".service": "ini", ".hosts": "ini",
".ksh": "bash", ".dockerfile": "dockerfile",
".makefile": "makefile", ".cmake": "cmake",
}
router = APIRouter(tags=["files"])
@router.get("/api/browse/{vault_name}", response_model=BrowseResponse)
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
"""Browse directories and files in a vault at a given path level.
Returns sorted entries (directories first, then files) with metadata.
Hidden files/directories (starting with ``"."`` ) are excluded.
Args:
vault_name: Name of the vault to browse.
path: Relative directory path within the vault (empty = root).
Returns:
``BrowseResponse`` with vault name, path, and item list.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return browse_directory(vault_name, path)
@router.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return raw file content as plain text.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return read_raw_file(vault_name, path)
@router.get("/api/file/{vault_name}/download", response_class=FileResponse)
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a file as an attachment.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
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}")
# Record history
record_open(current_user.get("username"), vault_name, path)
return FileResponse(
path=str(file_path),
filename=file_path.name,
media_type="application/octet-stream",
)
@router.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
async def api_file_backlinks(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""Get backlinks (files linking to this file via wikilinks).
Returns a list of files that contain `[[wikilinks]]` pointing
to the requested file, across all accessible vaults.
Args:
vault_name: Name of the vault containing the target file.
path: Relative path of the target file within the vault.
Returns:
``{"vault": str, "path": str, "backlinks": [...]}``
"""
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")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
backlinks = get_backlinks(vault_name, path)
# Filter by user-accessible vaults
if "*" not in user_vaults:
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
return {
"vault": vault_name,
"path": path,
"backlinks": backlinks,
"total": len(backlinks),
}
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
Markdown files are parsed for frontmatter, rendered with wikilink
support, and returned with extracted tags. Other supported file
types are syntax-highlighted as code blocks.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
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}")
# Record history
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
ext = file_path.suffix.lower()
# === PDF: special handling before read_text (binary file) ===
if ext == ".pdf":
try:
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc
pdf_text = extract_pdf_text(file_path, max_chars=100000)
pdf_meta = extract_pdf_metadata(file_path)
pdf_toc = extract_pdf_toc(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": pdf_meta.get("title") or file_path.name,
"tags": [],
"frontmatter": {},
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_pdf": True,
"unsupported": False,
"pdf_metadata": pdf_meta,
"pdf_toc": pdf_toc,
"size_bytes": size,
}
except Exception as e:
logger.error(f"PDF read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
if ext == ".xlsx":
try:
from backend.xlsx_reader import render_sheets
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": sheets[0]["html"] if sheets else "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
"unsupported": False,
"size_bytes": size,
}
except Exception as e:
logger.error(f"XLSX read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
# === Images: return as viewable image ===
if is_image(ext):
size = file_path.stat().st_size
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="{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 {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_image": True,
"image_mime": mime,
"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:
logger.error(f"Permission denied reading file {path}: {e}")
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
except UnicodeDecodeError:
# Binary / unsupported file — return structured info with download option
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"size_bytes": size,
}
except Exception as e:
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
# === CSV: render as HTML table ===
if ext == ".csv":
import csv
import io as csv_io
reader = csv.reader(csv_io.StringIO(raw))
rows = list(reader)
if not rows:
html = "<p><em>Fichier CSV vide</em></p>"
else:
headers = rows[0]
data_rows = rows[1:]
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
for h in headers:
html += f"<th>{h}</th>"
html += "</tr></thead><tbody>"
for row in data_rows:
html += "<tr>"
for cell in row:
html += f"<td>{cell}</td>"
html += "</tr>"
html += "</tbody></table></div>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_csv": True,
}
# === JSON: syntax-highlighted display ===
if ext == ".json":
import json as json_mod
try:
parsed = json_mod.loads(raw)
formatted = json_mod.dumps(parsed, indent=2, ensure_ascii=False)
except json_mod.JSONDecodeError:
formatted = raw
html = f"<pre class='json-viewer'><code>{html_mod.escape(formatted)}</code></pre>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_json": True,
}
# === Excalidraw .excalidraw.md (Obsidian plugin format) ===
if path.lower().endswith(".excalidraw.md"):
import re as re_mod
raw_lower = file_path.read_text(encoding="utf-8", errors="replace")
# Check for excalidraw-plugin in frontmatter or body
if "excalidraw-plugin:" in raw_lower:
# Extract compressed JSON block
match = re_mod.search(r'```compressed-json\n(.*?)\n```', raw_lower, re_mod.DOTALL)
if match:
compressed = match.group(1).strip()
return {
"vault": vault_name, "path": path,
"title": file_path.name.replace(".excalidraw.md", ""),
"tags": [], "frontmatter": {},
"html": "", "raw_length": len(raw_lower),
"extension": ".excalidraw.md",
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data_compressed": compressed,
}
# Fallback: treat as regular markdown
raw = raw_lower
if ext == ".excalidraw":
import json as json_mod
try:
parsed = json_mod.loads(raw)
except json_mod.JSONDecodeError:
parsed = None
if parsed and parsed.get("type") == "excalidraw":
return {
"vault": vault_name,
"path": path,
"title": parsed.get("appState", {}).get("name") or file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data": {
"elements": parsed.get("elements", []),
"appState": parsed.get("appState", {}),
"files": parsed.get("files", {}),
},
}
else:
# Not a valid Excalidraw file — fall through to text viewer
pass
# === Plain text / other readable files ===
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
".xml", ".rst", ".tex", ".sql", ".conf", ".env"}
if ext in TEXT_EXTENSIONS or ext == ".md":
pass # handled below or by markdown section
if ext == ".md":
post = parse_markdown_file(raw)
# Extract metadata using shared indexer logic
tags = _extract_tags(post)
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
html_content = _render_markdown(post.content, vault_name, file_path)
return {
"vault": vault_name,
"path": path,
"title": str(title),
"tags": tags,
"frontmatter": dict(post.metadata) if post.metadata else {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": True,
}
else:
# Non-markdown: wrap in syntax-highlighted code block
lang = EXT_TO_LANG.get(ext, "")
if not lang:
# Fichiers sans extension usuels (Dockerfile, Makefile, etc.)
NAME_TO_LANG = {
"dockerfile": "dockerfile", "makefile": "makefile",
"cmakelists.txt": "cmake", "jenkinsfile": "groovy",
"vagrantfile": "ruby", "rakefile": "ruby", "gemfile": "ruby",
"procfile": "plaintext", "bashrc": "bash", "bash_profile": "bash",
"zshrc": "bash", "profile": "bash", "gitignore": "plaintext",
}
lang = NAME_TO_LANG.get(file_path.name.lower(), "plaintext")
escaped = html_mod.escape(raw)
html_content = f'<pre><code class="language-{lang}">{escaped}</code></pre>'
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
}
+51
View File
@@ -0,0 +1,51 @@
"""Shared helpers for the file routers (ROADMAP #85, tranche 6a).
Petites fonctions pures extraites de :mod:`backend.main` sans changement
de comportement. Regroupées ici car utilisées par plusieurs routers
(``files_read`` aujourd'hui, ``files_media`` / mutations ensuite) :
- :func:`content_disposition` — aussi utilisée par ``_stream_file_with_range``
(resté dans ``main`` jusqu'à la tranche media).
- :func:`media_max_inline_bytes` — aussi utilisée par ``/api/media``.
"""
from __future__ import annotations
import os
from pathlib import Path
def content_disposition(disposition: str, filename: str) -> str:
"""Build a header-safe Content-Disposition value.
HTTP header values must be ASCII. Unicode filenames are sent per
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
the filename contains accented characters (e.g. 'Bière blonde…pdf').
"""
from urllib.parse import quote
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
ext = Path(filename).suffix
if ext and not Path(ascii_name).suffix:
ascii_name = ascii_name + ext
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)
+67
View File
@@ -214,6 +214,73 @@ class RestoreResponse(BaseModel):
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any") current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
# ---------------------------------------------------------------------------
# Files — browse / read (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class BrowseItem(BaseModel):
"""A single entry (file or directory) returned by the browse endpoint."""
name: str = Field(description="File or directory name")
path: str = Field(description="Relative path within vault")
type: str = Field(description="'file' or 'directory'")
children_count: int | None = Field(default=None, description="Number of children (directories only)")
size: int | None = Field(default=None, description="File size in bytes")
extension: str | None = Field(default=None, description="File extension")
class BrowseResponse(BaseModel):
"""Paginated directory listing for a vault."""
vault: str
path: str
items: list[BrowseItem]
class FileContentResponse(BaseModel):
"""Rendered file content with metadata."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
title: str = Field(description="File title (from frontmatter or filename)")
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
html: str = Field(description="Rendered HTML content")
raw_length: int = Field(description="Length of raw file content in characters")
extension: str = Field(description="File extension (e.g. .md, .txt)")
is_markdown: bool = Field(description="Whether the file is markdown")
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
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_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
xlsx_sheets: list[dict[str, Any]] | None = Field(
default=None, description="Rendered xlsx sheets [{name, html}]"
)
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")
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
image_mime: str | None = Field(default=None, description="MIME type for image files")
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
raw: str = Field(description="Raw file content as text")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé) # Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.27.6" version = "2.27.7"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.27.6" version = "2.27.7"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.27.6", "version": "2.27.7",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+3 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.27.6 | **Dernière mise à jour :** 2026-09-26 > **Version :** 2.27.7 | **Dernière mise à jour :** 2026-09-26
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -67,10 +67,10 @@
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend - **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).** - **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). - **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`).
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe. - **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
- **Sous-tâches :** - **Sous-tâches :**
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer) - [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés - [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process) - [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test) - [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.27.6", "version": "2.27.7",
"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.", "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", "main": "patch.js",
"directories": { "directories": {