feat: v6.0.0 PWA offline support

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

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

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

- migrations offline_sync_queue + sync_version (triggers)

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

- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
This commit is contained in:
2026-09-18 13:05:40 -04:00
parent 62620ef884
commit b5207216f1
35 changed files with 3115 additions and 23 deletions
+13 -10
View File
@@ -33,6 +33,7 @@ from app.routers import (
security,
sharing,
sidebar_config,
sync,
webhooks,
workspace,
)
@@ -107,7 +108,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.14.0",
version="6.0.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -147,6 +148,7 @@ app.include_router(agent.router)
app.include_router(search.router)
app.include_router(security.router)
app.include_router(onboarding.router)
app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
@@ -155,15 +157,16 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json")
async def pwa_manifest():
return {
"name": "FlowDeck",
"short_name": "FlowDeck",
"start_url": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
}
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js")
async def service_worker():
"""Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript")
# ═══════════ API aliases (v4.0.1) ═══════════
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+79
View File
@@ -630,3 +630,82 @@ def _migration_synced_blocks(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)"
)
@register(16, "v6.0.0: offline sync queue")
def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
"""v6.0.0 — PWA offline support.
``offline_sync_queue`` persists server-side the mutations received from
offline clients (``/api/v2/sync/batch``) so work is not lost and can be
audited/replayed per device.
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS offline_sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
-- 'page_create', 'page_update', 'page_delete', 'page_move',
-- 'collection_create', 'collection_update', 'collection_delete'
payload TEXT NOT NULL,
client_timestamp REAL NOT NULL,
server_version INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'syncing', 'synced', 'failed'
retries INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_user ON offline_sync_queue(user_id, status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_device ON offline_sync_queue(device_id, status)"
)
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@register(17, "v6.0.0: sync_version columns")
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
Every write on a page / collection increments its ``sync_version`` so a
reconnecting client can detect edit-edit conflicts via version mismatch.
A ``BEFORE UPDATE`` trigger performs the increment automatically on every
write path (no need to patch dozens of ``UPDATE`` call-sites).
"""
for table in ("pages", "collection_pages", "collections"):
_add_sync_version(conn, table)
# AFTER UPDATE + inner UPDATE: bumps sync_version on every write path.
# `recursive_triggers` is OFF by default, so the inner UPDATE never
# re-fires the trigger (no infinite loop), incl. the page FTS triggers.
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS pages_sync_version_bu
AFTER UPDATE ON pages
FOR EACH ROW BEGIN
UPDATE pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collection_pages_sync_version_bu
AFTER UPDATE ON collection_pages
FOR EACH ROW BEGIN
UPDATE collection_pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collections_sync_version_bu
AFTER UPDATE ON collections
FOR EACH ROW BEGIN
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
+29
View File
@@ -554,6 +554,16 @@ async def help_page(request: Request):
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📶</span>Offline & PWA</h3>
<p>Install FlowDeck as an app and keep working without a connection.</p>
<ul>
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
<li>Edits made offline are queued locally and synced automatically</li>
<li>A <b>⟳</b> marker shows pages with pending changes</li>
</ul>
</div>
</div>
<div class="help-section">
@@ -579,6 +589,25 @@ FlowDeck supports three authentication methods:<br>
</p>
</div>
<div class="help-section">
<h2>📶 Offline mode (PWA)</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
edits are saved locally, then synchronised when the connection returns.<br><br>
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
<b>Offline editing:</b> while offline, the editor stores changes in the browser
(IndexedDB) and shows an offline banner with the number of pending changes. A
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
A spinner badge appears while syncing, followed by a confirmation toast.<br>
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
page. If a page with the same title already exists, the offline copy is renamed
<i>“Title (copie offline)”</i>.
</p>
</div>
<div class="help-section">
<h2>💡 Tips</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
+88
View File
@@ -0,0 +1,88 @@
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync).
Pairs with ``app/services/sync_engine.py``. All routes require an authenticated
session (``flowdeck_session`` cookie).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.services.sync_engine import SyncEngine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
_engine = SyncEngine()
def _user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
@router.get("/delta")
async def sync_delta(
request: Request,
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
workspace_id: int = Query(default=None),
):
"""Pull server-side changes since `since` (for the given workspace)."""
user = _user(request)
if workspace_id is None:
raise HTTPException(status_code=400, detail="workspace_id is required")
result = await _engine.get_delta(user["id"], since, workspace_id)
if result.get("error") == "forbidden":
return JSONResponse({"detail": "Forbidden"}, status_code=403)
return result
@router.post("/batch")
async def sync_batch(request: Request):
"""Apply a batch of offline mutations and return per-mutation results."""
user = _user(request)
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
mutations = body.get("mutations") or []
device_id = body.get("device_id") or "unknown"
if not isinstance(mutations, list) or not mutations:
return {"results": [], "conflicts": [], "server_time": SyncEngine._now_epoch()}
result = await _engine.apply_batch(user["id"], mutations, device_id)
result["server_time"] = SyncEngine._now_epoch()
return result
@router.get("/status")
async def sync_status(request: Request, workspace_id: int = Query(default=None)):
"""Synchronization status for the workspace (pending server queue, last sync)."""
user = _user(request)
from app.db import get_conn
with get_conn() as conn:
if not SyncEngine._can_access(conn, user["id"], workspace_id):
return JSONResponse({"detail": "Forbidden"}, status_code=403)
pending = conn.execute(
"SELECT COUNT(*) AS n FROM offline_sync_queue WHERE user_id=? AND status='pending'",
(user["id"],),
).fetchone()["n"]
last = conn.execute(
"SELECT MAX(created_at) AS last FROM offline_sync_queue "
"WHERE user_id=? AND status='synced'",
(user["id"],),
).fetchone()["last"]
return {
"pending_count": pending,
"last_sync": last,
"is_syncing": False,
"server_time": SyncEngine._now_epoch(),
"workspace_id": workspace_id,
}
+421
View File
@@ -0,0 +1,421 @@
"""FlowDeck — offline synchronization engine (v6.0.0 PWA).
Reconciles offline mutations (queued on the client) with server state:
- ``get_delta`` — changes on the server since a given timestamp, for offline
clients to pull before pushing their own batch.
- ``apply_batch`` — replays a batch of offline mutations with optimistic
concurrency control. ``sync_version`` (auto-bumped by SQLite triggers added
in migration v17) is the version token.
Conflict model (from docs/V6_PWA_Progressive_Web_App.md):
- edit-edit → last-write-wins by default (applied + reported)
- edit-delete→ the page was deleted server-side → orphan copy created
- create-create → same title already exists server-side → renamed "… (copie offline)"
- delete → soft-delete (idempotent)
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import time
from app.db import get_conn
logger = logging.getLogger(__name__)
class ConflictError(Exception):
"""Raised when a mutation conflicts with server state."""
def __init__(self, detail: dict):
super().__init__(detail.get("type"))
self.details = detail
class SyncEngine:
"""Apply/read offline mutations. Stateless methods, thin sqlite access."""
# ── helpers ────────────────────────────────────────────────────────────
@staticmethod
def _now_epoch() -> float:
return time.time()
@staticmethod
def _can_access(conn, user_id: int, workspace_id: int | None) -> bool:
"""Owner or member of the workspace; legacy NULL workspace → allow."""
if not workspace_id:
return True
ws = conn.execute(
"SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)
).fetchone()
if ws and ws["owner_id"] == user_id:
return True
member = conn.execute(
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, user_id),
).fetchone()
return bool(member)
@staticmethod
def _page_workspace_id(conn, page_id: int) -> int | None:
row = conn.execute(
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
).fetchone()
return row["workspace_id"] if row else None
# ── delta ─────────────────────────────────────────────────────────────
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
"""Return server-side changes since `since` (epoch seconds)."""
if since and since > 1e12:
since /= 1000.0 # accept epoch-millis from legacy clients
changes: list[dict] = []
with get_conn() as conn:
if not self._can_access(conn, user_id, workspace_id):
return {"error": "forbidden"}
# ── pages (created / updated / soft-deleted) ──
rows = conn.execute(
"""SELECT * FROM pages
WHERE (? IS NULL OR workspace_id = ?)
AND (CAST(strftime('%s', COALESCE(updated_at, created_at)) AS REAL) > ?
OR (deleted_at IS NOT NULL
AND CAST(strftime('%s', deleted_at) AS REAL) > ?))""",
(workspace_id, workspace_id, since, since),
).fetchall()
for r in rows:
data = dict(r)
deleted = data.get("deleted_at") is not None
created_epoch = _iso_epoch(data.get("created_at"))
created_after = bool(created_epoch and created_epoch > since)
if deleted:
ctype = "page_deleted"
elif created_after:
ctype = "page_created"
else:
ctype = "page_updated"
changes.append({"change_type": ctype, "data": data})
# ── collections (created / updated) ──
coll_rows = conn.execute(
"SELECT * FROM collections WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
(since,),
).fetchall()
for r in coll_rows:
data = dict(r)
created_epoch = _iso_epoch(data.get("created_at"))
changes.append({
"change_type": "collection_created" if created_epoch and created_epoch > since
else "collection_updated",
"data": data,
})
# ── collection rows (informational for offline reading) ──
cp_rows = conn.execute(
"SELECT * FROM collection_pages WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
(since,),
).fetchall()
for r in cp_rows:
changes.append({"change_type": "collection_row_updated", "data": dict(r)})
return {
"changes": changes,
"server_time": self._now_epoch(),
"has_more": False,
}
# ── batch ──────────────────────────────────────────────────────────────
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
"""Apply a batch of offline mutations; returns per-mutation results.
``mutations`` = [{"id"|"mutation_id", "type", "payload", "client_timestamp"}]
"""
results: list[dict] = []
conflicts: list[dict] = []
for mut in mutations:
mut_id = mut.get("id") or mut.get("mutation_id") or f"m{len(results)}"
mtype = mut.get("type", "")
payload = mut.get("payload") or {}
client_ts = float(mut.get("client_timestamp") or 0)
result = {
"mutation_id": mut_id,
"type": mtype,
"status": "synced",
"server_version": None,
}
try:
handler = getattr(self, f"_mut_{mtype}", None)
if handler is None:
raise ValueError(f"unknown mutation type: {mtype}")
outcome = handler(user_id, payload)
result.update(outcome)
status = "conflict" if outcome.get("conflict") else "synced"
result["status"] = status
if outcome.get("conflict"):
conflicts.append({
"mutation_id": mut_id,
"type": mtype,
**outcome["conflict"],
})
except ConflictError as exc:
result["status"] = "conflict"
result["conflict"] = exc.details
conflicts.append({"mutation_id": mut_id, "type": mtype, **exc.details})
except Exception as exc: # noqa: BLE001 — report, don't kill the batch
logger.warning("sync mutation %s failed: %s", mut_id, exc)
result["status"] = "failed"
result["error"] = str(exc)
finally:
self._record(user_id, device_id, mtype, mut, client_ts, result)
results.append(result)
return {"results": results, "conflicts": conflicts}
# ── page mutations ─────────────────────────────────────────────────────
def _mut_page_create(self, user_id: int, payload: dict) -> dict:
title = (payload.get("title") or "New page").strip() or "New page"
workspace_id = payload.get("workspace_id")
parent_id = payload.get("parent_id")
with get_conn() as conn:
if not self._can_access(conn, user_id, workspace_id):
raise ConflictError({"type": "forbidden", "workspace_id": workspace_id})
# create-create conflict: same title already present at same parent
new_title = title
dup = conn.execute(
"""SELECT id FROM pages
WHERE title=? AND (? IS NULL OR workspace_id = ?)
AND (? IS NULL OR parent_id IS ?)
AND deleted_at IS NULL
LIMIT 1""",
(title, workspace_id, workspace_id, parent_id, parent_id),
).fetchone()
if dup:
new_title = f"{title} (copie offline)"
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format,
parent_id, parent_section, sort_order)
VALUES ('', ?, ?, ?, ?, ?, 'Private', ?)""",
(workspace_id, new_title,
payload.get("content", ""),
payload.get("content_format", "blocks"),
parent_id,
payload.get("sort_order", 0)),
)
conn.commit()
page_id = cur.lastrowid
synced = payload.get("client_page_id")
return {"page_id": page_id, "server_version": 1, "client_page_id": synced,
"conflict": {"type": "create_create", "renamed": new_title != title}
if new_title != title else None}
def _mut_page_update(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
base_version = payload.get("base_version")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise ConflictError({"type": "page_not_found", "page_id": page_id})
ws_id = row["workspace_id"] or payload.get("workspace_id")
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
# edit-delete: page soft-deleted server-side → orphan copy
if row["deleted_at"] is not None:
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format, parent_section)
VALUES ('', ?, ?, ?, ?, 'Private')""",
(ws_id,
payload.get("title") or row["title"],
(payload.get("content")
if payload.get("content") is not None else row["content"]),
payload.get("content_format") or row["content_format"]),
)
conn.commit()
orphan_id = cur.lastrowid
raise ConflictError({
"type": "edit_delete",
"page_id": page_id,
"new_page_id": orphan_id,
"detail": "La page a été supprimée côté serveur — copie récréée en page orpheline",
})
# edit-edit: version mismatch → last-write-wins + conflict report
server_version = row["sync_version"]
conflict = None
if base_version is not None and server_version != base_version:
conflict = {
"type": "edit_edit",
"page_id": page_id,
"client_version": base_version,
"server_version": server_version,
}
sets, params = [], []
if payload.get("title") is not None:
sets.append("title=?")
params.append(payload["title"])
if payload.get("content") is not None:
sets.append("content=?")
params.append(payload["content"])
if payload.get("content_format") is not None:
sets.append("content_format=?")
params.append(payload["content_format"])
sets_str = ", ".join(sets) if sets else "updated_at=updated_at"
params.append(page_id)
conn.execute(
f"UPDATE pages SET {sets_str}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version,
"conflict": conflict}
def _mut_page_delete(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
with get_conn() as conn:
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return {"page_id": page_id, "server_version": None} # idempotent / already hard-deleted
ws_id = row["workspace_id"]
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
conn.execute(
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP, "
"updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
(page_id,),
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version}
def _mut_page_move(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
with get_conn() as conn:
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return {"page_id": page_id, "server_version": None}
ws_id = row["workspace_id"]
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
sets, params = [], []
if payload.get("parent_id") is not None:
sets.append("parent_id=?")
params.append(payload["parent_id"])
if payload.get("sort_order") is not None:
sets.append("sort_order=?")
params.append(payload["sort_order"])
if sets:
params.append(page_id)
conn.execute(
f"UPDATE pages SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version}
# ── collection mutations ───────────────────────────────────────────────
def _mut_collection_create(self, user_id: int, payload: dict) -> dict:
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO collections (name, description, icon, schema_json)
VALUES (?, ?, ?, ?)""",
(payload.get("name", ""), payload.get("description", ""),
payload.get("icon", "📋"), json.dumps(payload.get("schema", []))),
)
conn.commit()
return {"collection_id": cur.lastrowid, "server_version": 1}
def _mut_collection_update(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not row:
raise ConflictError({"type": "collection_not_found", "collection_id": cid})
sets, params = [], []
if payload.get("name") is not None:
sets.append("name=?")
params.append(payload["name"])
if payload.get("description") is not None:
sets.append("description=?")
params.append(payload["description"])
if payload.get("icon") is not None:
sets.append("icon=?")
params.append(payload["icon"])
if payload.get("schema") is not None:
sets.append("schema_json=?")
params.append(json.dumps(payload["schema"]))
if sets:
params.append(cid)
conn.execute(
f"UPDATE collections SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM collections WHERE id=?", (cid,)
).fetchone()["sync_version"]
return {"collection_id": cid, "server_version": new_version}
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
conn.commit()
return {"collection_id": cid, "server_version": None}
# ── audit ──────────────────────────────────────────────────────────────
def _record(self, user_id: int, device_id: str, mtype: str, mut: dict,
client_ts: float, result: dict) -> None:
"""Persist every mutation in the server-side audit queue."""
try:
with get_conn() as conn:
conn.execute(
"""INSERT INTO offline_sync_queue
(user_id, device_id, type, payload, client_timestamp,
server_version, status, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, device_id, mtype, json.dumps(mut),
client_ts, result.get("server_version"),
result.get("status", "synced"),
result.get("error") or (json.dumps(result["conflict"], ensure_ascii=False)
if result.get("conflict") else None)),
)
conn.commit()
except Exception: # noqa: BLE001 — audit must never break the batch
logger.warning("failed to record sync audit row", exc_info=True)
def _iso_epoch(value) -> float | None:
"""Best-effort ISO→epoch. SQLite CURRENT_TIMESTAMP → 'YYYY-MM-DD HH:MM:SS'."""
try:
if value is None:
return None
# append a timezone so strptime behaves on naive timestamps
text = str(value).replace("T", " ").split(".")[0]
parsed = _dt.datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
return parsed.replace(tzinfo=_dt.UTC).timestamp()
except ValueError:
return None
+11 -2
View File
@@ -2350,12 +2350,21 @@ applyAIBlocks(text){
if(this.saving){ if(cb) cb(); return; }
this.sync();
this.saving=true;
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
// v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau
const queueOffline=()=>{
if(!window.FlowOffline){this.saving=false;return;}
window.FlowOffline.savePageOffline({id:this.pid,title:this.pageTitle,content:JSON.stringify(blocksArr),content_format:'blocks'})
.then(()=>{this.saving=false;this.dirty=false;this.lastSaved='offline';if(cb)cb();})
.catch(()=>{this.saving=false;});
};
if(!navigator.onLine){queueOffline();return;}
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
this._syncTitleUI();
if(cb)cb();
}).catch(()=>{this.saving=false;});
}).catch(()=>{this.saving=false;queueOffline();});
},
runAutomationButton(bid){
const idx=this.getIdx(bid);if(idx<0)return;
+60
View File
@@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
@@ -750,6 +755,11 @@
</template>
</div>
<!-- ============ PWA SYNC BADGE (v6.0.0) ============ -->
<div id="fd-sync-badge" class="fd-sync-badge" style="display:none" role="status" aria-live="polite">
<span class="fd-sync-spinner"></span><span>Synchronisation…</span>
</div>
</div>
<script data-cfasync="false">
@@ -2271,5 +2281,55 @@
})();
</script>
{# ─── PWA: sync indicators (v6.0.0) ─── #}
<style>
.fd-sync-badge{position:fixed;top:10px;right:14px;z-index:600;display:flex;align-items:center;gap:8px;
background:var(--bg-secondary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));
color:var(--text-secondary,#b8b8b8);border-radius:20px;padding:5px 12px;font-size:12px;
box-shadow:0 4px 16px rgba(0,0,0,.35);}
.fd-sync-spinner{width:12px;height:12px;border:2px solid rgba(255,255,255,.25);
border-top-color:var(--accent,#2383E2);border-radius:50%;display:inline-block;
animation:fd-spin .8s linear infinite;}
@keyframes fd-spin{to{transform:rotate(360deg)}}
.sidebar-item.fd-dirty .page-name::after{content:'⟳';margin-left:6px;font-size:11px;
color:var(--accent,#2383E2);opacity:.85;}
</style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script>
<script data-cfasync="false">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').then(function(reg) {
if ('sync' in reg && reg.sync) {
reg.sync.register('sync-flowdeck').catch(function() {});
}
}).catch(function() {});
});
})();
</script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false">
document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) {
var badge = document.getElementById('fd-sync-badge');
if (badge) badge.style.display = s.isSyncing ? 'flex' : 'none';
});
window.addEventListener('flowdeck:sync-done', function(ev) {
var d = ev.detail || {};
if (d.failed) {
if (window.showToast) window.showToast(d.failed + ' modification(s) non synchronisée(s)', 'error');
} else if (d.conflicts) {
if (window.showToast) window.showToast('Synchronisé — ' + d.conflicts + ' conflit(s) résolu(s)', 'info');
} else if (d.synced) {
if (window.showToast) window.showToast('Modifications synchronisées', 'success');
}
});
});
</script>
</body>
</html>
+14
View File
@@ -6,6 +6,11 @@
{% from '_icons.html' import fd_icon %}
<title>FlowDeck — All-in-one workspace</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<style>
:root {
--bg: #191919;
@@ -201,5 +206,14 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer>
<script data-cfasync="false">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function() {});
});
})();
</script>
</body>
</html>
+11 -1
View File
@@ -212,7 +212,8 @@
.modified-dot.show{display:block;}
/* Offline banner */
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;}
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;display:flex;align-items:center;justify-content:center;gap:12px;}
.offline-badge{background:rgba(255,255,255,.22);border-radius:10px;padding:1px 8px;font-size:11px;font-weight:600;}
/* Pinned indicator */
.pin-icon{font-size:10px;color:var(--text-tertiary);cursor:pointer;margin-left:4px;opacity:0;transition:opacity 150ms;}
@@ -595,6 +596,7 @@ window._wsInitData = (function() {
// Online/offline
online: navigator.onLine,
pendingSyncCount: 0,
pinnedIds: (function() {
try { return JSON.parse(localStorage.getItem('fd_ws_pinned') || '[]'); }
catch(e) { return []; }
@@ -648,6 +650,13 @@ window._wsInitData = (function() {
var self = this;
window.addEventListener('online', function() { self.online = true; });
window.addEventListener('offline', function() { self.online = false; });
// v6.0.0 PWA: reflect offline queue state from the client module
if (window.FlowOffline && window.FlowOffline.onChange) {
window.FlowOffline.onChange(function(s) {
self.online = s.online;
self.pendingSyncCount = s.pendingSyncCount;
});
}
// Side-peek editor posts renames here so the tree/peek title stay in sync.
window.addEventListener('message', function(ev) {
if (ev.origin !== window.location.origin) return;
@@ -3015,6 +3024,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Offline banner -->
<div class="offline-banner" x-show="!online" x-transition>
<span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span>
<span class="offline-badge" x-show="pendingSyncCount > 0" x-text="pendingSyncCount + ' modification(s) en attente'"></span>
</div>
<!-- Move-to modal -->