refactor: #85 T9 extrait realtime et render vers modules dedies (comportement inchange)
This commit is contained in:
+12
-1
@@ -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
@@ -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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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*
|
||||||
|
|||||||
+9
-305
@@ -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 & 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
|
||||||
|
|||||||
@@ -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 ``<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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Real-time endpoints — SSE stream & collaboration WebSocket (ROADMAP #85, tranche 9).
|
||||||
|
|
||||||
|
Handlers déplacés depuis :mod:`backend.main` sans changement de
|
||||||
|
comportement : mêmes chemins (``/api/events``,
|
||||||
|
``/ws/collab/{vault}/{path}``), même authentification (Depend pour le SSE,
|
||||||
|
manuelle pour le WebSocket — les ``Depends`` FastAPI ne s'exécutent pas sur
|
||||||
|
les routes WebSocket).
|
||||||
|
|
||||||
|
Pas de tags déclarés : assignation par chemin via
|
||||||
|
``openapi_docs.tag_for_path`` comme avant (``/api/events`` → System).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, WebSocket
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from backend.auth.middleware import check_vault_access, require_auth
|
||||||
|
from backend.collab import authenticate_websocket, collab_manager
|
||||||
|
from backend.services.paths import resolve_safe_path
|
||||||
|
from backend.services.vaults import get_vault_root
|
||||||
|
from backend.sse import sse_manager
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/events",
|
||||||
|
response_class=StreamingResponse,
|
||||||
|
responses={200: {"content": {"text/event-stream": {}}, "description": "Server-Sent Events stream"}},
|
||||||
|
)
|
||||||
|
async def api_events(current_user=Depends(require_auth)):
|
||||||
|
"""SSE stream for real-time index update notifications.
|
||||||
|
|
||||||
|
Sends keepalive comments every 30s. Events:
|
||||||
|
- ``index_updated``: partial index change (file create/modify/delete/move)
|
||||||
|
- ``index_reloaded``: full re-index completed
|
||||||
|
- ``vault_added``: new vault added dynamically
|
||||||
|
- ``vault_removed``: vault removed dynamically
|
||||||
|
"""
|
||||||
|
queue = await sse_manager.connect()
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
try:
|
||||||
|
# Send initial connection event
|
||||||
|
yield f"event: connected\ndata: {_json.dumps({'sse_clients': sse_manager.client_count})}\n\n"
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||||
|
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Keepalive comment
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
sse_manager.disconnect(queue)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/collab/{vault_name}/{path:path}")
|
||||||
|
async def collab_websocket(websocket: WebSocket, vault_name: str, path: str):
|
||||||
|
"""Real-time collaborative editing over WebSocket (ROADMAP #62).
|
||||||
|
|
||||||
|
One *room* is created per ``vault::path``; all clients editing the same
|
||||||
|
file share Yjs/CRDT updates, awareness (cursors/selection) and a debounced
|
||||||
|
server-side persistence of the markdown content.
|
||||||
|
|
||||||
|
Authentication is performed manually (FastAPI ``Depends`` do not run for
|
||||||
|
WebSocket routes) and vault access is enforced per connection.
|
||||||
|
"""
|
||||||
|
from backend.services.errors import ServiceError
|
||||||
|
|
||||||
|
user = authenticate_websocket(websocket)
|
||||||
|
if user is None:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not check_vault_access(vault_name, user):
|
||||||
|
await websocket.close(code=4403)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
vault_root = get_vault_root(vault_name)
|
||||||
|
file_path = resolve_safe_path(vault_root, path)
|
||||||
|
except ServiceError:
|
||||||
|
await websocket.close(code=4404)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
await websocket.close(code=4404)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
await collab_manager.connect(websocket, vault_name, path, file_path, user)
|
||||||
@@ -11,9 +11,8 @@ Adaptations strictement équivalentes (pas de changement de comportement) :
|
|||||||
wrappers directs : appelés ici via :mod:`backend.services.paths` et
|
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")
|
||||||
|
|||||||
@@ -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é)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Generated
+1
-1
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user