(function (root, factory) { const api = factory(); if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.createOfflineWorkStore = api; })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; const ENABLED_KEY = 'stackchain.offline-work.enabled.v1'; const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1'; const DETAILS_KEY = 'stackchain.offline-work.details.v1'; const VERSION = 1; const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const ITEM_FIELDS = [ 'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees', 'updated_at', 'due_date', 'milestone', 'url', 'work_reasons', ]; const NOTIFICATION_FIELDS = [ 'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url', ]; const DETAIL_FIELDS = [ 'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone', 'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at', 'head_sha', 'ci_state', ]; const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url']; const REVIEW_FILE_FIELDS = [ 'filename', 'status', 'additions', 'deletions', 'diff_available', 'diff_binary', 'diff_truncated', ]; const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at']; function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') { if (!indexedDB) return null; let databasePromise; function database() { if (!databasePromise) databasePromise = new Promise((resolve, reject) => { const request = indexedDB.open(dbName, 1); request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains('work')) { request.result.createObjectStore('work', { keyPath: 'id' }); } }; request.onsuccess = () => { const db = request.result; db.onversionchange = () => { db.close(); databasePromise = undefined; }; resolve(db); }; request.onerror = () => reject(request.error || new Error('Offline work database failed to open.')); }); return databasePromise; } const requested = request => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return async work => { const db = await database(); return new Promise((resolve, reject) => { const transaction = db.transaction('work', 'readwrite'); const store = transaction.objectStore('work'); let result; let workError; transaction.oncomplete = () => workError ? undefined : resolve(result); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.')); Promise.resolve(work({ get: key => requested(store.get(key)), getAll: () => requested(store.getAll()), put: value => requested(store.put(value)), delete: key => requested(store.delete(key)), clear: () => requested(store.clear()), })).then(value => { result = value; }).catch(error => { workError = error; try { transaction.abort(); } catch (_) { reject(error); } }); }); }; } function pick(source, fields) { const output = {}; fields.forEach(field => { if (source && source[field] !== undefined) output[field] = source[field]; }); return output; } function createOfflineWorkStore({ storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10, }) { const transact = transaction || createIndexedDbTransaction(indexedDB); let migrationPromise; let recordCache = null; function migrateLegacy() { if (!transact) return Promise.resolve(true); if (!migrationPromise) migrationPromise = (async () => { let snapshot = null; let details = []; try { snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]'); details = Array.isArray(parsedDetails) ? parsedDetails : []; } catch (_) { return false; } if (!snapshot && !details.length) return true; await transact(async records => { if (snapshot && !(await records.get('snapshot'))) { await records.put({ ...snapshot, id:'snapshot' }); } for (const record of details.slice(-Math.max(1, maxDetails))) { if (!record?.key || !record?.user_login || !record?.data) continue; const id = 'detail:' + record.user_login + ':' + record.key; if (!(await records.get(id))) await records.put({ ...record, id }); } }); storage.removeItem(SNAPSHOT_KEY); storage.removeItem(DETAILS_KEY); return true; })().catch(() => false); return migrationPromise; } function ready() { if (!transact) return Promise.resolve(true); if (recordCache) return Promise.resolve(true); return migrateLegacy().then(migrated => { if (!migrated) { recordCache = new Map(); try { const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); if (snapshot) recordCache.set('snapshot', { ...snapshot, id:'snapshot' }); const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]'); if (Array.isArray(details)) details.forEach(record => { if (!record?.key || !record?.user_login || !record?.data) return; const id = 'detail:' + record.user_login + ':' + record.key; recordCache.set(id, { ...record, id }); }); } catch (_) { recordCache.clear(); } return false; } return transact(async records => { const currentTime = now().getTime(); const valid = []; for (const record of await records.getAll()) { const savedAt = Date.parse(record?.saved_at || ''); const validShape = record?.id === 'snapshot' ? record.version === VERSION && record.user_login && record.data : record?.id?.startsWith('detail:') && record.user_login && record.key && record.data; if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) { if (record?.id) await records.delete(record.id); } else valid.push(record); } recordCache = new Map(valid.map(record => [record.id, record])); return true; }); }).catch(() => false); } function enabled() { try { return storage.getItem(ENABLED_KEY) === 'true'; } catch (_) { return false; } } function clear() { if (transact) { try { storage.removeItem(SNAPSHOT_KEY); storage.removeItem(DETAILS_KEY); } catch (_) { /* IndexedDB remains the source of truth. */ } migrationPromise = Promise.resolve(true); return transact(async records => { await records.clear(); recordCache = new Map(); return true; }).catch(() => false); } try { storage.removeItem(SNAPSHOT_KEY); storage.removeItem(DETAILS_KEY); } catch (_) { return false; } return true; } function detailKey(item) { if (item?.kind === 'update') { const notificationId = Number(item?.notification_id || 0); return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : ''; } const kind = item?.kind === 'review' ? 'review' : item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : ''; const repository = String(item?.repository || ''); const number = Number(item?.number || 0); return kind && repository && number > 0 ? [kind, repository, number].join(':') : ''; } function readDetails() { try { const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]'); return Array.isArray(value) ? value : []; } catch (_) { try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ } return []; } } function saveDetail(login, item, detail) { const key = detailKey(item); login = String(login || '').trim(); if (!enabled() || !login || !key || !detail) return false; const conversation = detail.conversation || {}; const data = { ...pick(detail, DETAIL_FIELDS), conversation: { comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)), page: Number(conversation.page || 1), older_page: conversation.older_page ?? null, total: Number(conversation.total || 0), }, }; if (item?.is_review || item?.kind === 'review') { data.files = (detail.files || []).slice(0, 50).map(file => ({ ...pick(file, REVIEW_FILE_FIELDS), diff_lines: (file?.diff_lines || []).slice(0, 400).map(line => String(line)), })); data.reviews = (detail.reviews || []).slice(-20).map(review => ({ ...pick(review, REVIEW_FIELDS), user: pick(review?.user, ['login']), })); } const record = { key, user_login: login, saved_at: now().toISOString(), data, }; if (transact) { record.id = 'detail:' + login + ':' + key; return ready().then(() => transact(async records => { const existing = (await records.getAll()).filter(candidate => candidate?.id?.startsWith('detail:') && candidate.id !== record.id ); await records.put(record); const overflow = existing.concat(record).sort((a, b) => String(a.saved_at).localeCompare(String(b.saved_at)) ).slice(0, -Math.max(1, maxDetails)); for (const candidate of overflow) await records.delete(candidate.id); overflow.forEach(candidate => recordCache.delete(candidate.id)); recordCache.set(record.id, record); return true; })).catch(() => false); } const records = readDetails().filter(candidate => !(candidate?.key === key && candidate?.user_login === login) ); records.push(record); try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); } catch (_) { return false; } return true; } function loadDetail(login, item) { const key = detailKey(item); login = String(login || '').trim(); if (!login || !key) return null; if (transact) { const id = 'detail:' + login + ':' + key; if (!recordCache) return ready().then(() => loadDetail(login, item)); const record = recordCache.get(id); if (!record) return null; const savedAt = Date.parse(record.saved_at || ''); if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) { recordCache.delete(id); transact(async records => { await records.delete(id); }).catch(() => {}); return null; } return { ...record.data, saved_at: record.saved_at }; } const records = readDetails(); const currentTime = now().getTime(); const valid = records.filter(record => { const savedAt = Date.parse(record?.saved_at || ''); return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) && currentTime - savedAt <= maxAgeMs; }); if (valid.length !== records.length) { try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ } } const record = valid.find(candidate => candidate.key === key && candidate.user_login === login); return record ? { ...record.data, saved_at: record.saved_at } : null; } function setEnabled(value) { try { storage.setItem(ENABLED_KEY, value ? 'true' : 'false'); if (!value) clear(); return true; } catch (_) { return false; } } function save(snapshot) { if (!enabled() || !snapshot?.user?.login) return false; const record = { id: 'snapshot', version: VERSION, user_login: String(snapshot.user.login), saved_at: now().toISOString(), data: { user: pick(snapshot.user, ['login', 'full_name']), issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)), pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)), notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)), work_pagination: snapshot.work_pagination || {}, notification_pagination: snapshot.notification_pagination || {}, }, }; if (transact) { return ready().then(() => transact(async records => { await records.put(record); recordCache.set(record.id, record); return true; })).catch(() => false); } try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); } catch (_) { return false; } return true; } function load(expectedLogin) { if (transact) { if (!recordCache) return ready().then(() => load(expectedLogin)); const record = recordCache.get('snapshot'); const savedAt = Date.parse(record?.saved_at || ''); const invalid = record?.version !== VERSION || !record?.user_login || !record?.data || !Number.isFinite(savedAt); const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs; if (invalid || expired) { if (record) { recordCache.delete('snapshot'); transact(async records => { await records.delete('snapshot'); }).catch(() => {}); } return null; } if (expectedLogin && record.user_login !== expectedLogin) return null; return { ...record.data, saved_at: record.saved_at }; } let record; try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); } catch (_) { clear(); return null; } const savedAt = Date.parse(record?.saved_at || ''); const invalid = record?.version !== VERSION || !record?.user_login || !record?.data || !Number.isFinite(savedAt); const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs; if (invalid || expired) { clear(); return null; } if (expectedLogin && record.user_login !== expectedLogin) return null; return { ...record.data, saved_at: record.saved_at }; } return { enabled, setEnabled, ready, save, load, saveDetail, loadDetail, clear }; } return createOfflineWorkStore; });