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
+31 -1
View File
@@ -550,6 +550,21 @@ Endpoints publics v2 :
|---------|-------|-------------|
| GET | `/api/v2/search?query=&workspace_id=&type=page|collection|all` | Résultats groupés par type, snippets |
### 4.18 Synchronisation offline (PWA)
| **Interne** | `routers/sync.py` + `services/sync_engine.py` — auth session (`flowdeck_session`), CSRF-exempt (`/api/v2`) |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/sync/delta?since=<epoch>&workspace_id=<id>` | Changements serveur depuis `since` (pages créées/màj/supprimées, collections) → `{changes, server_time, has_more}` |
| POST | `/api/v2/sync/batch` | Rejoue un lot de mutations offline → `{results, conflicts, server_time}` |
| GET | `/api/v2/sync/status?workspace_id=<id>` | `{pending_count, last_sync, is_syncing, server_time}` |
**Format batch** : `{device_id, mutations:[{id, type, payload, client_timestamp}]}` — `type` ∈ `page_create|page_update|page_delete|page_move|collection_create|collection_update|collection_delete`. `page_update` accepte `base_version` (colonne `sync_version`) pour la détection de conflit.
**Conflits** : `edit_edit` (last-write-wins + rapport), `edit_delete` (page orpheline recréée), `create_create` (renommage `« … (copie offline) »`). Chaque mutation est tracée dans `offline_sync_queue`.
---
## 5. Webhooks sortants (à étendre en v2)
@@ -675,4 +690,19 @@ EVENTS = [
- Frontend : `app/templates/*.html`, `static/js/app.js`
- Webhooks sortants : `app/services/webhook_outbound.py`
- Types de propriétés : `app/services/property_types.py`
- Roadmap : `ROADMAP.md` (v6.0.0 — API publique, realtime, synced blocks, web clipper, permissions granulaires)
- Roadmap : `ROADMAP.md` (v6.0.0 — API publique, realtime, synced blocks, web clipper, permissions granulaires)
---
## 11. Documents de conception détaillée v6.0.0
Chaque feature v6.0.0 dispose d'un document de conception détaillé dans `/docs/` :
| Feature | Document | Description |
|---------|----------|-------------|
| **PWA** | [`V6_PWA_Progressive_Web_App.md`](/docs/V6_PWA_Progressive_Web_App.md) | Offline support, service worker, IndexedDB, sync batch |
| **SSO/SAML** | [`V6_SSO_SAML_Enterprise_Auth.md`](/docs/V6_SSO_SAML_Enterprise_Auth.md) | SAML 2.0, OIDC, auto-provisioning, group mapping |
| **Permissions granulaires** | [`V6_Granular_Permissions.md`](/docs/V6_Granular_Permissions.md) | Page-level, property-level, collection-level, groupes |
| **Web Clipper** | [`V6_Web_Clipper.md`](/docs/V6_Web_Clipper.md) | Extension navigateur, capture d'articles, OAuth |
> **Note** : Le présent guide couvre la couche API v2 commune à toutes les features. Chaque document de conception ci-dessus détaille les endpoints, tables, services et UI spécifiques à sa feature.
+537
View File
@@ -0,0 +1,537 @@
# V6.0.0 — PWA : Progressive Web App, Offline Support
> **Statut** : Conception détaillée — v6.0.0
> **Date** : 2026-09-15
> **Route** : `feat/v6-pwa` → `develop` → `main`
> **Dépendances** : v5.14.0 Synced blocks (requis pour la synchronisation hors ligne)
---
## 1. Vision & objectifs
Transformer FlowDeck en une **Progressive Web App** fonctionnellement identique en ligne et hors ligne. L'utilisateur doit pouvoir :
- **Naviguer** entre les pages, workspaces, databases sans connexion
- **Créer, éditer, supprimer** du contenu en mode offline
- **Synchroniser** automatiquement les modifications dès le retour du réseau
- **Installer** l'application sur le bureau/l'écran d'accueil (manifest + service worker)
- **Bouncer** gracefulment entre modes en ligne/hors ligne avec indication claire
### Objectifs de qualité
| Critère | Cible |
|---------|-------|
| Temps de chargement hors ligne | < 1 s (cache local) |
| Taille du cache initial | < 15 MB (shell de l'app + assets critiques) |
| Synchronisation différée | Queue de mutations → replay automatique |
| Conflits | Détection par version de page → résolution manuelle ou last-write-wins |
| Disponibilité UI | Indicateur visuel online/offline persistant |
---
## 2. Architecture PWA
### 2.1 Composants
```
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ App Shell │ │ Service │ │ IndexedDB │ │
│ │ (Jinja2 + │ │ Worker │ │ (données offline) │ │
│ │ Alpine.js) │ │ (cache + │ │ │ │
│ │ │ │ sync) │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
│ │ │ │ │
│ └────────────┬────┴───────────────────────┘ │
│ │ │
│ ┌────────────▼──────────────────────────────┐ │
│ │ Sync Queue (Background Sync) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Mutations pending → replay │ │ │
│ │ │ sur reconnexion │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────┘
│ HTTPS / Service Worker
┌──────────────────────────▼──────────────────────────────────────┐
│ FASTAPI (Server) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Routes existantes (incluses dans le cache) │ │
│ │ ├─ /, /workspaces, /library, /settings │ │
│ │ ├─ /board/{owner}/{repo}, /db/{id}/pages │ │
│ │ ├─ /api/pages/*, /db/*/pages/api │ │
│ │ └─ /p/{slug} (pages publiques) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Nouveau : Offline Sync Endpoint │ │
│ │ POST /api/v2/sync/batch — batch de mutations │ │
│ │ GET /api/v2/sync/delta ?since=timestamp │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 Flux de synchronisation
```
[En ligne] [Hors ligne]
│ │
│ ── Requête HTTP normale ──► │
│ │ L'utilisateur édite
│ ◄── Réponse JSON ── │ → mutation mise en queue
│ │ → IndexedDB mis à jour
│ │ → UI optimiste
│ │
│ Reconnexion détectée │
│ ── GET /api/v2/sync/delta ──► │
│ ◄── Changements serveur ────────┤
│ ── POST /api/v2/sync/batch ──► │
│ ◄── 200 OK (toutes mut. ack'd)─┤
│ → UI refresh + cache invalidé │
```
---
## 3. Service Worker
### 3.1 Fichier : `static/sw.js`
```javascript
const CACHE_NAME = 'flowdeck-v6';
const PRECACHE_URLS = [
'/', '/workspaces', '/library', '/settings',
'/static/css/app.css', '/static/css/design-tokens.css',
'/static/css/components.css',
'/static/js/app.js', '/static/js/alpine.js',
'/static/js/sortable.js', '/static/js/prism.js',
'/static/js/katex.min.js', '/static/css/katex.min.css',
];
// ── Install : precache du shell ──
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
});
// ── Activate : purge des anciens caches ──
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
// ── Fetch : stratégie cache-first pour assets, network-first pour API ──
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// API : network-first avec fallback offline
event.respondWith(networkFirst(event.request));
} else if (url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|woff2|ico)$/)) {
// Assets statiques : cache-first
event.respondWith(cacheFirst(event.request));
} else {
// Pages HTML : network-first avec fallback offline
event.respondWith(networkFirst(event.request));
}
});
```
### 3.2 Stratégies de cache
| Type | Stratégie | Fallback offline |
|------|-----------|-----------------|
| Shell (HTML/CSS/JS) | Cache-first | Cache stocké lors de l'install |
| API GET (pages, vues) | Network-first, cache en fallback | Dernière version IndexedDB |
| API POST/PUT/DELETE | Network-only | Offline queue → Background Sync |
| Images uploadées | Cache-first après upload | Stockées dans IndexedDB |
---
## 4. IndexedDB — Structure de données offline
### 4.1 Base de données : `flowdeck-offline`
```sql
-- Tables stockées dans IndexedDB (via idb-keyval ou Dexie.js)
-- Pages locales (modifiées hors ligne)
STORE pages_offline {
id: number (primary key)
workspace_id: number
title: string
content: string (blocs JSON)
content_format: string
parent_id: number | null
deleted_at: string | null
_modified_at: number (timestamp local)
_dirty: boolean (true = pending sync)
}
-- Collections vues hors ligne
STORE collections_offline {
id: number (primary key)
workspace_id: number
name: string
schema_json: string
_modified_at: number
_dirty: boolean
}
-- Queue de synchronisation
STORE sync_queue {
id: number (auto-increment)
type: string ('page_create' | 'page_update' | 'page_delete' | 'collection_create' | ...)
payload: string (JSON de la mutation)
timestamp: number
retries: number
status: string ('pending' | 'syncing' | 'synced' | 'failed')
}
-- Métadonnées de sync
STORE sync_meta {
key: string ('last_server_sync' | 'last_page_version' | ...)
value: string (timestamp ou version)
}
```
### 4.2 Modèle de conflit
```
Cas 1 — Pas de conflit (page non modifiée côté serveur)
→ Apply la mutation locale directement
Cas 2 — Conflit edit-edit
→ Détection via version de page (column `updated_at` serveur vs `_modified_at` local)
→ Résolution :
a) Last-write-wins (par défaut, configurable dans Settings)
b) Manuel : l'utilisateur choisit via un diff UI
Cas 3 — Conflit edit-delete
→ La page a été supprimée côté serveur pendant le mode offline
→ Créer une nouvelle page orphan → notification à l'utilisateur
Cas 4 — Conflit create-create (même titre)
→ Le serveur a créé une page avec le même titre pendant l'offset
→ Renommer la copie locale : "Titre (copie offline)"
```
---
## 5. Endpoints serveur pour le sync offline
### 5.1 Nouveaux endpoints `app/routers/sync.py`
```
GET /api/v2/sync/delta?since=<timestamp>&workspace_id=<id>
→ Retourne les changements serveur depuis le dernier sync
→ Format: { changes: [{type, page_id, content, version}], new_pages: [...] }
POST /api/v2/sync/batch
→ Batch de mutations offline → serveur
→ Input: { mutations: [{type, payload, client_timestamp}], device_id: string }
→ Output: { results: [{mutation_id, status, server_version}], conflicts: [...] }
GET /api/v2/sync/status
→ État de synchronisation pour le workspace courant
→ { pending_count, last_sync, is_syncing }
```
### 5.2 Service serveur `app/services/sync_engine.py`
```python
class SyncEngine:
"""Moteur de synchronisation offline↔online."""
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
"""Retourne les changements serveur depuis `since`."""
# Requêter pages, collections, comments modifiées depuis `since`
# Inclure les versions pour détection de conflits
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
"""Applique un batch de mutations en une transaction."""
results = []
conflicts = []
for mut in mutations:
try:
# Validation de la version attendue
# Application de la mutation
# Génération du conflit si version mismatch
results.append({"mutation_id": mut["id"], "status": "synced", "server_version": ...})
except ConflictError as e:
conflicts.append({"mutation_id": mut["id"], "conflict": e.details})
return {"results": results, "conflicts": conflicts}
```
---
## 6. Interface utilisateur — Indicateurs offline
### 6.1 Offline banner (existant à améliorer)
Le banner offline existant dans `local_workspace.html` (ligne 3016-3017) est étendu :
```css
/* Amélioration : ajouter dans app.css */
.offline-banner {
position: fixed; top: 0; left: 0; right: 0;
background: var(--danger); color: #fff;
text-align: center; padding: 8px 16px;
font-size: 13px; z-index: 500; font-weight: 500;
/* Ajout v6 */
display: flex; align-items: center; justify-content: center; gap: 12px;
animation: slideDown 0.3s ease;
}
```
```html
<!-- Extension du banner -->
<div class="offline-banner" x-show="!online" x-transition>
<span>{{ fd_icon('alert-triangle', 14) }} Mode hors ligne — vos modifications seront synchronisées</span>
<span x-show="pendingSyncCount > 0" class="badge">
{{ pendingSyncCount }} modification(s) en attente
</span>
</div>
```
### 6.2 Indicateur de synchronisation
- **Badge syncing** dans la topbar quand le batch est en cours
- **Toast** à la fin d'une synchronisation réussie ou avec erreurs
- **Cône de synchronisation** dans le sidebar pour les pages modifiées hors ligne (icône `⟳` sur les pages `_dirty = true`)
---
## 7. Manifest Web & Installation
### 7.1 `static/manifest.json`
```json
{
"name": "FlowDeck",
"short_name": "FlowDeck",
"description": "Clone Notion intégré à Gitea/GitHub",
"start_url": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"orientation": "any",
"icons": [
{ "src": "/static/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/static/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/static/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/static/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/static/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/static/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"categories": ["productivity", "business"],
"lang": "fr",
"dir": "ltr",
"prefer_related_applications": false
}
```
### 7.2 Service Worker registration (dans `base.html`)
```html
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(reg => {
console.log('SW registered:', reg.scope);
// Background Sync support
if ('sync' in reg) {
reg.sync.register('sync-flowdeck');
}
});
});
}
</script>
```
---
## 8. Tables de base de données — Modifications nécessaires
### 8.1 Nouveau table `offline_sync_queue`
```sql
CREATE TABLE offline_sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
-- 'page_create', 'page_update', 'page_delete', 'page_move',
-- 'collection_create', 'collection_update', 'collection_delete'
payload TEXT NOT NULL, -- JSON de la mutation
client_timestamp REAL NOT NULL, -- epoch ms
server_version INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'syncing', 'synced', 'failed'
retries INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_syncqueue_user ON offline_sync_queue(user_id, status);
CREATE INDEX idx_syncqueue_device ON offline_sync_queue(device_id, status);
```
### 8.2 Colonnes ajoutées sur `pages` et `collections`
```sql
-- Détection de conflit
ALTER TABLE pages ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE collection_pages ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE collections ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
```
### 8.3 Migration
```python
# app/migrations.py — ajouter à la séquence
MIGRATIONS = [
# ... migrations existantes v1-v5 ...
("v6-offline-sync-queue", """
CREATE TABLE offline_sync_queue (...);
CREATE INDEX ...;
"""),
("v6-sync-version-columns", """
ALTER TABLE pages ADD COLUMN sync_version INTEGER DEFAULT 1;
ALTER TABLE collection_pages ADD COLUMN sync_version INTEGER DEFAULT 1;
ALTER TABLE collections ADD COLUMN sync_version INTEGER DEFAULT 1;
"""),
]
```
---
## 9. Frontend — Modules Alpine.js
### 9.1 Module `app/offline.js`
```javascript
// Module hors ligne — gestion de la connexion et de la queue
export const offlineModule = {
data() {
return {
online: navigator.onLine,
pendingSyncCount: 0,
lastSyncAt: null,
isSyncing: false,
};
},
async created() {
window.addEventListener('online', () => { this.online = true; this.processQueue(); });
window.addEventListener('offline', () => { this.online = false; });
this.pendingSyncCount = await this.getPendingCount();
// Polling status toutes les 30s
setInterval(() => this.syncStatus(), 30000);
},
methods: {
async getPendingCount() {
const r = await fetch('/api/v2/sync/status');
const d = await r.json();
this.pendingSyncCount = d.pending_count;
this.lastSyncAt = d.last_sync;
},
async processQueue() {
if (this.isSyncing || !this.online) return;
this.isSyncing = true;
const queue = await this.getQueue();
if (queue.length > 0) {
await fetch('/api/v2/sync/batch', {
method: 'POST',
body: JSON.stringify({ mutations: queue, device_id: DEVICE_ID }),
});
}
this.isSyncing = false;
await this.getPendingCount();
},
async getQueue() { /* Lire IndexedDB sync_queue */ },
async syncStatus() { /* GET /api/v2/sync/status */ },
},
};
```
### 9.2 Integration dans l'éditeur
Lorsqu'un utilisateur est hors ligne et modifie une page :
1. La mutation est immédiatement appliquée dans IndexedDB (optimistic UI)
2. L'éditeur sauvegarde normalement dans le cache local
3. Une entrée est ajoutée à `sync_queue` via `POST /api/v2/sync/batch` quand le réseau revient
4. Le contenu de la page est marqué `_dirty = true` → icône `⟳` dans le sidebar
---
## 10. Tests
| Test | Description | Outil |
|------|-------------|-------|
| Service worker install | Cache precache valide | Playwright |
| Offline page load | Navigation sans réseau → cache servi | Playwright |
| Offline mutation | Créer une page offline → IndexedDB + queue | Playwright + SQLite |
| Online sync | Reconnexion → batch replay → serveur à jour | Playwright + API |
| Conflict resolution | Edit-edit conflict → résolution last-write-wins | Unit test + Playwright |
| Manifest install | `navigator.standalone` ou `beforeinstallprompt` | Playwright |
| Background sync | `SyncManager.register` appelé | Playwright |
| Cache invalidation | Nouvelle version → ancien cache supprimé | Unit test |
```bash
# Commandes
pytest tests/test_pwa_offline.py # Tests sync offline
pytest tests/test_service_worker.py # Tests SW
npx playwright tests/pwa-offline.spec.ts # E2E offline flows
```
---
## 11. Checklist d'implémentation
1. **`static/manifest.json`** + icônes (512x512 maskable)
2. **`static/sw.js`** — Service Worker avec precache + API strategy
3. **`app/routers/sync.py`** — endpoints `/api/v2/sync/*`
4. **`app/services/sync_engine.py`** — moteur de synchronisation
5. **Migrations** — table `offline_sync_queue` + colonnes `sync_version`
6. **`app/offline.js`** — module Alpine.js offline
7. **Amélioration banner offline** — pending count + sync status
8. **IndexedDB côté client** — wrapper pour pages, collections, queue
9. **Tests** — offline scenarios + sync conflict + background sync
10. **Dockerfile** — servir le SW + manifest via le server
11. **Documentation utilisateur** — section `/help` sur le mode offline
---
## 12. Performance & contraintes
| Contrainte | Détail |
|-----------|--------|
| Taille maximale cache shell | 15 MB |
| Timeout sync batch | 30 s |
| Max mutations par batch | 100 |
| Retention queue offline | 30 jours (auto-purge) |
| Device ID | UUID stocké dans localStorage |
| Fallback sans SW | Mode "lite" → pas de PWA, mais app fonctionne normalement |
---
## 13. Références
- [MDN: Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
- [Google: Offline Storage Best Practices](https://web.dev/offline-storage-best-practices/)
- [Workbox — Google's SW library](https://developers.google.com/web/tools/workbox)
- [Background Sync API](https://web.dev/background-sync/)
- [IndexedDB MDN](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
- `app/routers/sync.py` — (à créer)
- `app/services/sync_engine.py` — (à créer)
- `docs/API_GUIDE_V6.md` — référence API v2 (endpoint `/api/v2/sync/*`)
- `ROADMAP.md` — v6.0.0 PWA item