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
+12 -1
View File
@@ -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). 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 > **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 ## [2.27.11] — 2026-09-26
--- ---
@@ -54,6 +58,13 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Modifié ### 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`.** - **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.**
13 routes servies par `backend/routers/vaults.py`, `history.py` et 13 routes servies par `backend/routers/vaults.py`, `history.py` et
`conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`, `conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`,
+3 -3
View File
@@ -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. **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.
[![Version](https://img.shields.io/badge/Version-2.27.11-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.27.12-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog ## 📝 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*
+3 -3
View File
@@ -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. **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.
[![Version](https://img.shields.io/badge/Version-2.27.11-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.27.12-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog ## 📝 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*
+1 -1
View File
@@ -1 +1 @@
2.27.11 2.27.12
+9 -305
View File
@@ -1,27 +1,19 @@
import asyncio import asyncio
import html as html_mod
import json as _json
import logging import logging
import os import os
import re
import secrets import secrets
import string import string
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import mistune from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from backend.collab import authenticate_websocket, collab_manager from backend.collab import collab_manager
from backend.image_processor import preprocess_images
from backend.indexer import ( from backend.indexer import (
build_index, build_index,
find_file_in_index,
get_vault_data,
handle_file_move, handle_file_move,
remove_single_file, remove_single_file,
update_single_file, update_single_file,
@@ -38,7 +30,6 @@ from backend.search import (
from backend.semantic_search import init_semantic_index from backend.semantic_search import init_semantic_index
from backend.services.backups import get_backup_dir as service_get_backup_dir from backend.services.backups import get_backup_dir as service_get_backup_dir
from backend.services.errors import ServiceError from backend.services.errors import ServiceError
from backend.services.sanitizer import sanitize_html
logging.basicConfig( logging.basicConfig(
level=logging.INFO, 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) # 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). # 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.ai_routes import router as ai_router
from backend.auth.middleware import ( from backend.auth.middleware import (
check_vault_access,
require_admin, require_admin,
require_auth,
) )
from backend.auth.router import router as auth_router from backend.auth.router import router as auth_router
from backend.bookslm_routes import router as bookslm_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.files_write import router as files_write_router
from backend.routers.health import router as health_router from backend.routers.health import router as health_router
from backend.routers.history import router as history_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.search import router as search_router
from backend.routers.sharing import router as sharing_router from backend.routers.sharing import router as sharing_router
from backend.routers.vaults import router as vaults_router from backend.routers.vaults import router as vaults_router
from backend.routers.webhooks import router as webhooks_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 from backend.skills_routes import router as skills_router
app.include_router(auth_router) app.include_router(auth_router)
@@ -460,6 +426,7 @@ app.include_router(bookslm_router)
app.include_router(skills_router) app.include_router(skills_router)
app.include_router(health_router) # ROADMAP #85 T1 — System / health app.include_router(health_router) # ROADMAP #85 T1 — System / health
app.include_router(history_router) # ROADMAP #85 T8 — History 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(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts 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) # 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 # Static files & SPA fallback
+207
View File
@@ -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. ``&amp;`` → ``&``)
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
+3 -3
View File
@@ -9,7 +9,8 @@ d'authentification.
Adaptations strictement équivalentes : Adaptations strictement équivalentes :
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through). - ``_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) - ``_resolve_export_target`` / ``_safe_export_name`` (export uniquement)
sont définis ici ; ``stream_file_with_range`` vit dans sont définis ici ; ``stream_file_with_range`` vit dans
:mod:`backend.routers.helpers` (partagé). :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.indexer import get_vault_data, index, parse_markdown_file
from backend.media_thumbs import generate_thumbnail, is_decodable 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.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.routers.helpers import media_max_inline_bytes, stream_file_with_range
from backend.schemas import ( from backend.schemas import (
AllVaultSettingsResponse, 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)): 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.""" """Download a markdown file as PDF."""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
if generate_pdf is None: if generate_pdf is None:
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)") raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
if not check_vault_access(vault_name, current_user): if not check_vault_access(vault_name, current_user):
+3 -4
View File
@@ -7,8 +7,8 @@ lecture), mêmes modèles de réponse (déménagés dans
Adaptations strictement équivalentes : Adaptations strictement équivalentes :
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through). - ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
- ``_render_markdown`` reste dans ``main`` (import différé, extraction - ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
prévue dans une tranche ultérieure). d'import).
- ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG`` - ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG``
ont déménagé : helpers partagés dans :mod:`backend.routers.helpers` ont déménagé : helpers partagés dans :mod:`backend.routers.helpers`
(``EXT_TO_LANG`` n'était utilisé que par la vue fichier). (``EXT_TO_LANG`` n'était utilisé que par la vue fichier).
@@ -31,6 +31,7 @@ from backend.indexer import (
parse_markdown_file, parse_markdown_file,
) )
from backend.media_types import is_audio, is_image, is_video, media_mime_type 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.routers.helpers import media_max_inline_bytes
from backend.schemas import ( from backend.schemas import (
BacklinksResponse, BacklinksResponse,
@@ -192,8 +193,6 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
Returns: Returns:
``FileContentResponse`` with HTML, metadata, and tags. ``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): if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name) vault_data = get_vault_data(vault_name)
+105
View File
@@ -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)
+3 -7
View File
@@ -11,9 +11,8 @@ Adaptations strictement équivalentes (pas de changement de comportement) :
wrappers directs : appelés ici via :mod:`backend.services.paths` et wrappers directs : appelés ici via :mod:`backend.services.paths` et
:mod:`backend.services.backups` (mêmes signatures, mêmes exceptions :mod:`backend.services.backups` (mêmes signatures, mêmes exceptions
``ServiceError`` toujours mappées par le handler global de ``main``). ``ServiceError`` toujours mappées par le handler global de ``main``).
- ``_render_markdown`` reste défini dans ``main`` (extraction prévue dans - ``_render_markdown`` vient de :mod:`backend.render` (#85 T9, sans cycle
une tranche ultérieure) : import différé à l'intérieur des handlers, donc d'import).
sans import circulaire au chargement.
""" """
import html as html_mod 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.auth.middleware import check_vault_access, require_auth
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file 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.schemas import ShareModel, StatusResponse
from backend.secret_redactor import redact_file_content from backend.secret_redactor import redact_file_content
from backend.services.backups import create_backup 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): async def public_share_pdf_download(token: str):
"""Download shared document as real PDF via WeasyPrint.""" """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: if generate_pdf is None:
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)") raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
share = get_share_by_token(token) share = get_share_by_token(token)
@@ -168,8 +166,6 @@ async def public_share_raw(token: str):
@router.get("/s/{token}", response_class=HTMLResponse) @router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(token: str): async def public_share_view(token: str):
"""Public share view — no authentication required.""" """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) share = get_share_by_token(token)
if not share: if not share:
raise HTTPException(404, "Share not found or expired") raise HTTPException(404, "Share not found or expired")
+24
View File
@@ -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") 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é) # Files — browse / read (#85 — extrait de backend.main, inchangé)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.27.11" version = "2.27.12"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.27.11" version = "2.27.12"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.27.11", "version": "2.27.12",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+3 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # 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 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **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 - **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).** - **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. - **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 :** - **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 - [ ] 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) - [ ] 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) - [ ] 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)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "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.", "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", "main": "patch.js",
"directories": { "directories": {
+14 -14
View File
@@ -624,44 +624,44 @@ class TestHumanizeMtime:
class TestHeadingSlugify: class TestHeadingSlugify:
def test_slugify_simple(self): 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" assert _heading_slugify("Hello World") == "hello-world"
def test_slugify_accented(self): 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") result = _heading_slugify("Café Crème")
assert "cafe" in result or "caf" in result assert "cafe" in result or "caf" in result
def test_slugify_strips_symbols(self): 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?") result = _heading_slugify("Hello, World! Test?")
assert result.startswith("hello") assert result.startswith("hello")
class TestRenderMarkdown: class TestRenderMarkdown:
def test_render_basic(self): 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") result = _render_markdown("# Hello\n\nThis is a test.", "TestVault")
assert "<h1" in result assert "<h1" in result
assert "Hello" in result assert "Hello" in result
def test_render_with_code(self): def test_render_with_code(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("```python\nprint('hello')\n```", "TestVault") result = _render_markdown("```python\nprint('hello')\n```", "TestVault")
assert "code" in result or "highlight" in result assert "code" in result or "highlight" in result
def test_render_with_heading_ids(self): def test_render_with_heading_ids(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("# Title\n## Subtitle", "TestVault") result = _render_markdown("# Title\n## Subtitle", "TestVault")
assert "id=" in result assert "id=" in result
def test_render_wikilink(self): def test_render_wikilink(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("Link [[nonexistent.md]] here", "TestVault") result = _render_markdown("Link [[nonexistent.md]] here", "TestVault")
assert "wikilink" in result assert "wikilink" in result
def test_render_image_wikilink(self): def test_render_image_wikilink(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("![[image.png]]", "TestVault") result = _render_markdown("![[image.png]]", "TestVault")
assert "img" in result or "image" in result or "wikilink" in result assert "img" in result or "image" in result or "wikilink" in result
@@ -709,31 +709,31 @@ class TestCheckVaultWritable:
class TestConvertWikilinks: class TestConvertWikilinks:
def test_convert_wikilink(self): def test_convert_wikilink(self):
from backend.main import _convert_wikilinks from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
# Missing wikilinks render as span.wikilink-missing # Missing wikilinks render as span.wikilink-missing
result = _convert_wikilinks("See [[Introduction à Python]] for details", "TestVault") result = _convert_wikilinks("See [[Introduction à Python]] for details", "TestVault")
assert "Introduction" in result assert "Introduction" in result
assert "wikilink" in result assert "wikilink" in result
def test_convert_wikilink_with_alias(self): def test_convert_wikilink_with_alias(self):
from backend.main import _convert_wikilinks from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
result = _convert_wikilinks("See [[file.md|a different name]] here", "TestVault") result = _convert_wikilinks("See [[file.md|a different name]] here", "TestVault")
assert "a different name" in result assert "a different name" in result
def test_convert_wikilink_image(self): def test_convert_wikilink_image(self):
from backend.main import _convert_wikilinks from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
result = _convert_wikilinks("See ![[image.png]] here", "TestVault") result = _convert_wikilinks("See ![[image.png]] here", "TestVault")
assert "image" in result or "img" in result assert "image" in result or "img" in result
def test_convert_wikilink_anchor(self): def test_convert_wikilink_anchor(self):
from backend.main import _convert_wikilinks from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
result = _convert_wikilinks("[[#Section importante|voir section]]", "TestVault") result = _convert_wikilinks("[[#Section importante|voir section]]", "TestVault")
assert 'href="#section-importante"' in result assert 'href="#section-importante"' in result
assert "wikilink-anchor" in result assert "wikilink-anchor" in result
assert "voir section" in result assert "voir section" in result
def test_convert_wikilink_anchor_without_alias(self): def test_convert_wikilink_anchor_without_alias(self):
from backend.main import _convert_wikilinks from backend.render import _convert_wikilinks # ROADMAP #85 T9 — déménagé depuis backend.main
result = _convert_wikilinks("[[#Claude Code]]", "TestVault") result = _convert_wikilinks("[[#Claude Code]]", "TestVault")
assert 'href="#claude-code"' in result assert 'href="#claude-code"' in result
assert "Claude Code" in result assert "Claude Code" in result
@@ -741,7 +741,7 @@ class TestConvertWikilinks:
class TestHeadingSlugifyHtmlStripping: class TestHeadingSlugifyHtmlStripping:
def test_slugify_strips_html_tags(self): def test_slugify_strips_html_tags(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('## 1. Agents installés localement <a href="#table-des-matieres">↩</a>') result = _heading_slugify('## 1. Agents installés localement <a href="#table-des-matieres">↩</a>')
assert result == "1-agents-installes-localement" assert result == "1-agents-installes-localement"