function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel }) { const prefix = 'stackchain.today-sync.v1.'; const migrationPrefix = 'stackchain.today-sync-migrated.v1.'; const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.'; let flushing = null; let channel = null; let channelKey = ''; function key() { const login = String(getLogin?.() || '').trim().toLowerCase(); return login ? prefix + encodeURIComponent(login) : ''; } function snapshotKey() { const storageKey = key(); return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : ''; } function savedRevision() { try { const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null'); return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1; } catch (_error) { return -1; } } function adopt(plan, broadcast = true) { if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false; if (plan.revision < savedRevision()) return false; try { storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids })); } catch (_error) { // A storage quota failure must not prevent the current tab from using server truth. } onRemoteIds?.(plan.ids); if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids }); return true; } function ensureChannel() { const storageKey = key(); if (!storageKey || channelKey === storageKey) return; channel?.close?.(); const factory = createChannel || (globalThis.window?.BroadcastChannel ? name => new globalThis.window.BroadcastChannel(name) : null); channelKey = storageKey; channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null; channel?.addEventListener?.('message', event => { if (key() === storageKey) adopt(event.data, false); }); } function pending() { const storageKey = key(); if (!storageKey || !storage) return []; try { const value = JSON.parse(storage.getItem(storageKey) || '[]'); return Array.isArray(value) ? value.filter(operation => operation && typeof operation.operation_id === 'string' && ['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string' ) : []; } catch (_error) { return []; } } function save(operations) { const storageKey = key(); if (!storageKey || !storage) return false; try { if (operations.length) storage.setItem(storageKey, JSON.stringify(operations)); else storage.removeItem(storageKey); return true; } catch (_error) { return false; } } function operationId() { if (createOperationId) return createOperationId(); if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); } function enqueue(action, itemId, direction = null) { const operations = pending(); if (action === 'remove' && operations.some(operation => operation.action === 'remove' && operation.item_id === itemId )) { onStatus?.('pending'); return true; } operations.push({ operation_id: operationId(), action, item_id: itemId, direction }); const saved = save(operations); onStatus?.(saved ? 'pending' : 'error'); return saved; } function migrate(ids) { const storageKey = key(); if (!storageKey || !storage) return false; const marker = migrationPrefix + storageKey.slice(prefix.length); try { if (storage.getItem(marker)) return false; for (const id of ids || []) enqueue('add', id); storage.setItem(marker, '1'); return true; } catch (_error) { return false; } } async function run() { if (!key()) return false; ensureChannel(); try { let plan = await fetchJson('api/v1/today'); const operations = pending(); for (const operation of operations) { try { plan = await fetchJson('api/v1/today', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(operation), }); } catch (error) { if (error?.status !== 409) throw error; const rejected = pending(); save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id)); adopt(plan); onStatus?.('full'); return false; } const remaining = pending(); if (remaining[0]?.operation_id === operation.operation_id) save(remaining.slice(1)); } adopt(plan); onStatus?.(pending().length ? 'pending' : 'saved'); return true; } catch (_error) { onStatus?.(pending().length ? 'pending' : 'error'); return false; } } function flush() { if (!flushing) flushing = run().finally(() => { flushing = null; }); return flushing; } function startLifecycle({ window: windowObject, document: documentObject }) { windowObject?.addEventListener?.('online', flush); documentObject?.addEventListener?.('visibilitychange', () => documentObject.hidden ? false : flush() ); } return { enqueue, migrate, flush, pending, startLifecycle }; } if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;