437 lines
20 KiB
JavaScript
437 lines
20 KiB
JavaScript
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 unfiledDraftDisplayTitle(item) {
|
||
return String(item?.title || '').trim() || (item?.hasAttachment ? 'Untitled photo draft' : 'Untitled draft');
|
||
}
|
||
|
||
function unfiledShouldFocusTitle({mobile = false, continuing = false} = {}) {
|
||
return !mobile || continuing;
|
||
}
|
||
|
||
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() || item.hasAttachment === true) &&
|
||
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);
|
||
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 (!title && !hasAttachment && !validAttachments) throw new Error('Add a title or photo before saving.');
|
||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||
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() && !evidence.length) || !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() && !evidence.length) || !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;
|
||
module.exports.displayTitle = unfiledDraftDisplayTitle;
|
||
module.exports.shouldFocusTitle = unfiledShouldFocusTitle;
|
||
}
|