Capture mobile evidence bundles with one issue #824

Merged
timmy merged 1 commits from timmy/823-mobile-evidence-bundles into main 2026-08-14 11:45:29 +00:00
16 changed files with 493 additions and 122 deletions

View File

@ -24,19 +24,21 @@ For online delivery, the screenshot uploads before the comment is posted—to th
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
preview when the operator later chooses a repository. The source Draft remains available until its
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
bundle when the operator later chooses a repository. The source Draft remains available until its
evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
delivery time.
Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
Delivery creates the issue exactly once, then uploads each image under a checkpointed per-image
identity and posts one ordered Markdown evidence comment. After a partial failure, retry resumes with the confirmed issue and from
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;

View File

@ -91,11 +91,18 @@ function createIssueSyncStore({
}
continue;
}
const preservedBundle = current.operationId === replacement.operationId &&
current.attachments?.every(value => value?.data || value?.blob) &&
replacement.attachments?.every(value => value?.stored && !value.data && !value.blob)
? current.attachments : null;
if (current.operationId === replacement.operationId &&
(current.attachment?.data || current.attachment?.blob) &&
replacement.attachment?.stored &&
!replacement.attachment.data && !replacement.attachment.blob) {
replacement = { ...replacement, attachment: current.attachment };
!replacement.attachment.data && !replacement.attachment.blob || preservedBundle) {
replacement = {
...replacement,
...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}),
};
incoming.set(current.id, replacement);
}
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
@ -525,8 +532,11 @@ function createBackgroundIssueSync({
deliveredIssue = await requestStage(item, request.url, request.options);
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
}
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const attachments = Array.isArray(item.attachments) ? item.attachments : [item.attachment];
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
? item.attachmentMarkdowns.slice(0, attachments.length)
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
for (let index = attachmentMarkdowns.length; index < attachments.length; index += 1) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
@ -534,19 +544,26 @@ function createBackgroundIssueSync({
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
'Idempotency-Key': stageOperationId(
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
),
},
body: attachmentMultipart(item.attachment),
body: attachmentMultipart(attachments[index]),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
attachmentMarkdowns.push(markdown);
await checkpointClaim(item, current => ({
...current, deliveredIssue, attachmentMarkdowns:attachmentMarkdowns.slice(),
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
}));
}
const attachmentMarkdown = attachmentMarkdowns.join('\n\n');
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
@ -638,7 +655,8 @@ function createBackgroundIssueSync({
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;

View File

@ -2652,7 +2652,9 @@
editingOutboxId = hydrated.id;
issueCapture.saveDraft(hydrated);
openCreateIssueSheet();
if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment);
if (hydrated.attachments || hydrated.attachment) {
createIssueAttachmentController.restore(hydrated.attachments || hydrated.attachment);
}
else createIssueAttachmentController.clear();
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
} catch (error) {
@ -4935,10 +4937,11 @@
});
qs('#save-unfiled-issue').addEventListener('click', async () => {
try {
const evidence = await createIssueAttachmentController.serialize();
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
attachment: await createIssueAttachmentController.serialize(),
...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
};
if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
@ -5089,9 +5092,10 @@
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const evidence = await createIssueAttachmentController.serialize();
const durableDraft = {
...captureDraft,
attachment: await createIssueAttachmentController.serialize(),
...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
@ -5104,7 +5108,7 @@
await issueOutbox.enqueueDurably(durableDraft));
const queued = admission.item;
const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) {
if (rUC && ((!durableDraft.attachment && !durableDraft.attachments) || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}

View File

@ -754,15 +754,15 @@
<button id="file-new-issue" type="button">File now</button>
</div>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
<strong id="create-issue-attachment-label">Evidence screenshots <span class="small">Optional · Up to 5 · PNG, JPEG, or WebP · 2 MB each</span></strong>
<div class="issue-attachment-controls">
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="create-issue-attachment">Add screenshots</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
</div>
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<button id="remove-create-issue-attachment" type="button">Remove</button>
<button id="remove-create-issue-attachment" type="button">Remove latest</button>
</div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>

View File

@ -6,6 +6,8 @@
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const MAX_FILES = 5;
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function namedBlob(blob, name) {
@ -65,6 +67,7 @@
function create(options) {
const upload = options.upload;
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
@ -72,10 +75,7 @@
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
});
let selected = null;
let confirmed = null;
let serialized = null;
let operationId = null;
let selected = [];
let selectionGeneration = 0;
function commitSelection(file) {
@ -83,14 +83,18 @@
file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
selected = file;
confirmed = null;
serialized = null;
operationId = createOperationId();
if (selected.length >= maxFiles) {
if (maxFiles === 1) selected = [];
else throw new Error(MAX_FILES_MESSAGE);
}
selected.push({file, confirmed:null, serialized:null, operationId:createOperationId()});
return state();
}
function select(file) {
if (selected.length >= maxFiles && maxFiles > 1) {
throw new Error(MAX_FILES_MESSAGE);
}
const generation = ++selectionGeneration;
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
@ -109,75 +113,100 @@
function clear() {
selectionGeneration += 1;
selected = null;
confirmed = null;
serialized = null;
operationId = null;
selected = [];
}
function remove(index) {
const position = Number(index);
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
selectionGeneration += 1;
selected.splice(position, 1);
return state();
}
function restore(value) {
if (Array.isArray(value)) {
clear();
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
value.forEach(attachment => restoreOne(attachment));
return state();
}
clear();
restoreOne(value);
return state();
}
function restoreOne(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) {
clear();
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
return state();
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
selected[selected.length - 1].serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
}
function state() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
const values = selected.map(item => ({
name: item.file.name,
size: item.file.size,
uploaded: Boolean(item.confirmed),
}));
return values.length > 1 ? values : (values[0] || null);
}
function serializeItem(item) {
if (!item.serialized) item.serialized = {
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
};
return { ...item.serialized };
}
async function serialize() {
if (!selected) return null;
if (!serialized) {
serialized = {
filename: selected.name,
contentType: selected.type,
blob: selected.blob || selected,
};
}
return { ...serialized };
if (!selected.length) return null;
const values = selected.map(serializeItem);
return values.length > 1 ? values : values[0];
}
async function prepareComment(item, body) {
async function prepareComment(target, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const attachment = await serialize();
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: attachment.filename,
content_type: attachment.contentType,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: operationId,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
if (!selected.length) return text;
const markdown = [];
for (const evidence of selected) {
if (!evidence.confirmed) {
const attachment = serializeItem(evidence);
evidence.confirmed = await upload({
repository: target.repository,
number: target.number,
filename: attachment.filename,
content_type: attachment.contentType,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: evidence.operationId,
});
if (!evidence.confirmed || typeof evidence.confirmed.markdown !== 'string' || !evidence.confirmed.markdown) {
evidence.confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
}
markdown.push(evidence.confirmed.markdown);
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
const evidence = markdown.join('\n\n');
return text ? text + '\n\n' + evidence : evidence;
}
return { select, restore, clear, state, serialize, prepareComment };
return { select, restore, remove, clear, state, serialize, prepareComment };
}
function mount(options) {
const controller = create(options);
const controller = create({
...options, maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
});
const clearSelection = controller.clear;
const restoreSelection = controller.restore;
let previewUrl = '';
let selectionSequence = 0;
@ -208,24 +237,43 @@
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
const files = Array.from(event.target.files || []);
const sequence = ++selectionSequence;
let result;
try {
result = controller.select(file);
} catch (error) {
if (!files.length) return;
let first;
try { first = controller.select(files[0]); }
catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
if (!result || typeof result.then !== 'function') {
showPreview(file, false);
if (files.length === 1 && (!first || typeof first.then !== 'function')) {
showPreview(files[0], false);
return;
}
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
return result.then(() => controller.serialize()).then(value => {
if (sequence === selectionSequence) showPreview(value.blob, true);
let optimized = Boolean(first && typeof first.then === 'function');
if (optimized) {
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
}
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
const result = controller.select(file);
if (result && typeof result.then === 'function') {
optimized = true;
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
await result;
}
}), Promise.resolve(first));
return selectAll.then(() => controller.serialize()).then(value => {
if (sequence !== selectionSequence) return;
const values = Array.isArray(value) ? value : [value];
const latest = values[values.length - 1];
showPreview(latest.blob, optimized);
if (values.length > 1) {
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
options.status.textContent = values.length + ' screenshots ready to file in this order.';
}
}).catch(error => {
if (sequence === selectionSequence) {
options.status.textContent = error.message;
@ -236,19 +284,40 @@
});
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
const current = controller.state();
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
if (count <= 1) {
clearPreview();
} else {
controller.remove(count - 1);
selectionSequence += 1;
controller.serialize().then(value => {
const values = Array.isArray(value) ? value : [value];
const latest = values[values.length - 1];
showPreview(latest.blob, false);
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
});
}
options.status.textContent = options.removedMessage || (count > 1 ?
'Latest screenshot removed. Your text is unchanged.' :
'Screenshot removed. Your comment is unchanged.');
});
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = value.blob ? options.createObjectURL(value.blob) :
'data:' + value.contentType + ';base64,' + value.data;
const restored = restoreSelection(value);
const values = Array.isArray(value) ? value : [value];
const latest = values[values.length - 1];
previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
'data:' + latest.contentType + ';base64,' + latest.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.meta.textContent = values.length > 1 ?
values.length + ' screenshots ready · latest: ' + latest.filename :
restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
options.status.textContent = values.length > 1 ?
values.length + ' screenshots ready to file in this order.' :
(options.readyMessage || 'Screenshot ready to upload with this comment.');
return restored;
}

View File

@ -17,6 +17,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return { filename, contentType, data };
}
function captureAttachments(values) {
if (!Array.isArray(values)) return undefined;
if (values.length > 5) throw new Error('You can attach up to 5 screenshots.');
const attachments = values.map(captureAttachment);
if (attachments.some(value => !value)) {
throw new Error('Some screenshots are unavailable. Choose them again before queueing.');
}
return attachments.length ? attachments : undefined;
}
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
@ -56,6 +66,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
const attachments = captureAttachments(draft?.attachments);
if (attachments) item.attachments = attachments;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
@ -65,14 +77,20 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
function localIndexItem(item) {
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
const hasAttachmentBytes = item?.attachment?.data || item?.attachment?.blob;
const hasBundleBytes = Array.isArray(item?.attachments) &&
item.attachments.some(value => value?.data || value?.blob);
if (!hasAttachmentBytes && !hasBundleBytes) return item;
return {
...item,
attachment: {
...(hasAttachmentBytes ? {attachment: {
filename: item.attachment.filename,
contentType: item.attachment.contentType,
stored: true,
},
}} : {}),
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
filename:value.filename, contentType:value.contentType, stored:true,
}))} : {}),
};
}
@ -120,16 +138,25 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return null;
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
const storedBundle = Array.isArray(item.attachments) && item.attachments.some(value => value?.stored);
const storedAttachment = item.attachment?.stored && !item.attachment.data && !item.attachment.blob;
if (!storedAttachment && !storedBundle) return { ...item };
if (!backgroundSync?.get) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const durable = await backgroundSync.get(id);
const attachment = captureAttachment(durable?.attachment);
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
const attachments = captureAttachments(durable?.attachments);
if (durable?.operationId !== item.operationId ||
(storedAttachment && (!attachment?.data && !attachment?.blob)) ||
(storedBundle && (!attachments || attachments.length !== item.attachments.length))) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
return { ...item, attachment };
return {
...item,
...(attachment ? {attachment} : {}),
...(attachments ? {attachments} : {}),
};
}
function prepareUpdate(id, draft) {
@ -145,7 +172,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
const nextAttachments = captureAttachments(draft?.attachments);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) ||
JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
@ -154,7 +183,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
milestoneId: nextMilestoneId, dueDate: nextDueDate,
attachment: nextAttachment, attachments:nextAttachments,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
@ -163,7 +193,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
if (attachmentChanged) delete updated.attachmentMarkdown;
if (nextAttachments === undefined) delete updated.attachments;
if (attachmentChanged) {
delete updated.attachmentMarkdown;
delete updated.attachmentMarkdowns;
}
delete updated.error;
delete updated.deliveryState;
return updated;

