feat: collaboration temps reel - edition simultanee (#62)
CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
WebSocket /ws/collab/{vault}/{path} (rooms par fichier), relais Yjs/CRDT, awareness (curseurs colores + presence), persistance serveur debounce 2s, auth WS + check_vault_access, reconnexion automatique. Frontend frontend/js/collab.js, backend backend/collab.py. Tests: 17 backend (5 clients simultanes) + 10 frontend. Docs: CHANGELOG, ROADMAP, fiche features/collaboration.md, README FR/EN.
This commit is contained in:
@@ -38,7 +38,7 @@ jobs:
|
||||
- name: Frontend unit tests
|
||||
run: node tests/frontend/unit.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW)
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
@@ -47,6 +47,7 @@ jobs:
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing — installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -55,6 +56,7 @@ jobs:
|
||||
node plugins.test.mjs
|
||||
node ai.test.mjs
|
||||
node sw.test.mjs
|
||||
node collab.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,6 +14,20 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#62 Collaboration temps réel — Édition simultanée** — plusieurs utilisateurs peuvent éditer le
|
||||
même document markdown en même temps, façon Google Docs. **WebSocket** : nouvel endpoint
|
||||
`/ws/collab/{vault}/{path}` (une *room* par fichier) qui relaie les changements instantanément ;
|
||||
authentification manuelle (cookie `access_token` ou paramètre `token`) + `check_vault_access` et
|
||||
`resolve_safe_path` par connexion. **Yjs/CRDT** côté client : `Y.Doc`/`Y.Text` fusionnent les
|
||||
modifications concurrentes sans conflit ni perte. **Awareness** : curseurs distants colorés et
|
||||
sélections dans CodeMirror, indicateur de présence (avatars + statut) dans l'en-tête de l'éditeur.
|
||||
**Persistance serveur** : le dernier texte reçu est écrit sur disque avec un debounce de 2 s
|
||||
(`backend/collab.py`). **Reconnexion** automatique avec backoff exponentiel et merge de l'état au
|
||||
retour. Nouveau module frontend `frontend/js/collab.js` (provider + liaison Y.Text↔CodeMirror +
|
||||
curseurs distants) et `backend/collab.py` (`CollabManager`). Tests : `tests/test_collab.py`
|
||||
(17 tests, dont 5 clients simultanés) et `tests/frontend/collab.test.mjs`. Détail :
|
||||
[docs/features/collaboration.md](./docs/features/collaboration.md).
|
||||
|
||||
- **#79 Phase F — Durcissement & documentation (rate limiting, redaction, OpenAPI/MCP, E2E)** —
|
||||
clôture de la feature #79. **Rate limiting** par jeton et par outil
|
||||
(`backend/tools/ratelimit.py`, fenêtre glissante ; identité = JTI du jeton sinon id/username ;
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
- **🤖 AI Editor intégré** — Éditeur CodeMirror 6 avec toolbar IA : amélioration, correction, traduction, génération, réécriture personnalisée, toolbox (liste, tableau, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
|
||||
- **🧩 Serveur MCP & agent IA** — Serveur Model Context Protocol intégré (`/mcp`) et assistant avec function calling : lisez, cherchez et modifiez vos vaults depuis Claude Desktop, Cursor… avec confirmations two-step, permissions par vault, rate limiting et redaction des secrets ([guide](docs/MCP_GUIDE.md))
|
||||
- **👥 Collaboration temps réel** — Édition simultanée d'un même document (Yjs/CRDT) : curseurs distants colorés, indicateur de présence, fusion sans conflit, reconnexion automatique et persistance serveur ([détail](docs/features/collaboration.md))
|
||||
- **🗺️ Vue graphe interactive** — Canvas force-directed avec Barnes-Hut O(n log n), filtres (tag, type), profondeur, mode focus, historique de navigation ←→↑, export PNG, aperçu au survol (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : Visualisez plusieurs vaults Obsidian simultanément
|
||||
- **🌳 Navigation arborescente** : Parcourez vos dossiers et fichiers dans la sidebar
|
||||
@@ -552,6 +553,25 @@ Cycle de vie : Tauri spawn le backend Python → health check → splash de dém
|
||||
|
||||
---
|
||||
|
||||
## 👥 Collaboration temps réel
|
||||
|
||||
Plusieurs utilisateurs peuvent éditer le même document markdown simultanément (façon Google Docs) :
|
||||
|
||||
- **Fusion sans conflit** grâce à Yjs (CRDT) : deux personnes peuvent taper au même endroit, aucune
|
||||
modification n'est perdue.
|
||||
- **Curseurs distants colorés** et sélections visibles dans CodeMirror, avec le nom de chaque
|
||||
utilisateur.
|
||||
- **Indicateur de présence** dans l'en-tête de l'éditeur (avatars + statut de connexion).
|
||||
- **Reconnexion automatique** (backoff exponentiel) : l'état est fusionné au retour.
|
||||
- **Persistance serveur** : le document est écrit sur disque 2 s après la dernière modification.
|
||||
- **Transport** : WebSocket `ws(s)://<hôte>/ws/collab/{vault}/{chemin}`, authentifié par cookie
|
||||
`access_token` (ou `?token=`) et soumis au contrôle d'accès par vault.
|
||||
|
||||
Aucune configuration n'est nécessaire : ouvrez le même fichier dans deux navigateurs (ou deux
|
||||
fenêtres) pour voir la collaboration en action.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API
|
||||
|
||||
ObsiGate expose une API REST complète :
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
- **🤖 Integrated AI Editor** — CodeMirror 6 editor with AI toolbar: improve, correct, translate, generate, custom rewrite, toolbox (list, table, frontmatter, canvas) — multi-provider DeepSeek/OpenRouter/Gemini
|
||||
- **🧩 MCP Server & AI Agent** — Built-in Model Context Protocol server (`/mcp`) and tool-calling assistant: read, search and edit your vaults from Claude Desktop, Cursor… with two-step confirmations, per-vault permissions, rate limiting and secret redaction ([guide](docs/MCP_GUIDE.md))
|
||||
- **👥 Real-time Collaboration** — Simultaneous editing of the same document (Yjs/CRDT): colored remote cursors, presence indicator, conflict-free merge, automatic reconnection and server-side persistence ([details](docs/features/collaboration.md))
|
||||
- **🗺️ Interactive Graph View** — Canvas force-directed with Barnes-Hut O(n log n), filters (tag, type), depth, focus mode, navigation history ←→↑, export PNG, preview on hover (Ctrl+click)
|
||||
- **🗂️ Multi-vault** : View multiple Obsidian vaults simultaneously
|
||||
- **🌳 Tree Navigation** : Browse your folders and files in the sidebar
|
||||
@@ -668,6 +669,22 @@ Lifecycle: Tauri spawns the Python backend → health check → opens the webvie
|
||||
|
||||
---
|
||||
|
||||
## 👥 Real-time Collaboration
|
||||
|
||||
Multiple users can edit the same markdown document simultaneously (Google Docs style):
|
||||
|
||||
- **Conflict-free merge** via Yjs (CRDT): two people can type in the same place, no change is lost.
|
||||
- **Colored remote cursors** and visible selections in CodeMirror, labelled with each user's name.
|
||||
- **Presence indicator** in the editor header (avatars + connection status).
|
||||
- **Automatic reconnection** (exponential backoff): state is merged on return.
|
||||
- **Server-side persistence**: the document is written to disk 2 s after the last change.
|
||||
- **Transport**: WebSocket `ws(s)://<host>/ws/collab/{vault}/{path}`, authenticated via the
|
||||
`access_token` cookie (or `?token=`) and subject to per-vault access control.
|
||||
|
||||
No configuration is required: open the same file in two browsers (or two windows) to see it live.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API
|
||||
|
||||
ObsiGate exposes a complete REST API :
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Collaboration temps réel — édition simultanée (ROADMAP #62).
|
||||
|
||||
Ce module implémente le cœur serveur de l'édition collaborative :
|
||||
|
||||
* un **relais WebSocket** : une *room* est créée par fichier ouvert
|
||||
(clé ``vault::chemin``) et tous les clients qui éditent le même fichier
|
||||
rejoignent la même room ;
|
||||
* un **relais de mises à jour Yjs** (CRDT) : le serveur ne décode pas le
|
||||
format binaire Yjs, il stocke le journal des mises à jour reçues et le
|
||||
rejoue aux nouveaux arrivants. La fusion sans conflit est assurée côté
|
||||
client par Yjs ;
|
||||
* un **awareness** (curseurs colorés + sélections) relayé entre clients ;
|
||||
* une **persistance différée** : le texte markdown reçu des clients est écrit
|
||||
sur disque après un debounce (2 s par défaut).
|
||||
|
||||
Sécurité : chaque connexion est authentifiée manuellement (les dépendances
|
||||
FastAPI ``Depends`` ne s'exécutent pas pour ``@app.websocket``), puis le
|
||||
chemin est validé via :func:`backend.services.paths.resolve_safe_path` et
|
||||
l'accès à la vault via ``check_vault_access``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
logger = logging.getLogger("obsigate.collab")
|
||||
|
||||
#: Délai (secondes) sans modification avant écriture sur disque.
|
||||
SAVE_DEBOUNCE_SECONDS = 2.0
|
||||
|
||||
#: Taille maximale d'une mise à jour Yjs encodée (protection anti-abus).
|
||||
MAX_UPDATE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
#: Taille maximale d'un snapshot texte (protection anti-abus).
|
||||
MAX_TEXT_CHARS = 8 * 1024 * 1024
|
||||
|
||||
#: Palette de couleurs attribuées aux utilisateurs (curseurs + avatars).
|
||||
PEER_COLORS = [
|
||||
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4",
|
||||
"#008080", "#9a6324", "#800000", "#808000", "#000075",
|
||||
]
|
||||
|
||||
|
||||
def color_for_index(index: int) -> str:
|
||||
"""Return a deterministic cursor color for a peer index."""
|
||||
return PEER_COLORS[index % len(PEER_COLORS)]
|
||||
|
||||
|
||||
def _b64encode(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(data: str) -> bytes:
|
||||
return base64.b64decode(data.encode("ascii"))
|
||||
|
||||
|
||||
def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
|
||||
"""Authenticate a WebSocket connection.
|
||||
|
||||
Mirrors :func:`backend.auth.middleware.get_current_user` but works on the
|
||||
WebSocket scope: the JWT is read from the ``access_token`` cookie (sent
|
||||
automatically by same-origin browsers during the handshake) or, as a
|
||||
fallback, from the ``token`` query parameter.
|
||||
|
||||
Returns the user dict, or ``None`` if authentication fails.
|
||||
"""
|
||||
from backend.auth.jwt_handler import decode_token
|
||||
from backend.auth.middleware import is_auth_enabled
|
||||
from backend.auth.user_store import get_user
|
||||
|
||||
if not is_auth_enabled():
|
||||
return {
|
||||
"username": "anonymous",
|
||||
"display_name": "Anonymous",
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"_token_vaults": ["*"],
|
||||
}
|
||||
|
||||
token = websocket.query_params.get("token") or websocket.cookies.get("access_token")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
return None
|
||||
|
||||
user = get_user(payload["sub"])
|
||||
if not user or not user.get("active"):
|
||||
return None
|
||||
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
user["_token_jti"] = payload.get("jti")
|
||||
return user
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollabClient:
|
||||
"""A single WebSocket connection inside a collaboration room."""
|
||||
|
||||
conn_id: int
|
||||
websocket: WebSocket
|
||||
username: str
|
||||
display_name: str
|
||||
color: str
|
||||
y_client_id: int | None = None
|
||||
awareness: dict[str, Any] | None = None
|
||||
|
||||
def peer(self) -> dict[str, Any]:
|
||||
return {
|
||||
"connId": self.conn_id,
|
||||
"clientId": self.y_client_id,
|
||||
"username": self.username,
|
||||
"displayName": self.display_name,
|
||||
"color": self.color,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollabRoom:
|
||||
"""State shared by every client editing the same file."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
file_path: Path
|
||||
initial_text: str = ""
|
||||
clients: dict[int, CollabClient] = field(default_factory=dict)
|
||||
#: Journal des mises à jour Yjs (binaires) depuis la création de la room.
|
||||
updates: list[bytes] = field(default_factory=list)
|
||||
has_updates: bool = False
|
||||
seed_sent: bool = False
|
||||
pending_text: str | None = None
|
||||
save_task: asyncio.Task | None = None
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.vault}::{self.path}"
|
||||
|
||||
def peers(self) -> list[dict[str, Any]]:
|
||||
return [client.peer() for client in self.clients.values()]
|
||||
|
||||
def awareness_snapshot(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"clientId": c.y_client_id, "state": c.awareness}
|
||||
for c in self.clients.values()
|
||||
if c.y_client_id is not None and c.awareness is not None
|
||||
]
|
||||
|
||||
|
||||
class CollabManager:
|
||||
"""Manages collaboration rooms, broadcasting and disk persistence."""
|
||||
|
||||
def __init__(self, save_debounce: float = SAVE_DEBOUNCE_SECONDS) -> None:
|
||||
self._rooms: dict[str, CollabRoom] = {}
|
||||
self._save_debounce = save_debounce
|
||||
self._next_conn_id = 1
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# -- introspection (used by tests / diagnostics) ------------------------
|
||||
@property
|
||||
def room_count(self) -> int:
|
||||
return len(self._rooms)
|
||||
|
||||
def room_peer_count(self, vault: str, path: str) -> int:
|
||||
room = self._rooms.get(f"{vault}::{path}")
|
||||
return len(room.clients) if room else 0
|
||||
|
||||
def get_room(self, vault: str, path: str) -> CollabRoom | None:
|
||||
return self._rooms.get(f"{vault}::{path}")
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
async def connect(
|
||||
self,
|
||||
websocket: WebSocket,
|
||||
vault: str,
|
||||
path: str,
|
||||
file_path: Path,
|
||||
user: dict[str, Any],
|
||||
) -> None:
|
||||
"""Register *websocket* in the room and relay messages until it closes."""
|
||||
async with self._lock:
|
||||
key = f"{vault}::{path}"
|
||||
room = self._rooms.get(key)
|
||||
if room is None:
|
||||
try:
|
||||
initial_text = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
initial_text = ""
|
||||
room = CollabRoom(vault=vault, path=path, file_path=file_path, initial_text=initial_text)
|
||||
self._rooms[key] = room
|
||||
|
||||
conn_id = self._next_conn_id
|
||||
self._next_conn_id += 1
|
||||
client = CollabClient(
|
||||
conn_id=conn_id,
|
||||
websocket=websocket,
|
||||
username=user.get("username", "anonymous"),
|
||||
display_name=user.get("display_name") or user.get("username", "anonymous"),
|
||||
color=color_for_index(conn_id - 1),
|
||||
)
|
||||
room.clients[conn_id] = client
|
||||
|
||||
seed: str | None = None
|
||||
if not room.has_updates and not room.seed_sent:
|
||||
seed = room.initial_text
|
||||
room.seed_sent = True
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "init",
|
||||
"connId": conn_id,
|
||||
"color": client.color,
|
||||
"seed": seed,
|
||||
"updates": [_b64encode(u) for u in room.updates],
|
||||
"peers": room.peers(),
|
||||
"awareness": room.awareness_snapshot(),
|
||||
})
|
||||
await self._broadcast(room, {"type": "peer_joined", "peer": client.peer()}, exclude=conn_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
await self._on_message(room, client, raw)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("Collab connection error (%s): %s", room.key, exc)
|
||||
finally:
|
||||
await self.disconnect(room, client)
|
||||
|
||||
async def disconnect(self, room: CollabRoom, client: CollabClient) -> None:
|
||||
"""Remove *client* from *room*, flushing and cleaning up if empty."""
|
||||
async with self._lock:
|
||||
room.clients.pop(client.conn_id, None)
|
||||
empty = not room.clients
|
||||
if empty:
|
||||
await self._flush(room)
|
||||
async with self._lock:
|
||||
# Only delete if nobody rejoined while we were flushing.
|
||||
if not room.clients and self._rooms.get(room.key) is room:
|
||||
if room.save_task:
|
||||
room.save_task.cancel()
|
||||
self._rooms.pop(room.key, None)
|
||||
else:
|
||||
await self._broadcast(
|
||||
room,
|
||||
{
|
||||
"type": "peer_left",
|
||||
"peer": client.peer(),
|
||||
"clientId": client.y_client_id,
|
||||
},
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Flush and cancel every room (called on application shutdown)."""
|
||||
async with self._lock:
|
||||
rooms = list(self._rooms.values())
|
||||
self._rooms.clear()
|
||||
for room in rooms:
|
||||
if room.save_task:
|
||||
room.save_task.cancel()
|
||||
await self._flush(room)
|
||||
|
||||
# -- message handling ---------------------------------------------------
|
||||
async def _on_message(self, room: CollabRoom, client: CollabClient, raw: str) -> None:
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if not isinstance(message, dict):
|
||||
return
|
||||
|
||||
msg_type = message.get("type")
|
||||
|
||||
if msg_type in ("sync", "update"):
|
||||
encoded = message.get("update")
|
||||
if not isinstance(encoded, str):
|
||||
return
|
||||
try:
|
||||
update = _b64decode(encoded)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if not update or len(update) > MAX_UPDATE_BYTES:
|
||||
return
|
||||
async with self._lock:
|
||||
room.updates.append(update)
|
||||
room.has_updates = True
|
||||
await self._broadcast(
|
||||
room,
|
||||
{"type": "update", "update": encoded, "from": client.conn_id},
|
||||
exclude=client.conn_id,
|
||||
)
|
||||
|
||||
elif msg_type == "awareness":
|
||||
y_client_id = message.get("clientId")
|
||||
state = message.get("state")
|
||||
if not isinstance(y_client_id, int):
|
||||
return
|
||||
client.y_client_id = y_client_id
|
||||
client.awareness = state if isinstance(state, dict) else None
|
||||
await self._broadcast(
|
||||
room,
|
||||
{
|
||||
"type": "awareness",
|
||||
"clientId": y_client_id,
|
||||
"state": client.awareness,
|
||||
"from": client.conn_id,
|
||||
},
|
||||
exclude=client.conn_id,
|
||||
)
|
||||
|
||||
elif msg_type == "text":
|
||||
text = message.get("text")
|
||||
if not isinstance(text, str) or len(text) > MAX_TEXT_CHARS:
|
||||
return
|
||||
room.pending_text = text
|
||||
self._schedule_save(room)
|
||||
|
||||
elif msg_type == "ping":
|
||||
await client.websocket.send_json({"type": "pong", "t": int(time.time() * 1000)})
|
||||
|
||||
# -- broadcasting -------------------------------------------------------
|
||||
async def _broadcast(self, room: CollabRoom, message: dict[str, Any], exclude: int | None = None) -> None:
|
||||
dead: list[CollabClient] = []
|
||||
for client in list(room.clients.values()):
|
||||
if exclude is not None and client.conn_id == exclude:
|
||||
continue
|
||||
try:
|
||||
await client.websocket.send_json(message)
|
||||
except Exception:
|
||||
dead.append(client)
|
||||
for client in dead:
|
||||
room.clients.pop(client.conn_id, None)
|
||||
|
||||
# -- persistence --------------------------------------------------------
|
||||
def _schedule_save(self, room: CollabRoom) -> None:
|
||||
if room.save_task and not room.save_task.done():
|
||||
room.save_task.cancel()
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError: # pragma: no cover - no running loop (tests)
|
||||
return
|
||||
room.save_task = loop.create_task(self._debounced_save(room))
|
||||
|
||||
async def _debounced_save(self, room: CollabRoom) -> None:
|
||||
try:
|
||||
await asyncio.sleep(self._save_debounce)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
await self._flush(room)
|
||||
|
||||
async def _flush(self, room: CollabRoom) -> None:
|
||||
"""Write the last received text snapshot to disk (if any)."""
|
||||
async with room.lock:
|
||||
text = room.pending_text
|
||||
room.pending_text = None
|
||||
if text is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(room.file_path.write_text, text, encoding="utf-8")
|
||||
logger.debug("Collab persisted %s", room.key)
|
||||
except OSError as exc:
|
||||
logger.warning("Collab persist failed for %s: %s", room.key, exc)
|
||||
|
||||
|
||||
#: Process-wide singleton used by the WebSocket endpoint.
|
||||
collab_manager = CollabManager()
|
||||
+45
-1
@@ -19,13 +19,14 @@ from typing import Any
|
||||
|
||||
import frontmatter
|
||||
import mistune
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request, WebSocket
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments
|
||||
from backend.collab import authenticate_websocket, collab_manager
|
||||
from backend.history import (
|
||||
get_bookmarks,
|
||||
record_open,
|
||||
@@ -739,6 +740,7 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await collab_manager.stop()
|
||||
if _vault_watcher:
|
||||
await _vault_watcher.stop()
|
||||
_vault_watcher = None
|
||||
@@ -4091,6 +4093,48 @@ async def api_conflict_resolve(body: dict = Body(...), current_user=Depends(requ
|
||||
raise HTTPException(500, f"Error resolving conflict: {e!s}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-time collaboration — WebSocket endpoint (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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -33,6 +33,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "AI", "description": "AI-powered editor actions, provider status and model discovery."},
|
||||
{"name": "BooksLM", "description": "Directory-scoped AI chat (NotebookLM-style) over a vault folder."},
|
||||
{"name": "MCP", "description": "Model Context Protocol server (Streamable HTTP) exposing the shared AI tool layer to external clients (Claude Desktop, Cursor…)."},
|
||||
{"name": "Collaboration", "description": "Real-time collaborative editing over WebSocket (`/ws/collab/{vault}/{path}`): Yjs/CRDT updates, awareness (cursors) and debounced server-side persistence."},
|
||||
{"name": "Sharing", "description": "Create and manage public read-only share links for documents."},
|
||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
||||
@@ -57,6 +58,13 @@ Authorization: Bearer <access_token>
|
||||
The same token is also accepted as an HTTP-only cookie, so browser clients can
|
||||
simply use `credentials: "include"`.
|
||||
|
||||
### Real-time collaboration
|
||||
Besides the REST API, a WebSocket endpoint `GET /ws/collab/{vault}/{path}` (upgrade) powers
|
||||
simultaneous editing of the same file: clients exchange Yjs/CRDT updates and awareness (remote
|
||||
cursors), and the server persists the document 2 s after the last change. Authentication uses the
|
||||
`access_token` cookie (or a `token` query parameter) and vault access is enforced per connection.
|
||||
See the collaboration feature documentation (`docs/features/collaboration.md`) for the protocol.
|
||||
|
||||
### Interactive documentation
|
||||
* **Swagger UI** — [/docs](/docs): try requests directly from the browser.
|
||||
* **ReDoc** — [/redoc](/redoc): clean, reading-oriented reference.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
fastapi==0.110.3
|
||||
uvicorn==0.30.0
|
||||
websockets>=12.0
|
||||
python-frontmatter==1.1.0
|
||||
mistune==3.0.2
|
||||
python-multipart==0.0.9
|
||||
|
||||
+3
-27
@@ -42,30 +42,6 @@
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 3 (P3)
|
||||
|
||||
### 62. Collaboration temps réel — Édition simultanée
|
||||
|
||||
- **Effort :** 5-7 jours | **Impact :** 🟢
|
||||
- **Description :** Permettre à plusieurs utilisateurs d'éditer le même document markdown en même temps, comme Google Docs. Chaque personne voit en temps réel ce que les autres tapent, avec leur curseur affiché en couleur.
|
||||
- **WebSocket** : connexion persistante bidirectionnelle entre le navigateur et le serveur. Contrairement à HTTP où le client doit constamment demander « y a-t-il du nouveau ? » (polling), le WebSocket permet au serveur de pousser les changements instantanément. Une room WebSocket est créée par fichier ouvert — tous les utilisateurs qui éditent le même fichier rejoignent la même room.
|
||||
- **Yjs + CRDT** : Yjs est une bibliothèque qui implémente un algorithme CRDT (Conflict-free Replicated Data Type). Imagine deux personnes qui tapent en même temps au même endroit — sans CRDT, on aurait un conflit et du texte perdu. Avec CRDT, les deux modifications sont fusionnées mathématiquement sans perte. Chaque caractère reçoit un identifiant unique, et l'ordre final est déterministe même si les opérations arrivent dans le désordre. Pas besoin de verrouiller le fichier ni de résoudre des conflits manuellement.
|
||||
- **Awareness** : chaque utilisateur voit le curseur des autres (position, sélection) représenté par un nom et une couleur. Un indicateur dans la barre d'outils montre qui est connecté.
|
||||
- **Persistance** : le serveur sauvegarde périodiquement le document (debounce 2s après la dernière modification) pour que les changements survivent à une déconnexion.
|
||||
- **Pourquoi c'est important :** Permet le travail d'équipe sur la documentation, les notes de réunion, les spécifications techniques, les brainstorms. C'est le passage d'ObsiGate de « outil personnel » à « outil d'équipe ».
|
||||
- **Sous-tâches :**
|
||||
- [ ] Serveur WebSocket : endpoint `/ws/collab/{vault}/{path}` avec gestion des rooms
|
||||
- [ ] Intégration Yjs : `Y.Doc` partagé, `Y.Text` pour le contenu markdown
|
||||
- [ ] Awareness : curseurs colorés par utilisateur, sélections visibles
|
||||
- [ ] Synchro backend : persistance périodique du document (debounce 2s)
|
||||
- [ ] Gestion des droits : vérification `check_vault_access` par connexion WS
|
||||
- [ ] UI : indicateur de présence (avatars dans la barre d'outils éditeur)
|
||||
- [ ] UI : curseurs distants dans CodeMirror (extension collaborative)
|
||||
- [ ] Gestion des déconnexions : reconnexion automatique, merge state au retour
|
||||
- [ ] Tests de charge : 5+ utilisateurs simultanés sur le même fichier
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 69. Éditeur mobile natif — Interface tactile optimisée
|
||||
@@ -147,6 +123,7 @@
|
||||
| 72 | API publique documentée — OpenAPI 3.1 | 2.2.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||
| 76 | BooksLM — Console AI contextuelle par répertoire | 2.2.0 | [features/bookslm.md](./features/bookslm.md) |
|
||||
| 78 | Éditeur Excalidraw | 2.2.0 | [features/excalidraw.md](./features/excalidraw.md) |
|
||||
| 62 | Collaboration temps réel — Édition simultanée (Yjs/CRDT, WebSocket, awareness) | 2.3.0 | [features/collaboration.md](./features/collaboration.md) |
|
||||
| 79 | Assistant IA — Outils (function calling) & serveur MCP | 2.3.0 | [features/ai-tools-mcp.md](./features/ai-tools-mcp.md) |
|
||||
| 80 | Assistant IA — Rendu Markdown, liens fichiers/paths & sessions | 2.3.0 | [features/ai-assistant-ux.md](./features/ai-assistant-ux.md) |
|
||||
| 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
@@ -157,11 +134,10 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78–80 | ~92 jours réalisés |
|
||||
| ✅ Complété | #1 → #59, #61–68, #71, #72, #74–76, #78–80 | ~97 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
|
||||
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) | ~5-7 jours |
|
||||
| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours |
|
||||
| **Total restant** | **5 items + finitions** | **~17-25 jours** |
|
||||
| **Total restant** | **4 items + finitions** | **~12-18 jours** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# #62 - Collaboration temps réel — Édition simultanée
|
||||
|
||||
> **Statut :** ✅ Terminé — 100 % implémenté + 17 tests backend + 10 tests frontend (2026-09-11)
|
||||
> **Effort :** 5-7 jours (réalisé) | **Impact :** 🟢
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md)
|
||||
|
||||
- **Fichiers clés :**
|
||||
- `backend/collab.py` — `CollabManager`, `CollabRoom`, `CollabClient`, auth WS, persistance
|
||||
- `backend/main.py` — endpoint `@app.websocket("/ws/collab/{vault_name}/{path:path}")`, hook `lifespan`
|
||||
- `frontend/js/collab.js` — provider WebSocket, liaison `Y.Text` ↔ CodeMirror, curseurs distants, présence
|
||||
- `frontend/js/utils.js` — démarrage/arrêt de la session à l'ouverture/fermeture de l'éditeur
|
||||
- `frontend/index.html` — import map `yjs`, `window.CodeMirror` (Decoration/ViewPlugin/WidgetType/StateEffect), conteneur `#collab-presence`
|
||||
- `frontend/style.css` — styles présence + curseurs distants
|
||||
- `tests/test_collab.py` — 17 tests (manager, auth, ACL, persistance, 5 clients simultanés)
|
||||
- `tests/frontend/collab.test.mjs` — 10 tests (diff de texte, URL, avatars)
|
||||
|
||||
- **Description :** plusieurs utilisateurs peuvent éditer le même document markdown en même temps,
|
||||
comme Google Docs. Chaque personne voit en temps réel les modifications des autres, avec leur
|
||||
curseur affiché en couleur et un indicateur de présence dans la barre d'outils de l'éditeur.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Navigateur A ──┐
|
||||
Navigateur B ──┼── WebSocket /ws/collab/{vault}/{path} ──► CollabManager (room par fichier)
|
||||
Navigateur C ──┘ │
|
||||
├── relais des updates Yjs (opaque)
|
||||
├── relais de l'awareness (curseurs)
|
||||
└── persistance différée (debounce 2 s)
|
||||
```
|
||||
|
||||
### Backend — `backend/collab.py`
|
||||
|
||||
- **`CollabManager`** (singleton `collab_manager`) :
|
||||
- `connect()` enregistre la connexion dans la room `vault::path`, envoie le message `init`
|
||||
(journal des updates, `seed` pour le premier client, pairs, awareness) et boucle sur
|
||||
`receive_text()`.
|
||||
- `disconnect()` retire le client, diffuse `peer_left`, et **flush** le fichier si la room devient
|
||||
vide (puis supprime la room).
|
||||
- `stop()` annule les tâches et flush toutes les rooms (arrêt de l'application).
|
||||
- `_on_message()` traite `sync`/`update` (Yjs), `awareness`, `text` (persistance), `ping`.
|
||||
- `_broadcast()` diffuse aux autres clients et purge les connexions mortes.
|
||||
- `_schedule_save()` / `_debounced_save()` / `_flush()` : écriture disque après 2 s d'inactivité
|
||||
(`asyncio.to_thread` pour ne pas bloquer l'event loop).
|
||||
- **`authenticate_websocket()`** : les dépendances FastAPI `Depends` ne s'exécutent pas sur les
|
||||
routes WebSocket. Le JWT est donc lu manuellement depuis le cookie `access_token` (envoyé
|
||||
automatiquement par le navigateur same-origin) ou, en repli, depuis `?token=`. Si
|
||||
`OBSIGATE_AUTH_ENABLED=false`, un utilisateur anonyme admin est retourné (comme les routes REST).
|
||||
- **Sécurité par connexion** : `check_vault_access()` puis `resolve_safe_path()` (anti path
|
||||
traversal) avant tout accès disque.
|
||||
|
||||
### Protocole WebSocket
|
||||
|
||||
Client → serveur :
|
||||
|
||||
| Message | Contenu | Rôle |
|
||||
|---|---|---|
|
||||
| `update` | `{update: <base64 Yjs>}` | mise à jour incrémentale |
|
||||
| `sync` | `{update: <base64 Yjs>}` | état complet (à la connexion / reconnexion) |
|
||||
| `awareness` | `{clientId, state}` | curseur + nom + couleur |
|
||||
| `text` | `{text}` | snapshot markdown pour la persistance |
|
||||
| `ping` | `{}` | keepalive |
|
||||
|
||||
Serveur → client :
|
||||
|
||||
| Message | Contenu | Rôle |
|
||||
|---|---|---|
|
||||
| `init` | `{connId, color, seed, updates[], peers[], awareness[]}` | état initial de la room |
|
||||
| `update` | `{update, from}` | mise à jour d'un autre client |
|
||||
| `awareness` | `{clientId, state, from}` | curseur d'un autre client |
|
||||
| `peer_joined` / `peer_left` | `{peer, clientId}` | présence |
|
||||
| `pong` | `{t}` | réponse keepalive |
|
||||
|
||||
### Frontend — `frontend/js/collab.js`
|
||||
|
||||
- **Yjs** chargé dynamiquement (`import('yjs')`, résolu par l'import map de `index.html`) : aucune
|
||||
dépendance statique externe, ce qui rend les fonctions pures testables en Node/JSDOM.
|
||||
- **Liaison `Y.Text` ↔ CodeMirror** : un `EditorView.updateListener` applique les changements locaux
|
||||
au `Y.Text` (`ydoc.transact`, origin `local`) ; un observateur `Y.Text` applique les changements
|
||||
distants à l'éditeur via un diff minimal à remplacement unique (`computeTextDiff`). Un flag
|
||||
`applying` évite les boucles.
|
||||
- **Curseurs distants** : extension CodeMirror (`ViewPlugin` + `Decoration.widget`) qui place un
|
||||
caret coloré + étiquette de nom à la position `head` de chaque pair. Un `StateEffect` force le
|
||||
rafraîchissement quand l'awareness change.
|
||||
- **Présence** : `#collab-presence` affiche un point de statut (connecté / connexion / interrompu),
|
||||
les avatars colorés et le nombre de personnes.
|
||||
- **Reconnexion** : backoff exponentiel 1 s → 30 s, puis `init` + renvoi de l'état complet (`sync`)
|
||||
et de l'awareness → merge CRDT au retour.
|
||||
- **Persistance** : le texte est envoyé au serveur 300 ms après la dernière modification ; le
|
||||
serveur écrit le fichier après 2 s d'inactivité.
|
||||
|
||||
---
|
||||
|
||||
## Sous-tâches (ROADMAP #62)
|
||||
|
||||
- [x] Serveur WebSocket : endpoint `/ws/collab/{vault}/{path}` avec gestion des rooms
|
||||
- [x] Intégration Yjs : `Y.Doc` partagé, `Y.Text` pour le contenu markdown
|
||||
- [x] Awareness : curseurs colorés par utilisateur, sélections visibles
|
||||
- [x] Synchro backend : persistance périodique du document (debounce 2 s)
|
||||
- [x] Gestion des droits : vérification `check_vault_access` par connexion WS
|
||||
- [x] UI : indicateur de présence (avatars dans la barre d'outils éditeur)
|
||||
- [x] UI : curseurs distants dans CodeMirror (extension collaborative)
|
||||
- [x] Gestion des déconnexions : reconnexion automatique, merge state au retour
|
||||
- [x] Tests de charge : 5+ utilisateurs simultanés sur le même fichier
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Authentification obligatoire si `OBSIGATE_AUTH_ENABLED=true` (cookie ou `?token=`).
|
||||
- Vérification `check_vault_access()` par connexion (un utilisateur ne peut pas rejoindre une room
|
||||
d'une vault non autorisée).
|
||||
- `resolve_safe_path()` empêche toute traversée de chemin (`../../`).
|
||||
- Bornes anti-abus : `MAX_UPDATE_BYTES` (8 Mo) par mise à jour, `MAX_TEXT_CHARS` (8 Mio) par snapshot.
|
||||
- Le serveur ne décode pas le binaire Yjs : il le stocke et le relaie tel quel (pas de surface
|
||||
d'attaque supplémentaire côté parsing).
|
||||
|
||||
---
|
||||
|
||||
## Limites connues
|
||||
|
||||
- Le journal des mises à jour Yjs est conservé en mémoire tant qu'au moins un client est connecté.
|
||||
Quand la room devient vide, le document est persisté sur disque et la room est supprimée : au
|
||||
redémarrage du serveur, l'état CRDT est réinitialisé à partir du contenu du fichier.
|
||||
- L'éditeur « fallback » (textarea, quand CodeMirror ne charge pas) n'est pas collaboratif : la
|
||||
session n'est démarrée que si `state.editorView` existe.
|
||||
- Yjs est chargé depuis `esm.sh` (comme CodeMirror) : une connexion Internet est requise au premier
|
||||
chargement, sauf mise en cache par le service worker.
|
||||
|
||||
---
|
||||
|
||||
## Vérifications
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest tests/test_collab.py -q
|
||||
.\.venv\Scripts\python.exe -m ruff check backend/
|
||||
.\.venv\Scripts\python.exe -m mypy backend/ --ignore-missing-imports
|
||||
node tests/frontend/collab.test.mjs
|
||||
node tests/frontend/validate-imports.mjs
|
||||
```
|
||||
+12
-2
@@ -93,7 +93,8 @@
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@codemirror/state": "https://esm.sh/@codemirror/[email protected]"
|
||||
"@codemirror/state": "https://esm.sh/@codemirror/[email protected]",
|
||||
"yjs": "https://esm.sh/[email protected]"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -111,8 +112,11 @@
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
highlightActiveLine,
|
||||
Decoration,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
} from "https://esm.sh/@codemirror/[email protected]?external=@codemirror/state";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorState, StateEffect } from "@codemirror/state";
|
||||
import {
|
||||
defaultHighlightStyle,
|
||||
syntaxHighlighting,
|
||||
@@ -196,6 +200,10 @@
|
||||
rust,
|
||||
oneDark,
|
||||
keymap,
|
||||
Decoration,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
StateEffect,
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
@@ -1368,6 +1376,8 @@
|
||||
title="Renommer le fichier"
|
||||
/>
|
||||
<div class="editor-spacer"></div>
|
||||
<div class="collab-presence" id="collab-presence" style="display: none"
|
||||
title="Collaboration"></div>
|
||||
<span class="editor-save-dot ok" id="editor-save-dot"
|
||||
><span class="dot"></span
|
||||
><span id="editor-save-label">Saved</span></span
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
/* ObsiGate — Collaboration temps réel (ROADMAP #62).
|
||||
*
|
||||
* Édition simultanée d'un même document markdown :
|
||||
* - Yjs (CRDT) côté client : deux personnes qui tapent au même endroit
|
||||
* fusionnent sans conflit ni perte ;
|
||||
* - WebSocket `/ws/collab/{vault}/{path}` : une room par fichier, le serveur
|
||||
* relaie les mises à jour et l'awareness, puis persiste le document
|
||||
* (debounce 2 s) ;
|
||||
* - awareness : curseurs distants colorés + indicateur de présence.
|
||||
*
|
||||
* Le module n'a aucune dépendance statique externe : Yjs est chargé
|
||||
* dynamiquement depuis l'import map (`yjs`), CodeMirror via `window.CodeMirror`.
|
||||
* Cela permet de tester les fonctions pures en Node/JSDOM.
|
||||
*/
|
||||
|
||||
import { state } from './state.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30000;
|
||||
const TEXT_SYNC_MS = 300;
|
||||
const PING_MS = 25000;
|
||||
|
||||
// ── Fonctions pures (testables) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calcule un diff minimal à remplacement unique entre deux textes.
|
||||
* Retourne `null` si les textes sont identiques, sinon `{from, to, insert}`.
|
||||
*/
|
||||
export function computeTextDiff(oldText, newText) {
|
||||
if (oldText === newText) return null;
|
||||
const oldLen = oldText.length;
|
||||
const newLen = newText.length;
|
||||
|
||||
let start = 0;
|
||||
const maxPrefix = Math.min(oldLen, newLen);
|
||||
while (start < maxPrefix && oldText.charCodeAt(start) === newText.charCodeAt(start)) {
|
||||
start++;
|
||||
}
|
||||
|
||||
let end = 0;
|
||||
const maxSuffix = Math.min(oldLen - start, newLen - start);
|
||||
while (
|
||||
end < maxSuffix &&
|
||||
oldText.charCodeAt(oldLen - 1 - end) === newText.charCodeAt(newLen - 1 - end)
|
||||
) {
|
||||
end++;
|
||||
}
|
||||
|
||||
return { from: start, to: oldLen - end, insert: newText.slice(start, newLen - end) };
|
||||
}
|
||||
|
||||
/** Initiale affichée dans l'avatar d'un pair (1er caractère, majuscule). */
|
||||
export function initialOf(name) {
|
||||
const value = String(name || '').trim();
|
||||
if (!value) return '?';
|
||||
return value.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
/** Construit l'URL WebSocket de collaboration (segments de chemin encodés). */
|
||||
export function buildCollabUrl(loc, vault, path) {
|
||||
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const segments = String(path || '')
|
||||
.split('/')
|
||||
.filter((s) => s.length > 0)
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
return `${proto}//${loc.host}/ws/collab/${encodeURIComponent(vault)}/${segments}`;
|
||||
}
|
||||
|
||||
/** Nombre de pairs affiché dans l'indicateur de présence. */
|
||||
export function formatPeerCount(count) {
|
||||
return String(Math.max(0, Number(count) || 0));
|
||||
}
|
||||
|
||||
// ── Encodage base64 binaire ────────────────────────────────────────────────
|
||||
|
||||
function toBase64(bytes) {
|
||||
let binary = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(encoded) {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function currentUserName() {
|
||||
const el = document.querySelector('.user-display-name');
|
||||
const name = el && el.textContent ? el.textContent.trim() : '';
|
||||
return name || 'Utilisateur';
|
||||
}
|
||||
|
||||
// ── Session de collaboration ───────────────────────────────────────────────
|
||||
|
||||
let _session = null;
|
||||
|
||||
export function isCollabActive() {
|
||||
return !!_session;
|
||||
}
|
||||
|
||||
export function getCollabSession() {
|
||||
return _session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre une session collaborative sur l'éditeur *view*.
|
||||
* @returns {Promise<object|null>} la session, ou null si Yjs est indisponible.
|
||||
*/
|
||||
export async function startCollab({ view, vault, path }) {
|
||||
await stopCollab();
|
||||
if (!view || !vault || !path) return null;
|
||||
|
||||
let Y;
|
||||
try {
|
||||
Y = await import('yjs');
|
||||
} catch (err) {
|
||||
console.warn('ObsiGate collab: Yjs indisponible, édition locale seulement', err);
|
||||
return null;
|
||||
}
|
||||
|
||||
const ydoc = new Y.Doc();
|
||||
const ytext = ydoc.getText('content');
|
||||
const session = {
|
||||
Y,
|
||||
ydoc,
|
||||
ytext,
|
||||
view,
|
||||
vault,
|
||||
path,
|
||||
userName: currentUserName(),
|
||||
color: null,
|
||||
connId: null,
|
||||
ws: null,
|
||||
status: 'connecting',
|
||||
stopped: false,
|
||||
reconnectAttempt: 0,
|
||||
reconnectTimer: null,
|
||||
pingTimer: null,
|
||||
textTimer: null,
|
||||
applying: false,
|
||||
peers: new Map(),
|
||||
notifyAwareness: null,
|
||||
observer: null,
|
||||
bindingExt: null,
|
||||
};
|
||||
_session = session;
|
||||
|
||||
bindYText(session);
|
||||
renderPresence(session);
|
||||
connect(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Arrête la session courante et nettoie tous les listeners. */
|
||||
export async function stopCollab() {
|
||||
const session = _session;
|
||||
if (!session) return;
|
||||
session.stopped = true;
|
||||
_session = null;
|
||||
|
||||
if (session.reconnectTimer) clearTimeout(session.reconnectTimer);
|
||||
if (session.pingTimer) clearInterval(session.pingTimer);
|
||||
if (session.textTimer) clearTimeout(session.textTimer);
|
||||
|
||||
try {
|
||||
if (session.ws && session.ws.readyState === 1) {
|
||||
// Flush final du texte pour que le serveur persiste le dernier état.
|
||||
sendText(session);
|
||||
session.ws.close(1000, 'editor closed');
|
||||
} else if (session.ws) {
|
||||
session.ws.close();
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (session.observer) {
|
||||
try {
|
||||
session.ytext.unobserve(session.observer);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
session.ydoc.destroy();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
removePresence();
|
||||
}
|
||||
|
||||
// ── Liaison Y.Text ↔ CodeMirror ────────────────────────────────────────────
|
||||
|
||||
function bindYText(session) {
|
||||
const CM = window.CodeMirror;
|
||||
if (!CM) return;
|
||||
const { EditorView } = CM;
|
||||
|
||||
const listener = EditorView.updateListener.of((update) => {
|
||||
if (session.stopped) return;
|
||||
if (update.docChanged && !session.applying) {
|
||||
applyEditorChangesToY(session);
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
sendAwareness(session);
|
||||
}
|
||||
});
|
||||
|
||||
const cursorExt = remoteCursors(session);
|
||||
session.notifyAwareness = cursorExt.notify;
|
||||
session.bindingExt = [listener, cursorExt.extension];
|
||||
session.view.dispatch({ effects: CM.StateEffect.appendConfig.of(session.bindingExt) });
|
||||
|
||||
session.observer = (event, transaction) => {
|
||||
if (transaction && transaction.origin === 'local') return;
|
||||
syncFromY(session);
|
||||
};
|
||||
session.ytext.observe(session.observer);
|
||||
syncFromY(session);
|
||||
}
|
||||
|
||||
function applyEditorChangesToY(session) {
|
||||
const { view, ydoc, ytext } = session;
|
||||
const diff = computeTextDiff(ytext.toString(), view.state.doc.toString());
|
||||
if (!diff) return;
|
||||
ydoc.transact(() => {
|
||||
if (diff.to > diff.from) ytext.delete(diff.from, diff.to - diff.from);
|
||||
if (diff.insert.length) ytext.insert(diff.from, diff.insert);
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
function syncFromY(session) {
|
||||
if (session.stopped) return;
|
||||
const { view, ytext } = session;
|
||||
const next = ytext.toString();
|
||||
const current = view.state.doc.toString();
|
||||
const diff = computeTextDiff(current, next);
|
||||
if (!diff) return;
|
||||
|
||||
session.applying = true;
|
||||
try {
|
||||
view.dispatch({ changes: diff });
|
||||
} finally {
|
||||
session.applying = false;
|
||||
}
|
||||
scheduleTextSync(session);
|
||||
}
|
||||
|
||||
// ── Awareness / présence ───────────────────────────────────────────────────
|
||||
|
||||
function sendAwareness(session) {
|
||||
const { ws } = session;
|
||||
if (!ws || ws.readyState !== 1 || !session.color) return;
|
||||
let cursor = null;
|
||||
try {
|
||||
const sel = session.view.state.selection.main;
|
||||
cursor = { anchor: sel.anchor, head: sel.head };
|
||||
} catch (e) {
|
||||
cursor = null;
|
||||
}
|
||||
try {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'awareness',
|
||||
clientId: session.ydoc.clientID,
|
||||
state: { user: { name: session.userName, color: session.color }, cursor },
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTextSync(session) {
|
||||
if (session.textTimer) clearTimeout(session.textTimer);
|
||||
session.textTimer = setTimeout(() => sendText(session), TEXT_SYNC_MS);
|
||||
}
|
||||
|
||||
function sendText(session) {
|
||||
const { ws } = session;
|
||||
if (!ws || ws.readyState !== 1) return;
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'text', text: session.ytext.toString() }));
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extension CodeMirror : curseurs distants ───────────────────────────────
|
||||
|
||||
function remoteCursors(session) {
|
||||
const CM = window.CodeMirror;
|
||||
if (!CM) return { extension: [], notify: () => {} };
|
||||
const { Decoration, ViewPlugin, WidgetType, StateEffect } = CM;
|
||||
|
||||
const awarenessEffect = StateEffect.define();
|
||||
|
||||
class CursorWidget extends WidgetType {
|
||||
constructor(name, color) {
|
||||
super();
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
}
|
||||
eq(other) {
|
||||
return other.name === this.name && other.color === this.color;
|
||||
}
|
||||
toDOM() {
|
||||
const caret = document.createElement('span');
|
||||
caret.className = 'cm-remote-cursor';
|
||||
caret.style.borderLeftColor = this.color;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'cm-remote-cursor-label';
|
||||
label.style.backgroundColor = this.color;
|
||||
label.textContent = this.name;
|
||||
caret.appendChild(label);
|
||||
return caret;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
constructor(view) {
|
||||
this.decorations = this._build(view);
|
||||
}
|
||||
update(update) {
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.transactions.some((tr) =>
|
||||
tr.effects.some((e) => e.is(awarenessEffect)),
|
||||
)
|
||||
) {
|
||||
this.decorations = this._build(update.view);
|
||||
}
|
||||
}
|
||||
_build(view) {
|
||||
const ranges = [];
|
||||
const docLength = view.state.doc.length;
|
||||
for (const [clientId, peer] of session.peers) {
|
||||
if (clientId === session.ydoc.clientID) continue;
|
||||
if (!peer || !peer.cursor) continue;
|
||||
const pos = Math.max(0, Math.min(peer.cursor.head, docLength));
|
||||
const name = (peer.user && peer.user.name) || '?';
|
||||
const color = (peer.user && peer.user.color) || '#4363d8';
|
||||
ranges.push(Decoration.widget({ widget: new CursorWidget(name, color), side: 1 }).range(pos));
|
||||
}
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
);
|
||||
|
||||
return {
|
||||
extension: plugin,
|
||||
notify: () => {
|
||||
if (session.stopped) return;
|
||||
try {
|
||||
session.view.dispatch({ effects: awarenessEffect.of(null) });
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── WebSocket ──────────────────────────────────────────────────────────────
|
||||
|
||||
function connect(session) {
|
||||
if (session.stopped) return;
|
||||
session.status = 'connecting';
|
||||
renderPresence(session);
|
||||
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(buildCollabUrl(window.location, session.vault, session.path));
|
||||
} catch (e) {
|
||||
scheduleReconnect(session);
|
||||
return;
|
||||
}
|
||||
session.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
session.reconnectAttempt = 0;
|
||||
session.pingTimer = setInterval(() => {
|
||||
if (ws.readyState === 1) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, PING_MS);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => handleMessage(session, event.data);
|
||||
|
||||
ws.onclose = () => {
|
||||
if (session.pingTimer) clearInterval(session.pingTimer);
|
||||
if (session.ws === ws && !session.stopped) {
|
||||
session.status = 'disconnected';
|
||||
renderPresence(session);
|
||||
scheduleReconnect(session);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
/* onclose follows */
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect(session) {
|
||||
if (session.stopped || session.reconnectTimer) return;
|
||||
const delay = Math.min(RECONNECT_BASE_MS * 2 ** session.reconnectAttempt, RECONNECT_MAX_MS);
|
||||
session.reconnectAttempt++;
|
||||
session.reconnectTimer = setTimeout(() => {
|
||||
session.reconnectTimer = null;
|
||||
connect(session);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function handleMessage(session, raw) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const { Y } = session;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init': {
|
||||
session.connId = msg.connId;
|
||||
session.color = msg.color || session.color;
|
||||
session.peers.clear();
|
||||
for (const peer of msg.peers || []) {
|
||||
if (peer.clientId == null) continue;
|
||||
session.peers.set(peer.clientId, {
|
||||
user: { name: peer.displayName, color: peer.color },
|
||||
cursor: null,
|
||||
});
|
||||
}
|
||||
for (const entry of msg.awareness || []) {
|
||||
if (entry.clientId != null) session.peers.set(entry.clientId, entry.state);
|
||||
}
|
||||
|
||||
for (const encoded of msg.updates || []) {
|
||||
try {
|
||||
Y.applyUpdate(session.ydoc, fromBase64(encoded), 'remote');
|
||||
} catch (e) {
|
||||
/* ignore malformed update */
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof msg.seed === 'string' && session.ytext.length === 0) {
|
||||
session.ydoc.transact(() => {
|
||||
session.ytext.insert(0, msg.seed);
|
||||
}, 'local');
|
||||
}
|
||||
|
||||
session.status = 'connected';
|
||||
syncFromY(session);
|
||||
sendFullState(session);
|
||||
sendAwareness(session);
|
||||
renderPresence(session);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
if (typeof msg.update !== 'string') return;
|
||||
try {
|
||||
Y.applyUpdate(session.ydoc, fromBase64(msg.update), 'remote');
|
||||
} catch (e) {
|
||||
/* ignore malformed update */
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'awareness': {
|
||||
if (msg.clientId == null) return;
|
||||
if (msg.state) session.peers.set(msg.clientId, msg.state);
|
||||
else session.peers.delete(msg.clientId);
|
||||
if (session.notifyAwareness) session.notifyAwareness();
|
||||
renderPresence(session);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'peer_left': {
|
||||
if (msg.clientId != null) session.peers.delete(msg.clientId);
|
||||
else if (msg.peer) {
|
||||
for (const [id, peer] of session.peers) {
|
||||
if (peer && peer.user && peer.user.name === msg.peer.displayName) {
|
||||
session.peers.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (session.notifyAwareness) session.notifyAwareness();
|
||||
renderPresence(session);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'peer_joined': {
|
||||
if (msg.peer && msg.peer.clientId != null && !session.peers.has(msg.peer.clientId)) {
|
||||
session.peers.set(msg.peer.clientId, {
|
||||
user: { name: msg.peer.displayName, color: msg.peer.color },
|
||||
cursor: null,
|
||||
});
|
||||
}
|
||||
renderPresence(session);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pong':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function sendFullState(session) {
|
||||
const { ws, Y, ydoc } = session;
|
||||
if (!ws || ws.readyState !== 1) return;
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'sync', update: toBase64(Y.encodeStateAsUpdate(ydoc)) }));
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Indicateur de présence (DOM) ───────────────────────────────────────────
|
||||
|
||||
function presenceContainer() {
|
||||
return document.getElementById('collab-presence');
|
||||
}
|
||||
|
||||
function removePresence() {
|
||||
const el = presenceContainer();
|
||||
if (el) {
|
||||
el.innerHTML = '';
|
||||
el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPresence(session) {
|
||||
const el = presenceContainer();
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
el.style.display = 'flex';
|
||||
el.title = t('collab.presence_title');
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = 'collab-status collab-status-' + session.status;
|
||||
const statusKey =
|
||||
session.status === 'connected'
|
||||
? 'collab.status_connected'
|
||||
: session.status === 'connecting'
|
||||
? 'collab.status_connecting'
|
||||
: 'collab.status_disconnected';
|
||||
status.title = t(statusKey);
|
||||
status.setAttribute('aria-label', t(statusKey));
|
||||
el.appendChild(status);
|
||||
|
||||
const peers = [];
|
||||
peers.push({ name: session.userName, color: session.color || '#4363d8', self: true });
|
||||
for (const [clientId, peer] of session.peers) {
|
||||
if (clientId === session.ydoc.clientID) continue;
|
||||
const user = (peer && peer.user) || {};
|
||||
peers.push({ name: user.name || '?', color: user.color || '#4363d8', self: false });
|
||||
}
|
||||
|
||||
const avatars = document.createElement('div');
|
||||
avatars.className = 'collab-avatars';
|
||||
for (const peer of peers.slice(0, 6)) {
|
||||
const avatar = document.createElement('span');
|
||||
avatar.className = 'collab-avatar' + (peer.self ? ' collab-avatar-self' : '');
|
||||
avatar.style.backgroundColor = peer.color;
|
||||
avatar.title = peer.name;
|
||||
avatar.textContent = initialOf(peer.name);
|
||||
avatars.appendChild(avatar);
|
||||
}
|
||||
el.appendChild(avatars);
|
||||
|
||||
const count = document.createElement('span');
|
||||
count.className = 'collab-count';
|
||||
count.textContent = formatPeerCount(peers.length);
|
||||
count.title = t('collab.peers_title', { count: peers.length });
|
||||
el.appendChild(count);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { api } from './auth.js';
|
||||
import { openFile, showWelcome } from './viewer.js';
|
||||
import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js';
|
||||
import { createAIToolbar } from './ai.js';
|
||||
import { startCollab, stopCollab } from './collab.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File extension → Lucide icon mapping
|
||||
@@ -409,6 +410,13 @@ async function openEditor(vaultName, filePath) {
|
||||
bodyEl.appendChild(state.fallbackEditorEl);
|
||||
}
|
||||
|
||||
// Real-time collaboration (ROADMAP #62) — bind Yjs to the CodeMirror view.
|
||||
if (state.editorView) {
|
||||
startCollab({ view: state.editorView, vault: vaultName, path: filePath }).catch((err) => {
|
||||
console.warn("Collab start failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
// Set up AI toolbar (recreate each time editor opens)
|
||||
var container = document.getElementById('ai-toolbar-container');
|
||||
if (container && !container.querySelector('.ai-toolbar') && !container.querySelector('div')) {
|
||||
@@ -480,6 +488,7 @@ function closeEditor() {
|
||||
// Restore brand text
|
||||
var brand = modal.querySelector('.editor-brand');
|
||||
if (brand) brand.textContent = 'ObsiGate';
|
||||
stopCollab();
|
||||
if (state.editorView) {
|
||||
state.editorView.destroy();
|
||||
state.editorView = null;
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"common.search": "Search",
|
||||
"common.unknown": "Unknown",
|
||||
"common.yes": "Yes",
|
||||
"collab.peers_title": "{count} person(s) connected",
|
||||
"collab.presence_title": "Real-time collaboration",
|
||||
"collab.status_connected": "Collaboration active",
|
||||
"collab.status_connecting": "Connecting to collaboration…",
|
||||
"collab.status_disconnected": "Collaboration lost — reconnecting…",
|
||||
"config.about": "About",
|
||||
"config.about_error": "Loading error",
|
||||
"config.about_loading": "Loading...",
|
||||
|
||||
@@ -306,6 +306,11 @@
|
||||
"common.search": "Rechercher",
|
||||
"common.unknown": "Inconnue",
|
||||
"common.yes": "Oui",
|
||||
"collab.peers_title": "{count} personne(s) connectée(s)",
|
||||
"collab.presence_title": "Collaboration temps réel",
|
||||
"collab.status_connected": "Collaboration active",
|
||||
"collab.status_connecting": "Connexion à la collaboration…",
|
||||
"collab.status_disconnected": "Collaboration interrompue — reconnexion…",
|
||||
"config.about": "À propos",
|
||||
"config.about_error": "Erreur de chargement",
|
||||
"config.about_loading": "Chargement...",
|
||||
|
||||
@@ -2772,6 +2772,20 @@ select {
|
||||
.editor-filename-input:hover { border-color: var(--border); }
|
||||
.editor-filename-input:focus { border-color: var(--ok); background: var(--bg-input); }
|
||||
.editor-spacer { flex: 1; }
|
||||
/* Collaboration temps réel (#62) — indicateur de présence */
|
||||
.collab-presence { display: flex; align-items: center; gap: 6px; margin-right: 10px; }
|
||||
.collab-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.collab-status-connected { background: var(--ok); }
|
||||
.collab-status-connecting { background: var(--warn); animation: editor-pulse 1s infinite; }
|
||||
.collab-status-disconnected { background: var(--err); animation: editor-pulse 0.8s infinite; }
|
||||
.collab-avatars { display: flex; align-items: center; }
|
||||
.collab-avatar { width: 20px; height: 20px; border-radius: 50%; color: #fff; font-size: 11px; font-weight: 600; display: flex; align-items: center; justify-content: center; margin-left: -6px; border: 2px solid var(--bg-secondary); }
|
||||
.collab-avatar:first-child { margin-left: 0; }
|
||||
.collab-avatar-self { box-shadow: 0 0 0 2px var(--bg-primary); }
|
||||
.collab-count { font-size: 11px; color: var(--text-muted); min-width: 10px; }
|
||||
/* Collaboration temps réel (#62) — curseurs distants CodeMirror */
|
||||
.cm-remote-cursor { display: inline-block; width: 0; border-left: 2px solid; height: 1.2em; vertical-align: text-bottom; position: relative; margin-left: -1px; }
|
||||
.cm-remote-cursor-label { position: absolute; top: -1.35em; left: -1px; padding: 1px 4px; border-radius: 3px 3px 3px 0; color: #fff; font-size: 10px; font-weight: 600; white-space: nowrap; pointer-events: none; z-index: 5; }
|
||||
.editor-save-dot { display: flex; align-items: center; gap: 4px; font-size: 11px; padding: 0 4px; margin-right: 8px; }
|
||||
.editor-btn + .editor-btn { margin-left: 6px; }
|
||||
.editor-save-dot .dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
/* ObsiGate — Service Worker (PWA offline + push)
|
||||
* Version: 2 (2026-09-11)
|
||||
* Version: 3 (2026-09-11)
|
||||
*
|
||||
* Caching policy (fixes stale-asset loads behind Cloudflare / mobile):
|
||||
* - navigations & code (HTML / JS / CSS / manifest) : NETWORK-FIRST
|
||||
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v2';
|
||||
const SW_VERSION = 'v3';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
@@ -38,6 +38,7 @@ const PRECACHE_URLS = [
|
||||
'/static/js/offline-db.js',
|
||||
'/static/js/sync.js',
|
||||
'/static/js/legacy.js',
|
||||
'/static/js/collab.js',
|
||||
];
|
||||
|
||||
// ── Install: precache the shell, then activate immediately ──────────────────
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the collaboration module (ROADMAP #62).
|
||||
*
|
||||
* Covers the pure helpers of frontend/js/collab.js (text diff used to apply
|
||||
* remote Yjs changes, cursor URL building, avatar initials). The WebSocket /
|
||||
* Yjs runtime paths are exercised by the backend pytest suite.
|
||||
*
|
||||
* Usage: node tests/frontend/collab.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// ── JSDOM bootstrap (collab.js imports i18n.js which touches window) ────────
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html><html><body>
|
||||
<div class="user-display-name">Alice</div>
|
||||
<div id="collab-presence" style="display:none"></div>
|
||||
</body></html>`,
|
||||
{ url: "https://example.com/vault", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.WebSocket = w.WebSocket || class {};
|
||||
|
||||
const collab = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "collab.js")).href
|
||||
);
|
||||
|
||||
const { computeTextDiff, initialOf, buildCollabUrl, formatPeerCount } = collab;
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n── collab.js unit tests (#62) ──\n");
|
||||
|
||||
test("computeTextDiff returns null for identical text", () => {
|
||||
assert.equal(computeTextDiff("abc", "abc"), null);
|
||||
});
|
||||
|
||||
test("computeTextDiff detects an insertion", () => {
|
||||
assert.deepEqual(computeTextDiff("ab", "aXb"), { from: 1, to: 1, insert: "X" });
|
||||
});
|
||||
|
||||
test("computeTextDiff detects a deletion", () => {
|
||||
assert.deepEqual(computeTextDiff("abc", "ac"), { from: 1, to: 2, insert: "" });
|
||||
});
|
||||
|
||||
test("computeTextDiff detects a replacement", () => {
|
||||
assert.deepEqual(computeTextDiff("hello world", "hello there"), {
|
||||
from: 6,
|
||||
to: 11,
|
||||
insert: "there",
|
||||
});
|
||||
});
|
||||
|
||||
test("computeTextDiff handles empty originals", () => {
|
||||
assert.deepEqual(computeTextDiff("", "new"), { from: 0, to: 0, insert: "new" });
|
||||
assert.deepEqual(computeTextDiff("old", ""), { from: 0, to: 3, insert: "" });
|
||||
});
|
||||
|
||||
test("computeTextDiff only replaces the changed region", () => {
|
||||
const oldText = "line1\nline2\nline3";
|
||||
const newText = "line1\nCHANGED\nline3";
|
||||
const diff = computeTextDiff(oldText, newText);
|
||||
assert.equal(diff.from, 6);
|
||||
assert.equal(diff.insert, "CHANGED");
|
||||
assert.equal(oldText.slice(0, diff.from) + diff.insert + oldText.slice(diff.to), newText);
|
||||
});
|
||||
|
||||
test("initialOf returns an uppercase initial", () => {
|
||||
assert.equal(initialOf("alice"), "A");
|
||||
assert.equal(initialOf("Bob"), "B");
|
||||
assert.equal(initialOf(""), "?");
|
||||
assert.equal(initialOf(" "), "?");
|
||||
});
|
||||
|
||||
test("buildCollabUrl encodes vault and path segments", () => {
|
||||
const loc = { protocol: "https:", host: "obsigate.local" };
|
||||
assert.equal(
|
||||
buildCollabUrl(loc, "Mon Vault", "Projets/mon fichier.md"),
|
||||
"wss://obsigate.local/ws/collab/Mon%20Vault/Projets/mon%20fichier.md",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCollabUrl uses ws:// for http origins", () => {
|
||||
const loc = { protocol: "http:", host: "localhost:8080" };
|
||||
assert.equal(
|
||||
buildCollabUrl(loc, "V", "note.md"),
|
||||
"ws://localhost:8080/ws/collab/V/note.md",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatPeerCount is always a non-negative integer string", () => {
|
||||
assert.equal(formatPeerCount(0), "0");
|
||||
assert.equal(formatPeerCount(5), "5");
|
||||
assert.equal(formatPeerCount(-2), "0");
|
||||
assert.equal(formatPeerCount(undefined), "0");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
@@ -0,0 +1,336 @@
|
||||
# tests/test_collab.py — Tests for real-time collaboration (ROADMAP #62)
|
||||
"""Unit and integration tests for the collaboration WebSocket relay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from backend.collab import (
|
||||
CollabManager,
|
||||
CollabRoom,
|
||||
authenticate_websocket,
|
||||
color_for_index,
|
||||
)
|
||||
|
||||
|
||||
class _StubWebSocket:
|
||||
"""Minimal WebSocket stub for auth unit tests."""
|
||||
|
||||
def __init__(self, query=None, cookies=None):
|
||||
self.query_params = query or {}
|
||||
self.cookies = cookies or {}
|
||||
|
||||
|
||||
def _recv_json(ws) -> dict:
|
||||
return json.loads(ws.receive_text())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_collab_manager():
|
||||
"""Isolate the collaboration singleton between tests."""
|
||||
from backend.collab import collab_manager
|
||||
|
||||
collab_manager._rooms.clear()
|
||||
collab_manager._next_conn_id = 1
|
||||
yield
|
||||
collab_manager._rooms.clear()
|
||||
collab_manager._next_conn_id = 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_color_for_index_is_deterministic_and_cycles():
|
||||
assert color_for_index(0) == color_for_index(10)
|
||||
assert color_for_index(0) != color_for_index(1)
|
||||
assert color_for_index(0).startswith("#")
|
||||
|
||||
|
||||
def test_authenticate_websocket_disabled_returns_anonymous(monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "false")
|
||||
user = authenticate_websocket(_StubWebSocket())
|
||||
assert user is not None
|
||||
assert user["role"] == "admin"
|
||||
assert user["vaults"] == ["*"]
|
||||
|
||||
|
||||
def test_authenticate_websocket_no_token_returns_none(monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
|
||||
assert authenticate_websocket(_StubWebSocket()) is None
|
||||
|
||||
|
||||
def test_authenticate_websocket_invalid_token_returns_none(monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
|
||||
assert authenticate_websocket(_StubWebSocket(cookies={"access_token": "garbage"})) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manager unit tests (no WebSocket transport)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_manager(tmp_path: Path, debounce: float = 0.05) -> CollabManager:
|
||||
return CollabManager(save_debounce=debounce)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_persists_text_after_debounce(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("initial", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||
|
||||
room.pending_text = "updated content"
|
||||
manager._schedule_save(room)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "updated content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_flush_writes_pending_text(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("initial", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target, pending_text="flushed")
|
||||
await manager._flush(room)
|
||||
assert target.read_text(encoding="utf-8") == "flushed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_rejects_oversized_update(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||
client = _FakeClient(conn_id=1)
|
||||
|
||||
payload = base64.b64encode(b"a" * 10).decode()
|
||||
# Monkeypatch the max size check by sending an oversized base64 string.
|
||||
import backend.collab as collab_mod
|
||||
|
||||
original = collab_mod.MAX_UPDATE_BYTES
|
||||
try:
|
||||
collab_mod.MAX_UPDATE_BYTES = 4
|
||||
await manager._on_message(room, client, json.dumps({"type": "update", "update": payload}))
|
||||
finally:
|
||||
collab_mod.MAX_UPDATE_BYTES = original
|
||||
|
||||
assert room.updates == []
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.sent: list[dict] = []
|
||||
|
||||
async def send_json(self, message: dict):
|
||||
self.sent.append(message)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, conn_id: int):
|
||||
self.conn_id = conn_id
|
||||
self.websocket = _FakeWebSocket()
|
||||
self.username = "u"
|
||||
self.display_name = "u"
|
||||
self.color = "#000"
|
||||
self.y_client_id = None
|
||||
self.awareness = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_appends_update_and_broadcasts(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||
sender = _FakeClient(1)
|
||||
receiver = _FakeClient(2)
|
||||
room.clients[1] = sender
|
||||
room.clients[2] = receiver
|
||||
|
||||
encoded = base64.b64encode(b"hello-yjs").decode()
|
||||
await manager._on_message(room, sender, json.dumps({"type": "update", "update": encoded}))
|
||||
|
||||
assert room.updates == [b"hello-yjs"]
|
||||
assert room.has_updates is True
|
||||
assert receiver.websocket.sent[-1]["type"] == "update"
|
||||
assert receiver.websocket.sent[-1]["update"] == encoded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_awareness_relayed(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||
sender = _FakeClient(1)
|
||||
receiver = _FakeClient(2)
|
||||
room.clients[1] = sender
|
||||
room.clients[2] = receiver
|
||||
|
||||
msg = {"type": "awareness", "clientId": 42, "state": {"cursor": {"anchor": 1, "head": 2}}}
|
||||
await manager._on_message(room, sender, json.dumps(msg))
|
||||
|
||||
assert sender.y_client_id == 42
|
||||
assert receiver.websocket.sent[-1]["type"] == "awareness"
|
||||
assert receiver.websocket.sent[-1]["state"]["cursor"]["head"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests (Starlette TestClient WebSocket)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_websocket_init_and_broadcast(client):
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws1:
|
||||
init1 = _recv_json(ws1)
|
||||
assert init1["type"] == "init"
|
||||
assert init1["connId"] == 1
|
||||
assert init1["seed"] is not None # first client receives the file content
|
||||
assert init1["updates"] == []
|
||||
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws2:
|
||||
init2 = _recv_json(ws2)
|
||||
assert init2["type"] == "init"
|
||||
assert init2["seed"] is None # already seeded
|
||||
# ws1 is notified of the new peer
|
||||
joined = _recv_json(ws1)
|
||||
assert joined["type"] == "peer_joined"
|
||||
assert len(init2["peers"]) == 2
|
||||
|
||||
encoded = base64.b64encode(b"payload").decode()
|
||||
ws1.send_text(json.dumps({"type": "update", "update": encoded}))
|
||||
relayed = _recv_json(ws2)
|
||||
assert relayed["type"] == "update"
|
||||
assert relayed["update"] == encoded
|
||||
|
||||
# ws2 left → ws1 gets peer_left
|
||||
left = _recv_json(ws1)
|
||||
assert left["type"] == "peer_left"
|
||||
|
||||
|
||||
def test_websocket_awareness_relay(client):
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws1:
|
||||
_recv_json(ws1)
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws2:
|
||||
_recv_json(ws2)
|
||||
_recv_json(ws1) # peer_joined
|
||||
|
||||
ws1.send_text(json.dumps({
|
||||
"type": "awareness",
|
||||
"clientId": 7,
|
||||
"state": {"user": {"name": "Alice"}, "cursor": {"anchor": 3, "head": 3}},
|
||||
}))
|
||||
msg = _recv_json(ws2)
|
||||
assert msg["type"] == "awareness"
|
||||
assert msg["clientId"] == 7
|
||||
assert msg["state"]["user"]["name"] == "Alice"
|
||||
|
||||
|
||||
def test_websocket_persists_text(client, test_vault_dir):
|
||||
from backend import collab as collab_mod
|
||||
|
||||
original = collab_mod.collab_manager._save_debounce
|
||||
collab_mod.collab_manager._save_debounce = 0.05
|
||||
try:
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws:
|
||||
_recv_json(ws)
|
||||
ws.send_text(json.dumps({"type": "text", "text": "# Persisted by collab\n"}))
|
||||
deadline = time.time() + 2
|
||||
target = Path(test_vault_dir) / "note1.md"
|
||||
while time.time() < deadline:
|
||||
if target.read_text(encoding="utf-8") == "# Persisted by collab\n":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert target.read_text(encoding="utf-8") == "# Persisted by collab\n"
|
||||
finally:
|
||||
collab_mod.collab_manager._save_debounce = original
|
||||
|
||||
|
||||
def test_websocket_unknown_file_rejected(client):
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect("/ws/collab/TestVault/missing.md") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_websocket_unknown_vault_rejected(client):
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect("/ws/collab/Nope/note1.md") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_websocket_requires_auth(admin_client):
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with admin_client.websocket_connect("/ws/collab/TestVault/note1.md") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_websocket_vault_acl_enforced(admin_client):
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
root = Path(get_vault_data("TestVault")["path"])
|
||||
target = root / "acl_test.md"
|
||||
target.write_text("hi", encoding="utf-8")
|
||||
try:
|
||||
_login(admin_client, "normaluser", "normal123") # sets the access_token cookie
|
||||
# normaluser has access to TestVault only.
|
||||
with admin_client.websocket_connect("/ws/collab/TestVault/acl_test.md") as ws:
|
||||
assert _recv_json(ws)["type"] == "init"
|
||||
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with admin_client.websocket_connect("/ws/collab/OtherVault/acl_test.md") as ws:
|
||||
ws.receive_text()
|
||||
finally:
|
||||
target.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _login(client: TestClient, username: str, password: str) -> str:
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _recv_until(ws, msg_type: str, limit: int = 20) -> dict:
|
||||
for _ in range(limit):
|
||||
msg = _recv_json(ws)
|
||||
if msg.get("type") == msg_type:
|
||||
return msg
|
||||
raise AssertionError(f"message type '{msg_type}' not received")
|
||||
|
||||
|
||||
def test_websocket_load_five_clients(client):
|
||||
"""5+ utilisateurs simultanés sur le même fichier (ROADMAP #62)."""
|
||||
from backend.collab import collab_manager
|
||||
|
||||
contexts = []
|
||||
try:
|
||||
sessions = []
|
||||
for _ in range(5):
|
||||
ctx = client.websocket_connect("/ws/collab/TestVault/note1.md")
|
||||
ws = ctx.__enter__()
|
||||
contexts.append(ctx)
|
||||
sessions.append(ws)
|
||||
assert _recv_json(ws)["type"] == "init"
|
||||
|
||||
assert collab_manager.room_peer_count("TestVault", "note1.md") == 5
|
||||
|
||||
# The first client broadcasts an update, all 4 others receive it.
|
||||
encoded = base64.b64encode(b"load-test-update").decode()
|
||||
sessions[0].send_text(json.dumps({"type": "update", "update": encoded}))
|
||||
for ws in sessions[1:]:
|
||||
relayed = _recv_until(ws, "update")
|
||||
assert relayed["update"] == encoded
|
||||
finally:
|
||||
for ctx in reversed(contexts):
|
||||
try:
|
||||
ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user