refactor: #85 T9 extrait realtime et render vers modules dedies (comportement inchange)
This commit is contained in:
@@ -9,7 +9,8 @@ d'authentification.
|
||||
|
||||
Adaptations strictement équivalentes :
|
||||
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
|
||||
- ``_render_markdown`` reste dans ``main`` (import différé).
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
- ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement)
|
||||
sont définis ici ; ``stream_file_with_range`` vit dans
|
||||
:mod:`backend.routers.helpers` (partagé).
|
||||
@@ -29,6 +30,7 @@ from backend.history import record_open
|
||||
from backend.indexer import get_vault_data, index, parse_markdown_file
|
||||
from backend.media_thumbs import generate_thumbnail, is_decodable
|
||||
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, stream_file_with_range
|
||||
from backend.schemas import (
|
||||
AllVaultSettingsResponse,
|
||||
@@ -85,8 +87,6 @@ def _safe_export_name(name: str) -> str:
|
||||
)
|
||||
async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||
"""Download a markdown file as PDF."""
|
||||
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
|
||||
|
||||
if generate_pdf is None:
|
||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
|
||||
@@ -7,8 +7,8 @@ lecture), mêmes modèles de réponse (déménagés dans
|
||||
|
||||
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).
|
||||
- ``_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).
|
||||
@@ -31,6 +31,7 @@ from backend.indexer import (
|
||||
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,
|
||||
@@ -192,8 +193,6 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Real-time endpoints — SSE stream & collaboration WebSocket (ROADMAP #85, tranche 9).
|
||||
|
||||
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||
comportement : mêmes chemins (``/api/events``,
|
||||
``/ws/collab/{vault}/{path}``), même authentification (Depend pour le SSE,
|
||||
manuelle pour le WebSocket — les ``Depends`` FastAPI ne s'exécutent pas sur
|
||||
les routes WebSocket).
|
||||
|
||||
Pas de tags déclarés : assignation par chemin via
|
||||
``openapi_docs.tag_for_path`` comme avant (``/api/events`` → System).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.collab import authenticate_websocket, collab_manager
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
from backend.sse import sse_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/events",
|
||||
response_class=StreamingResponse,
|
||||
responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}},
|
||||
)
|
||||
async def api_events(current_user=Depends(require_auth)):
|
||||
"""SSE stream for real-time index update notifications.
|
||||
|
||||
Sends keepalive comments every 30s. Events:
|
||||
- ``index_updated``: partial index change (file create/modify/delete/move)
|
||||
- ``index_reloaded``: full re-index completed
|
||||
- ``vault_added``: new vault added dynamically
|
||||
- ``vault_removed``: vault removed dynamically
|
||||
"""
|
||||
queue = await sse_manager.connect()
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
# Send initial connection event
|
||||
yield f"event: connected\ndata: {_json.dumps({'sse_clients': sse_manager.client_count})}\n\n"
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
# Keepalive comment
|
||||
yield ": keepalive\n\n"
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
finally:
|
||||
sse_manager.disconnect(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/ws/collab/{vault_name}/{path:path}")
|
||||
async def collab_websocket(websocket: WebSocket, vault_name: str, path: str):
|
||||
"""Real-time collaborative editing over WebSocket (ROADMAP #62).
|
||||
|
||||
One *room* is created per ``vault::path``; all clients editing the same
|
||||
file share Yjs/CRDT updates, awareness (cursors/selection) and a debounced
|
||||
server-side persistence of the markdown content.
|
||||
|
||||
Authentication is performed manually (FastAPI ``Depends`` do not run for
|
||||
WebSocket routes) and vault access is enforced per connection.
|
||||
"""
|
||||
from backend.services.errors import ServiceError
|
||||
|
||||
user = authenticate_websocket(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
if not check_vault_access(vault_name, user):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
try:
|
||||
vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
except ServiceError:
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
await collab_manager.connect(websocket, vault_name, path, file_path, user)
|
||||
@@ -11,9 +11,8 @@ Adaptations strictement équivalentes (pas de changement de comportement) :
|
||||
wrappers directs : appelés ici via :mod:`backend.services.paths` et
|
||||
:mod:`backend.services.backups` (mêmes signatures, mêmes exceptions
|
||||
``ServiceError`` toujours mappées par le handler global de ``main``).
|
||||
- ``_render_markdown`` reste défini dans ``main`` (extraction prévue dans
|
||||
une tranche ultérieure) : import différé à l'intérieur des handlers, donc
|
||||
sans import circulaire au chargement.
|
||||
- ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
|
||||
d'import).
|
||||
"""
|
||||
|
||||
import html as html_mod
|
||||
@@ -27,6 +26,7 @@ from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
|
||||
from backend.render import _render_markdown
|
||||
from backend.schemas import ShareModel, StatusResponse
|
||||
from backend.secret_redactor import redact_file_content
|
||||
from backend.services.backups import create_backup
|
||||
@@ -115,8 +115,6 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
)
|
||||
async def public_share_pdf_download(token: str):
|
||||
"""Download shared document as real PDF via WeasyPrint."""
|
||||
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
|
||||
|
||||
if generate_pdf is None:
|
||||
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
|
||||
share = get_share_by_token(token)
|
||||
@@ -168,8 +166,6 @@ async def public_share_raw(token: str):
|
||||
@router.get("/s/{token}", response_class=HTMLResponse)
|
||||
async def public_share_view(token: str):
|
||||
"""Public share view — no authentication required."""
|
||||
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
|
||||
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
|
||||
Reference in New Issue
Block a user