View File

@ -115,17 +115,28 @@ async function acceptSharedContent(request) {
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
let marker = '';
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
if (images.length > 1) marker = 'multiple';
else if (images.length === 1) {
const image = images[0];
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
if (images.length > 5) marker = 'multiple';
else if (images.length > 0) {
const supported = images.every(image =>
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
);
if (!supported) {
marker = 'unsupported';
} else {
} else if (images.length === 1) {
const image = images[0];
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image,
});
marker = 'image';
} else {
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
attachments:images.map(image => ({
filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image,
})),
});
marker = 'images';
}
}
const target = new URL(BASE, self.location.origin);

View File

@ -10,11 +10,23 @@
async function consume({marker, store, restore, status = () => {}}) {
if (!marker) return false;
if (marker !== 'image') {
if (!['image', 'images'].includes(marker)) {
status('Share one PNG, JPEG, or WebP screenshot.');
return false;
}
const value = await store?.get(RECORD_ID);
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
if (marker === 'images') {
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
status('The shared screenshots are unavailable. Share them again.');
return false;
}
restore(attachments);
await store.delete(RECORD_ID);
status(attachments.length + ' shared screenshots ready to file with this issue.');
return true;
}
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
status('The shared screenshot is unavailable. Share it again.');
return false;

View File

@ -42,19 +42,35 @@ function createUnfiledCaptures({
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 && !attachmentStore) {
if ((hasAttachment || validAttachments) && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
return {title, body, ownerLogin, attachment, hasAttachment};
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} : {}),
savedAt:Number(now()), ...(prepared.hasAttachment ? {
hasAttachment:true, attachmentCount:prepared.attachmentCount,
} : {}),
};
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
@ -66,7 +82,12 @@ function createUnfiledCaptures({
return item;
};
const stage = prepared.hasAttachment
? Promise.resolve(attachmentStore.put(item.id, {
? 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,
})),
} : {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
@ -137,6 +158,13 @@ function createUnfiledCaptures({
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.');
}

View File

@ -22,7 +22,9 @@ COMMONJS_BROWSER_BRANCH = re.compile(
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
"issue-capture": (
"static/create-issue-sheet.js", "static/update-follow-up.js", "static/shared-image-capture.js",
),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),

View File

@ -164,6 +164,42 @@ const fetchJson=async(url,options={{}})=>{{
assert output["second"]["confirmed"][0]["number"] == 469
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Journey',body:'',labelIds:[],attachments:[
{{filename:'one.png',contentType:'image/png',data:'b25l'}},
{{filename:'two.png',contentType:'image/png',data:'dHdv'}},
{{filename:'three.png',contentType:'image/png',data:'dGhyZWU='}},
]}};
const calls=[];let twoAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
let filename=null;if(options.body instanceof FormData)filename=options.body.get('file').name;
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename,body:options.body instanceof FormData?null:JSON.parse(options.body)}});
if(url.endsWith('/issues'))return{{number:823}};
if(url.endsWith('/attachments')){{if(filename==='two.png' && twoAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}}return{{markdown:'!['+filename+'](url/'+filename+')'}};}}
return{{id:9}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls}}));}})();
"""
output = run_node(script)
assert output["first"] == "offline"
uploads = [call for call in output["calls"] if call["filename"]]
assert [call["filename"] for call in uploads] == ["one.png", "two.png", "two.png", "three.png"]
assert [call["key"] for call in uploads] == [
"bundle:attachment-0", "bundle:attachment-1", "bundle:attachment-1", "bundle:attachment-2"
]
assert output["checkpoint"]["attachmentMarkdowns"] == ["![one.png](url/one.png)"]
comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
assert len(comments) == 1
assert comments[0]["body"]["body"].split("\n\n") == [
"![one.png](url/one.png)", "![two.png](url/two.png)", "![three.png](url/three.png)"
]
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
@ -633,22 +669,32 @@ const transaction=work=>{{const run=tail.then(()=>work({{
await store.reconcile([{{
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
}}]);
}},{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
{{filename:'a.png',contentType:'image/png',blob:new Blob(['a-bytes'],{{type:'image/png'}})}},
{{filename:'b.png',contentType:'image/png',blob:new Blob(['b-bytes'],{{type:'image/png'}})}},
]}}]);
await store.reconcile([
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
{{id:'bundle',operationId:'bundle',ownerLogin:'timmy',status:'queued',attachments:[
{{filename:'a.png',contentType:'image/png',stored:true}},
{{filename:'b.png',contentType:'image/png',stored:true}},
]}},
]);
const snapshot=await store.snapshot();
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
const byId=Object.fromEntries(snapshot.map(value=>[value.id,value]));
process.stdout.write(JSON.stringify({{first:{{isBlob:byId.first.attachment.blob instanceof Blob,
text:await byId.first.attachment.blob?.text()}},second:byId.second.attachment.data,
bundle:await Promise.all(byId.bundle.attachments.map(value=>value.blob?.text()))}}));
}})();
"""
output = run_node(script)
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
assert output["bundle"] == ["a-bytes", "b-bytes"]
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():

