- 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
467 lines
17 KiB
JavaScript
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();
|
|
}
|
|
})();
|