stackchain-dashboard/frontend/unfiled-draft-sync.js
timmy 3dd0ba2024
All checks were successful
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: sync unfiled drafts across devices (Closes #851)
2026-08-14 21:39:22 +00:00

140 lines
5.2 KiB
JavaScript

(function (root, factory) {
const createUnfiledDraftSync = factory();
if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync;
if (root) root.createUnfiledDraftSync = createUnfiledDraftSync;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createUnfiledDraftSync(options, mountedFetch) {
if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch);
const {captures, fetchJson, storage, getLogin = () => '', onState = () => {}} = options;
const key = 'stackchain.unfiled-draft-sync.v1';
let inFlight = null;
let rerun = false;
function read(login) {
try {
const all = JSON.parse(storage?.getItem(key) || '{}');
const state = all?.[login];
return {
all:all && typeof all === 'object' ? all : {},
known:new Set(Array.isArray(state?.known) ? state.known : []),
deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []),
};
} catch (_error) {
return {all:{}, known:new Set(), deleted:new Set()};
}
}
function write(login, state) {
state.all[login] = {known:[...state.known], deleted:[...state.deleted]};
storage?.setItem(key, JSON.stringify(state.all));
}
function publish(status, message) {
onState({status, ...(message ? {message} : {})});
const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status');
if (message && visibleStatus) visibleStatus.textContent = message;
}
function sameDrafts(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
async function runSync(login) {
const owner = String(login || '').trim();
if (!owner) return false;
publish('syncing', 'Syncing Drafts…');
const state = read(owner);
try {
const remote = await fetchJson('api/v1/unfiled-drafts');
const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : [];
const remoteIds = new Set(remoteDrafts.map(item => item.id));
const before = await captures.exportOwned(owner);
for (const local of before) {
if (state.known.has(local.id) && !remoteIds.has(local.id) && !state.deleted.has(local.id)) {
await captures.discard(local.id);
}
}
await captures.mergeRemote(
remoteDrafts.filter(item => !state.deleted.has(item.id)), owner
);
const local = await captures.exportOwned(owner);
const merged = [];
const byId = new Map();
for (const item of remoteDrafts) if (!state.deleted.has(item.id)) byId.set(item.id, item);
for (const item of local) if (!state.deleted.has(item.id)) byId.set(item.id, item);
for (const item of byId.values()) merged.push(item);
merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at));
let finalSnapshot = remote;
if (!sameDrafts(merged, remoteDrafts)) {
finalSnapshot = await fetchJson('api/v1/unfiled-drafts', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}),
});
}
state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
state.deleted.clear();
write(owner, state);
publish('ready', 'Drafts synced across devices.');
return true;
} catch (error) {
if (error?.status === 409) {
publish('conflict', 'Drafts changed on another device. Sync again to combine them.');
} else {
publish('pending', 'Saved on this device · sync pending');
}
throw error;
}
}
function sync(login) {
const owner = String(login || '').trim();
if (!owner) return Promise.resolve(false);
if (inFlight) {
rerun = true;
return inFlight;
}
inFlight = (async () => {
let result = false;
do {
rerun = false;
result = await runSync(owner);
} while (rerun);
return result;
})().finally(() => { inFlight = null; });
return inFlight;
}
async function remove(id, login) {
const owner = String(login || '').trim();
if (!owner) return false;
const state = read(owner);
state.deleted.add(String(id));
write(owner, state);
await captures.discard(String(id));
return sync(owner);
}
captures.subscribe?.((id, removed) => {
const login = getLogin();
if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {});
});
return {sync, remove};
}
createUnfiledDraftSync.mount = (captures, fetchJson) => {
const getLogin = captures.currentLogin;
const controller = createUnfiledDraftSync({captures, fetchJson, storage:globalThis.localStorage, getLogin});
const connect = () => {
const login = getLogin();
if (login) void controller.sync(login).catch(() => {});
else globalThis.setTimeout(connect, 250);
};
globalThis.setTimeout(connect, 0);
globalThis.addEventListener?.('online', connect);
return controller;
};
return createUnfiledDraftSync;
});