function withFilingEstimate(draft, value) { draft.estimateMinutes = Number(value) || null; return draft; } function unfiledDraftSummary(item) { const plan = item.filingPlan || {}; const ready = Boolean(plan.repository); const startsToday = plan.completionIntent === 'create-and-start'; const evidenceCount = Number(item.attachmentCount || (item.hasAttachment ? 1 : 0)); const evidence = evidenceCount ? `${evidenceCount} screenshot${evidenceCount === 1 ? '' : 's'}` : 'No screenshots'; return { label:ready ? 'Ready to file' : 'Needs filing', repository:plan.repository || '', ready, action:ready ? (startsToday ? 'Review & start' : 'Review & file') : 'Choose repository', details:[startsToday ? 'Create & start' : 'Create issue', plan.estimateMinutes ? `${plan.estimateMinutes} min` : '', evidence].filter(Boolean).join(' · '), }; } function unfiledSavedMessage(item) { return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' : 'Saved to Drafts. Choose a repository when you’re ready to file it.'; } function applyIssueFilingMode(qs, enabled) { qs('#create-issue-filing').hidden = !enabled; qs('.create-issue-capture-actions').hidden = false; qs('#file-new-issue').hidden = enabled; qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work'; } 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'; const listeners = new Set(); const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed)); 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 filingPlan(value = {}, remote = false) { const get = (local, wire) => value?.[remote ? wire : local]; const repository = String(get('repository', 'repository') || '').trim(); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return null; const labelIds = Array.from(new Set((Array.isArray(get('labelIds', 'label_ids')) ? get('labelIds', 'label_ids') : []).filter(id => Number.isInteger(id) && id > 0))).slice(0, 20); const plan = {repository, labelIds}; const milestoneId = Number(get('milestoneId', 'milestone_id')); if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId; const dueDate = String(get('dueDate', 'due_date') || ''); if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) plan.dueDate = dueDate; const bounded = (local, wire, limit) => { const text = String(get(local, wire) || '').trim(); if (text) plan[local] = text.slice(0, limit); }; bounded('templateName', 'template_name', 80); bounded('templateId', 'template_id', 80); bounded('capturedBody', 'captured_body', 10000); if (get('unassigned', 'unassigned') === true) plan.unassigned = true; const assignee = String(get('assignee', 'assignee') || '').trim(); if (!plan.unassigned && /^[A-Za-z0-9_.-]+$/.test(assignee)) { plan.assignee = assignee; bounded('assigneeName', 'assignee_name', 255); } const estimateMinutes = Number(get('estimateMinutes', 'estimate_minutes')); if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) { plan.estimateMinutes = estimateMinutes; } const completionIntent = String(get('completionIntent', 'completion_intent') || ''); if (['create', 'create-and-start'].includes(completionIntent)) plan.completionIntent = completionIntent; return plan; } function exportFilingPlan(plan) { if (!plan) return null; return { repository:plan.repository, label_ids:plan.labelIds, ...(plan.milestoneId ? {milestone_id:plan.milestoneId} : {}), ...(plan.dueDate ? {due_date:plan.dueDate} : {}), ...(plan.templateName ? {template_name:plan.templateName} : {}), ...(plan.templateId ? {template_id:plan.templateId} : {}), ...(plan.capturedBody ? {captured_body:plan.capturedBody} : {}), ...(plan.unassigned ? {unassigned:true} : {}), ...(plan.assignee ? {assignee:plan.assignee} : {}), ...(plan.assigneeName ? {assignee_name:plan.assigneeName} : {}), ...(plan.estimateMinutes ? {estimate_minutes:plan.estimateMinutes} : {}), ...(plan.completionIntent ? {completion_intent:plan.completionIntent} : {}), }; } 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.'); } const seenBlockers = new Set(); const blockers = (Array.isArray(note?.blockers) ? note.blockers : []).reduce((items, blocker) => { const repository = String(blocker?.repository || '').trim(); const number = Number(blocker?.number); const key = repository + '#' + number; if (items.length >= 5 || seenBlockers.has(key) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || !Number.isInteger(number) || number < 1) return items; seenBlockers.add(key); items.push({repository, number, title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)}); return items; }, []); return { title, body, ownerLogin, attachment, attachments, blockers, filingPlan:filingPlan(note), 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, } : {}), ...(prepared.blockers.length ? { blockers:prepared.blockers, blockerCount:prepared.blockers.length, } : {}), ...(prepared.filingPlan ? {filingPlan:prepared.filingPlan} : {}), }; 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; } onChange(item.id, false); 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); onChange(id, true); 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; } onChange(id, true); 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 = {...(item.filingPlan || {repository:'', labelIds:[]}), title:item.title, body:item.body, ...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {})}; 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); } 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), ...(item.filingPlan ? {filing_plan:exportFilingPlan(item.filingPlan)} : {}), ...(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 plan = filingPlan(remote.filing_plan, true); const item = { id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''), savedAt:Number(remote.saved_at), ...(plan ? {filingPlan:plan} : {}), ...(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; } async function reconcileRemote(drafts, login) { const ownerLogin = String(login || '').trim(); if (!ownerLogin || !Array.isArray(drafts)) return 0; const existing = read(); const retained = existing.filter(item => item.ownerLogin !== ownerLogin); const reconciled = []; const stagedEvidence = new Map(); for (const remote of drafts) { if (!remote || reconciled.length + retained.length >= maxItems) continue; const evidence = Array.isArray(remote.evidence) ? remote.evidence : []; if (evidence.length && !attachmentStore) { throw new Error('The synchronized screenshots cannot be stored on this device.'); } const plan = filingPlan(remote.filing_plan, true); const item = { id:String(remote.id || ''), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''), savedAt:Number(remote.saved_at), ...(plan ? {filingPlan:plan} : {}), ...(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) { stagedEvidence.set(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)} : {}), }))}); } reconciled.push(item); } const evidenceIds = new Set(reconciled.filter(item => item.hasAttachment).map(item => item.id)); const affectedIds = new Set(stagedEvidence.keys()); for (const item of existing) if (item.ownerLogin === ownerLogin && item.hasAttachment) affectedIds.add(item.id); const previousEvidence = new Map(); for (const id of affectedIds) previousEvidence.set(id, await attachmentStore?.get(id)); try { for (const [id, value] of stagedEvidence) await attachmentStore.put(id, value); for (const item of existing) { if (item.ownerLogin === ownerLogin && item.hasAttachment && !evidenceIds.has(item.id)) { await attachmentStore?.delete(item.id); } } write(reconciled.concat(retained)); } catch (error) { for (const [id, value] of previousEvidence) { await Promise.resolve(value ? attachmentStore?.put(id, value) : attachmentStore?.delete(id)).catch(() => {}); } throw error; } return reconciled.length; } return {list, capacity, save, replaceOldest, discard, resume, completeResume, exportOwned, mergeRemote, reconcileRemote, currentLogin:getCurrentLogin, subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))}; } if (typeof module !== 'undefined' && module.exports) { module.exports = createUnfiledCaptures; module.exports.summary = unfiledDraftSummary; }