feat: v6.0.0 PWA offline support

- manifest + icones, service worker (precache, network-first, Background Sync)

- module client FlowOffline (IndexedDB, queue, delta, flush) + hook editeur

- endpoints /api/v2/sync/{delta,batch,status} + moteur de sync (conflits LWW/orpheline/copie offline)

- migrations offline_sync_queue + sync_version (triggers)

- UI offline (banner, badge sync, toasts, icone dirty) + doc /help

- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
This commit is contained in:
2026-09-18 13:05:40 -04:00
parent 62620ef884
commit b5207216f1
35 changed files with 3115 additions and 23 deletions
+32
View File
@@ -1,5 +1,37 @@
# Changelog - FlowDeck
## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support
> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est
> mis en cache par un service worker, les pages visitées restent disponibles,
> et les modifications faites sans réseau sont mises en file d'attente dans
> IndexedDB puis synchronisées au retour de la connexion, avec résolution de
> conflits côté serveur.
### PWA & Service Worker
- **`static/manifest.json`** + **`static/icons/*`** (72→512 + maskable + apple-touch) générés par `scripts/generate_pwa_icons.py`.
- **`static/sw.js`** — precache du shell (CSS/JS/fonts/icônes), stratégies cache-first (assets) / network-first (HTML, API GET), page offline inline, Background Sync (`sync-flowdeck`), purge des anciens caches.
- **`base.html` / `landing.html`** — meta PWA (manifest, theme-color, apple-mobile-web-app) + enregistrement du service worker. Routes `GET /manifest.json` et `GET /sw.js` servies par FastAPI.
### Offline client (`static/js/offline.js`)
- Base IndexedDB `flowdeck-offline` : `pages_offline`, `collections_offline`, `sync_queue`, `sync_meta`.
- `window.FlowOffline` : `enqueue`, `flush`, `delta`, `status`, `savePageOffline`, `getQueue`, `pendingCount`, `onChange`, `markDirtySidebar`.
- Sauvegarde optimiste hors ligne (hook de l'éditeur), file de mutations rejouée automatiquement (online, Background Sync, polling 30 s).
- Durcissement : max 100 mutations/batch, timeout 30 s (`AbortController`), rétention queue 30 jours.
### Serveur de synchronisation
- **`app/services/sync_engine.py`** — `get_delta` (pull des changements, détection des soft-deletes), `apply_batch` (mutations optimistes + contrôle de version `sync_version`).
- **`app/routers/sync.py`** — `GET /api/v2/sync/delta`, `POST /api/v2/sync/batch`, `GET /api/v2/sync/status` (auth session, CSRF-exempt via `/api/v2`).
- **Migrations** — table `offline_sync_queue` (+ index) et colonnes `sync_version` sur `pages`, `collection_pages`, `collections` avec triggers `AFTER UPDATE`.
- **Conflits** — edit-edit (last-write-wins + rapport), edit-delete (page orpheline recréée), create-create (renommage « copie offline »).
### UI
- Banner hors ligne avec compteur de modifications en attente, badge de synchronisation (spinner), toasts de fin de sync, icône ⟳ sur les pages non synchronisées.
- Section `/help` « Offline mode (PWA) ».
### Tests
- `tests/test_sync.py`, `tests/test_sync_migrations.py`, `tests/test_service_worker.py`, `tests/test_pwa_offline.py` ; E2E Playwright `e2e/pwa_offline.spec.js` (manifest, SW, queue offline, replay, navigation hors ligne).
## v5.12.0 (2026-09-14) — v5.11.0 Wiki-links & v5.12.0 Templates & lock
> Deux versions roadmap livrées ensemble : le graphe de connaissances
+23 -6
View File
@@ -697,13 +697,30 @@ Détails livrés :
---
## v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18)
> **Objectif** : support hors ligne complet (manifest, service worker, IndexedDB,
> queue de mutations, sync serveur + résolution de conflits). **COMPLETED**.
- [x] **Manifest & icônes PWA** — `static/manifest.json`, `static/icons/*`, `scripts/generate_pwa_icons.py`
- [x] **Service Worker** — `static/sw.js` (precache shell, network-first HTML/API, page offline, Background Sync)
- [x] **IndexedDB client** — `static/js/offline.js` (`window.FlowOffline`) : queue, delta, flush, marqueurs dirty
- [x] **Endpoints sync** — `app/routers/sync.py` + `app/services/sync_engine.py` (`/api/v2/sync/delta|batch|status`)
- [x] **Migrations** — table `offline_sync_queue` + colonnes `sync_version` (triggers AFTER UPDATE)
- [x] **Conflits** — edit-edit (last-write-wins), edit-delete (page orpheline), create-create (« copie offline »)
- [x] **UI** — banner offline + pending count, badge de synchronisation, toasts, icône ⟳ sur pages dirty
- [x] **Durcissement** — max 100 mutations/batch, timeout 30 s, rétention queue 30 j
- [x] **Tests** — `test_sync.py`, `test_sync_migrations.py`, `test_service_worker.py`, `test_pwa_offline.py`, E2E `e2e/pwa_offline.spec.js`
- [x] **Doc** — section `/help` « Offline mode (PWA) »
---
## v6.0.0 — Pro (futur)
- [ ] **PWA** — Progressive Web App, offline support
- [ ] **SSO/SAML** — enterprise authentication
- [ ] **Granular permissions** — page-level, property-level access control
- [ ] **Web Clipper** — extension navigateur
- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist)
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
- [ ] **Granular permissions** — page-level, property-level access control — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
- [ ] **Web Clipper** — extension navigateur — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
- [ ] **API publique complète** — REST API documentée (OpenAPI) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 Référence des features v6](/docs/API_GUIDE_V6.md#11-documents-de-conception-détaillée-v600)
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues
@@ -751,4 +768,4 @@ Quality DB views, Agent IA Palette → Realtime + E
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2)
*Dernière mise à jour: 2026-09-15 — **v5.14.0 Synced blocks COMPLETED** (bloc `synced_block`, rendering, unsync, copy & sync). Reste: v6.0.0 Pro (PWA, SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)*
*Dernière mise à jour: 2026-09-18 — **v6.0.0 PWA (offline support) COMPLETED** (manifest, service worker, IndexedDB, sync `/api/v2/sync/*`, conflits, UI offline, tests + E2E). Reste: v6.0.0 Pro (SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)*
+1 -1
View File
@@ -1 +1 @@
5.14.0
6.0.0
+13 -10
View File
@@ -33,6 +33,7 @@ from app.routers import (
security,
sharing,
sidebar_config,
sync,
webhooks,
workspace,
)
@@ -107,7 +108,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.14.0",
version="6.0.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -147,6 +148,7 @@ app.include_router(agent.router)
app.include_router(search.router)
app.include_router(security.router)
app.include_router(onboarding.router)
app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
@@ -155,15 +157,16 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json")
async def pwa_manifest():
return {
"name": "FlowDeck",
"short_name": "FlowDeck",
"start_url": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
}
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js")
async def service_worker():
"""Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript")
# ═══════════ API aliases (v4.0.1) ═══════════
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+79
View File
@@ -630,3 +630,82 @@ def _migration_synced_blocks(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)"
)
@register(16, "v6.0.0: offline sync queue")
def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
"""v6.0.0 — PWA offline support.
``offline_sync_queue`` persists server-side the mutations received from
offline clients (``/api/v2/sync/batch``) so work is not lost and can be
audited/replayed per device.
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS offline_sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
-- 'page_create', 'page_update', 'page_delete', 'page_move',
-- 'collection_create', 'collection_update', 'collection_delete'
payload TEXT NOT NULL,
client_timestamp REAL NOT NULL,
server_version INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'syncing', 'synced', 'failed'
retries INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_user ON offline_sync_queue(user_id, status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_device ON offline_sync_queue(device_id, status)"
)
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@register(17, "v6.0.0: sync_version columns")
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
Every write on a page / collection increments its ``sync_version`` so a
reconnecting client can detect edit-edit conflicts via version mismatch.
A ``BEFORE UPDATE`` trigger performs the increment automatically on every
write path (no need to patch dozens of ``UPDATE`` call-sites).
"""
for table in ("pages", "collection_pages", "collections"):
_add_sync_version(conn, table)
# AFTER UPDATE + inner UPDATE: bumps sync_version on every write path.
# `recursive_triggers` is OFF by default, so the inner UPDATE never
# re-fires the trigger (no infinite loop), incl. the page FTS triggers.
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS pages_sync_version_bu
AFTER UPDATE ON pages
FOR EACH ROW BEGIN
UPDATE pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collection_pages_sync_version_bu
AFTER UPDATE ON collection_pages
FOR EACH ROW BEGIN
UPDATE collection_pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collections_sync_version_bu
AFTER UPDATE ON collections
FOR EACH ROW BEGIN
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
+29
View File
@@ -554,6 +554,16 @@ async def help_page(request: Request):
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📶</span>Offline & PWA</h3>
<p>Install FlowDeck as an app and keep working without a connection.</p>
<ul>
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
<li>Edits made offline are queued locally and synced automatically</li>
<li>A <b>⟳</b> marker shows pages with pending changes</li>
</ul>
</div>
</div>
<div class="help-section">
@@ -579,6 +589,25 @@ FlowDeck supports three authentication methods:<br>
</p>
</div>
<div class="help-section">
<h2>📶 Offline mode (PWA)</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
edits are saved locally, then synchronised when the connection returns.<br><br>
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
<b>Offline editing:</b> while offline, the editor stores changes in the browser
(IndexedDB) and shows an offline banner with the number of pending changes. A
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
A spinner badge appears while syncing, followed by a confirmation toast.<br>
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
page. If a page with the same title already exists, the offline copy is renamed
<i>“Title (copie offline)”</i>.
</p>
</div>
<div class="help-section">
<h2>💡 Tips</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
+88
View File
@@ -0,0 +1,88 @@
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync).
Pairs with ``app/services/sync_engine.py``. All routes require an authenticated
session (``flowdeck_session`` cookie).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.services.sync_engine import SyncEngine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
_engine = SyncEngine()
def _user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
@router.get("/delta")
async def sync_delta(
request: Request,
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
workspace_id: int = Query(default=None),
):
"""Pull server-side changes since `since` (for the given workspace)."""
user = _user(request)
if workspace_id is None:
raise HTTPException(status_code=400, detail="workspace_id is required")
result = await _engine.get_delta(user["id"], since, workspace_id)
if result.get("error") == "forbidden":
return JSONResponse({"detail": "Forbidden"}, status_code=403)
return result
@router.post("/batch")
async def sync_batch(request: Request):
"""Apply a batch of offline mutations and return per-mutation results."""
user = _user(request)
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
mutations = body.get("mutations") or []
device_id = body.get("device_id") or "unknown"
if not isinstance(mutations, list) or not mutations:
return {"results": [], "conflicts": [], "server_time": SyncEngine._now_epoch()}
result = await _engine.apply_batch(user["id"], mutations, device_id)
result["server_time"] = SyncEngine._now_epoch()
return result
@router.get("/status")
async def sync_status(request: Request, workspace_id: int = Query(default=None)):
"""Synchronization status for the workspace (pending server queue, last sync)."""
user = _user(request)
from app.db import get_conn
with get_conn() as conn:
if not SyncEngine._can_access(conn, user["id"], workspace_id):
return JSONResponse({"detail": "Forbidden"}, status_code=403)
pending = conn.execute(
"SELECT COUNT(*) AS n FROM offline_sync_queue WHERE user_id=? AND status='pending'",
(user["id"],),
).fetchone()["n"]
last = conn.execute(
"SELECT MAX(created_at) AS last FROM offline_sync_queue "
"WHERE user_id=? AND status='synced'",
(user["id"],),
).fetchone()["last"]
return {
"pending_count": pending,
"last_sync": last,
"is_syncing": False,
"server_time": SyncEngine._now_epoch(),
"workspace_id": workspace_id,
}
+421
View File
@@ -0,0 +1,421 @@
"""FlowDeck — offline synchronization engine (v6.0.0 PWA).
Reconciles offline mutations (queued on the client) with server state:
- ``get_delta`` — changes on the server since a given timestamp, for offline
clients to pull before pushing their own batch.
- ``apply_batch`` — replays a batch of offline mutations with optimistic
concurrency control. ``sync_version`` (auto-bumped by SQLite triggers added
in migration v17) is the version token.
Conflict model (from docs/V6_PWA_Progressive_Web_App.md):
- edit-edit → last-write-wins by default (applied + reported)
- edit-delete→ the page was deleted server-side → orphan copy created
- create-create → same title already exists server-side → renamed "… (copie offline)"
- delete → soft-delete (idempotent)
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import time
from app.db import get_conn
logger = logging.getLogger(__name__)
class ConflictError(Exception):
"""Raised when a mutation conflicts with server state."""
def __init__(self, detail: dict):
super().__init__(detail.get("type"))
self.details = detail
class SyncEngine:
"""Apply/read offline mutations. Stateless methods, thin sqlite access."""
# ── helpers ────────────────────────────────────────────────────────────
@staticmethod
def _now_epoch() -> float:
return time.time()
@staticmethod
def _can_access(conn, user_id: int, workspace_id: int | None) -> bool:
"""Owner or member of the workspace; legacy NULL workspace → allow."""
if not workspace_id:
return True
ws = conn.execute(
"SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)
).fetchone()
if ws and ws["owner_id"] == user_id:
return True
member = conn.execute(
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, user_id),
).fetchone()
return bool(member)
@staticmethod
def _page_workspace_id(conn, page_id: int) -> int | None:
row = conn.execute(
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
).fetchone()
return row["workspace_id"] if row else None
# ── delta ─────────────────────────────────────────────────────────────
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
"""Return server-side changes since `since` (epoch seconds)."""
if since and since > 1e12:
since /= 1000.0 # accept epoch-millis from legacy clients
changes: list[dict] = []
with get_conn() as conn:
if not self._can_access(conn, user_id, workspace_id):
return {"error": "forbidden"}
# ── pages (created / updated / soft-deleted) ──
rows = conn.execute(
"""SELECT * FROM pages
WHERE (? IS NULL OR workspace_id = ?)
AND (CAST(strftime('%s', COALESCE(updated_at, created_at)) AS REAL) > ?
OR (deleted_at IS NOT NULL
AND CAST(strftime('%s', deleted_at) AS REAL) > ?))""",
(workspace_id, workspace_id, since, since),
).fetchall()
for r in rows:
data = dict(r)
deleted = data.get("deleted_at") is not None
created_epoch = _iso_epoch(data.get("created_at"))
created_after = bool(created_epoch and created_epoch > since)
if deleted:
ctype = "page_deleted"
elif created_after:
ctype = "page_created"
else:
ctype = "page_updated"
changes.append({"change_type": ctype, "data": data})
# ── collections (created / updated) ──
coll_rows = conn.execute(
"SELECT * FROM collections WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
(since,),
).fetchall()
for r in coll_rows:
data = dict(r)
created_epoch = _iso_epoch(data.get("created_at"))
changes.append({
"change_type": "collection_created" if created_epoch and created_epoch > since
else "collection_updated",
"data": data,
})
# ── collection rows (informational for offline reading) ──
cp_rows = conn.execute(
"SELECT * FROM collection_pages WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
(since,),
).fetchall()
for r in cp_rows:
changes.append({"change_type": "collection_row_updated", "data": dict(r)})
return {
"changes": changes,
"server_time": self._now_epoch(),
"has_more": False,
}
# ── batch ──────────────────────────────────────────────────────────────
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
"""Apply a batch of offline mutations; returns per-mutation results.
``mutations`` = [{"id"|"mutation_id", "type", "payload", "client_timestamp"}]
"""
results: list[dict] = []
conflicts: list[dict] = []
for mut in mutations:
mut_id = mut.get("id") or mut.get("mutation_id") or f"m{len(results)}"
mtype = mut.get("type", "")
payload = mut.get("payload") or {}
client_ts = float(mut.get("client_timestamp") or 0)
result = {
"mutation_id": mut_id,
"type": mtype,
"status": "synced",
"server_version": None,
}
try:
handler = getattr(self, f"_mut_{mtype}", None)
if handler is None:
raise ValueError(f"unknown mutation type: {mtype}")
outcome = handler(user_id, payload)
result.update(outcome)
status = "conflict" if outcome.get("conflict") else "synced"
result["status"] = status
if outcome.get("conflict"):
conflicts.append({
"mutation_id": mut_id,
"type": mtype,
**outcome["conflict"],
})
except ConflictError as exc:
result["status"] = "conflict"
result["conflict"] = exc.details
conflicts.append({"mutation_id": mut_id, "type": mtype, **exc.details})
except Exception as exc: # noqa: BLE001 — report, don't kill the batch
logger.warning("sync mutation %s failed: %s", mut_id, exc)
result["status"] = "failed"
result["error"] = str(exc)
finally:
self._record(user_id, device_id, mtype, mut, client_ts, result)
results.append(result)
return {"results": results, "conflicts": conflicts}
# ── page mutations ─────────────────────────────────────────────────────
def _mut_page_create(self, user_id: int, payload: dict) -> dict:
title = (payload.get("title") or "New page").strip() or "New page"
workspace_id = payload.get("workspace_id")
parent_id = payload.get("parent_id")
with get_conn() as conn:
if not self._can_access(conn, user_id, workspace_id):
raise ConflictError({"type": "forbidden", "workspace_id": workspace_id})
# create-create conflict: same title already present at same parent
new_title = title
dup = conn.execute(
"""SELECT id FROM pages
WHERE title=? AND (? IS NULL OR workspace_id = ?)
AND (? IS NULL OR parent_id IS ?)
AND deleted_at IS NULL
LIMIT 1""",
(title, workspace_id, workspace_id, parent_id, parent_id),
).fetchone()
if dup:
new_title = f"{title} (copie offline)"
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format,
parent_id, parent_section, sort_order)
VALUES ('', ?, ?, ?, ?, ?, 'Private', ?)""",
(workspace_id, new_title,
payload.get("content", ""),
payload.get("content_format", "blocks"),
parent_id,
payload.get("sort_order", 0)),
)
conn.commit()
page_id = cur.lastrowid
synced = payload.get("client_page_id")
return {"page_id": page_id, "server_version": 1, "client_page_id": synced,
"conflict": {"type": "create_create", "renamed": new_title != title}
if new_title != title else None}
def _mut_page_update(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
base_version = payload.get("base_version")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise ConflictError({"type": "page_not_found", "page_id": page_id})
ws_id = row["workspace_id"] or payload.get("workspace_id")
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
# edit-delete: page soft-deleted server-side → orphan copy
if row["deleted_at"] is not None:
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format, parent_section)
VALUES ('', ?, ?, ?, ?, 'Private')""",
(ws_id,
payload.get("title") or row["title"],
(payload.get("content")
if payload.get("content") is not None else row["content"]),
payload.get("content_format") or row["content_format"]),
)
conn.commit()
orphan_id = cur.lastrowid
raise ConflictError({
"type": "edit_delete",
"page_id": page_id,
"new_page_id": orphan_id,
"detail": "La page a été supprimée côté serveur — copie récréée en page orpheline",
})
# edit-edit: version mismatch → last-write-wins + conflict report
server_version = row["sync_version"]
conflict = None
if base_version is not None and server_version != base_version:
conflict = {
"type": "edit_edit",
"page_id": page_id,
"client_version": base_version,
"server_version": server_version,
}
sets, params = [], []
if payload.get("title") is not None:
sets.append("title=?")
params.append(payload["title"])
if payload.get("content") is not None:
sets.append("content=?")
params.append(payload["content"])
if payload.get("content_format") is not None:
sets.append("content_format=?")
params.append(payload["content_format"])
sets_str = ", ".join(sets) if sets else "updated_at=updated_at"
params.append(page_id)
conn.execute(
f"UPDATE pages SET {sets_str}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version,
"conflict": conflict}
def _mut_page_delete(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
with get_conn() as conn:
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return {"page_id": page_id, "server_version": None} # idempotent / already hard-deleted
ws_id = row["workspace_id"]
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
conn.execute(
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP, "
"updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
(page_id,),
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version}
def _mut_page_move(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
with get_conn() as conn:
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return {"page_id": page_id, "server_version": None}
ws_id = row["workspace_id"]
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
sets, params = [], []
if payload.get("parent_id") is not None:
sets.append("parent_id=?")
params.append(payload["parent_id"])
if payload.get("sort_order") is not None:
sets.append("sort_order=?")
params.append(payload["sort_order"])
if sets:
params.append(page_id)
conn.execute(
f"UPDATE pages SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version}
# ── collection mutations ───────────────────────────────────────────────
def _mut_collection_create(self, user_id: int, payload: dict) -> dict:
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO collections (name, description, icon, schema_json)
VALUES (?, ?, ?, ?)""",
(payload.get("name", ""), payload.get("description", ""),
payload.get("icon", "📋"), json.dumps(payload.get("schema", []))),
)
conn.commit()
return {"collection_id": cur.lastrowid, "server_version": 1}
def _mut_collection_update(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not row:
raise ConflictError({"type": "collection_not_found", "collection_id": cid})
sets, params = [], []
if payload.get("name") is not None:
sets.append("name=?")
params.append(payload["name"])
if payload.get("description") is not None:
sets.append("description=?")
params.append(payload["description"])
if payload.get("icon") is not None:
sets.append("icon=?")
params.append(payload["icon"])
if payload.get("schema") is not None:
sets.append("schema_json=?")
params.append(json.dumps(payload["schema"]))
if sets:
params.append(cid)
conn.execute(
f"UPDATE collections SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM collections WHERE id=?", (cid,)
).fetchone()["sync_version"]
return {"collection_id": cid, "server_version": new_version}
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
conn.commit()
return {"collection_id": cid, "server_version": None}
# ── audit ──────────────────────────────────────────────────────────────
def _record(self, user_id: int, device_id: str, mtype: str, mut: dict,
client_ts: float, result: dict) -> None:
"""Persist every mutation in the server-side audit queue."""
try:
with get_conn() as conn:
conn.execute(
"""INSERT INTO offline_sync_queue
(user_id, device_id, type, payload, client_timestamp,
server_version, status, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, device_id, mtype, json.dumps(mut),
client_ts, result.get("server_version"),
result.get("status", "synced"),
result.get("error") or (json.dumps(result["conflict"], ensure_ascii=False)
if result.get("conflict") else None)),
)
conn.commit()
except Exception: # noqa: BLE001 — audit must never break the batch
logger.warning("failed to record sync audit row", exc_info=True)
def _iso_epoch(value) -> float | None:
"""Best-effort ISO→epoch. SQLite CURRENT_TIMESTAMP → 'YYYY-MM-DD HH:MM:SS'."""
try:
if value is None:
return None
# append a timezone so strptime behaves on naive timestamps
text = str(value).replace("T", " ").split(".")[0]
parsed = _dt.datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
return parsed.replace(tzinfo=_dt.UTC).timestamp()
except ValueError:
return None
+11 -2
View File
@@ -2350,12 +2350,21 @@ applyAIBlocks(text){
if(this.saving){ if(cb) cb(); return; }
this.sync();
this.saving=true;
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
// v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau
const queueOffline=()=>{
if(!window.FlowOffline){this.saving=false;return;}
window.FlowOffline.savePageOffline({id:this.pid,title:this.pageTitle,content:JSON.stringify(blocksArr),content_format:'blocks'})
.then(()=>{this.saving=false;this.dirty=false;this.lastSaved='offline';if(cb)cb();})
.catch(()=>{this.saving=false;});
};
if(!navigator.onLine){queueOffline();return;}
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
this._syncTitleUI();
if(cb)cb();
}).catch(()=>{this.saving=false;});
}).catch(()=>{this.saving=false;queueOffline();});
},
runAutomationButton(bid){
const idx=this.getIdx(bid);if(idx<0)return;
+60
View File
@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
@@ -750,6 +755,11 @@
</template>
</div>
<!-- ============ PWA SYNC BADGE (v6.0.0) ============ -->
<div id="fd-sync-badge" class="fd-sync-badge" style="display:none" role="status" aria-live="polite">
<span class="fd-sync-spinner"></span><span>Synchronisation…</span>
</div>
</div>
<script data-cfasync="false">
@@ -2271,5 +2281,55 @@
})();
</script>
{# ─── PWA: sync indicators (v6.0.0) ─── #}
<style>
.fd-sync-badge{position:fixed;top:10px;right:14px;z-index:600;display:flex;align-items:center;gap:8px;
background:var(--bg-secondary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));
color:var(--text-secondary,#b8b8b8);border-radius:20px;padding:5px 12px;font-size:12px;
box-shadow:0 4px 16px rgba(0,0,0,.35);}
.fd-sync-spinner{width:12px;height:12px;border:2px solid rgba(255,255,255,.25);
border-top-color:var(--accent,#2383E2);border-radius:50%;display:inline-block;
animation:fd-spin .8s linear infinite;}
@keyframes fd-spin{to{transform:rotate(360deg)}}
.sidebar-item.fd-dirty .page-name::after{content:'⟳';margin-left:6px;font-size:11px;
color:var(--accent,#2383E2);opacity:.85;}
</style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script>
<script data-cfasync="false">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').then(function(reg) {
if ('sync' in reg && reg.sync) {
reg.sync.register('sync-flowdeck').catch(function() {});
}
}).catch(function() {});
});
})();
</script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false">
document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) {
var badge = document.getElementById('fd-sync-badge');
if (badge) badge.style.display = s.isSyncing ? 'flex' : 'none';
});
window.addEventListener('flowdeck:sync-done', function(ev) {
var d = ev.detail || {};
if (d.failed) {
if (window.showToast) window.showToast(d.failed + ' modification(s) non synchronisée(s)', 'error');
} else if (d.conflicts) {
if (window.showToast) window.showToast('Synchronisé — ' + d.conflicts + ' conflit(s) résolu(s)', 'info');
} else if (d.synced) {
if (window.showToast) window.showToast('Modifications synchronisées', 'success');
}
});
});
</script>
</body>
</html>
+14
View File
@@ -6,6 +6,11 @@
{% from '_icons.html' import fd_icon %}
<title>FlowDeck — All-in-one workspace</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<style>
:root {
--bg: #191919;
@@ -201,5 +206,14 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer>
<script data-cfasync="false">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function() {});
});
})();
</script>
</body>
</html>
+11 -1
View File
@@ -212,7 +212,8 @@
.modified-dot.show{display:block;}
/* Offline banner */
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;}
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;display:flex;align-items:center;justify-content:center;gap:12px;}
.offline-badge{background:rgba(255,255,255,.22);border-radius:10px;padding:1px 8px;font-size:11px;font-weight:600;}
/* Pinned indicator */
.pin-icon{font-size:10px;color:var(--text-tertiary);cursor:pointer;margin-left:4px;opacity:0;transition:opacity 150ms;}
@@ -595,6 +596,7 @@ window._wsInitData = (function() {
// Online/offline
online: navigator.onLine,
pendingSyncCount: 0,
pinnedIds: (function() {
try { return JSON.parse(localStorage.getItem('fd_ws_pinned') || '[]'); }
catch(e) { return []; }
@@ -648,6 +650,13 @@ window._wsInitData = (function() {
var self = this;
window.addEventListener('online', function() { self.online = true; });
window.addEventListener('offline', function() { self.online = false; });
// v6.0.0 PWA: reflect offline queue state from the client module
if (window.FlowOffline && window.FlowOffline.onChange) {
window.FlowOffline.onChange(function(s) {
self.online = s.online;
self.pendingSyncCount = s.pendingSyncCount;
});
}
// Side-peek editor posts renames here so the tree/peek title stay in sync.
window.addEventListener('message', function(ev) {
if (ev.origin !== window.location.origin) return;
@@ -3015,6 +3024,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Offline banner -->
<div class="offline-banner" x-show="!online" x-transition>
<span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span>
<span class="offline-badge" x-show="pendingSyncCount > 0" x-text="pendingSyncCount + ' modification(s) en attente'"></span>
</div>
<!-- Move-to modal -->
+31 -1
View File
@@ -550,6 +550,21 @@ Endpoints publics v2 :
|---------|-------|-------------|
| GET | `/api/v2/search?query=&workspace_id=&type=page|collection|all` | Résultats groupés par type, snippets |
### 4.18 Synchronisation offline (PWA)
| **Interne** | `routers/sync.py` + `services/sync_engine.py` — auth session (`flowdeck_session`), CSRF-exempt (`/api/v2`) |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/sync/delta?since=<epoch>&workspace_id=<id>` | Changements serveur depuis `since` (pages créées/màj/supprimées, collections) → `{changes, server_time, has_more}` |
| POST | `/api/v2/sync/batch` | Rejoue un lot de mutations offline → `{results, conflicts, server_time}` |
| GET | `/api/v2/sync/status?workspace_id=<id>` | `{pending_count, last_sync, is_syncing, server_time}` |
**Format batch** : `{device_id, mutations:[{id, type, payload, client_timestamp}]}` — `type` ∈ `page_create|page_update|page_delete|page_move|collection_create|collection_update|collection_delete`. `page_update` accepte `base_version` (colonne `sync_version`) pour la détection de conflit.
**Conflits** : `edit_edit` (last-write-wins + rapport), `edit_delete` (page orpheline recréée), `create_create` (renommage `« … (copie offline) »`). Chaque mutation est tracée dans `offline_sync_queue`.
---
## 5. Webhooks sortants (à étendre en v2)
@@ -675,4 +690,19 @@ EVENTS = [
- Frontend : `app/templates/*.html`, `static/js/app.js`
- Webhooks sortants : `app/services/webhook_outbound.py`
- Types de propriétés : `app/services/property_types.py`
- Roadmap : `ROADMAP.md` (v6.0.0 — API publique, realtime, synced blocks, web clipper, permissions granulaires)
- Roadmap : `ROADMAP.md` (v6.0.0 — API publique, realtime, synced blocks, web clipper, permissions granulaires)
---
## 11. Documents de conception détaillée v6.0.0
Chaque feature v6.0.0 dispose d'un document de conception détaillé dans `/docs/` :
| Feature | Document | Description |
|---------|----------|-------------|
| **PWA** | [`V6_PWA_Progressive_Web_App.md`](/docs/V6_PWA_Progressive_Web_App.md) | Offline support, service worker, IndexedDB, sync batch |
| **SSO/SAML** | [`V6_SSO_SAML_Enterprise_Auth.md`](/docs/V6_SSO_SAML_Enterprise_Auth.md) | SAML 2.0, OIDC, auto-provisioning, group mapping |
| **Permissions granulaires** | [`V6_Granular_Permissions.md`](/docs/V6_Granular_Permissions.md) | Page-level, property-level, collection-level, groupes |
| **Web Clipper** | [`V6_Web_Clipper.md`](/docs/V6_Web_Clipper.md) | Extension navigateur, capture d'articles, OAuth |
> **Note** : Le présent guide couvre la couche API v2 commune à toutes les features. Chaque document de conception ci-dessus détaille les endpoints, tables, services et UI spécifiques à sa feature.
+537
View File
@@ -0,0 +1,537 @@
# V6.0.0 — PWA : Progressive Web App, Offline Support
> **Statut** : Conception détaillée — v6.0.0
> **Date** : 2026-09-15
> **Route** : `feat/v6-pwa` → `develop` → `main`
> **Dépendances** : v5.14.0 Synced blocks (requis pour la synchronisation hors ligne)
---
## 1. Vision & objectifs
Transformer FlowDeck en une **Progressive Web App** fonctionnellement identique en ligne et hors ligne. L'utilisateur doit pouvoir :
- **Naviguer** entre les pages, workspaces, databases sans connexion
- **Créer, éditer, supprimer** du contenu en mode offline
- **Synchroniser** automatiquement les modifications dès le retour du réseau
- **Installer** l'application sur le bureau/l'écran d'accueil (manifest + service worker)
- **Bouncer** gracefulment entre modes en ligne/hors ligne avec indication claire
### Objectifs de qualité
| Critère | Cible |
|---------|-------|
| Temps de chargement hors ligne | < 1 s (cache local) |
| Taille du cache initial | < 15 MB (shell de l'app + assets critiques) |
| Synchronisation différée | Queue de mutations → replay automatique |
| Conflits | Détection par version de page → résolution manuelle ou last-write-wins |
| Disponibilité UI | Indicateur visuel online/offline persistant |
---
## 2. Architecture PWA
### 2.1 Composants
```
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ App Shell │ │ Service │ │ IndexedDB │ │
│ │ (Jinja2 + │ │ Worker │ │ (données offline) │ │
│ │ Alpine.js) │ │ (cache + │ │ │ │
│ │ │ │ sync) │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
│ │ │ │ │
│ └────────────┬────┴───────────────────────┘ │
│ │ │
│ ┌────────────▼──────────────────────────────┐ │
│ │ Sync Queue (Background Sync) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Mutations pending → replay │ │ │
│ │ │ sur reconnexion │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────┘
│ HTTPS / Service Worker
┌──────────────────────────▼──────────────────────────────────────┐
│ FASTAPI (Server) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Routes existantes (incluses dans le cache) │ │
│ │ ├─ /, /workspaces, /library, /settings │ │
│ │ ├─ /board/{owner}/{repo}, /db/{id}/pages │ │
│ │ ├─ /api/pages/*, /db/*/pages/api │ │
│ │ └─ /p/{slug} (pages publiques) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Nouveau : Offline Sync Endpoint │ │
│ │ POST /api/v2/sync/batch — batch de mutations │ │
│ │ GET /api/v2/sync/delta ?since=timestamp │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 Flux de synchronisation
```
[En ligne] [Hors ligne]
│ │
│ ── Requête HTTP normale ──► │
│ │ L'utilisateur édite
│ ◄── Réponse JSON ── │ → mutation mise en queue
│ │ → IndexedDB mis à jour
│ │ → UI optimiste
│ │
│ Reconnexion détectée │
│ ── GET /api/v2/sync/delta ──► │
│ ◄── Changements serveur ────────┤
│ ── POST /api/v2/sync/batch ──► │
│ ◄── 200 OK (toutes mut. ack'd)─┤
│ → UI refresh + cache invalidé │
```
---
## 3. Service Worker
### 3.1 Fichier : `static/sw.js`
```javascript
const CACHE_NAME = 'flowdeck-v6';
const PRECACHE_URLS = [
'/', '/workspaces', '/library', '/settings',
'/static/css/app.css', '/static/css/design-tokens.css',
'/static/css/components.css',
'/static/js/app.js', '/static/js/alpine.js',
'/static/js/sortable.js', '/static/js/prism.js',
'/static/js/katex.min.js', '/static/css/katex.min.css',
];
// ── Install : precache du shell ──
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
});
// ── Activate : purge des anciens caches ──
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
// ── Fetch : stratégie cache-first pour assets, network-first pour API ──
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// API : network-first avec fallback offline
event.respondWith(networkFirst(event.request));
} else if (url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|woff2|ico)$/)) {
// Assets statiques : cache-first
event.respondWith(cacheFirst(event.request));
} else {
// Pages HTML : network-first avec fallback offline
event.respondWith(networkFirst(event.request));
}
});
```
### 3.2 Stratégies de cache
| Type | Stratégie | Fallback offline |
|------|-----------|-----------------|
| Shell (HTML/CSS/JS) | Cache-first | Cache stocké lors de l'install |
| API GET (pages, vues) | Network-first, cache en fallback | Dernière version IndexedDB |
| API POST/PUT/DELETE | Network-only | Offline queue → Background Sync |
| Images uploadées | Cache-first après upload | Stockées dans IndexedDB |
---
## 4. IndexedDB — Structure de données offline
### 4.1 Base de données : `flowdeck-offline`
```sql
-- Tables stockées dans IndexedDB (via idb-keyval ou Dexie.js)
-- Pages locales (modifiées hors ligne)
STORE pages_offline {
id: number (primary key)
workspace_id: number
title: string
content: string (blocs JSON)
content_format: string
parent_id: number | null
deleted_at: string | null
_modified_at: number (timestamp local)
_dirty: boolean (true = pending sync)
}
-- Collections vues hors ligne
STORE collections_offline {
id: number (primary key)
workspace_id: number
name: string
schema_json: string
_modified_at: number
_dirty: boolean
}
-- Queue de synchronisation
STORE sync_queue {
id: number (auto-increment)
type: string ('page_create' | 'page_update' | 'page_delete' | 'collection_create' | ...)
payload: string (JSON de la mutation)
timestamp: number
retries: number
status: string ('pending' | 'syncing' | 'synced' | 'failed')
}
-- Métadonnées de sync
STORE sync_meta {
key: string ('last_server_sync' | 'last_page_version' | ...)
value: string (timestamp ou version)
}
```
### 4.2 Modèle de conflit
```
Cas 1 — Pas de conflit (page non modifiée côté serveur)
→ Apply la mutation locale directement
Cas 2 — Conflit edit-edit
→ Détection via version de page (column `updated_at` serveur vs `_modified_at` local)
→ Résolution :
a) Last-write-wins (par défaut, configurable dans Settings)
b) Manuel : l'utilisateur choisit via un diff UI
Cas 3 — Conflit edit-delete
→ La page a été supprimée côté serveur pendant le mode offline
→ Créer une nouvelle page orphan → notification à l'utilisateur
Cas 4 — Conflit create-create (même titre)
→ Le serveur a créé une page avec le même titre pendant l'offset
→ Renommer la copie locale : "Titre (copie offline)"
```
---
## 5. Endpoints serveur pour le sync offline
### 5.1 Nouveaux endpoints `app/routers/sync.py`
```
GET /api/v2/sync/delta?since=<timestamp>&workspace_id=<id>
→ Retourne les changements serveur depuis le dernier sync
→ Format: { changes: [{type, page_id, content, version}], new_pages: [...] }
POST /api/v2/sync/batch
→ Batch de mutations offline → serveur
→ Input: { mutations: [{type, payload, client_timestamp}], device_id: string }
→ Output: { results: [{mutation_id, status, server_version}], conflicts: [...] }
GET /api/v2/sync/status
→ État de synchronisation pour le workspace courant
→ { pending_count, last_sync, is_syncing }
```
### 5.2 Service serveur `app/services/sync_engine.py`
```python
class SyncEngine:
"""Moteur de synchronisation offline↔online."""
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
"""Retourne les changements serveur depuis `since`."""
# Requêter pages, collections, comments modifiées depuis `since`
# Inclure les versions pour détection de conflits
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
"""Applique un batch de mutations en une transaction."""
results = []
conflicts = []
for mut in mutations:
try:
# Validation de la version attendue
# Application de la mutation
# Génération du conflit si version mismatch
results.append({"mutation_id": mut["id"], "status": "synced", "server_version": ...})
except ConflictError as e:
conflicts.append({"mutation_id": mut["id"], "conflict": e.details})
return {"results": results, "conflicts": conflicts}
```
---
## 6. Interface utilisateur — Indicateurs offline
### 6.1 Offline banner (existant à améliorer)
Le banner offline existant dans `local_workspace.html` (ligne 3016-3017) est étendu :
```css
/* Amélioration : ajouter dans app.css */
.offline-banner {
position: fixed; top: 0; left: 0; right: 0;
background: var(--danger); color: #fff;
text-align: center; padding: 8px 16px;
font-size: 13px; z-index: 500; font-weight: 500;
/* Ajout v6 */
display: flex; align-items: center; justify-content: center; gap: 12px;
animation: slideDown 0.3s ease;
}
```
```html
<!-- Extension du banner -->
<div class="offline-banner" x-show="!online" x-transition>
<span>{{ fd_icon('alert-triangle', 14) }} Mode hors ligne — vos modifications seront synchronisées</span>
<span x-show="pendingSyncCount > 0" class="badge">
{{ pendingSyncCount }} modification(s) en attente
</span>
</div>
```
### 6.2 Indicateur de synchronisation
- **Badge syncing** dans la topbar quand le batch est en cours
- **Toast** à la fin d'une synchronisation réussie ou avec erreurs
- **Cône de synchronisation** dans le sidebar pour les pages modifiées hors ligne (icône `⟳` sur les pages `_dirty = true`)
---
## 7. Manifest Web & Installation
### 7.1 `static/manifest.json`
```json
{
"name": "FlowDeck",
"short_name": "FlowDeck",
"description": "Clone Notion intégré à Gitea/GitHub",
"start_url": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"orientation": "any",
"icons": [
{ "src": "/static/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/static/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/static/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/static/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/static/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/static/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"categories": ["productivity", "business"],
"lang": "fr",
"dir": "ltr",
"prefer_related_applications": false
}
```
### 7.2 Service Worker registration (dans `base.html`)
```html
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(reg => {
console.log('SW registered:', reg.scope);
// Background Sync support
if ('sync' in reg) {
reg.sync.register('sync-flowdeck');
}
});
});
}
</script>
```
---
## 8. Tables de base de données — Modifications nécessaires
### 8.1 Nouveau table `offline_sync_queue`
```sql
CREATE TABLE offline_sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
-- 'page_create', 'page_update', 'page_delete', 'page_move',
-- 'collection_create', 'collection_update', 'collection_delete'
payload TEXT NOT NULL, -- JSON de la mutation
client_timestamp REAL NOT NULL, -- epoch ms
server_version INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'syncing', 'synced', 'failed'
retries INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_syncqueue_user ON offline_sync_queue(user_id, status);
CREATE INDEX idx_syncqueue_device ON offline_sync_queue(device_id, status);
```
### 8.2 Colonnes ajoutées sur `pages` et `collections`
```sql
-- Détection de conflit
ALTER TABLE pages ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE collection_pages ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE collections ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
```
### 8.3 Migration
```python
# app/migrations.py — ajouter à la séquence
MIGRATIONS = [
# ... migrations existantes v1-v5 ...
("v6-offline-sync-queue", """
CREATE TABLE offline_sync_queue (...);
CREATE INDEX ...;
"""),
("v6-sync-version-columns", """
ALTER TABLE pages ADD COLUMN sync_version INTEGER DEFAULT 1;
ALTER TABLE collection_pages ADD COLUMN sync_version INTEGER DEFAULT 1;
ALTER TABLE collections ADD COLUMN sync_version INTEGER DEFAULT 1;
"""),
]
```
---
## 9. Frontend — Modules Alpine.js
### 9.1 Module `app/offline.js`
```javascript
// Module hors ligne — gestion de la connexion et de la queue
export const offlineModule = {
data() {
return {
online: navigator.onLine,
pendingSyncCount: 0,
lastSyncAt: null,
isSyncing: false,
};
},
async created() {
window.addEventListener('online', () => { this.online = true; this.processQueue(); });
window.addEventListener('offline', () => { this.online = false; });
this.pendingSyncCount = await this.getPendingCount();
// Polling status toutes les 30s
setInterval(() => this.syncStatus(), 30000);
},
methods: {
async getPendingCount() {
const r = await fetch('/api/v2/sync/status');
const d = await r.json();
this.pendingSyncCount = d.pending_count;
this.lastSyncAt = d.last_sync;
},
async processQueue() {
if (this.isSyncing || !this.online) return;
this.isSyncing = true;
const queue = await this.getQueue();
if (queue.length > 0) {
await fetch('/api/v2/sync/batch', {
method: 'POST',
body: JSON.stringify({ mutations: queue, device_id: DEVICE_ID }),
});
}
this.isSyncing = false;
await this.getPendingCount();
},
async getQueue() { /* Lire IndexedDB sync_queue */ },
async syncStatus() { /* GET /api/v2/sync/status */ },
},
};
```
### 9.2 Integration dans l'éditeur
Lorsqu'un utilisateur est hors ligne et modifie une page :
1. La mutation est immédiatement appliquée dans IndexedDB (optimistic UI)
2. L'éditeur sauvegarde normalement dans le cache local
3. Une entrée est ajoutée à `sync_queue` via `POST /api/v2/sync/batch` quand le réseau revient
4. Le contenu de la page est marqué `_dirty = true` → icône `⟳` dans le sidebar
---
## 10. Tests
| Test | Description | Outil |
|------|-------------|-------|
| Service worker install | Cache precache valide | Playwright |
| Offline page load | Navigation sans réseau → cache servi | Playwright |
| Offline mutation | Créer une page offline → IndexedDB + queue | Playwright + SQLite |
| Online sync | Reconnexion → batch replay → serveur à jour | Playwright + API |
| Conflict resolution | Edit-edit conflict → résolution last-write-wins | Unit test + Playwright |
| Manifest install | `navigator.standalone` ou `beforeinstallprompt` | Playwright |
| Background sync | `SyncManager.register` appelé | Playwright |
| Cache invalidation | Nouvelle version → ancien cache supprimé | Unit test |
```bash
# Commandes
pytest tests/test_pwa_offline.py # Tests sync offline
pytest tests/test_service_worker.py # Tests SW
npx playwright tests/pwa-offline.spec.ts # E2E offline flows
```
---
## 11. Checklist d'implémentation
1. **`static/manifest.json`** + icônes (512x512 maskable)
2. **`static/sw.js`** — Service Worker avec precache + API strategy
3. **`app/routers/sync.py`** — endpoints `/api/v2/sync/*`
4. **`app/services/sync_engine.py`** — moteur de synchronisation
5. **Migrations** — table `offline_sync_queue` + colonnes `sync_version`
6. **`app/offline.js`** — module Alpine.js offline
7. **Amélioration banner offline** — pending count + sync status
8. **IndexedDB côté client** — wrapper pour pages, collections, queue
9. **Tests** — offline scenarios + sync conflict + background sync
10. **Dockerfile** — servir le SW + manifest via le server
11. **Documentation utilisateur** — section `/help` sur le mode offline
---
## 12. Performance & contraintes
| Contrainte | Détail |
|-----------|--------|
| Taille maximale cache shell | 15 MB |
| Timeout sync batch | 30 s |
| Max mutations par batch | 100 |
| Retention queue offline | 30 jours (auto-purge) |
| Device ID | UUID stocké dans localStorage |
| Fallback sans SW | Mode "lite" → pas de PWA, mais app fonctionne normalement |
---
## 13. Références
- [MDN: Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
- [Google: Offline Storage Best Practices](https://web.dev/offline-storage-best-practices/)
- [Workbox — Google's SW library](https://developers.google.com/web/tools/workbox)
- [Background Sync API](https://web.dev/background-sync/)
- [IndexedDB MDN](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
- `app/routers/sync.py` — (à créer)
- `app/services/sync_engine.py` — (à créer)
- `docs/API_GUIDE_V6.md` — référence API v2 (endpoint `/api/v2/sync/*`)
- `ROADMAP.md` — v6.0.0 PWA item
+2 -1
View File
@@ -4,7 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "playwright test",
"test:pwa": "playwright test pwa_offline.spec.js"
},
"keywords": [],
"author": "",
+179
View File
@@ -0,0 +1,179 @@
/**
* FlowDeck — PWA offline E2E (v6.0.0)
*
* Requires a running FlowDeck server (FD_BASE_URL, default http://localhost:8080)
* with local registration enabled. Tests are skipped when the server is down.
*
* Covers: service worker registration, manifest, offline shell load,
* offline mutation queueing (IndexedDB) and replay on reconnect.
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
let serverUp = false;
test.beforeAll(async ({ request }) => {
try {
const r = await request.get(`${FD_BASE}/auth/login?provider=local`, { timeout: 4000 });
serverUp = r.status() < 500;
} catch {
serverUp = false;
}
});
test.beforeEach(async () => {
test.skip(!serverUp, `FlowDeck server not reachable at ${FD_BASE}`);
});
async function registerAndEnter(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.click('button:has-text("Register")');
await page.fill('#name', 'PWA E2E');
await page.fill('#email', 'pwa' + Date.now() + '@flowdeck.local');
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 20000 });
await page.waitForTimeout(800);
await page.keyboard.press('Escape').catch(() => {});
const ws = await page.evaluate(async () => {
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'PWA WS ' + Date.now() }),
});
const d = await r.json();
await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST' });
return d.id;
});
await page.waitForTimeout(500);
return ws;
}
async function waitForServiceWorker(page) {
await page.evaluate(async () => {
if ('serviceWorker' in navigator) {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise((resolve) => {
navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true });
setTimeout(resolve, 4000);
});
}
}
});
}
test.describe('PWA — manifest & service worker', () => {
test('manifest is linked and served with correct metadata', async ({ page }) => {
await page.goto(`${FD_BASE}/auth/login?provider=local`);
const href = await page.getAttribute('link[rel="manifest"]', 'href');
expect(href).toContain('manifest.json');
const manifest = await page.evaluate(async () => {
const r = await fetch('/manifest.json');
return r.json();
});
expect(manifest.name).toBe('FlowDeck');
expect(manifest.display).toBe('standalone');
expect(Array.isArray(manifest.icons)).toBe(true);
expect(manifest.icons.length).toBeGreaterThan(0);
});
test('service worker registers and controls the page', async ({ page }) => {
await registerAndEnter(page);
await page.goto(`${FD_BASE}/local-workspace`);
await waitForServiceWorker(page);
const info = await page.evaluate(async () => {
if (!('serviceWorker' in navigator)) return { supported: false };
const reg = await navigator.serviceWorker.ready;
return {
supported: true,
active: !!reg.active,
controlled: !!navigator.serviceWorker.controller,
hasSync: 'sync' in reg,
};
});
expect(info.supported).toBe(true);
expect(info.active).toBe(true);
expect(info.controlled).toBe(true);
});
});
test.describe('PWA — offline queue & sync', () => {
test('offline mutation is queued in IndexedDB and synced on reconnect', async ({ page, context }) => {
test.setTimeout(120000);
const wsId = await registerAndEnter(page);
const pageId = await page.evaluate(async () => {
const r = await fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Offline Doc', type: 'page' }),
});
const d = await r.json();
return d.id;
});
expect(pageId).toBeTruthy();
await page.goto(`${FD_BASE}/local-workspace`);
await waitForServiceWorker(page);
await page.waitForFunction(() => !!window.FlowOffline, null, { timeout: 10000 });
// ── go offline and queue a mutation ──
await context.setOffline(true);
const pendingAfterEnqueue = await page.evaluate(async (pid) => {
await window.FlowOffline.enqueue('page_update', {
page_id: pid,
title: 'Offline Title',
content: '[]',
content_format: 'blocks',
});
return window.FlowOffline.pendingCount();
}, pageId);
expect(pendingAfterEnqueue).toBeGreaterThan(0);
const stored = await page.evaluate(async () => {
const q = await window.FlowOffline.getQueue();
return q.map((i) => i.type);
});
expect(stored).toContain('page_update');
// ── reconnect: the queue must drain ──
await context.setOffline(false);
await page.evaluate(() => window.FlowOffline.flush());
await page.waitForFunction(
() => window.FlowOffline.pendingCount().then((n) => n === 0),
null,
{ timeout: 30000 }
);
const pendingAfterSync = await page.evaluate(() => window.FlowOffline.pendingCount());
expect(pendingAfterSync).toBe(0);
// ── server has the offline edit ──
const synced = await page.evaluate(async (wid) => {
const r = await fetch('/api/v2/sync/delta?since=0&workspace_id=' + wid);
const d = await r.json();
return (d.changes || []).some((c) => c.data && c.data.title === 'Offline Title');
}, wsId);
expect(synced).toBe(true);
});
test('offline navigation is served from the app shell cache', async ({ page, context }) => {
test.setTimeout(120000);
await registerAndEnter(page);
await page.goto(`${FD_BASE}/local-workspace`);
await waitForServiceWorker(page);
await page.waitForTimeout(1000);
await context.setOffline(true);
await page.reload({ waitUntil: 'domcontentloaded' }).catch(() => {});
const title = await page.title();
expect(title).toContain('FlowDeck');
await context.setOffline(false);
});
});
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Generate FlowDeck PWA icons as PNG (72→512 px) from the favicon design.
Draws the same logo as static/favicon.svg (blue rounded square + white "FD")
at every size required by the web manifest. No cairosvg dependency required —
pure Pillow.
"""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ICON_BLUE = "#2383E2"
ICON_WHITE = "#FFFFFF"
SIZES = [72, 96, 128, 144, 152, 192, 384, 512]
OUT_DIR = Path(__file__).resolve().parent.parent / "static" / "icons"
def _font_at(px: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""Pick the best available bold font for the requested icon size."""
candidates = [
"C:/Windows/Fonts/arialbd.ttf",
"C:/Windows/Fonts/Arialbd.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]
for path in candidates:
if Path(path).exists():
try:
return ImageFont.truetype(path, size=int(px * 0.42))
except OSError:
continue
return ImageFont.load_default()
def draw_icon(size: int) -> Image.Image:
"""Render the FlowDeck rounded-square logo at `size` x `size` px."""
radius = max(2, round(size * 0.1875)) # rx=6 on a 32 viewBox → ~18.75%
img = Image.new("RGB", (size, size), ICON_BLUE)
draw = ImageDraw.Draw(img)
draw.rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=ICON_BLUE)
font = _font_at(size)
bbox = draw.textbbox((0, 0), "FD", font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(
((size - tw) / 2 - bbox[0], (size - th) / 2 - bbox[1]),
"FD",
font=font,
fill=ICON_WHITE,
)
return img
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
for size in SIZES:
path = OUT_DIR / f"icon-{size}x{size}.png"
draw_icon(size).save(path, "PNG", optimize=True)
print(f"Generated {path}")
# Apple touch icon: standalone 180 px (not part of the manifest set).
apple = OUT_DIR / "apple-touch-icon.png"
draw_icon(180).save(apple, "PNG", optimize=True)
print(f"Generated {apple}")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

+466
View File
@@ -0,0 +1,466 @@
/* ═══════════════════════════════════════════════════════════
FlowDeck — Offline client module (v6.0.0 PWA)
Companion to static/sw.js and app/routers/sync.py:
- IndexedDB `flowdeck-offline` : pages_offline / collections_offline /
sync_queue / sync_meta
- Optimistic local writes when offline
- Mutation queue replayed to POST /api/v2/sync/batch on reconnect
- Pull server changes via GET /api/v2/sync/delta
Exposed as `window.FlowOffline`. No bundler: plain global IIFE.
═══════════════════════════════════════════════════════════ */
'use strict';
(function () {
var DB_NAME = 'flowdeck-offline';
var DB_VERSION = 1;
var STORES = {
PAGES: 'pages_offline',
COLLECTIONS: 'collections_offline',
QUEUE: 'sync_queue',
META: 'sync_meta',
};
var DEVICE_KEY = 'fd_device_id';
var LAST_SYNC_KEY = 'last_server_sync';
var MAX_BATCH = 100;
var QUEUE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
var FETCH_TIMEOUT_MS = 30000;
var _dbPromise = null;
var _listeners = new Set();
var state = {
online: (typeof navigator !== 'undefined') ? navigator.onLine : true,
pendingSyncCount: 0,
isSyncing: false,
lastSyncAt: null,
lastError: null,
};
// ── state / events ────────────────────────────────────────────────────
function snapshot() {
return {
online: state.online,
pendingSyncCount: state.pendingSyncCount,
isSyncing: state.isSyncing,
lastSyncAt: state.lastSyncAt,
lastError: state.lastError,
};
}
function emit() {
var snap = snapshot();
_listeners.forEach(function (cb) {
try { cb(snap); } catch { /* listener errors must not break sync */ }
});
}
function onChange(cb) {
_listeners.add(cb);
try { cb(snapshot()); } catch { /* ignore */ }
return function () { _listeners.delete(cb); };
}
function getState() { return snapshot(); }
function deviceId() {
try {
var v = localStorage.getItem(DEVICE_KEY);
if (!v) {
v = 'dev-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
localStorage.setItem(DEVICE_KEY, v);
}
return v;
} catch {
return 'unknown';
}
}
// ── IndexedDB plumbing ────────────────────────────────────────────────
function openDB() {
if (_dbPromise) return _dbPromise;
_dbPromise = new Promise(function (resolve, reject) {
if (typeof window.indexedDB === 'undefined') {
reject(new Error('IndexedDB unavailable'));
return;
}
var req = window.indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(STORES.PAGES)) {
db.createObjectStore(STORES.PAGES, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORES.COLLECTIONS)) {
db.createObjectStore(STORES.COLLECTIONS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORES.QUEUE)) {
var q = db.createObjectStore(STORES.QUEUE, { keyPath: 'id', autoIncrement: true });
q.createIndex('status', 'status', { unique: false });
}
if (!db.objectStoreNames.contains(STORES.META)) {
db.createObjectStore(STORES.META, { keyPath: 'key' });
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
return _dbPromise;
}
function idbRequest(store, mode, build) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
var t = db.transaction(store, mode);
var req = build(t.objectStore(store));
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
});
}
function idbGet(store, key) { return idbRequest(store, 'readonly', function (s) { return s.get(key); }); }
function idbAll(store) { return idbRequest(store, 'readonly', function (s) { return s.getAll(); }); }
function idbPut(store, val) { return idbRequest(store, 'readwrite', function (s) { return s.put(val); }); }
function idbAdd(store, val) { return idbRequest(store, 'readwrite', function (s) { return s.add(val); }); }
function idbDelete(store, key) { return idbRequest(store, 'readwrite', function (s) { return s.delete(key); }); }
// Fetch with a hard timeout (design §12: sync batch ≤ 30 s).
function fetchWithTimeout(url, opts) {
opts = opts || {};
if (typeof window.AbortController === 'undefined') return fetch(url, opts);
var ctrl = new window.AbortController();
var timer = setTimeout(function () { ctrl.abort(); }, FETCH_TIMEOUT_MS);
opts.signal = ctrl.signal;
return fetch(url, opts).then(
function (res) { clearTimeout(timer); return res; },
function (err) { clearTimeout(timer); throw err; }
);
}
function getMeta(key) {
return idbGet(STORES.META, key).then(function (row) { return row ? row.value : null; })
.catch(function () { return null; });
}
function setMeta(key, value) {
return idbPut(STORES.META, { key: key, value: value }).catch(function () {});
}
// ── queue ─────────────────────────────────────────────────────────────
function toMutation(item) {
return {
id: item.mutation_id,
type: item.type,
payload: item.payload || {},
client_timestamp: item.timestamp,
};
}
function enqueue(type, payload, opts) {
opts = opts || {};
var item = {
mutation_id: opts.mutation_id || ('m-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)),
type: type,
payload: payload || {},
timestamp: opts.timestamp || Date.now(),
retries: 0,
status: 'pending',
};
return idbAdd(STORES.QUEUE, item).then(function (id) {
item.id = id;
return refreshPending();
}).then(function () {
if (state.online) flush();
return item;
});
}
function getQueue() {
return idbAll(STORES.QUEUE).catch(function () { return []; });
}
function pendingCount() {
return getQueue().then(function (all) {
return all.filter(function (i) { return i.status !== 'synced'; }).length;
});
}
function refreshPending() {
return getQueue().then(function (all) {
state.pendingSyncCount = all.filter(function (i) { return i.status !== 'synced'; }).length;
emit();
return state.pendingSyncCount;
}).then(function (n) {
markDirtySidebar();
return n;
});
}
// Auto-purge of stale queue entries (design §12: 30-day retention).
function purgeOld() {
var cutoff = Date.now() - QUEUE_RETENTION_MS;
return getQueue().then(function (all) {
var stale = all.filter(function (i) {
return i.status !== 'pending' && i.timestamp < cutoff;
});
return Promise.all(stale.map(function (i) { return idbDelete(STORES.QUEUE, i.id); }));
}).catch(function () { return []; });
}
// ── flush: replay queue → server ──────────────────────────────────────
function flush() {
if (state.isSyncing || !state.online) return Promise.resolve(false);
return purgeOld().then(getQueue).then(function (all) {
var pending = all.filter(function (i) { return i.status === 'pending' || i.status === 'failed'; });
if (!pending.length) {
state.pendingSyncCount = 0;
emit();
return false;
}
state.isSyncing = true;
emit();
var summary = { synced: 0, conflicts: 0, failed: 0, batches: 0 };
var chain = Promise.resolve();
for (var i = 0; i < pending.length; i += MAX_BATCH) {
(function (chunk) {
chain = chain.then(function () { return flushChunk(chunk, summary); });
})(pending.slice(i, i + MAX_BATCH));
}
return chain.then(function () {
state.lastSyncAt = Date.now();
state.lastError = summary.failed ? (summary.failed + ' mutation(s) en échec') : null;
return setMeta(LAST_SYNC_KEY, Date.now()).then(function () { return summary; });
}).catch(function (e) {
state.lastError = String((e && e.message) || e);
return summary;
}).then(function (result) {
state.isSyncing = false;
return refreshPending().then(function () {
emit();
notifySyncDone(result);
return result;
});
});
});
}
function flushChunk(chunk, summary) {
summary.batches++;
return fetchWithTimeout('/api/v2/sync/batch', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: deviceId(), mutations: chunk.map(toMutation) }),
}).then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}).then(function (data) {
var byId = {};
(data.results || []).forEach(function (r) { byId[r.mutation_id] = r; });
var work = Promise.resolve();
chunk.forEach(function (item) {
var r = byId[item.mutation_id];
if (!r) return;
if (r.status === 'synced') {
summary.synced++;
work = work.then(function () { return idbDelete(STORES.QUEUE, item.id); });
} else if (r.status === 'conflict') {
summary.conflicts++;
work = work.then(function () { return idbDelete(STORES.QUEUE, item.id); });
} else {
summary.failed++;
item.retries = (item.retries || 0) + 1;
item.status = 'failed';
item.error = r.error || '';
work = work.then(function () { return idbPut(STORES.QUEUE, item); });
}
});
return work;
});
}
function notifySyncDone(summary) {
if (!summary || (!summary.synced && !summary.conflicts && !summary.failed)) return;
try {
window.dispatchEvent(new CustomEvent('flowdeck:sync-done', { detail: summary }));
} catch { /* CustomEvent unavailable */ }
}
// ── delta: pull server changes ────────────────────────────────────────
function delta(workspaceId) {
return getMeta(LAST_SYNC_KEY).then(function (since) {
var url = '/api/v2/sync/delta?since=' + encodeURIComponent(since || 0) +
'&workspace_id=' + encodeURIComponent(workspaceId);
return fetchWithTimeout(url, { credentials: 'same-origin' });
}).then(function (res) {
if (!res.ok) throw new Error('delta HTTP ' + res.status);
return res.json();
}).then(function (data) {
var work = Promise.resolve();
(data.changes || []).forEach(function (ch) {
var d = ch.data || {};
if (d.id == null) return;
var ctype = ch.change_type || '';
if (ctype.indexOf('page') === 0) {
work = work.then(function () {
return idbGet(STORES.PAGES, d.id).then(function (local) {
if (local && local._dirty) return null; // keep unsynced local edits
return idbPut(STORES.PAGES, {
id: d.id,
workspace_id: d.workspace_id,
title: d.title,
content: d.content,
content_format: d.content_format,
parent_id: d.parent_id,
deleted_at: d.deleted_at || null,
sync_version: d.sync_version || 1,
_server_version: d.sync_version || 1,
_modified_at: Date.now(),
_dirty: false,
});
});
});
} else if (ctype.indexOf('collection') === 0) {
work = work.then(function () {
return idbPut(STORES.COLLECTIONS, {
id: d.id,
workspace_id: d.workspace_id,
name: d.name,
schema_json: d.schema_json,
_modified_at: Date.now(),
_dirty: false,
});
});
}
});
return work.then(function () {
return setMeta(LAST_SYNC_KEY, data.server_time || Date.now());
}).then(function () { return data; });
});
}
// ── optimistic page save ──────────────────────────────────────────────
function savePageOffline(page) {
if (!page || page.id == null) return Promise.resolve(null);
return idbGet(STORES.PAGES, page.id).then(function (local) {
local = local || {};
var baseVersion = (local._server_version != null) ? local._server_version : page.base_version;
var merged = {
id: page.id,
workspace_id: (page.workspace_id != null) ? page.workspace_id : local.workspace_id,
title: (page.title != null) ? page.title : local.title,
content: (page.content != null) ? page.content : local.content,
content_format: page.content_format || local.content_format || 'blocks',
parent_id: (page.parent_id != null) ? page.parent_id : local.parent_id,
_server_version: local._server_version || 1,
_modified_at: Date.now(),
_dirty: true,
};
return idbPut(STORES.PAGES, merged).then(function () {
var payload = { page_id: page.id };
if (page.title != null) payload.title = page.title;
if (page.content != null) payload.content = page.content;
if (page.content_format != null) payload.content_format = page.content_format;
if (baseVersion != null) payload.base_version = baseVersion;
return enqueue('page_update', payload).then(function () {
markDirtySidebar();
return merged;
});
});
});
}
function status(workspaceId) {
var url = '/api/v2/sync/status' + (workspaceId != null ? '?workspace_id=' + encodeURIComponent(workspaceId) : '');
return fetchWithTimeout(url, { credentials: 'same-origin' }).then(function (res) {
if (!res.ok) throw new Error('status HTTP ' + res.status);
return res.json();
});
}
// ── dirty-page UI marker (⟳ in the sidebar) ───────────────────────────
function getDirtyPageIds() {
return idbAll(STORES.PAGES).then(function (pages) {
return pages.filter(function (p) { return p._dirty; }).map(function (p) { return String(p.id); });
}).catch(function () { return []; });
}
function markDirtySidebar() {
if (typeof document === 'undefined') return Promise.resolve([]);
return getDirtyPageIds().then(function (ids) {
var set = {};
ids.forEach(function (id) { set[id] = true; });
document.querySelectorAll('.sidebar-item[data-id]').forEach(function (el) {
var id = el.getAttribute('data-id');
if (set[id]) el.classList.add('fd-dirty'); else el.classList.remove('fd-dirty');
});
document.querySelectorAll('.sidebar-item[data-page-id]').forEach(function (el) {
var pid = (el.getAttribute('data-page-id') || '').replace('page/', '');
if (set[pid]) el.classList.add('fd-dirty'); else el.classList.remove('fd-dirty');
});
return ids;
});
}
// ── background sync + wiring ──────────────────────────────────────────
function registerBackgroundSync() {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
navigator.serviceWorker.ready.then(function (reg) {
if ('sync' in reg && reg.sync) {
reg.sync.register('sync-flowdeck').catch(function () {});
}
}).catch(function () {});
}
function init() {
if (typeof window === 'undefined') return;
window.addEventListener('online', function () {
state.online = true;
emit();
flush();
});
window.addEventListener('offline', function () {
state.online = false;
emit();
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function (ev) {
if (ev.data && ev.data.type === 'flowdeck-sync') flush();
});
}
refreshPending();
registerBackgroundSync();
setInterval(function () {
if (state.online) refreshPending();
}, 30000);
}
window.FlowOffline = {
STORES: STORES,
openDB: openDB,
deviceId: deviceId,
onChange: onChange,
getState: getState,
enqueue: enqueue,
getQueue: getQueue,
pendingCount: pendingCount,
flush: flush,
delta: delta,
status: status,
savePageOffline: savePageOffline,
getDirtyPageIds: getDirtyPageIds,
markDirtySidebar: markDirtySidebar,
purgeOld: purgeOld,
registerBackgroundSync: registerBackgroundSync,
init: init,
MAX_BATCH: MAX_BATCH,
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+25
View File
@@ -0,0 +1,25 @@
{
"name": "FlowDeck",
"short_name": "FlowDeck",
"description": "Clone Notion intégré à Gitea/GitHub",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"orientation": "any",
"lang": "fr",
"dir": "ltr",
"categories": ["productivity", "business"],
"prefer_related_applications": false,
"icons": [
{ "src": "/static/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/static/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/static/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/static/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/static/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/static/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
+207
View File
@@ -0,0 +1,207 @@
/* ═══════════════════════════════════════════════════════════
FlowDeck Service Worker — v6.0.0 PWA
Stratégies :
- Shell (CSS/JS/fonts/icons) → cache-first
- Pages HTML → network-first, fallback cache
- API GET → network-first, fallback cache
- API mutations (POST/...) → network-only (queue côté client)
═══════════════════════════════════════════════════════════ */
'use strict';
const CACHE_NAME = 'flowdeck-v6';
const DATA_CACHE = 'flowdeck-data-v6';
// App shell (assets versionnés comme référencés dans les templates).
const PRECACHE_URLS = [
// Pages
'/', '/workspaces', '/library', '/settings',
// CSS
'/static/css/app.css?v=5.1.1',
'/static/css/design-tokens.css?v=5.2.0',
'/static/css/components.css?v=5.2.0',
'/static/css/katex.min.css?v=0.16.11',
'/static/css/prism.css',
// JS
'/static/js/htmx.min.js',
'/static/js/alpine.min.js',
'/static/js/sortable.min.js',
'/static/js/app.js?v=2.4.8',
'/static/js/offline.js?v=6.0.0',
'/static/js/katex.min.js?v=0.16.11',
'/static/js/prism.min.js',
// Icônes & métadonnées
'/static/favicon.svg',
'/static/manifest.json',
'/static/icons/icon-192x192.png',
'/static/icons/icon-512x512.png',
];
const SHELL_ASSET = /\.(css|js|png|jpg|jpeg|svg|gif|webp|ico|woff2?|ttf|eot|json)(\?.*)?$/;
const OFFLINE_PAGE = [
'<!DOCTYPE html>',
'<html lang="fr">',
'<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">',
'<title>FlowDeck — Hors ligne</title>',
'<style>',
'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#191919;color:#fff;',
'display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;text-align:center;}',
'.box{padding:32px;background:#262626;border-radius:8px;max-width:340px;}',
'h1{font-size:20px;margin:0 0 8px;}',
'p{color:#b8b8b8;font-size:14px;margin:0;line-height:1.5;}',
'.sync{margin-top:12px;font-size:12px;color:#2383E2;animation:blink 1.4s infinite;}',
'@keyframes blink{50%{opacity:.35}}',
'</style>',
'</head>',
'<body>',
'<div class="box"><h1>Vous êtes hors ligne</h1>',
'<p>Les pages consultées récemment restent disponibles. Votre contenu sera synchronisé dès le retour du réseau.</p>',
'<div class="sync">⟳ Synchronisation en attente…</div>',
'</div>',
'</body></html>',
].join('\n');
// ── Install : precache du shell (ignore redirections /login) ──
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(async (cache) => {
await Promise.all(
PRECACHE_URLS.map(async (url) => {
try {
const res = await fetch(url, { redirect: 'manual', credentials: 'same-origin' });
// status 0 = opaqueredirect (vers /login par ex.) → on ne cache pas
if (res.status === 0 || !res.ok) return;
await cache.put(url, res);
} catch {
// réseau indisponible pendant install : on continue
}
})
);
// Page de secours offline
await cache.put('/offline.html', new Response(OFFLINE_PAGE, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
}));
})
.then(() => self.skipWaiting())
);
});
// ── Activate : purge des anciens caches ──
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((k) => k !== CACHE_NAME && k !== DATA_CACHE).map((k) => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
// ── Fetch : routing par stratégie ──
self.addEventListener('fetch', (event) => {
const request = event.request;
if (request.method !== 'GET') {
// Mutations : always network (queue côté client en mode offline)
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return; // cross-origin (fonts Google, CDN…) non intercepté
}
const isNavigation = request.mode === 'navigate';
const isApi = url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/db/') ||
url.pathname.startsWith('/board/') ||
url.pathname.startsWith('/p/');
// HTML / navigation : network-first, fallback cache, offline page
if (isNavigation || request.headers.get('accept')?.includes('text/html')) {
event.respondWith(networkFirst(request, { fallbackToShell: true }));
return;
}
// API GET : réseau d'abord, cache en repli
if (isApi) {
event.respondWith(networkFirst(request, { cacheName: DATA_CACHE }));
return;
}
// Assets statiques : cache-first avec mise en cache à la volée
if (SHELL_ASSET.test(url.pathname) || url.pathname === '/manifest.json') {
event.respondWith(cacheFirst(request, { cacheName: CACHE_NAME }));
return;
}
// Tout le reste : network-first
event.respondWith(networkFirst(request));
});
async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request, { ignoreSearch: false });
if (cached) return cached;
try {
const res = await fetch(request);
if (res && res.ok) cache.put(request, res.clone());
return res;
} catch {
return cached;
}
}
async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) {
const cache = await caches.open(cacheName);
try {
const res = await timeoutFetch(request, timeoutMs);
if (res && res.ok) {
cache.put(request, res.clone());
return res;
}
throw new Error('bad status');
} catch {
const cached = await cache.match(request, { ignoreSearch: false });
if (cached) return cached;
if (fallbackToShell) {
const shell = await cache.match('/');
if (shell) return shell;
return caches.match('/offline.html');
}
return cached;
}
}
function timeoutFetch(request, ms) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const controller = new AbortController();
controller.abort();
reject(new Error('timeout'));
}, ms);
fetch(request).then(
(res) => { clearTimeout(timer); resolve(res); },
(err) => { clearTimeout(timer); reject(err); }
);
});
}
// ── Background Sync : notifier les clients pour rejouer la queue ──
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-flowdeck') {
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clients) => {
clients.forEach((client) => client.postMessage({ type: 'flowdeck-sync' }));
})
);
}
});
// ── Messages : mise à jour immédiate ──
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
+19
View File
@@ -1024,6 +1024,25 @@ def test_pwa_manifest(client):
data = resp.json()
assert data["name"] == "FlowDeck"
assert data["display"] == "standalone"
assert any(i.get("sizes") == "192x192" for i in data["icons"])
def test_pwa_manifest_static_assets():
"""v6.0.0: the static manifest and every referenced icon must exist on disk."""
import json
from pathlib import Path
root = Path(__file__).resolve().parent.parent
manifest_path = root / "static" / "manifest.json"
assert manifest_path.is_file(), "static/manifest.json missing"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
assert manifest["name"] == "FlowDeck"
assert "icons" in manifest and len(manifest["icons"]) >= 8
for icon in manifest["icons"]:
src = icon["src"].lstrip("/")
assert (root / src).is_file(), f"icon missing: {src}"
# ══════════════════════════════════════════════════════
+188
View File
@@ -0,0 +1,188 @@
"""FlowDeck — PWA offline client module tests (v6.0.0).
Covers static/js/offline.js (IndexedDB + sync queue), its inclusion in the
templates and the editor's offline save hook. The server-side endpoints are
exercised in tests/test_sync.py.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OFFLINE_JS = ROOT / "static" / "js" / "offline.js"
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 _create_user_ws(client, login="offlinetest", ws_name="Offline WS"):
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO users (login, full_name, email) VALUES (?, ?, ?)",
(login, login.title(), f"{login}@test.com"),
)
user_id = cur.lastrowid
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
(ws_name, user_id),
)
conn.commit()
return _token(user_id, login)
# ── static asset ───────────────────────────────────────────────────────────
def test_offline_js_served(client):
resp = client.get("/static/js/offline.js")
assert resp.status_code == 200
assert "javascript" in resp.headers.get("content-type", "")
def test_offline_js_exposes_public_api():
src = OFFLINE_JS.read_text(encoding="utf-8")
assert "window.FlowOffline" in src
for api in ("enqueue", "flush", "delta", "status", "savePageOffline",
"getQueue", "pendingCount", "onChange"):
assert api in src, f"missing FlowOffline API: {api}"
def test_offline_js_indexeddb_schema():
src = OFFLINE_JS.read_text(encoding="utf-8")
assert "flowdeck-offline" in src
for store in ("pages_offline", "collections_offline", "sync_queue", "sync_meta"):
assert store in src, f"missing IndexedDB store: {store}"
def test_offline_js_targets_sync_endpoints():
src = OFFLINE_JS.read_text(encoding="utf-8")
assert "/api/v2/sync/batch" in src
assert "/api/v2/sync/delta" in src
assert "/api/v2/sync/status" in src
# mutation shape expected by the server engine
for field in ("mutation_id", "client_timestamp", "device_id"):
assert field in src, f"missing mutation field: {field}"
def test_offline_js_background_sync():
src = OFFLINE_JS.read_text(encoding="utf-8")
assert "sync-flowdeck" in src
assert "registerBackgroundSync" in src
# ── template wiring ────────────────────────────────────────────────────────
def test_base_includes_offline_module(client):
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
assert "/static/js/offline.js" in base
assert "/sw.js" in base
# served on a base.html page as well
session = _create_user_ws(client, login="basemod")
resp = client.get("/local-workspace", cookies={"flowdeck_session": session})
assert resp.status_code == 200
assert "/static/js/offline.js" in resp.text
def test_workspace_offline_banner_shows_pending(client):
session = _create_user_ws(client)
resp = client.get("/local-workspace", cookies={"flowdeck_session": session})
assert resp.status_code == 200
body = resp.text
assert "offline-banner" in body
assert "pendingSyncCount" in body
assert "FlowOffline" in body
def test_editor_save_has_offline_hook():
src = (ROOT / "app" / "templates" / "_page_editor_scripts.html").read_text(encoding="utf-8")
assert "FlowOffline" in src
assert "savePageOffline" in src
assert "navigator.onLine" in src
def test_sw_precache_includes_offline_js():
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
urls = re.findall(r"'(/static/js/offline\.js[^']*)'", sw)
assert urls, "offline.js missing from SW precache"
for url in urls:
assert (ROOT / url.split("?")[0].lstrip("/")).is_file()
# ── phase 6: hardening + sync UI ───────────────────────────────────────────
def test_offline_js_batch_limits_and_timeout():
src = OFFLINE_JS.read_text(encoding="utf-8")
assert "MAX_BATCH" in src
assert "FETCH_TIMEOUT_MS" in src
assert "AbortController" in src
assert "fetchWithTimeout" in src
assert "purgeOld" in src
assert "QUEUE_RETENTION_MS" in src
def test_offline_js_dirty_markers_and_sync_event():
src = OFFLINE_JS.read_text(encoding="utf-8")
assert "getDirtyPageIds" in src
assert "markDirtySidebar" in src
assert "fd-dirty" in src
assert "flowdeck:sync-done" in src
def test_base_has_sync_badge_and_toast_wiring(client):
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
assert "fd-sync-badge" in base
assert "fd-sync-spinner" in base
assert "fd-dirty" in base
assert "flowdeck:sync-done" in base
# rendered on an authenticated base.html page
session = _create_user_ws(client, login="syncbadge")
resp = client.get("/local-workspace", cookies={"flowdeck_session": session})
assert resp.status_code == 200
assert "fd-sync-badge" in resp.text
# ── phase 7: E2E spec (runs against a live server via Playwright) ───────────
def test_e2e_pwa_offline_spec_present():
spec = ROOT / "e2e" / "pwa_offline.spec.js"
assert spec.is_file()
src = spec.read_text(encoding="utf-8")
assert "FlowOffline" in src
assert "/api/v2/sync/delta" in src
assert "setOffline(true)" in src
assert "setOffline(false)" in src
assert "navigator.serviceWorker" in src
assert "manifest" in src
# skips gracefully when the server is not running
assert "test.skip" in src
# ── phase 8: release docs + version ────────────────────────────────────────
def test_version_bumped_to_6_0_0():
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "6.0.0"
assert 'version="6.0.0"' in (ROOT / "app" / "main.py").read_text(encoding="utf-8")
def test_help_documents_offline_mode(client):
session = _create_user_ws(client, login="helpuser")
resp = client.get("/help", cookies={"flowdeck_session": session})
assert resp.status_code == 200
body = resp.text
assert "Offline mode (PWA)" in body
assert "IndexedDB" in body
def test_changelog_and_roadmap_mention_v6(client):
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
assert "## v6.0.0" in changelog
assert "PWA" in changelog
assert "v6.0.0 — PWA" in roadmap
+112
View File
@@ -0,0 +1,112 @@
"""FlowDeck — PWA service worker tests (v6.0.0).
Validates that the service worker, static manifest and PWA icons are served
correctly (status codes, content types) and stay in sync with the manifest.
"""
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
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 _create_user_ws(client, login="pwatest", ws_name="PWA WS"):
"""Insert a user + workspace and return an authenticated session cookie."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO users (login, full_name, email) VALUES (?, ?, ?)",
(login, login.title(), f"{login}@test.com"),
)
user_id = cur.lastrowid
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
(ws_name, user_id),
)
conn.commit()
return _token(user_id, login)
def test_sw_served_at_top_level(client):
"""GET /sw.js returns the JS source so registration at scope '/' works."""
resp = client.get("/sw.js")
assert resp.status_code == 200
assert "javascript" in resp.headers.get("content-type", "")
def test_sw_served_via_static_mount(client):
resp = client.get("/static/sw.js")
assert resp.status_code == 200
assert "javascript" in resp.headers.get("content-type", "")
def test_sw_registration_present_on_landing(client):
"""Anonymous entry point (/) registers the SW (assets are public)."""
resp = client.get("/")
assert resp.status_code in (200, 302)
body = resp.text
assert "serviceWorker" in body
assert "/sw.js" in body
def test_sw_registration_with_background_sync_in_base(client):
"""base.html pages register /sw.js and the background-sync tag."""
session = _create_user_ws(client)
resp = client.get("/local-workspace", cookies={"flowdeck_session": session})
assert resp.status_code == 200
body = resp.text
assert "serviceWorker" in body
assert "/sw.js" in body
assert "sync-flowdeck" in body
def test_base_has_pwa_meta_tags(client):
resp = client.get("/")
body = resp.text
assert 'rel="manifest"' in body
assert "theme-color" in body
assert "apple-touch-icon" in body
def test_manifest_self_consistent(client):
"""Every icon listed in the served manifest exists on disk."""
resp = client.get("/manifest.json")
assert resp.status_code == 200
manifest = resp.json()
for icon in manifest["icons"]:
path = ROOT / icon["src"].lstrip("/")
assert path.is_file(), f"manifest references missing icon: {icon['src']}"
pwa_manifest = ROOT / "static" / "manifest.json"
assert pwa_manifest.is_file()
assert json.loads(pwa_manifest.read_text(encoding="utf-8")) == manifest
def test_sw_precache_urls_exist():
"""Every URL listed in the SW precache maps to a served static asset."""
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
urls = re.findall(r"'(/static/[^']+)'", sw)
assert urls, "no static URLs found in precache list"
for url in urls:
file_path = ROOT / url.split("?")[0].lstrip("/")
assert file_path.is_file(), f"precache URL missing on disk: {url}"
def test_sw_cache_strategies_defined():
"""The SW must define both cache-first and network-first helpers."""
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
assert "cacheFirst" in sw
assert "networkFirst" in sw
assert "CACHE_NAME" in sw
assert "addEventListener('install'" in sw
assert "addEventListener('activate'" in sw
assert "addEventListener('fetch'" in sw
assert "addEventListener('sync'" in sw
+372
View File
@@ -0,0 +1,372 @@
"""FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints."""
import time
# ── helpers ────────────────────────────────────────────────────────────────
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": 0})
def _user_ws(client, login="syncer"):
"""Create user + workspace, return (session_cookie, user_id, workspace_id)."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO users (login, full_name, email) VALUES (?, ?, ?)",
(login, login.title(), f"{login}@test.co"),
)
uid = cur.lastrowid
ws = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("Sync WS", uid)
)
conn.commit()
return _token(uid, login), uid, ws.lastrowid
def _insert_page(ws_id, title="P", content="[]", content_format="blocks"):
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
"VALUES ('', ?, ?, ?, ?, 'Private')",
(ws_id, title, content, content_format),
)
conn.commit()
return cur.lastrowid
def _page_version(pid):
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone()
return row["sync_version"]
def _page_updated_epoch(pid) -> float:
"""Epoch (float) of the page's stored updated_at (second granularity)."""
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT strftime('%s', updated_at) AS s FROM pages WHERE id=?",
(pid,)).fetchone()
return float(row["s"])
# ── authentication & routing ───────────────────────────────────────────────
def test_sync_requires_auth(client):
assert client.get("/api/v2/sync/status").status_code == 401
assert client.post("/api/v2/sync/batch", json={"mutations": []}).status_code == 401
assert client.get("/api/v2/sync/delta").status_code == 401
def test_delta_requires_workspace(client):
sess, _, _ = _user_ws(client)
resp = client.get("/api/v2/sync/delta", cookies={"flowdeck_session": sess})
assert resp.status_code == 400
def test_delta_forbidden_other_user_workspace(client):
session, _, ws_id = _user_ws(client, "owner_a")
sess2, uid2, _ = _user_ws(client, "owner_b")
# owner_b has no access to owner_a's workspace
resp = client.get(
f"/api/v2/sync/delta?since=0&workspace_id={ws_id}",
cookies={"flowdeck_session": sess2},
)
assert resp.status_code == 403
# ── delta ──────────────────────────────────────────────────────────────────
def test_delta_returns_changes_since(client):
session, _, ws_id = _user_ws(client)
pid = _insert_page(ws_id, "D1", "[]")
since = _page_updated_epoch(pid)
time.sleep(1.1) # CURRENT_TIMESTAMP has 1s granularity → ensure next second
_insert_page(ws_id, "D2", "[]")
resp = client.get(
f"/api/v2/sync/delta?since={since}&workspace_id={ws_id}",
cookies={"flowdeck_session": session},
)
assert resp.status_code == 200
changes = resp.json()["changes"]
assert len(changes) == 1
assert changes[0]["change_type"] == "page_created"
assert changes[0]["data"]["title"] == "D2"
# full history from since=0
resp2 = client.get(
f"/api/v2/sync/delta?since=0&workspace_id={ws_id}",
cookies={"flowdeck_session": session},
)
titles = {c["data"]["title"] for c in resp2.json()["changes"] if c["change_type"].startswith("page_")}
assert {"D1", "D2"} <= titles
def test_delta_includes_soft_deleted(client):
from app.db import get_conn
session, _, ws_id = _user_ws(client)
pid = _insert_page(ws_id, "ToDelete")
time.sleep(1.1) # 1s granularity: ensure the delete lands in a later second
since = _page_updated_epoch(pid)
with get_conn() as conn:
conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,))
conn.commit()
resp = client.get(
f"/api/v2/sync/delta?since={since}&workspace_id={ws_id}",
cookies={"flowdeck_session": session},
)
changes = resp.json()["changes"]
assert any(c["change_type"] == "page_deleted" for c in changes)
# ── batch: page mutations ──────────────────────────────────────────────────
def test_batch_page_create(client):
session, _, ws_id = _user_ws(client)
resp = client.post(
"/api/v2/sync/batch",
json={
"device_id": "dev-1",
"mutations": [{
"id": "m1",
"type": "page_create",
"client_timestamp": time.time(),
"payload": {"title": "Offline Page", "content": "[]",
"content_format": "blocks", "workspace_id": ws_id},
}],
},
cookies={"flowdeck_session": session},
)
assert resp.status_code == 200
body = resp.json()
assert body["results"][0]["status"] == "synced"
pid = body["results"][0]["page_id"]
assert body["results"][0]["server_version"] == 1
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT title, workspace_id FROM pages WHERE id=?", (pid,)).fetchone()
assert row["title"] == "Offline Page"
assert row["workspace_id"] == ws_id
def test_batch_page_update_synced(client):
session, _, ws_id = _user_ws(client)
pid = _insert_page(ws_id, "Versioned", "[]")
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "m1", "type": "page_update", "client_timestamp": time.time(),
"payload": {"page_id": pid, "base_version": 1,
"title": "Versioned v2", "content": "[1,2,3]"},
}]},
cookies={"flowdeck_session": session},
)
r = resp.json()["results"][0]
assert r["status"] == "synced"
assert r["server_version"] == 2 # trigger bumped 1 → 2
def test_batch_page_update_edit_edit_conflict_lww(client):
"""Server page already moved to v2 → client base v1 → LWW applied + conflict."""
session, _, ws_id = _user_ws(client)
pid = _insert_page(ws_id, "Conflict", "[]")
from app.db import get_conn
with get_conn() as conn:
conn.execute("UPDATE pages SET content='[server edit]' WHERE id=?", (pid,))
conn.commit()
assert _page_version(pid) == 2
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "m2", "type": "page_update", "client_timestamp": time.time(),
"payload": {"page_id": pid, "base_version": 1, "content": "[client edit]"},
}]},
cookies={"flowdeck_session": session},
)
r = resp.json()["results"][0]
assert r["status"] == "conflict"
assert r["conflict"]["type"] == "edit_edit"
assert r["conflict"]["server_version"] == 2
assert r["server_version"] == 3 # LWW applied
# content overwritten by client (last write wins)
from app.db import get_conn
with get_conn() as conn:
content = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()["content"]
assert content == "[client edit]"
def test_batch_page_update_edit_delete_orphan(client):
session, _, ws_id = _user_ws(client)
pid = _insert_page(ws_id, "Deleted", "[old]")
from app.db import get_conn
with get_conn() as conn:
conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,))
conn.commit()
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "m3", "type": "page_update", "client_timestamp": time.time(),
"payload": {"page_id": pid, "base_version": 1, "content": "[client]"},
}]},
cookies={"flowdeck_session": session},
)
r = resp.json()["results"][0]
assert r["status"] == "conflict"
assert r["conflict"]["type"] == "edit_delete"
orphan_id = r["conflict"]["new_page_id"]
with get_conn() as conn:
orphan = conn.execute("SELECT content, deleted_at FROM pages WHERE id=?", (orphan_id,)).fetchone()
original = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone()
assert orphan["content"] == "[client]"
assert not orphan["deleted_at"] # orphan is live
assert original["deleted_at"] # original stays deleted
def test_batch_page_create_create_create_conflict(client):
session, _, ws_id = _user_ws(client)
_insert_page(ws_id, "Same Title", "[]")
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "m4", "type": "page_create", "client_timestamp": time.time(),
"payload": {"title": "Same Title", "content": "[]", "workspace_id": ws_id},
}]},
cookies={"flowdeck_session": session},
)
r = resp.json()["results"][0]
assert r["status"] == "conflict"
assert r["conflict"]["type"] == "create_create"
assert r["conflict"]["renamed"] is True
from app.db import get_conn
with get_conn() as conn:
title = conn.execute("SELECT title FROM pages WHERE id=?", (r["page_id"],)).fetchone()["title"]
assert title == "Same Title (copie offline)"
def test_batch_page_delete_idempotent(client):
session, _, ws_id = _user_ws(client)
pid = _insert_page(ws_id, "D")
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "m5", "type": "page_delete", "client_timestamp": time.time(),
"payload": {"page_id": pid},
}]},
cookies={"flowdeck_session": session},
)
assert resp.json()["results"][0]["status"] == "synced"
# second delete is still ok (idempotent)
resp2 = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "m6", "type": "page_delete", "client_timestamp": time.time(),
"payload": {"page_id": pid},
}]},
cookies={"flowdeck_session": session},
)
assert resp2.json()["results"][0]["status"] == "synced"
def test_batch_unknown_type_failed(client):
session, _, ws_id = _user_ws(client)
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "m7", "type": "nope", "client_timestamp": time.time(), "payload": {},
}]},
cookies={"flowdeck_session": session},
)
r = resp.json()["results"][0]
assert r["status"] == "failed"
# ── batch: collections ─────────────────────────────────────────────────────
def test_batch_collection_crud(client):
from app.db import get_conn
session, _, _ = _user_ws(client)
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "c1", "type": "collection_create", "client_timestamp": time.time(),
"payload": {"name": "DB Off", "schema": [{"name": "Type", "prop_type": "text"}]},
}]},
cookies={"flowdeck_session": session},
)
r = resp.json()["results"][0]
assert r["status"] == "synced"
cid = r["collection_id"]
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "c2", "type": "collection_update", "client_timestamp": time.time(),
"payload": {"collection_id": cid, "name": "DB Off v2"},
}]},
cookies={"flowdeck_session": session},
)
assert resp.json()["results"][0]["server_version"] == 2
resp = client.post(
"/api/v2/sync/batch",
json={"device_id": "dev-1", "mutations": [{
"id": "c3", "type": "collection_delete", "client_timestamp": time.time(),
"payload": {"collection_id": cid},
}]},
cookies={"flowdeck_session": session},
)
assert resp.json()["results"][0]["status"] == "synced"
with get_conn() as conn:
assert conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone() is None
# ── status & audit ─────────────────────────────────────────────────────────
def test_sync_status_zero(client):
session, _, ws_id = _user_ws(client)
resp = client.get(
f"/api/v2/sync/status?workspace_id={ws_id}",
cookies={"flowdeck_session": session},
)
body = resp.json()
assert body["pending_count"] == 0
assert body["is_syncing"] is False
def test_batch_records_audit_rows(client):
session, uid, ws_id = _user_ws(client)
client.post(
"/api/v2/sync/batch",
json={"device_id": "audit-dev", "mutations": [{
"id": "a1", "type": "page_create", "client_timestamp": 123.0,
"payload": {"title": "Audited", "content": "[]", "workspace_id": ws_id},
}]},
cookies={"flowdeck_session": session},
)
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT user_id, device_id, type, status FROM offline_sync_queue ORDER BY id"
).fetchone()
assert row["user_id"] == uid
assert row["device_id"] == "audit-dev"
assert row["type"] == "page_create"
assert row["status"] == "synced"
+125
View File
@@ -0,0 +1,125 @@
"""FlowDeck — v6.0.0 PWA: offline-sync schema migrations.
Validates the ``offline_sync_queue`` table, the ``sync_version`` columns and
the BEFORE-UPDATE triggers that bump them on every write.
"""
from app.db import get_conn
def _insert_page(title="Page"):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
"VALUES ('', ?, '', 'blocks', 'Private')",
(title,),
)
conn.commit()
return cur.lastrowid
def _insert_collection(name="Coll"):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO collections (name, schema_json) VALUES (?, '[]')", (name,)
)
conn.commit()
return cur.lastrowid
def _insert_collection_page(coll_id, title="Row"):
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)",
(coll_id, title),
)
conn.commit()
return cur.lastrowid
def test_offline_sync_queue_table_and_indexes(client):
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(offline_sync_queue)")}
for expected in ("id", "user_id", "device_id", "type", "payload",
"client_timestamp", "server_version", "status",
"retries", "error", "created_at"):
assert expected in cols, f"offline_sync_queue missing column {expected}"
indexes = {r[1] for r in conn.execute("PRAGMA index_list('offline_sync_queue')")}
assert "idx_syncqueue_user" in indexes
assert "idx_syncqueue_device" in indexes
def test_offline_sync_queue_insert(client):
with get_conn() as conn:
uid = conn.execute(
"INSERT INTO users (login, full_name, email) VALUES ('queuetest', 'Queue', '[email protected]')"
).lastrowid
cur = conn.execute(
"INSERT INTO offline_sync_queue (user_id, device_id, type, payload, client_timestamp) "
"VALUES (?, ?, ?, ?, ?)",
(uid, "dev-abc", "page_update", '{"page_id": 1}', 1750000000000.0),
)
conn.commit()
qid = cur.lastrowid
row = conn.execute(
"SELECT status, server_version, retries FROM offline_sync_queue WHERE id=?",
(qid,),
).fetchone()
assert row["status"] == "pending"
assert row["server_version"] == 0
assert row["retries"] == 0
def test_sync_version_column_and_defaults(client):
pid = _insert_page()
cid = _insert_collection()
cpid = _insert_collection_page(cid)
with get_conn() as conn:
for table, pk in (("pages", pid), ("collection_pages", cpid), ("collections", cid)):
row = conn.execute(f"SELECT sync_version FROM {table} WHERE id=?", (pk,)).fetchone()
assert row is not None, f"sync_version missing on {table}"
assert row["sync_version"] == 1, f"{table} default sync_version should be 1"
def test_sync_version_increments_on_update(client):
pid = _insert_page()
with get_conn() as conn:
conn.execute("UPDATE pages SET title='Renamed' WHERE id=?", (pid,))
conn.commit()
v1 = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone()
conn.execute("UPDATE pages SET content='[...]' WHERE id=?", (pid,))
conn.commit()
v2 = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone()
assert v1["sync_version"] == 2
assert v2["sync_version"] == 3
def test_sync_version_increments_on_soft_delete(client):
"""deleted_at changes must bump the version too (delta sync sees deletions)."""
pid = _insert_page()
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
(pid,),
)
conn.commit()
row = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone()
assert row["sync_version"] == 2
def test_sync_version_increments_collection_and_rows(client):
cid = _insert_collection()
cpid = _insert_collection_page(cid)
with get_conn() as conn:
conn.execute("UPDATE collections SET name='Renamed' WHERE id=?", (cid,))
conn.execute(
"UPDATE collection_pages SET title='Row2', property_values_json='{}' WHERE id=?",
(cpid,),
)
conn.commit()
col = conn.execute("SELECT sync_version FROM collections WHERE id=?", (cid,)).fetchone()
row = conn.execute(
"SELECT sync_version FROM collection_pages WHERE id=?", (cpid,)
).fetchone()
assert col["sync_version"] == 2
assert row["sync_version"] == 2