365 lines
15 KiB
JavaScript
365 lines
15 KiB
JavaScript
(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;
|
|
});
|