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

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:
2026-09-11 23:27:22 -04:00
parent 3f7b7847a5
commit 063b02e996
19 changed files with 1741 additions and 33 deletions
+3 -1
View File
@@ -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
View File
@@ -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 ;
+20
View File
@@ -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 :
+17
View File
@@ -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 :
+378
View File
@@ -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
View File
@@ -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
# ---------------------------------------------------------------------------
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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** |
---
+142
View File
@@ -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
View File
@@ -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
+596
View File
@@ -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);
}
+9
View File
@@ -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;
+5
View File
@@ -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...",
+5
View File
@@ -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...",
+14
View File
@@ -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
View File
@@ -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 ──────────────────
+130
View File
@@ -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);
+336
View File
@@ -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