View File

@ -58,6 +58,37 @@ controller.select(file);
}
def test_mobile_evidence_bundle_keeps_order_limits_selection_and_uploads_each_image_once():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
let sequence=0; const calls=[];
const image=name=>{{const blob=new Blob([name],{{type:'image/png'}});blob.name=name;return blob;}};
const controller=attachment.create({{
maxFiles:5,
createOperationId:()=> 'evidence-' + (++sequence),
upload:async payload=>{{calls.push([payload.filename,payload.operation_id]);return {{markdown:'!['+payload.filename+'](url/'+payload.filename+')'}};}},
}});
for(const name of ['one.png','two.png','three.png','four.png','five.png']) controller.select(image(name));
let limit='';try{{controller.select(image('six.png'));}}catch(error){{limit=error.message;}}
controller.remove(1);
controller.select(image('replacement.png'));
(async()=>{{
const serialized=await controller.serialize();
const comment=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
const second=await controller.prepareComment({{repository:'o/r',number:823}},'Evidence sequence');
process.stdout.write(JSON.stringify({{limit,state:controller.state(),names:serialized.map(x=>x.filename),comment,second,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output["limit"] == "Up to 5 screenshots. Remove one before adding another."
assert output["names"] == ["one.png", "three.png", "four.png", "five.png", "replacement.png"]
assert [item["name"] for item in output["state"]] == output["names"]
assert output["comment"] == output["second"]
assert output["comment"].startswith("Evidence sequence\n\n![one.png]")
assert len(output["calls"]) == 5
assert len({key for _, key in output["calls"]}) == 5
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@ -224,6 +255,24 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
assert output["removed"] is None
def test_attachment_view_restores_ordered_evidence_bundle_preview():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
class Element{{constructor(){{this.listeners={{}};this.hidden=true;this.value='';this.textContent='';this.src='';this.disabled=false;}}addEventListener(t,f){{this.listeners[t]=f;}}}}
const input=new Element(),preview=new Element(),image=new Element(),meta=new Element(),remove=new Element(),status=new Element();input.multiple=true;
const controller=attachment.mount({{input,preview,image,meta,remove,status,createObjectURL:blob=>'blob:'+blob.size,revokeObjectURL:()=>{{}},upload:async()=>{{}}}});
const value=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const restored=controller.restore([value('one.png'),value('two.png')]);
process.stdout.write(JSON.stringify({{restored,src:image.src,meta:meta.textContent,hidden:preview.hidden}}));
"""
output = json.loads(run_node(script))
assert [item["name"] for item in output["restored"]] == ["one.png", "two.png"]
assert output["src"] == "blob:7"
assert output["meta"] == "2 screenshots ready · latest: two.png"
assert output["hidden"] is False
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
@ -264,11 +313,13 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
assert 'id="create-issue-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'multiple' in html
assert 'Up to 5' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachment: await createIssueAttachmentController.serialize()" in source
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
assert "attachments:evidence" in source
assert "hydrated.attachments || hydrated.attachment" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert "overflow-x:hidden" in css

View File

@ -71,6 +71,40 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
assert output["stored"]["version"] == 3
def test_issue_outbox_preserves_bounded_ordered_evidence_bundle():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const image=name=>({{filename:name,contentType:'image/png',data:name}});
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle'}});
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:['one','two','three','four','five'].map(image)}});
let limit='';try{{outbox.enqueue({{repository:'o/r',title:'Too many',attachments:['1','2','3','4','5','6'].map(image)}});}}catch(error){{limit=error.message;}}
process.stdout.write(JSON.stringify({{queued,list:outbox.list(),limit}}));
"""
output = run_node(script)
assert [item["filename"] for item in output["queued"]["attachments"]] == [
"one", "two", "three", "four", "five"
]
assert output["list"][0]["attachments"] == output["queued"]["attachments"]
assert output["limit"] == "You can attach up to 5 screenshots."
def test_issue_outbox_replaces_evidence_bundle_when_editing_a_queued_issue():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};const image=name=>({{filename:name,contentType:'image/png',data:name}});
let sequence=0;const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>String(++sequence)}});
const queued=outbox.enqueue({{repository:'o/r',title:'Journey',attachments:[image('old-one'),image('old-two')]}});
const updated=outbox.update(queued.id,{{...queued,attachments:[image('new-one'),image('new-two'),image('new-three')]}});
process.stdout.write(JSON.stringify(updated));
"""
output = run_node(script)
assert [item["filename"] for item in output["attachments"]] == ["new-one", "new-two", "new-three"]
assert output["operationId"] != output["id"]
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@ -94,6 +128,27 @@ process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
}
def test_issue_outbox_hydrates_durable_evidence_bundle_without_copying_blobs_to_localstorage():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
let durable=null;
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'bundle',backgroundSync:{{
reconcile:async items=>{{durable=items[0];}},requestSync:async()=>{{}},get:async()=>durable,
}}}});
(async()=>{{const admitted=await outbox.enqueueDurably({{repository:'o/r',title:'Journey',attachments:['one','two'].map(image)}});
const raw=values.get('stackchain.issue-outbox.v1');const hydrated=await outbox.hydrateForEdit(admitted.item.id);
process.stdout.write(JSON.stringify({{raw,names:hydrated.attachments.map(x=>x.filename),sizes:hydrated.attachments.map(x=>x.blob.size)}}));}})();
"""
output = run_node(script)
assert '"stored":true' in output["raw"]
assert '"blob"' not in output["raw"]
assert output["names"] == ["one", "two"]
assert output["sizes"] == [3, 3]
def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -247,7 +247,7 @@ def test_image_share_target_stages_one_supported_image_and_redirects_to_capture(
}
def test_image_share_target_rejects_multiple_images_without_staging_private_data():
def test_image_share_target_stages_ordered_evidence_bundle():
result = run_worker_scenario(
"""
const form = new FormData();
@ -256,16 +256,19 @@ def test_image_share_target_rejects_multiple_images_without_staging_private_data
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
const record=state.sharedRecords['shared-image'];
process.stdout.write(JSON.stringify({
status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
status:response.status, location:response.headers.get('Location'),
names:record.attachments.map(value=>value.filename),sizes:record.attachments.map(value=>value.blob.size),
}));
"""
)
assert result == {
"status": 303,
"location": "/dashboard/?launch=new&shared=multiple",
"records": [],
"location": "/dashboard/?launch=new&shared=images",
"names": ["one.png", "two.jpg"],
"sizes": [3, 3],
}

View File

@ -36,6 +36,23 @@ shared.consume({{
}
def test_shared_evidence_bundle_is_consumed_once_in_order():
script = f"""
const shared=require({json.dumps(str(MODULE))});const calls=[];
const store={{get:async()=>({{attachments:[
{{filename:'one.png',contentType:'image/png',blob:new Blob(['one'],{{type:'image/png'}})}},
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two'],{{type:'image/jpeg'}})}},
]}}),delete:async id=>calls.push(['delete',id])}};
(async()=>{{const result=await shared.consume({{marker:'images',store,restore:value=>calls.push(['restore',value.map(x=>x.filename)]),status:value=>calls.push(['status',value])}});process.stdout.write(JSON.stringify({{result,calls}}));}})();
"""
output = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
assert output == {"result": True, "calls": [
["restore", ["one.png", "two.jpg"]], ["delete", "shared-image"],
["status", "2 shared screenshots ready to file with this issue."],
]}
def test_invalid_shared_image_reports_error_without_touching_open_capture():
script = f"""
const shared = require({json.dumps(str(MODULE))});

View File

@ -85,6 +85,25 @@ process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement
assert output["restored"][0]["ownerLogin"] == "timmy"
def test_unfiled_capture_restores_ordered_evidence_bundle():
script = f"""
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
const values=new Map(),blobs=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore={{put:async(id,value)=>blobs.set(id,value),get:async id=>blobs.get(id),delete:async id=>blobs.delete(id)}};
const image=name=>({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
(async()=>{{const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'bundle'}});
const saved=await captures.save({{title:'Journey',body:'Steps',attachments:['one','two','three'].map(image)}});
const resumed=await captures.resume(saved.id,'timmy');
process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.attachments.map(x=>x.filename),stored:blobs.get('bundle').attachments.map(x=>x.filename)}}));}})();
"""
output = run_node(script)
assert output["listed"]["attachmentCount"] == 3
assert output["names"] == ["one", "two", "three"]
assert output["stored"] == output["names"]
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});