"""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`` vient de :mod:`backend.render` (#85 T9, sans cycle d'import). - ``_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.render import _render_markdown 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. """ 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"
PDF — {pdf_meta.get('pages', '?')} pages
{pdf_text[:5000]}Fichier CSV vide
" else: headers = rows[0] data_rows = rows[1:] html = '| {h} | " html += "
|---|
| {cell} | " html += "
{html_mod.escape(formatted)}"
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'{escaped}'
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
}