Files
flowdeck/static/js/offline.js
T
bruno b5207216f1 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
2026-09-18 13:05:40 -04:00

467 lines
17 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════
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();
}
})();