diff --git a/CHANGELOG.md b/CHANGELOG.md
index e4a4981..ab59f23 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
> **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.11**.
+> [Unreleased](#unreleased). La dernière version livrée est **2.27.12**.
---
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [2.27.12] — 2026-09-26
+
+---
+
## [2.27.11] — 2026-09-26
---
@@ -54,6 +58,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Modifié
+- **#85 (T9) — extraction realtime + render hors du monolithe `backend/main.py`.**
+ Le stream SSE `/api/events` et le WebSocket `/ws/collab/*` sont servis par
+ `backend/routers/realtime.py`, le pipeline markdown (mistune, wikilinks,
+ slugs, sanitizer) par `backend/render.py` (imports directs, plus de
+ couplage différé). `main.py` (4 827 → ~760 lignes) ne contient plus que
+ l'assemblage : lifespan, middlewares, montage des 16 routers, racine
+ `/api`, statique/SPA et cales de compatibilité testées.
- **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.**
13 routes servies par `backend/routers/vaults.py`, `history.py` et
`conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`,
diff --git a/README.fr.md b/README.fr.md
index 07ae461..fe50f50 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -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.
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.docker.com/)
[](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
-Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.11).
+Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.12).
---
-*Projet : ObsiGate | Version : 2.27.11 | Dernière mise à jour : Septembre 2026*
+*Projet : ObsiGate | Version : 2.27.12 | Dernière mise à jour : Septembre 2026*
diff --git a/README.md b/README.md
index 0a3dd11..86314d9 100644
--- a/README.md
+++ b/README.md
@@ -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.
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.docker.com/)
[](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
-See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.11).
+See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.12).
---
-*Project: ObsiGate | Version: 2.27.11 | Last updated: September 2026*
+*Project: ObsiGate | Version: 2.27.12 | Last updated: September 2026*
diff --git a/VERSION b/VERSION
index 273dc5a..9cff9c4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.27.11
+2.27.12
diff --git a/backend/main.py b/backend/main.py
index e267998..18d313f 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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 & 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 ``
`` through ```` 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])[^<]*)*)',
- _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 ````.
-
- 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'{link_display}'
-
- found = find_file_in_index(target, current_vault)
- if found:
- return (
- f'{display}'
- )
- return f'{display}'
-
- 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 ``
``). 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"(? 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
diff --git a/backend/render.py b/backend/render.py
new file mode 100644
index 0000000..00a0d76
--- /dev/null
+++ b/backend/render.py
@@ -0,0 +1,207 @@
+"""Markdown rendering pipeline (ROADMAP #85, tranche 9).
+
+Helpers extraits de :mod:`backend.main` sans changement de comportement :
+slugification des headings, IDs d'ancrage, rendu mistune singleton,
+wikilinks, normalisation des sauts de ligne et pipeline complet
+:func:`_render_markdown` (rendu + sanitizer XSS BUG-021).
+
+Les noms gardent leur préfixe ``_`` d'origine pour un déplacement
+strictement verbatim (tests et routers pointent ici désormais).
+"""
+
+from __future__ import annotations
+
+import html as html_mod
+import re
+import unicodedata
+from pathlib import Path
+
+import mistune
+
+from backend.image_processor import preprocess_images
+from backend.indexer import find_file_in_index, get_vault_data
+from backend.secret_redactor import redact_file_content
+from backend.services.sanitizer import sanitize_html
+
+
+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 & 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 ```` through ```` 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])[^<]*)*)',
+ _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 ````.
+
+ 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'{link_display}'
+
+ found = find_file_in_index(target, current_vault)
+ if found:
+ return (
+ f'{display}'
+ )
+ return f'{display}'
+
+ 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 ``
``). 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"(? 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
diff --git a/backend/routers/files_media.py b/backend/routers/files_media.py
index df2b5fc..b15c964 100644
--- a/backend/routers/files_media.py
+++ b/backend/routers/files_media.py
@@ -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):
diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py
index 6996cef..20b6f63 100644
--- a/backend/routers/files_read.py
+++ b/backend/routers/files_read.py
@@ -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)
diff --git a/backend/routers/realtime.py b/backend/routers/realtime.py
new file mode 100644
index 0000000..af6f63d
--- /dev/null
+++ b/backend/routers/realtime.py
@@ -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)
diff --git a/backend/routers/sharing.py b/backend/routers/sharing.py
index 90d12f0..0efcba4 100644
--- a/backend/routers/sharing.py
+++ b/backend/routers/sharing.py
@@ -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")
diff --git a/backend/schemas.py b/backend/schemas.py
index 795cc84..a1a1b1c 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -214,6 +214,30 @@ 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")
+class BackupEntry(BaseModel):
+ """A single backup version of a file (#85 — extrait de backend.main, inchangé)."""
+
+ 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 (#85 — extrait de backend.main, inchangé)."""
+
+ 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 (#85 — extrait de backend.main, inchangé)."""
+
+ 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.")
+
+
# ---------------------------------------------------------------------------
# Files — browse / read (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index 018738d..f9f22dc 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
-version = "2.27.11"
+version = "2.27.12"
dependencies = [
"chrono",
"env_logger",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index 9f536ed..efe39ae 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
-version = "2.27.11"
+version = "2.27.12"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json
index eadc01c..4cb2424 100644
--- a/desktop/tauri.conf.json
+++ b/desktop/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
- "version": "2.27.11",
+ "version": "2.27.12",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 1abda5b..4634359 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
-> **Version :** 2.27.11 | **Dernière mise à jour :** 2026-09-26
+> **Version :** 2.27.12 | **Dernière mise à jour :** 2026-09-26
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **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
- **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`). **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`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`). **T7 livrée (v2.27.10) :** config (app/ai-keys/tool-keys/ai-models/diagnostics/dashboard → `backend/routers/config.py`, `_FALLBACK_MODELS` + clés déplacés, `test_ai_models` réaligné). **T8 livrée (v2.27.11) :** vaults + history + conflicts (→ `backend/routers/vaults.py|history.py|conflicts.py`, `VaultInfo`/`BookmarkToggleRequest` → `schemas.py`, watcher → `backend/watcher_state.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`). **T6b livrée (v2.27.8) :** mutations fichiers/dossiers (save, xlsx/save, delete, create, rename, move, directories ×3, batch-upload → `backend/routers/files_write.py`, 15 modèles → `schemas.py`). **T6c livrée (v2.27.9) :** media/pdf/export/guide (file/pdf, exports ×3, guide, pdf/stream|info, image, media+thumb, attachments ×2, vault settings ×3, vault files → `backend/routers/files_media.py`, Range helper → `helpers.py`). **T7 livrée (v2.27.10) :** config (app/ai-keys/tool-keys/ai-models/diagnostics/dashboard → `backend/routers/config.py`, `_FALLBACK_MODELS` + clés déplacés, `test_ai_models` réaligné). **T8 livrée (v2.27.11) :** vaults + history + conflicts (→ `backend/routers/vaults.py|history.py|conflicts.py`, `VaultInfo`/`BookmarkToggleRequest` → `schemas.py`, watcher → `backend/watcher_state.py`). **T9 livrée (v2.27.12) :** realtime + render (events SSE + collab WS → `backend/routers/realtime.py`, pipeline markdown → `backend/render.py`, derniers modèles → `schemas.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.
- **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`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`), `files-write` ✅ (T6b, `backend/routers/files_write.py`), `files-media` ✅ (T6c, `backend/routers/files_media.py`), `config` ✅ (T7, `backend/routers/config.py`), `vaults|history|conflicts` ✅ (T8, 3 routers + `watcher_state.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`), `files-write` ✅ (T6b, `backend/routers/files_write.py`), `files-media` ✅ (T6c, `backend/routers/files_media.py`), `config` ✅ (T7, `backend/routers/config.py`), `vaults|history|conflicts` ✅ (T8, 3 routers + `watcher_state.py`), `realtime|render` ✅ (T9, `routers/realtime.py` + `render.py`) ; reste T10 (persistance + verrous + registry)
- [ ] 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)
- [ ] 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)
diff --git a/package.json b/package.json
index c7f8d33..33e87bd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsigate",
- "version": "2.27.11",
+ "version": "2.27.12",
"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",
"directories": {
diff --git a/tests/test_api_main.py b/tests/test_api_main.py
index 518dbee..ad08130 100644
--- a/tests/test_api_main.py
+++ b/tests/test_api_main.py
@@ -624,44 +624,44 @@ class TestHumanizeMtime:
class TestHeadingSlugify:
def test_slugify_simple(self):
- from backend.main import _heading_slugify
+ from backend.render import _heading_slugify # ROADMAP #85 T9 — déménagé depuis backend.main
assert _heading_slugify("Hello World") == "hello-world"
def test_slugify_accented(self):
- from backend.main import _heading_slugify
+ from backend.render import _heading_slugify # ROADMAP #85 T9 — déménagé depuis backend.main
result = _heading_slugify("Café Crème")
assert "cafe" in result or "caf" in result
def test_slugify_strips_symbols(self):
- from backend.main import _heading_slugify
+ from backend.render import _heading_slugify # ROADMAP #85 T9 — déménagé depuis backend.main
result = _heading_slugify("Hello, World! Test?")
assert result.startswith("hello")
class TestRenderMarkdown:
def test_render_basic(self):
- from backend.main import _render_markdown
+ from backend.render import _render_markdown # ROADMAP #85 T9 — déménagé depuis backend.main
result = _render_markdown("# Hello\n\nThis is a test.", "TestVault")
assert "↩')
assert result == "1-agents-installes-localement"