refactor: #85 T9 extrait realtime et render vers modules dedies (comportement inchange)

This commit is contained in:
2026-09-26 16:59:25 -04:00
parent 3b0927a8c9
commit 58312e64da
17 changed files with 394 additions and 348 deletions
+9 -305
View File
@@ -1,27 +1,19 @@
import asyncio
import html as html_mod
import json as _json
import logging
import os
import re
import secrets
import string
from contextlib import asynccontextmanager
from pathlib import Path
import mistune
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware
from backend.collab import authenticate_websocket, collab_manager
from backend.image_processor import preprocess_images
from backend.collab import collab_manager
from backend.indexer import (
build_index,
find_file_in_index,
get_vault_data,
handle_file_move,
remove_single_file,
update_single_file,
@@ -38,7 +30,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.sanitizer import sanitize_html
logging.basicConfig(
level=logging.INFO,
@@ -48,33 +39,10 @@ logger = logging.getLogger("obsigate")
# ---------------------------------------------------------------------------
# Pydantic response models : voir backend.schemas (vaults/history : #85 T8)
# Pydantic models : voir backend.schemas (#85 T5→T9)
# ---------------------------------------------------------------------------
# Filesystem mutation + search / suggest / graph models : voir backend.schemas (#85 T5, T6b)
class BackupEntry(BaseModel):
"""A single backup version of a file."""
timestamp: int = Field(description="Unix timestamp of when the backup was created")
datetime: str = Field(description="ISO 8601 datetime string")
size: int = Field(description="File size in bytes")
filename: str = Field(description="Backup filename on disk")
class BackupListResponse(BaseModel):
"""Response listing all available backups for a file."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
backups: list[BackupEntry] = Field(description="Available backups, newest first")
class DiffRequest(BaseModel):
"""Request parameters for generating a diff."""
version: int = Field(description="Timestamp of the backup version to compare")
compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.")
# ---------------------------------------------------------------------------
# SSE Manager — voir backend.sse (ROADMAP #85 T4, instance partagée)
# ---------------------------------------------------------------------------
@@ -432,9 +400,7 @@ app.add_middleware(SecurityHeadersMiddleware)
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
from backend.ai_routes import router as ai_router
from backend.auth.middleware import (
check_vault_access,
require_admin,
require_auth,
)
from backend.auth.router import router as auth_router
from backend.bookslm_routes import router as bookslm_router
@@ -447,11 +413,11 @@ from backend.routers.files_read import router as files_read_router
from backend.routers.files_write import router as files_write_router
from backend.routers.health import router as health_router
from backend.routers.history import router as history_router
from backend.routers.realtime import router as realtime_router
from backend.routers.search import router as search_router
from backend.routers.sharing import router as sharing_router
from backend.routers.vaults import router as vaults_router
from backend.routers.webhooks import router as webhooks_router
from backend.secret_redactor import redact_file_content
from backend.skills_routes import router as skills_router
app.include_router(auth_router)
@@ -460,6 +426,7 @@ app.include_router(bookslm_router)
app.include_router(skills_router)
app.include_router(health_router) # ROADMAP #85 T1 — System / health
app.include_router(history_router) # ROADMAP #85 T8 — History
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
@@ -569,193 +536,9 @@ def _check_vault_writable(vault_root: Path) -> bool:
# ---------------------------------------------------------------------------
# Markdown rendering helpers (singleton renderer)
# Markdown rendering helpers : voir backend.render (#85 T9)
# ---------------------------------------------------------------------------
import unicodedata
def _heading_slugify(text: str) -> str:
"""Generate a URL-safe slug from heading text.
Matches the JavaScript slugify algorithm exactly using
Unicode-aware character classification:
1. Strip HTML tags (e.g. wikilink spans rendered inside headings)
2. Decode HTML entities (e.g. ``&`` → ``&``)
3. Lowercase
4. NFD normalize + strip combining marks
5. Keep only Unicode letters, numbers, spaces, hyphens
6. Replace spaces with hyphens, collapse multiple hyphens
Args:
text: The heading text content (may contain inline HTML).
Returns:
A URL-safe slug string.
"""
# Strip any inline HTML so it does not pollute the slug
text = re.sub(r"<[^>]+>", "", text)
# Decode HTML entities so &amp; becomes & before slugification
text = html_mod.unescape(text)
text = text.lower()
text = unicodedata.normalize("NFD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
# Unicode-aware: keep letters (L*), numbers (N*), spaces, and hyphens
cleaned = []
for ch in text:
cat = unicodedata.category(ch)
if cat.startswith('L') or cat.startswith('N') or ch in (' ', '-'):
cleaned.append(ch)
text = "".join(cleaned)
text = re.sub(r"\s+", "-", text)
text = re.sub(r"-+", "-", text)
result = text.strip("-")
return result if result else "heading"
def _add_heading_ids(html: str) -> str:
"""Post-process rendered HTML to add IDs to heading tags.
Adds an ``id`` attribute to every ``<h1>`` through ``<h6>`` tag
using a slug generated from the heading's text content.
Duplicate slugs get a ``-2``, ``-3``, etc. suffix.
Args:
html: Rendered HTML string.
Returns:
HTML with heading IDs injected.
"""
used_ids: dict[str, int] = {}
def _replace_heading(match):
tag = match.group(1)
content = match.group(2)
slug = _heading_slugify(content)
count = used_ids.get(slug, 0)
used_ids[slug] = count + 1
if count > 0:
slug = f"{slug}-{count + 1}"
return f'<{tag} id="{slug}">{content}</{tag}>'
# Match h1-h6 tags with text content (no existing id attribute)
return re.sub(
r'<(h[1-6])>([^<]*(?:<(?!/?h[1-6])[^<]*)*)</h[1-6]>',
_replace_heading,
html,
)
# Cached mistune renderer — avoids re-creating on every request
_markdown_renderer = mistune.create_markdown(
escape=False,
plugins=["table", "strikethrough", "footnotes", "task_lists"],
)
def _convert_wikilinks(content: str, current_vault: str) -> str:
"""Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML.
Supports:
- Internal file links: ``[[My Note]]`` / ``[[My Note|display]]``
- Same-document anchors: ``[[#Heading]]`` / ``[[#Heading|display]]``
Resolved file links get a ``data-vault`` / ``data-path`` attribute pair.
Anchor links target the slugified heading ID in the current document.
Unresolved links are rendered as ``<span class="wikilink-missing">``.
Args:
content: Markdown string potentially containing wikilinks.
current_vault: Active vault name for resolution priority.
Returns:
Markdown string with wikilinks replaced by HTML anchors.
"""
def _replace(match):
target = match.group(1).strip()
display = match.group(2).strip() if match.group(2) else target
# Same-document anchor link: [[#Heading|display]]
if target.startswith("#"):
anchor_text = target[1:].strip()
anchor_slug = _heading_slugify(anchor_text)
link_display = display if display != target else anchor_text
return f'<a class="wikilink-anchor" href="#{anchor_slug}">{link_display}</a>'
found = find_file_in_index(target, current_vault)
if found:
return (
f'<a class="wikilink" href="#" '
f'data-vault="{found["vault"]}" '
f'data-path="{found["path"]}">{display}</a>'
)
return f'<span class="wikilink-missing">{display}</span>'
pattern = r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
return re.sub(pattern, _replace, content)
def _normalize_line_breaks(text: str) -> str:
"""Convert single newlines to hard breaks (matching Obsidian default behavior).
In standard Markdown, a single ``\\n`` is a "soft break" — it renders as a space,
not a visible line break. Obsidian defaults to treating single newlines as hard
breaks (equivalent to ``<br>``). This function pre-processes the Markdown source
so that mistune renders standalone lines on separate rows, while still honouring
blank lines as paragraph separators.
Fenced code blocks (`` ``` ``) are left untouched so their internal newlines are
preserved verbatim.
"""
parts = re.split(r"(```[\s\S]*?```)", text)
for i, part in enumerate(parts):
if part.startswith("```"):
continue # Protect fenced code blocks
# Single \n (not preceded or followed by another \n) → two spaces + \n
parts[i] = re.sub(r"(?<!\n)\n(?!\n)", " \n", part)
return "".join(parts)
def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str:
"""Render a markdown string to HTML with wikilink and image support.
Uses the cached singleton mistune renderer for performance.
Args:
raw_md: Raw markdown text (frontmatter already stripped).
vault_name: Current vault for wikilink resolution context.
current_file_path: Absolute path to the current markdown file.
Returns:
HTML string.
"""
# Get vault data for image resolution
vault_data = get_vault_data(vault_name)
vault_root = Path(vault_data["path"]) if vault_data else None
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
# Redact secrets before rendering (P0 security)
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
# Preprocess images first
if vault_root:
raw_md = preprocess_images(raw_md, vault_name, vault_root, current_file_path, attachments_path)
# Convert wikilinks
converted = _convert_wikilinks(raw_md, vault_name)
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
converted = _normalize_line_breaks(converted)
rendered = _markdown_renderer(converted)
# Add heading IDs for TOC navigation
rendered = _add_heading_ids(rendered)
# Sanitize: raw HTML in vault content must never reach the DOM (BUG-021).
rendered = sanitize_html(rendered)
return rendered
# ---------------------------------------------------------------------------
@@ -826,51 +609,9 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
# ---------------------------------------------------------------------------
# SSE endpoint — Server-Sent Events stream
# SSE endpoint : voir backend.routers.realtime (#85 T9)
# ---------------------------------------------------------------------------
@app.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",
},
)
# ---------------------------------------------------------------------------
# Dynamic vault management endpoints : voir backend.routers.vaults (#85 T8)
@@ -940,46 +681,9 @@ async def api_events(current_user=Depends(require_auth)):
# ---------------------------------------------------------------------------
# Real-time collaboration — WebSocket endpoint (ROADMAP #62)
# Real-time collaboration — WebSocket endpoint : voir backend.routers.realtime (#85 T9, ROADMAP #62)
# ---------------------------------------------------------------------------
@app.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
from backend.services.vaults import get_vault_root
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)
# ---------------------------------------------------------------------------
# Static files & SPA fallback