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