refactor: #85 T6a extrait lecture fichiers vers backend/routers (comportement inchange)
This commit is contained in:
+10
-577
@@ -11,7 +11,6 @@ import urllib.request
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
@@ -33,11 +32,9 @@ from backend.history import (
|
||||
)
|
||||
from backend.image_processor import preprocess_images
|
||||
from backend.indexer import (
|
||||
_extract_tags,
|
||||
add_vault_to_index,
|
||||
build_index,
|
||||
find_file_in_index,
|
||||
get_backlinks,
|
||||
get_conflicts,
|
||||
get_vault_data,
|
||||
handle_file_move,
|
||||
@@ -64,7 +61,6 @@ from backend.schemas import (
|
||||
AppConfigResponse,
|
||||
AttachmentRescanResponse,
|
||||
AttachmentStatsResponse,
|
||||
BacklinksResponse,
|
||||
BookmarksResponse,
|
||||
BookmarkToggleResponse,
|
||||
ConflictResolveResponse,
|
||||
@@ -87,7 +83,6 @@ from backend.search import (
|
||||
from backend.semantic_search import init_semantic_index
|
||||
from backend.services.backups import get_backup_dir as service_get_backup_dir
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import (
|
||||
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.sanitizer import sanitize_html
|
||||
from backend.services.vaults import (
|
||||
browse_directory,
|
||||
list_accessible_vaults,
|
||||
list_all_files,
|
||||
)
|
||||
@@ -149,64 +143,6 @@ class VaultInfo(BaseModel):
|
||||
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):
|
||||
"""Confirmation after saving a file."""
|
||||
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.export import ExportError, export_epub, export_html, export_md_bundle
|
||||
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.search import router as search_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(search_router) # ROADMAP #85 T5 — Search
|
||||
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(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:
|
||||
"""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)
|
||||
from backend.routers.helpers import content_disposition as _content_disposition
|
||||
from backend.routers.helpers import media_max_inline_bytes as _media_max_inline_bytes
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
@app.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)
|
||||
|
||||
|
||||
# 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",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File browse & read endpoints : voir backend.routers.files_read (#85 T6a)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get(
|
||||
"/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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.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),
|
||||
}
|
||||
|
||||
|
||||
@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,
|
||||
}
|
||||
# File backlinks + view endpoints : voir backend.routers.files_read (#85 T6a)
|
||||
|
||||
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/s/{token}", response_class=HTMLResponse)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Syncthing conflict endpoints
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Syncthing conflict endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user