feat: sync unfiled drafts across devices (Closes #851)
CI / lint (pull_request) Successful in 1m52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 21:39:22 +00:00
1 parent 871e35c10b
commit 3dd0ba2024
16 files changed
+759 -6

No files matched your search

+7 -1
View File
@@ -304,6 +304,7 @@
if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
error.retryAfter = retryAfter === null ? undefined : Number(retryAfter);
@@ -530,6 +531,9 @@
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
createUnfiledDraftSync(
unfiledCaptures, fetchReviewJson
);
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
@@ -2782,7 +2786,9 @@
button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
if (item?.kind === 'unfiled-issue') {
await unfiledCaptures.discard(item.capture_id);
}
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
+1
View File
@@ -1222,6 +1222,7 @@
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/unfiled-draft-sync.js"></script>
<script src="static/shared-image-capture.js"></script>
<script src="static/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script>
+1
View File
@@ -24,6 +24,7 @@ const SHELL = [
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/unfiled-draft-sync.js',
BASE + 'static/shared-image-capture.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
+85 -1
View File
@@ -8,6 +8,8 @@ function createUnfiledCaptures({
maxItems = 20,
}) {
const storageKey = 'stackchain.unfiled-issues.v1';
const listeners = new Set();
const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
function read() {
try {
@@ -94,6 +96,7 @@ function createUnfiledCaptures({
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
onChange(item.id, false);
return item;
};
const stage = prepared.hasAttachment
@@ -150,6 +153,7 @@ function createUnfiledCaptures({
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) {
write(remaining);
onChange(id, true);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
@@ -159,6 +163,7 @@ function createUnfiledCaptures({
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
onChange(id, true);
return true;
})
);
@@ -191,7 +196,86 @@ function createUnfiledCaptures({
function completeResume(id) { return discard(id); }
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
function encodeBytes(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function decodeBytes(value, contentType) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return new Blob([bytes], {type:contentType});
}
async function exportOwned(login) {
const owner = String(login || '').trim();
const exported = [];
for (const item of read().filter(candidate => candidate.ownerLogin === owner)) {
let evidence = [];
if (item.hasAttachment) {
const stored = await attachmentStore?.get(item.id);
const attachments = Array.isArray(stored?.attachments) ? stored.attachments :
(stored?.blob ? [stored] : []);
if (attachments.length !== Number(item.attachmentCount || 1)) {
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
}
evidence = await Promise.all(attachments.map(async attachment => ({
filename:attachment.filename,
content_type:attachment.contentType,
...(attachment.note ? {note:attachment.note} : {}),
data:encodeBytes(await attachment.blob.arrayBuffer()),
})));
}
exported.push({
id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt),
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}),
...(evidence.length ? {evidence} : {}),
});
}
return exported;
}
async function mergeRemote(drafts, login) {
const ownerLogin = String(login || '').trim();
if (!ownerLogin || !Array.isArray(drafts)) return 0;
const existing = read();
const known = new Set(existing.map(item => item.id));
const imported = [];
for (const remote of drafts) {
if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue;
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
if (evidence.length && !attachmentStore) continue;
const item = {
id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
savedAt:Number(remote.saved_at),
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
blockers:remote.blockers, blockerCount:remote.blockers.length,
} : {}),
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
};
if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue;
if (evidence.length) {
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
filename:String(entry.filename), contentType:String(entry.content_type),
blob:decodeBytes(String(entry.data), String(entry.content_type)),
...(entry.note ? {note:String(entry.note)} : {}),
}))});
}
known.add(item.id);
imported.push(item);
}
if (imported.length) write(imported.concat(existing));
return imported.length;
}
return {list, capacity, save, replaceOldest, discard, resume, completeResume,
exportOwned, mergeRemote, currentLogin:getCurrentLogin,
subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
+139
View File
@@ -0,0 +1,139 @@
(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;
});