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
@@ -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
|
||||
|
||||
@@ -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)*
|
||||
|
||||
@@ -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) ═══════════
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
)
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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)
|
||||
@@ -676,3 +691,18 @@ EVENTS = [
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 999 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 624 B |
|
After Width: | Height: | Size: 767 B |
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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}"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||