stackchain-dashboard/frontend/unfiled-captures.js
timmy af5fe9b69d
All checks were successful
CI / lint (pull_request) Successful in 1m47s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
feat: explain mobile evidence screenshots (Closes #827)
2026-08-14 13:41:02 +00:00

182 lines
7.9 KiB
JavaScript

function createUnfiledCaptures({
storage,
attachmentStore = null,
getCaptureLogin = () => '',
getCurrentLogin = () => '',
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
now = () => Date.now(),
maxItems = 20,
}) {
const storageKey = 'stackchain.unfiled-issues.v1';
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
);
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
}
function list() {
const currentLogin = String(getCurrentLogin() || '').trim();
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
}
function capacity() {
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
}
function prepare(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const attachment = note?.attachment;
const attachments = note?.attachments;
if (Array.isArray(attachments) && attachments.length > 5) {
throw new Error('You can attach up to 5 screenshots.');
}
const validAttachments = Array.isArray(attachments) && attachments.length > 0 &&
attachments.every(value => value?.blob && value?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(value?.contentType || '')));
if (attachments && !validAttachments) {
throw new Error('One or more screenshots are unavailable. Choose them again before saving.');
}
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if ((hasAttachment || validAttachments) && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
return {
title, body, ownerLogin, attachment, attachments,
hasAttachment:hasAttachment || validAttachments,
attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0),
};
}
function persist(prepared, existing, removed = null) {
const item = {
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
savedAt:Number(now()), ...(prepared.hasAttachment ? {
hasAttachment:true, attachmentCount:prepared.attachmentCount,
} : {}),
};
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
try { write(items); }
catch (error) {
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
return item;
};
const stage = prepared.hasAttachment
? Promise.resolve(attachmentStore.put(item.id, prepared.attachments ? {
attachments:prepared.attachments.map(value => ({
filename:String(value.filename).slice(0, 255),
contentType:String(value.contentType), blob:value.blob,
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
})),
} : {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
: null;
if (!stage && !removed?.hasAttachment) return writeItems();
return Promise.resolve(stage).then(async () => {
if (!removed?.hasAttachment) return writeItems();
const removedAttachment = await attachmentStore.get(removed.id);
try {
await attachmentStore.delete(removed.id);
} catch (error) {
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
try { return writeItems(); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
throw error;
}
});
}
function save(note) {
const prepared = prepare(note);
const existing = read();
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
return persist(prepared, existing);
}
function replaceOldest(note, expectedOldestId) {
const prepared = prepare(note);
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
const oldest = existing.at(-1);
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
throw new Error('Drafts changed. Review them before replacing anything.');
}
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
}
function discard(id) {
const items = read();
const remaining = items.filter(item => item.id !== id);
if (remaining.length === items.length) return false;
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) {
write(remaining);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
try { write(remaining); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
return true;
})
);
}
function resume(id, confirmedLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) throw new Error('This capture is no longer available.');
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
if (Array.isArray(attachment?.attachments) && attachment.attachments.length) {
if (attachment.attachments.length > 5 || attachment.attachments.some(value =>
!value?.blob || !value?.filename || !value?.contentType)) {
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
}
return {...draft, attachments:attachment.attachments};
}
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
}
return {...draft, attachment};
});
}
function completeResume(id) { return discard(id); }
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;