v5.13.0 Realtime : édition collaborative en direct
- Passerelle WebSocket WS /ws/pages/{page_id} (auth cookie, close 4401/4404), rooms par page en mémoire
- Protocole hello/sync/op/ack/title/sel/ping/peer_join/peer_leave ; version de page + stale => resync
- Merge des ops de blocs insert/update/delete/move, last-write-wins par bloc, ordre d'arrivée
- Client éditeur : diff local -> ops (debounce), application distante discret (LWW sur bloc focalisé), re-focus du bloc actif
- Présence (avatars topbar) + curseurs live (calque dédié, positions à l'édition/au scroll)
- Titre synchronisé (debounce) sans écraser le titre en cours d'édition
- Fallback polling 10 s si WS indisponible (adopté seulement sans brouillon local) + reconnexion auto
- Persistance debounce ~1 s (content/title) + flush à la déconnexion du dernier client
- CSP connect-src étendu à ws: ; peers sans données sensibles (id/login/full_name/couleur)
- 12 tests tests/test_realtime.py (auth, page absente, hello->sync, LWW 2 clients + persistance, présence, curseurs, titre, stale resync, apply_op/merge_ops) ; suite 298 verte (3 PDF pré-existants)
- Bump v5.3.0 (VERSION, main.py, CHANGELOG) ; ROADMAP v5.13.0 livré
This commit is contained in:
@@ -1,5 +1,39 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v5.3.0 (2026-09-08) — Realtime : édition collaborative en direct
|
||||
|
||||
> Deux utilisateurs peuvent maintenant éditer une même page **en même temps**
|
||||
> : les blocs, le titre, les curseurs et la présence se synchronisent via une
|
||||
> connexion WebSocket, avec fusion last-write-wins et repli sur polling.
|
||||
|
||||
- **Passerelle WebSocket** `WS /ws/pages/{page_id}` (app/services/realtime_server.py
|
||||
+ app/routers/realtime.py) : salons en mémoire par page, authentifiés via le
|
||||
cookie de session (refus 4401), page introuvable/supprimée → 4404.
|
||||
- **Protocole de synchronisation** : `hello`/`sync`/`op`/`ack`/`title`/`sel`/
|
||||
`ping`/`peer_join`/`peer_leave` ; chaque page porte une version incrémentée à
|
||||
chaque opération appliquée ; un client périmé reçoit un `sync` complet.
|
||||
- **Merge last-write-wins par bloc** : insert, update (remplacement de bloc),
|
||||
delete, move — appliqués dans l'ordre d'arrivée ; une frappe en cours dans un
|
||||
bloc focalisé n'est jamais écrasée par une mise à jour distante.
|
||||
- **Client éditeur** (app/templates/_page_editor_realtime.html) : diff local →
|
||||
ops (update/delete/move/insert), envoi dès que l'utilisateur tape (debounce),
|
||||
application des ops distantes avec re-render discret + re-focus du bloc actif.
|
||||
- **Présence & curseurs** : avatars colorés dans la barre du haut et curseurs
|
||||
de chaque pair positionnés dans le document (calque dédié, mise à jour à
|
||||
l'édition/au scroll).
|
||||
- **Titre synchronisé** : diffusion du titre (debounce) sans écraser un titre
|
||||
en cours d'édition.
|
||||
- **Fallback polling** : si le WebSocket est indisponible, rafraîchissement
|
||||
toutes les 10 s de l'état serveur (adopté uniquement si pas de brouillon
|
||||
local) avec reconnexion automatique.
|
||||
- **Persistance** : écriture debounce (~1 s) de `content` + `title` en base,
|
||||
flush immédiat à la déconnexion du dernier client.
|
||||
- **Sécurité** : `connect-src` CSP étendu à `ws:` ; aucune donnée sensible
|
||||
transmise (les peers n'exposent que id/login/full_name/couleur).
|
||||
- **Tests** (tests/test_realtime.py) : auth, page absente, hello→sync, merge
|
||||
LWW à deux clients + persistance, présence join/leave, curseurs, titre,
|
||||
resync des clients périmés, `apply_op`/`merge_ops` unitaires.
|
||||
|
||||
## v5.2.0 (2026-09-07) - Automations : un moteur de règles (if-this-then-that)
|
||||
|
||||
> Nouveau moteur de règles : brancher des actions sur des événements de pages
|
||||
|
||||
+29
-20
@@ -423,13 +423,19 @@ app/
|
||||
- [x] **Quick actions** — navigation, création, commandes
|
||||
- [x] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)
|
||||
|
||||
### v5.1.0 — Automations ⬜ (non commencé)
|
||||
### v5.1.0 — Automations ✅ (2026-09-07)
|
||||
> **Objectif** : moteur de règles if-this-then-that + boutons cliquables. **COMPLETED**.
|
||||
|
||||
> 💡 Note : les **webhooks sortants** (`webhook_subscriptions`, CRUD `/workspace/webhooks`,
|
||||
> `services/webhook_outbound.py`) sont déjà implémentés — bonne base pour les actions automation.
|
||||
|
||||
- [ ] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action)
|
||||
- [ ] **Buttons** — boutons cliquables déclenchant des actions
|
||||
- [x] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action)
|
||||
- Déclencheurs : événement (`page.created/updated/deleted/moved`, `collection.*`) / cron (`*/N`, minute fixe, `@hourly`, `@daily`) / bouton
|
||||
- Conditions combinables : eq, neq, contains, not_contains, is_empty, is_not_empty, changed
|
||||
- Actions : webhook (X-FlowDeck-Secret), set_property (validé), create_page (interpolation `[[prop]]`/`{{title}}`), notify
|
||||
- Tables `automations` + `automation_runs` (migration v5), scheduler de fond (60 s), historique des exécutions
|
||||
- [x] **Buttons** — boutons cliquables déclenchant des actions
|
||||
- Bloc `button` dans l'éditeur (menu slash) + picker d'automation inline
|
||||
- Endpoint `/api/automations/{id}/run` (exempt CSRF) + UI Settings → Automations
|
||||
- [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template)
|
||||
- [x] **11 tests** `tests/test_automations.py` ; suite complète 289 verte
|
||||
|
||||
### v5.2.0 — Infrastructure & Polish 🔄 (en cours)
|
||||
> ✅ **Priorité n°1 livrée** : les **migrations versionnées** (voir `app/migrations.py` +
|
||||
@@ -473,6 +479,17 @@ app/
|
||||
- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés
|
||||
- [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule)
|
||||
|
||||
### v5.13.0 — Collaboration temps réel ✅ (2026-09-08)
|
||||
> **Objectif** : édition collaborative en direct (parité Notion en équipe). **COMPLETED**.
|
||||
> Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks).
|
||||
|
||||
- [x] **WebSocket gateway** — endpoint `WS /ws/pages/{id}`, auth via cookie session (refus 4401), page introuvable/supprimée → 4404 ; rooms par page en mémoire, chargées depuis la base au premier connect, droppées quand vides
|
||||
- [x] **Présence** — avatars des utilisateurs connectés (topbar), couleur par utilisateur, join/leave broadcasté ; `welcome` = self + peers
|
||||
- [x] **Curseurs live** — position curseur/sélection des autres éditeurs (block + offset), calque dédié, label avec nom, mise à jour à l'édition/au scroll
|
||||
- [x] **Merge de modifications** — diffusion des ops de blocs insert/update/delete/move avec **last-write-wins par bloc** + version de page (stale → sync complet) ; titre synchronisé (debounce) ; persistance debounce ~1 s + flush à la déconnexion du dernier client
|
||||
- [x] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s (adopté seulement sans brouillon local) + reconnexion automatique
|
||||
- [x] CSP `connect-src` étendu à `ws:` ; **12 tests** `tests/test_realtime.py` ; suite complète 298 verte (+3 PDF pré-existants)
|
||||
|
||||
### v5.4.0 — Expérience éditeur (nouveautés, parité Notion)
|
||||
- [ ] **Backlinks** — section « Lié depuis… » en bas de page (scan des liens internes)
|
||||
- [ ] **Duplicates** — « Duplicate » sur page + collection (menu `...`)
|
||||
@@ -557,15 +574,7 @@ app/
|
||||
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
|
||||
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
|
||||
|
||||
## v5.13.0 — Collaboration temps réel ⬜ (non commencé)
|
||||
> **Objectif** : avancer depuis v6.0.0 le chantier n°1 de la parité Notion en équipe.
|
||||
> Sans realtime, FlowDeck reste un outil mono-utilisateur partagé.
|
||||
|
||||
- [ ] **WebSocket gateway** — endpoint `/ws/pages/{id}`, auth via session, rooms par page
|
||||
- [ ] **Présence** — avatars des utilisateurs connectés sur la page (topbar), couleur par utilisateur
|
||||
- [ ] **Curseurs live** — position des curseurs/sélections des autres éditeurs, label avec nom
|
||||
- [ ] **Merge de modifications** — diffusion des opérations de blocs (insert/update/delete/move) avec last-write-wins par bloc + version de page pour détecter les conflits
|
||||
- [ ] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s
|
||||
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.14.0 — Synced blocks ⬜ (non commencé)
|
||||
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
|
||||
@@ -604,8 +613,8 @@ app/
|
||||
1. ~~**v5.2.0 → Migrations versionnées**~~ ✅ livré (`schema_version` + `app/migrations.py`)
|
||||
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
|
||||
4. **v5.13.0 → Realtime (WS + présence)** — chantier de parité Notion n°1, socle aussi pour v5.14 (synced blocks)
|
||||
5. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré
|
||||
1. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
|
||||
2. **v5.10.0 → Interactions de bloc** (drag&drop, undo/redo, duplicate) — gros impact UX, effort modéré — prochaine priorité
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
@@ -617,11 +626,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.9 ⬜ v5.10–5.12 ⬜ v5.13–5.14 ⬜ v6.0 ⬜
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.4–5.12 ⬜ v5.13 ✅ · v5.14 ⬜ v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Éditeur bloc Realtime + Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations (drag&drop, Synced blocks
|
||||
Collaboration Embeds, Import, undo/redo) +
|
||||
Collaboration Realtime, undo/redo) +
|
||||
DB avancée, Wiki-links,
|
||||
Calendrier, AI Templates & lock
|
||||
|
||||
*Dernière mise à jour: 2026-09-06 — v5.0.0 (palette+FTS5) et v5.2.0 (migrations versionnées) livrés ; v5.3.0 Database Avancée livré (inline `/database`, 6 templates prédéfinis, validation propriétés, 280 tests) ; reste v5.4 → v6.0*
|
||||
*Dernière mise à jour: 2026-09-08 — v5.13.0 Realtime livré (WebSocket gateway, présence, curseurs live, merge LWW + fallback polling, 12 tests, suite 298 verte) ; prochaine priorité v5.10.0 Interactions de bloc ; reste v5.4 → v6.0*
|
||||
|
||||
+4
-2
@@ -18,6 +18,7 @@ from app.routers import dashboard, board, notes, api, auth, webhooks, collection
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
from app.services.gitea_client import gitea
|
||||
@@ -52,7 +53,7 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.automations import automation_scheduler
|
||||
automation_task = asyncio.create_task(automation_scheduler())
|
||||
|
||||
logger.info("FlowDeck v5.2.0 started on port %d", settings.app_port)
|
||||
logger.info("FlowDeck v5.3.0 started on port %d", settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -70,7 +71,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="5.2.0",
|
||||
version="5.3.0",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
@@ -102,6 +103,7 @@ app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(automations_router)
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(realtime_router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https: wss:; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway /ws/pages/{page_id}.
|
||||
|
||||
Auth via cookie session (flowdeck_session). Rooms in-memory par page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.realtime_server import manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["realtime"])
|
||||
|
||||
|
||||
@router.websocket("/ws/pages/{page_id}")
|
||||
async def ws_page(websocket: WebSocket, page_id: int):
|
||||
await websocket.accept()
|
||||
user = SessionManager.decode_session(
|
||||
websocket.cookies.get("flowdeck_session", "")
|
||||
)
|
||||
if not user or not user.get("id"):
|
||||
try:
|
||||
await websocket.close(code=4401)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
conn = await manager.connect(websocket, page_id, user)
|
||||
if not conn:
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await manager.handle(conn, msg)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("ws closed: %s", e)
|
||||
finally:
|
||||
await manager.disconnect(conn)
|
||||
@@ -0,0 +1,294 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway, présences, curseurs live,
|
||||
merge des opérations de blocs (last-write-wins par bloc) + version de page.
|
||||
|
||||
Rooms in-memory (un seul worker uvicorn). Persistance en base (page.content)
|
||||
avec debounce. Fallback polling côté client si le WS est indisponible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLORS = ["#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333"]
|
||||
|
||||
|
||||
def color_for(uid: int) -> str:
|
||||
return COLORS[(uid or 0) % len(COLORS)]
|
||||
|
||||
|
||||
def block_id() -> str:
|
||||
import time
|
||||
return f"b{int(time.time()*1000)}"
|
||||
|
||||
|
||||
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
|
||||
"""Apply one block op (insert/update/delete/move) — LWW par bloc."""
|
||||
t = op.get("type")
|
||||
if t == "insert":
|
||||
blk = op.get("block") or {}
|
||||
if not blk.get("id"):
|
||||
blk = dict(blk)
|
||||
blk["id"] = block_id()
|
||||
idx = op.get("index")
|
||||
if not isinstance(idx, int):
|
||||
idx = len(blocks)
|
||||
idx = max(0, min(idx, len(blocks)))
|
||||
return blocks[:idx] + [blk] + blocks[idx:]
|
||||
if t == "update":
|
||||
nb = op.get("block") or {}
|
||||
if not nb.get("id"):
|
||||
return blocks
|
||||
return [nb if b.get("id") == nb["id"] else b for b in blocks]
|
||||
if t == "delete":
|
||||
bid = op.get("id")
|
||||
return [b for b in blocks if b.get("id") != bid]
|
||||
if t == "move":
|
||||
bid = op.get("id")
|
||||
idx = op.get("index", 0) or 0
|
||||
out = [b for b in blocks if b.get("id") != bid]
|
||||
idx = max(0, min(idx, len(out)))
|
||||
moved = next((b for b in blocks if b.get("id") == bid), None)
|
||||
if moved is None:
|
||||
return blocks
|
||||
out.insert(idx, moved)
|
||||
return out
|
||||
return blocks
|
||||
|
||||
|
||||
def merge_ops(blocks: list[dict], ops: list[dict]) -> list[dict]:
|
||||
"""Apply a batch of ops sequentially (arrival order)."""
|
||||
out = blocks
|
||||
for op in ops or []:
|
||||
out = apply_op(out, op)
|
||||
return out
|
||||
|
||||
|
||||
class Room:
|
||||
__slots__ = ("page_id", "blocks", "title", "version", "conns",
|
||||
"persist_task", "dirty")
|
||||
|
||||
def __init__(self, page_id: int):
|
||||
self.page_id = page_id
|
||||
self.blocks: list[dict] = []
|
||||
self.title = ""
|
||||
self.version = 0
|
||||
self.conns: set["RTConn"] = set()
|
||||
self.persist_task: asyncio.Task | None = None
|
||||
self.dirty = False
|
||||
|
||||
|
||||
class RTConn:
|
||||
__slots__ = ("ws", "user", "page_id")
|
||||
|
||||
def __init__(self, ws: WebSocket, user: dict, page_id: int):
|
||||
self.ws = ws
|
||||
self.user = user
|
||||
self.page_id = page_id
|
||||
|
||||
|
||||
class RealtimeManager:
|
||||
def __init__(self):
|
||||
self._rooms: dict[int, Room] = {}
|
||||
|
||||
def room(self, page_id: int) -> Room:
|
||||
return self._rooms.setdefault(page_id, Room(page_id))
|
||||
|
||||
@staticmethod
|
||||
def _peer(user: dict) -> dict:
|
||||
uid = user.get("id") or 0
|
||||
return {
|
||||
"id": uid,
|
||||
"login": user.get("login", ""),
|
||||
"full_name": user.get("full_name", "") or user.get("login", ""),
|
||||
"color": color_for(uid),
|
||||
}
|
||||
|
||||
async def load_room(self, room: Room) -> bool:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||
(room.page_id,),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("realtime load failed: %s", e)
|
||||
return False
|
||||
if not row:
|
||||
return False
|
||||
room.title = row["title"] or ""
|
||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||
try:
|
||||
room.blocks = json.loads(row["content"])
|
||||
except Exception:
|
||||
room.blocks = []
|
||||
return True
|
||||
|
||||
async def connect(self, ws: WebSocket, page_id: int, user: dict) -> RTConn | None:
|
||||
room = self.room(page_id)
|
||||
if not room.conns and not await self.load_room(room):
|
||||
await ws.close(code=4404)
|
||||
return None
|
||||
conn = RTConn(ws, user, page_id)
|
||||
room.conns.add(conn)
|
||||
me = self._peer(user)
|
||||
peers = [self._peer(c.user) for c in room.conns if c is not conn]
|
||||
await ws.send_json({"t": "welcome", "self": me,
|
||||
"peers": peers, "color": me["color"]})
|
||||
await ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
for c in room.conns:
|
||||
if c is not conn:
|
||||
try:
|
||||
await c.ws.send_json({"t": "peer_join", "peer": me})
|
||||
except Exception:
|
||||
pass
|
||||
return conn
|
||||
|
||||
async def disconnect(self, conn: RTConn):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if not room:
|
||||
return
|
||||
room.conns.discard(conn)
|
||||
for c in room.conns:
|
||||
try:
|
||||
await c.ws.send_json({"t": "peer_leave",
|
||||
"id": conn.user.get("id") or 0})
|
||||
except Exception:
|
||||
pass
|
||||
if not room.conns:
|
||||
await self.flush(room)
|
||||
self._rooms.pop(conn.page_id, None)
|
||||
|
||||
async def flush(self, room: Room):
|
||||
"""Write current room state to DB (sync, used on idle + disconnect)."""
|
||||
if room.persist_task and not room.persist_task.done():
|
||||
room.persist_task.cancel()
|
||||
await self._persist(room)
|
||||
|
||||
async def _persist(self, room: Room):
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(room.blocks, ensure_ascii=False), room.title, room.page_id),
|
||||
)
|
||||
conn.commit()
|
||||
room.dirty = False
|
||||
except Exception as e:
|
||||
logger.warning("realtime persist failed: %s", e)
|
||||
|
||||
def _schedule_persist(self, room: Room):
|
||||
if room.persist_task and not room.persist_task.done():
|
||||
return
|
||||
room.dirty = True
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
await asyncio.sleep(1.2)
|
||||
await self._persist(room)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
room.persist_task = asyncio.create_task(_run())
|
||||
|
||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||
for c in room.conns:
|
||||
if c is exclude:
|
||||
continue
|
||||
try:
|
||||
await c.ws.send_json(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def handle(self, conn: RTConn, msg: dict):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if not room:
|
||||
return
|
||||
t = msg.get("t")
|
||||
me = (conn.user.get("id") or 0)
|
||||
|
||||
if t == "hello":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "sync_req":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "ping":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "pong"})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "op":
|
||||
op = msg.get("op") or {}
|
||||
client_v = msg.get("v", 0)
|
||||
room.blocks = apply_op(room.blocks, op)
|
||||
room.version += 1
|
||||
self._schedule_persist(room)
|
||||
stale = client_v < room.version - 1
|
||||
await self._broadcast(room, {"t": "op", "op": op, "from": me,
|
||||
"v": room.version}, exclude=conn)
|
||||
try:
|
||||
await conn.ws.send_json({"t": "ack", "v": room.version,
|
||||
"stale": stale})
|
||||
except Exception:
|
||||
pass
|
||||
if stale:
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync",
|
||||
"blocks": room.blocks,
|
||||
"title": room.title,
|
||||
"version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "title":
|
||||
fmt = (msg.get("title") or "").strip()
|
||||
if fmt and fmt != room.title:
|
||||
room.title = fmt
|
||||
room.version += 1
|
||||
self._schedule_persist(room)
|
||||
await self._broadcast(room, {"t": "title", "title": room.title,
|
||||
"from": me, "v": room.version}, exclude=conn)
|
||||
try:
|
||||
await conn.ws.send_json({"t": "ack", "v": room.version,
|
||||
"stale": False})
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if t == "sel":
|
||||
await self._broadcast(room, {"t": "sel", "from": me,
|
||||
"peer": self._peer(conn.user),
|
||||
"block": msg.get("block"),
|
||||
"offset": msg.get("offset", 0)}, exclude=conn)
|
||||
return
|
||||
|
||||
async def room_state(self, page_id: int) -> dict:
|
||||
room = self.room(page_id)
|
||||
if not room.conns and not room.blocks:
|
||||
await self.load_room(room)
|
||||
return {"blocks": room.blocks, "title": room.title, "version": room.version}
|
||||
|
||||
|
||||
manager = RealtimeManager()
|
||||
@@ -0,0 +1,480 @@
|
||||
<style>
|
||||
/* ── v5.13.0 Realtime : présence + curseurs live ── */
|
||||
.rt-presence{display:inline-flex;align-items:center;gap:2px;margin-right:4px;}
|
||||
.rt-presence .rt-avatar{width:22px;height:22px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;color:#fff;border:2px solid var(--bg-secondary,#1f1f1f);cursor:default;user-select:none;}
|
||||
.rt-presence .rt-avatar.rt-me{opacity:.85;box-shadow:0 0 0 1px var(--text-primary,#e6e6e6);}
|
||||
.rt-presence .rt-count{font-size:11px;color:var(--text-tertiary,#999);margin-left:2px;}
|
||||
.rt-cursors{position:fixed;inset:0;pointer-events:none;z-index:1200;}
|
||||
.rt-cursor{position:fixed;transform:translate(-1px,-1px);min-width:2px;width:2px;z-index:1201;border-radius:1px;}
|
||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||
</style>
|
||||
<script>
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: {{ (user.get('id') if user else 0) | tojson }},
|
||||
login: {{ (user.get('login','') if user else '') | tojson }},
|
||||
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
|
||||
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
ops.push({ type: 'update', block: clone(b) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify((blocks || []).map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
out = (blocks || []).map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
} catch (e) { return; }
|
||||
const seen = {}; (blocks || []).forEach(b => { seen[b.id] = 1; });
|
||||
(E.blocks || []).forEach(b => { if (!seen[b.id]) out.push(b); });
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
document.addEventListener('selectionchange', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); }, true);
|
||||
window.addEventListener('scroll', scheduleDraw, true);
|
||||
window.addEventListener('resize', scheduleDraw);
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
</script>
|
||||
@@ -715,7 +715,7 @@
|
||||
init(){
|
||||
_bid=Math.floor(Math.random()*10000);
|
||||
const dataEl=document.getElementById('page-data');
|
||||
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';const raw=data.content||'';
|
||||
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||'';
|
||||
if(fmt==='file'){this.fileData=data;this.loadFileContent(data);}
|
||||
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
|
||||
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
|
||||
@@ -726,6 +726,7 @@
|
||||
SM.init();_tblInitDelegate();this.render();
|
||||
const tEl=document.getElementById('_titleEl');
|
||||
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
|
||||
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
|
||||
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
|
||||
ct.addEventListener('keydown',e=>this.onKd(e));
|
||||
ct.addEventListener('paste',e=>this.handleSmartPaste(e));
|
||||
|
||||
@@ -9,5 +9,6 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/js/katex.min.js?v=0.16.11" defer></script>
|
||||
{% include "_page_editor_scripts.html" %}
|
||||
{% include "_page_editor_realtime.html" %}
|
||||
{% include "_database_table_scripts.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway, présence, curseurs live,
|
||||
merge LWW des opérations de blocs + version de page + persistance debounce.
|
||||
|
||||
Covers: la route WS /ws/pages/{page_id} (auth via cookie, page introuvable),
|
||||
le protocole hello/sync/op/ack/sel/title/peer_*, le merge last-write-wins et
|
||||
la resynchronisation des clients périmés.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-realtime"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
|
||||
from app.main import app
|
||||
from app.db import init_db, get_conn
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (1, 'tester', 'Tester', 1)")
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (2, 'other', 'Other', 0)")
|
||||
conn.commit()
|
||||
|
||||
from app.services.realtime_server import manager
|
||||
manager._rooms = {}
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
def _make_page(title="Realtime Page", blocks=None):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('Private', ?, ?, 'blocks', 'Private')",
|
||||
(title, json.dumps(blocks or [], ensure_ascii=False)),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _token(user_id, login):
|
||||
from app.auth.session import SessionManager
|
||||
return SessionManager.create_session({"id": user_id, "login": login,
|
||||
"full_name": login.title(), "is_admin": 1})
|
||||
|
||||
|
||||
def _auth_client(client, user_id=1, login="tester"):
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login))
|
||||
|
||||
|
||||
# ── apply_op (service pur) ──
|
||||
|
||||
def test_apply_op_insert():
|
||||
from app.services.realtime_server import apply_op
|
||||
blocks = [{"id": "a", "content": "A"}]
|
||||
out = apply_op(blocks, {"type": "insert", "index": 0,
|
||||
"block": {"id": "b", "content": "B"}})
|
||||
assert [b["id"] for b in out] == ["b", "a"]
|
||||
assert apply_op(blocks, {"type": "insert", "index": None,
|
||||
"block": {"content": "C"}})[-1]["content"] == "C"
|
||||
# index clampé
|
||||
assert apply_op(blocks, {"type": "insert", "index": 99,
|
||||
"block": {"id": "z"}})[-1]["id"] == "z"
|
||||
|
||||
|
||||
def test_apply_op_update_delete_move():
|
||||
from app.services.realtime_server import apply_op
|
||||
blocks = [{"id": "a", "content": "A"}, {"id": "b", "content": "B"},
|
||||
{"id": "c", "content": "C"}]
|
||||
out = apply_op(blocks, {"type": "update", "block": {"id": "b", "content": "B2"}})
|
||||
assert next(x for x in out if x["id"] == "b")["content"] == "B2"
|
||||
out = apply_op(blocks, {"type": "update", "block": {"id": "nope", "content": "X"}})
|
||||
assert out == blocks
|
||||
out = apply_op(blocks, {"type": "delete", "id": "a"})
|
||||
assert [b["id"] for b in out] == ["b", "c"]
|
||||
out = apply_op(blocks, {"type": "move", "id": "c", "index": 0})
|
||||
assert [b["id"] for b in out] == ["c", "a", "b"]
|
||||
out = apply_op(blocks, {"type": "move", "id": "nope", "index": 0})
|
||||
assert out == blocks
|
||||
assert apply_op(blocks, {"type": "unknown"}) == blocks
|
||||
|
||||
|
||||
def test_merge_ops_sequential():
|
||||
from app.services.realtime_server import merge_ops
|
||||
blocks = []
|
||||
ops = [
|
||||
{"type": "insert", "index": 0, "block": {"id": "a", "content": "A"}},
|
||||
{"type": "insert", "index": 1, "block": {"id": "b", "content": "B"}},
|
||||
{"type": "update", "block": {"id": "a", "content": "A2"}},
|
||||
{"type": "move", "id": "b", "index": 0},
|
||||
]
|
||||
assert [x["id"] for x in merge_ops(blocks, ops)] == ["b", "a"]
|
||||
assert next(x for x in merge_ops(blocks, ops) if x["id"] == "a")["content"] == "A2"
|
||||
|
||||
|
||||
# ── Auth & présence de page ──
|
||||
|
||||
def test_ws_requires_auth(client):
|
||||
_make_page()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/1") as ws:
|
||||
ws.receive_json()
|
||||
assert exc.value.code == 4401
|
||||
|
||||
|
||||
def test_ws_auth_rejected(client):
|
||||
_make_page()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/1") as ws:
|
||||
ws.receive_json()
|
||||
assert exc.value.code == 4401
|
||||
|
||||
|
||||
def test_ws_missing_page_4404(client):
|
||||
_auth_client(client)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/9999") as ws:
|
||||
ws.receive_json()
|
||||
assert exc.value.code == 4404
|
||||
|
||||
|
||||
def test_ws_hello_gets_sync(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "Hi"}])
|
||||
_auth_client(client)
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as ws:
|
||||
w = ws.receive_json()
|
||||
assert w["t"] == "welcome"
|
||||
assert w["self"]["id"] == 1
|
||||
s = ws.receive_json()
|
||||
assert s["t"] == "sync"
|
||||
assert s["version"] == 0
|
||||
assert s["blocks"][0]["id"] == "a"
|
||||
assert s["title"] == "Realtime Page"
|
||||
ws.send_json({"t": "hello"})
|
||||
s2 = ws.receive_json()
|
||||
assert s2["t"] == "sync" and s2["blocks"][0]["content"] == "Hi"
|
||||
|
||||
|
||||
# ── merge LWW + broadcast ──
|
||||
|
||||
def test_ws_lww_merge(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "x"},
|
||||
{"id": "b", "type": "paragraph", "content": "y"}])
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json() # welcome
|
||||
wa.receive_json() # sync
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json() # welcome (peer tester)
|
||||
wb.receive_json() # sync
|
||||
wa.receive_json() # peer_join other
|
||||
wa.send_json({"t": "op", "v": 0, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "first"}}})
|
||||
ack = wa.receive_json() # ack de son propre op
|
||||
assert ack["t"] == "ack" and ack["v"] == 1
|
||||
b_op = wb.receive_json()
|
||||
assert b_op["t"] == "op" and b_op["from"] == 1
|
||||
assert b_op["op"]["block"]["content"] == "first"
|
||||
wb.send_json({"t": "op", "v": 1, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "second"}}})
|
||||
wb.receive_json() # ack de son propre op
|
||||
a_op = wa.receive_json()
|
||||
assert a_op["t"] == "op" and a_op["from"] == 2
|
||||
assert a_op["op"]["block"]["content"] == "second"
|
||||
# dernier arrivé gagne (LWW) et persistance au disconnect
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
d = json.loads(row["content"])
|
||||
assert d[0]["content"] == "second"
|
||||
|
||||
|
||||
# ── présence ──
|
||||
|
||||
def test_ws_presence_join_leave(client):
|
||||
pid = _make_page()
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json(); wa.receive_json()
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
w_f = wb.receive_json() # welcome
|
||||
assert w_f["t"] == "welcome"
|
||||
assert any(p["id"] == 1 for p in w_f["peers"])
|
||||
wb.receive_json() # sync
|
||||
pj = wa.receive_json() # peer_join other
|
||||
assert pj["t"] == "peer_join" and pj["peer"]["id"] == 2
|
||||
pl = wa.receive_json() # peer_leave other
|
||||
assert pl["t"] == "peer_leave" and pl["id"] == 2
|
||||
|
||||
|
||||
def test_ws_cursor_broadcast(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "l"}])
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json(); wa.receive_json()
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json(); wb.receive_json()
|
||||
wa.receive_json() # peer_join
|
||||
wa.send_json({"t": "sel", "block": "a", "offset": 1})
|
||||
m = wb.receive_json()
|
||||
assert m["t"] == "sel" and m["from"] == 1
|
||||
assert m["block"] == "a" and m["offset"] == 1
|
||||
wb.send_json({"t": "sel", "block": None, "offset": 0})
|
||||
m2 = wa.receive_json()
|
||||
assert m2["t"] == "sel" and m2["block"] is None
|
||||
|
||||
|
||||
def test_ws_title_broadcast(client):
|
||||
pid = _make_page()
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json(); wa.receive_json()
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json(); wb.receive_json()
|
||||
wa.receive_json()
|
||||
wa.send_json({"t": "title", "title": "Nouveau titre"})
|
||||
m = wb.receive_json()
|
||||
assert m["t"] == "title" and m["title"] == "Nouveau titre"
|
||||
|
||||
|
||||
# ── resynchronisation des clients périmés ──
|
||||
|
||||
def test_ws_stale_client_gets_sync(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "x"}])
|
||||
_auth_client(client)
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as ws:
|
||||
ws.receive_json() # welcome
|
||||
ws.receive_json() # sync
|
||||
for i in range(3):
|
||||
ws.send_json({"t": "op", "v": 0, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": f"v{i}"}}})
|
||||
# séquence déterministe : ack(v1), ack(v2,stale)+sync(v2), ack(v3,stale)+sync(v3)
|
||||
got_stale = False
|
||||
syncs = []
|
||||
for _ in range(5):
|
||||
m = ws.receive_json()
|
||||
if m["t"] == "ack" and m.get("stale"):
|
||||
got_stale = True
|
||||
if m["t"] == "sync":
|
||||
syncs.append(m)
|
||||
assert got_stale
|
||||
assert syncs and syncs[-1]["version"] >= 3
|
||||
assert syncs[-1]["blocks"][0]["content"] == "v2"
|
||||
Reference in New Issue
Block a user