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. -[![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) [![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/) @@ -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. -[![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) [![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/) @@ -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}' - - # 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}' + + # 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"