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:
+31
-1
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user