192 lines
7.8 KiB
JavaScript
192 lines
7.8 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 = () => {},
|
|
createConflictId = () => 'conflict-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36)),
|
|
} = 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 : []),
|
|
base:state?.base && typeof state.base === 'object' ? state.base : {},
|
|
};
|
|
} catch (_error) {
|
|
return {all:{}, known:new Set(), deleted:new Set(), base:{}};
|
|
}
|
|
}
|
|
|
|
function write(login, state) {
|
|
state.all[login] = {known:[...state.known], deleted:[...state.deleted], base:state.base};
|
|
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 fingerprint(draft) {
|
|
if (!draft) return null;
|
|
const text = JSON.stringify(draft);
|
|
if (globalThis.crypto?.subtle && globalThis.TextEncoder) {
|
|
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
let first = 2166136261, second = 3335557771;
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
first = Math.imul(first ^ text.charCodeAt(index), 16777619);
|
|
second = Math.imul(second ^ text.charCodeAt(index), 2246822519);
|
|
}
|
|
return text.length + ':' + (first >>> 0).toString(16) + (second >>> 0).toString(16);
|
|
}
|
|
|
|
async function reconcile(captures, drafts, owner) {
|
|
if (typeof captures.reconcileRemote === 'function') {
|
|
await captures.reconcileRemote(drafts, owner);
|
|
return;
|
|
}
|
|
const finalIds = new Set(drafts.map(item => item.id));
|
|
for (const item of await captures.exportOwned(owner)) {
|
|
if (!finalIds.has(item.id)) await captures.discard(item.id);
|
|
}
|
|
await captures.mergeRemote(drafts, owner);
|
|
}
|
|
|
|
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 local = await captures.exportOwned(owner);
|
|
const merged = [];
|
|
const localById = new Map(local.map(item => [item.id, item]));
|
|
const remoteById = new Map(remoteDrafts.map(item => [item.id, item]));
|
|
const ids = new Set([...state.known, ...localById.keys(), ...remoteById.keys()]);
|
|
let preservedConflict = false;
|
|
const usedIds = new Set(ids);
|
|
for (const id of ids) {
|
|
const localDraft = state.deleted.has(id) ? null : localById.get(id);
|
|
const remoteDraft = remoteById.get(id);
|
|
const localHash = await fingerprint(localDraft);
|
|
const remoteHash = await fingerprint(remoteDraft);
|
|
const baseHash = state.base[id] || null;
|
|
const localChanged = localHash !== baseHash;
|
|
const remoteChanged = remoteHash !== baseHash;
|
|
if (localChanged && remoteChanged && localHash !== remoteHash) {
|
|
preservedConflict = true;
|
|
if (remoteDraft) merged.push(remoteDraft);
|
|
if (localDraft) {
|
|
let conflictId = String(createConflictId()).slice(0, 100);
|
|
for (let suffix = 2; usedIds.has(conflictId); suffix += 1) {
|
|
conflictId = (String(createConflictId()).slice(0, 95) + '-' + suffix).slice(0, 100);
|
|
}
|
|
usedIds.add(conflictId);
|
|
const suffix = ' (conflict copy)';
|
|
merged.push({...localDraft, id:conflictId,
|
|
title:(String(localDraft.title).slice(0, 255 - suffix.length) + suffix)});
|
|
}
|
|
continue;
|
|
}
|
|
const selected = localChanged ? localDraft : (remoteChanged ? remoteDraft : (localDraft || remoteDraft));
|
|
if (selected) merged.push(selected);
|
|
}
|
|
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}),
|
|
});
|
|
}
|
|
await reconcile(captures, finalSnapshot.drafts || [], owner);
|
|
state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
|
|
state.deleted.clear();
|
|
state.base = {};
|
|
for (const item of finalSnapshot.drafts || []) state.base[item.id] = await fingerprint(item);
|
|
write(owner, state);
|
|
publish(preservedConflict ? 'conflict-preserved' : 'ready', preservedConflict
|
|
? 'Draft changed on two devices · both versions were saved.'
|
|
: '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;
|
|
});
|