Preserve screenshots in capture-first mobile Drafts #616
17
README.md
17
README.md
|
|
@ -24,13 +24,16 @@ 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
|
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
|
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
|
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||||
either stage. The mobile **New issue**
|
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
|
||||||
sheet accepts the same image formats and stores the screenshot with its account-bound
|
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
|
||||||
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
|
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
|
||||||
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
|
preview when the operator later chooses a repository. The source Draft remains available until its
|
||||||
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
|
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
|
||||||
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
|
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
|
||||||
queued base64 screenshot payloads remain readable and are converted only at delivery time.
|
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
|
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
|
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||||
creating a duplicate.
|
creating a duplicate.
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,20 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||||
return transact;
|
return transact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
|
||||||
|
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
|
||||||
|
return {
|
||||||
|
put: (id, value) => transact(records => records.put({id, ...value})),
|
||||||
|
get: id => transact(async records => {
|
||||||
|
const value = await records.get(id);
|
||||||
|
if (!value) return null;
|
||||||
|
const {id: _id, ...attachment} = value;
|
||||||
|
return attachment;
|
||||||
|
}),
|
||||||
|
delete: id => transact(records => records.delete(id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createIssueSyncStore({
|
function createIssueSyncStore({
|
||||||
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
||||||
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
||||||
|
|
@ -798,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
|
||||||
if (typeof globalThis !== 'undefined') {
|
if (typeof globalThis !== 'undefined') {
|
||||||
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
||||||
globalThis.createIssueSyncStore = createIssueSyncStore;
|
globalThis.createIssueSyncStore = createIssueSyncStore;
|
||||||
|
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -400,11 +400,14 @@
|
||||||
loadMilestones: item => issueController.loadMilestones(item),
|
loadMilestones: item => issueController.loadMilestones(item),
|
||||||
});
|
});
|
||||||
let issueCapture = null;
|
let issueCapture = null;
|
||||||
|
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||||
const unfiledCaptures = createUnfiledCaptures({
|
const unfiledCaptures = createUnfiledCaptures({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
|
attachmentStore: unfiledAttachmentStore,
|
||||||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||||
getCurrentLogin: () => activeFlushLogin,
|
getCurrentLogin: () => activeFlushLogin,
|
||||||
});
|
});
|
||||||
|
let resumedUnfiledCaptureId = '';
|
||||||
let backgroundIssueSync = null;
|
let backgroundIssueSync = null;
|
||||||
if ('indexedDB' in window) {
|
if ('indexedDB' in window) {
|
||||||
const backgroundIssueStore = createIssueSyncStore();
|
const backgroundIssueStore = createIssueSyncStore();
|
||||||
|
|
@ -1940,7 +1943,8 @@
|
||||||
function listDrafts() {
|
function listDrafts() {
|
||||||
const unfiled = unfiledCaptures.list().map(item => ({
|
const unfiled = unfiledCaptures.list().map(item => ({
|
||||||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
||||||
title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
|
||||||
|
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||||
updated_at:item.savedAt, quarantined:item.quarantined,
|
updated_at:item.savedAt, quarantined:item.quarantined,
|
||||||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||||
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||||||
|
|
@ -2107,15 +2111,16 @@
|
||||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', async () => {
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
if (item.kind === 'unfiled-issue') {
|
if (item.kind === 'unfiled-issue') {
|
||||||
try {
|
try {
|
||||||
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
const resumed = await unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
||||||
issueCapture.saveDraft(resumed);
|
issueCapture.saveDraft(resumed);
|
||||||
refreshMyWorkView();
|
resumedUnfiledCaptureId = item.capture_id;
|
||||||
openCreateIssueSheet();
|
await openCreateIssueSheet();
|
||||||
|
if (resumed.attachment) createIssueAttachmentController.restore(resumed.attachment);
|
||||||
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
|
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
|
||||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||||
|
|
@ -2191,10 +2196,10 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', async () => {
|
||||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
|
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
|
||||||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||||
else if (item) draftInbox.discard(item.id);
|
else if (item) draftInbox.discard(item.id);
|
||||||
|
|
@ -4057,16 +4062,22 @@
|
||||||
qs('#create-issue-capture-status').textContent = '';
|
qs('#create-issue-capture-status').textContent = '';
|
||||||
qs('#create-issue-repository-search').focus();
|
qs('#create-issue-repository-search').focus();
|
||||||
});
|
});
|
||||||
qs('#save-unfiled-issue').addEventListener('click', () => {
|
qs('#save-unfiled-issue').addEventListener('click', async () => {
|
||||||
const captureDraft = {
|
|
||||||
title: qs('#create-issue-title').value.trim(),
|
|
||||||
body: qs('#create-issue-body').value.trim(),
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
unfiledCaptures.save(captureDraft);
|
const captureDraft = {
|
||||||
|
title: qs('#create-issue-title').value.trim(),
|
||||||
|
body: qs('#create-issue-body').value.trim(),
|
||||||
|
attachment: await createIssueAttachmentController.serialize(),
|
||||||
|
};
|
||||||
|
await unfiledCaptures.save(captureDraft);
|
||||||
|
if (resumedUnfiledCaptureId) {
|
||||||
|
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||||
|
resumedUnfiledCaptureId = '';
|
||||||
|
}
|
||||||
issueCapture.clearDraft();
|
issueCapture.clearDraft();
|
||||||
qs('#create-issue-title').value = '';
|
qs('#create-issue-title').value = '';
|
||||||
qs('#create-issue-body').value = '';
|
qs('#create-issue-body').value = '';
|
||||||
|
createIssueAttachmentController.clear();
|
||||||
closeCreateIssueSheet(true, false);
|
closeCreateIssueSheet(true, false);
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
|
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
|
||||||
|
|
@ -4200,6 +4211,10 @@
|
||||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||||
await issueOutbox.enqueueDurably(durableDraft);
|
await issueOutbox.enqueueDurably(durableDraft);
|
||||||
const queued = admission.item;
|
const queued = admission.item;
|
||||||
|
if (resumedUnfiledCaptureId && (!durableDraft.attachment || admission.background)) {
|
||||||
|
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||||
|
resumedUnfiledCaptureId = '';
|
||||||
|
}
|
||||||
if (!admission.background) {
|
if (!admission.background) {
|
||||||
editingOutboxId = queued.id;
|
editingOutboxId = queued.id;
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
|
|
|
||||||
|
|
@ -536,18 +536,6 @@
|
||||||
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
||||||
<button id="file-new-issue" type="button">File now</button>
|
<button id="file-new-issue" type="button">File now</button>
|
||||||
</div>
|
</div>
|
||||||
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
|
||||||
<div class="create-issue-repository-picker">
|
|
||||||
<label for="create-issue-repository-search">Repository search
|
|
||||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
|
||||||
<label for="create-issue-repository">Selected repository
|
|
||||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
|
||||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
|
||||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
<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">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||||
<div class="issue-attachment-controls">
|
<div class="issue-attachment-controls">
|
||||||
|
|
@ -561,6 +549,18 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
||||||
|
<div class="create-issue-repository-picker">
|
||||||
|
<label for="create-issue-repository-search">Repository search
|
||||||
|
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||||
|
<label for="create-issue-repository">Selected repository
|
||||||
|
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||||
|
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||||
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||||
<option value="">No milestone</option>
|
<option value="">No milestone</option>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
function createUnfiledCaptures({
|
function createUnfiledCaptures({
|
||||||
storage,
|
storage,
|
||||||
|
attachmentStore = null,
|
||||||
getCaptureLogin = () => '',
|
getCaptureLogin = () => '',
|
||||||
getCurrentLogin = () => '',
|
getCurrentLogin = () => '',
|
||||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||||
|
|
@ -35,10 +36,39 @@ function createUnfiledCaptures({
|
||||||
if (!title) throw new Error('Add a title before saving.');
|
if (!title) throw new Error('Add a title before saving.');
|
||||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
const attachment = note?.attachment;
|
||||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||||
write(items);
|
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||||
return item;
|
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||||
|
if (hasAttachment && !attachmentStore) {
|
||||||
|
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||||
|
}
|
||||||
|
const item = {
|
||||||
|
id:String(createId()), ownerLogin, title, body, savedAt:Number(now()),
|
||||||
|
...(hasAttachment ? {hasAttachment:true} : {}),
|
||||||
|
};
|
||||||
|
const existing = read().filter(candidate => candidate.id !== item.id);
|
||||||
|
const items = [item, ...existing].slice(0, maxItems);
|
||||||
|
const pruned = existing.filter(candidate => !items.some(retained => retained.id === candidate.id));
|
||||||
|
if (!hasAttachment) {
|
||||||
|
write(items);
|
||||||
|
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
|
||||||
|
Promise.resolve(attachmentStore?.delete(candidate.id)).catch(() => {}));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return Promise.resolve(attachmentStore.put(item.id, {
|
||||||
|
filename:String(attachment.filename).slice(0, 255),
|
||||||
|
contentType:String(attachment.contentType), blob:attachment.blob,
|
||||||
|
})).then(() => {
|
||||||
|
try { write(items); }
|
||||||
|
catch (error) {
|
||||||
|
Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
|
||||||
|
Promise.resolve(attachmentStore.delete(candidate.id)).catch(() => {}));
|
||||||
|
return item;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function discard(id) {
|
function discard(id) {
|
||||||
|
|
@ -46,7 +76,9 @@ function createUnfiledCaptures({
|
||||||
const remaining = items.filter(item => item.id !== id);
|
const remaining = items.filter(item => item.id !== id);
|
||||||
if (remaining.length === items.length) return false;
|
if (remaining.length === items.length) return false;
|
||||||
write(remaining);
|
write(remaining);
|
||||||
return true;
|
const removed = items.find(item => item.id === id);
|
||||||
|
if (!removed?.hasAttachment) return true;
|
||||||
|
return Promise.resolve(attachmentStore?.delete(id)).then(() => true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resume(id, confirmedLogin) {
|
function resume(id, confirmedLogin) {
|
||||||
|
|
@ -55,11 +87,20 @@ function createUnfiledCaptures({
|
||||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||||
throw new Error('Reconnect with the account that saved this capture.');
|
throw new Error('Reconnect with the account that saved this capture.');
|
||||||
}
|
}
|
||||||
discard(id);
|
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||||
return {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 (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||||
|
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||||
|
}
|
||||||
|
return {...draft, attachment};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {list, save, discard, resume};
|
function completeResume(id) { return discard(id); }
|
||||||
|
|
||||||
|
return {list, save, discard, resume, completeResume};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ FEATURE_SOURCES = {
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js",
|
"static/today-rollover.js", "static/unfiled-captures.js",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ const second = captures.save({{title:'Second',body:'Two'}});
|
||||||
let mismatch = '';
|
let mismatch = '';
|
||||||
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
|
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
|
||||||
const resumed = captures.resume(first.id, 'timmy');
|
const resumed = captures.resume(first.id, 'timmy');
|
||||||
|
captures.completeResume(first.id);
|
||||||
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
|
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
|
||||||
"""
|
"""
|
||||||
output = run_node(script)
|
output = run_node(script)
|
||||||
|
|
@ -74,6 +75,52 @@ process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list()
|
||||||
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
|
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_capture_durably_restores_screenshot_before_explicit_handoff_completion():
|
||||||
|
script = f"""
|
||||||
|
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||||
|
const values = new Map();
|
||||||
|
const blobs = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
const attachmentStore = {{
|
||||||
|
put: async (id, attachment) => blobs.set(id, attachment),
|
||||||
|
get: async id => blobs.get(id) || null,
|
||||||
|
delete: async id => blobs.delete(id),
|
||||||
|
}};
|
||||||
|
(async () => {{
|
||||||
|
const captures = createUnfiledCaptures({{
|
||||||
|
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||||
|
createId:()=>'capture-1', now:()=>42,
|
||||||
|
}});
|
||||||
|
const screenshot = {{filename:'phone.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}};
|
||||||
|
const saved = await captures.save({{title:'Broken mobile layout',body:'At 320px',attachment:screenshot}});
|
||||||
|
const listed = captures.list();
|
||||||
|
const resumed = await captures.resume(saved.id, 'timmy');
|
||||||
|
const beforeComplete = captures.list().length;
|
||||||
|
await captures.completeResume(saved.id);
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
listed, beforeComplete, afterComplete:captures.list().length,
|
||||||
|
resumed:{{title:resumed.title,body:resumed.body,filename:resumed.attachment.filename,
|
||||||
|
contentType:resumed.attachment.contentType,size:resumed.attachment.blob.size}},
|
||||||
|
blobRemoved:!blobs.has(saved.id),
|
||||||
|
}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["listed"][0]["hasAttachment"] is True
|
||||||
|
assert "attachment" not in output["listed"][0]
|
||||||
|
assert output["beforeComplete"] == 1
|
||||||
|
assert output["afterComplete"] == 0
|
||||||
|
assert output["blobRemoved"] is True
|
||||||
|
assert output["resumed"] == {
|
||||||
|
"title": "Broken mobile layout",
|
||||||
|
"body": "At 320px",
|
||||||
|
"filename": "phone.png",
|
||||||
|
"contentType": "image/png",
|
||||||
|
"size": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
|
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||||
|
|
@ -104,7 +151,12 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||||
assert "createUnfiledCaptures({" in html
|
assert "createUnfiledCaptures({" in html
|
||||||
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
|
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
|
||||||
assert "unfiledCaptures.save(captureDraft)" in html
|
assert "unfiledCaptures.save(captureDraft)" in html
|
||||||
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||||
|
assert "await createIssueAttachmentController.serialize()" in html
|
||||||
|
assert "createUnfiledAttachmentStore()" in html
|
||||||
|
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
|
||||||
|
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" in html
|
||||||
|
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||||
assert "issueCapture.saveDraft(resumed)" in html
|
assert "issueCapture.saveDraft(resumed)" in html
|
||||||
assert "item.kind === 'unfiled-issue'" in html
|
assert "item.kind === 'unfiled-issue'" in html
|
||||||
assert '.create-issue-actions button { min-height:44px;' in html
|
assert '.create-issue-actions button { min-height:44px;' in html
|
||||||
|
|
@ -125,3 +177,6 @@ async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_f
|
||||||
assert "qs('#file-new-issue').addEventListener('click'" in html
|
assert "qs('#file-new-issue').addEventListener('click'" in html
|
||||||
assert '.create-issue-capture-actions button { min-height:44px;' in html
|
assert '.create-issue-capture-actions button { min-height:44px;' in html
|
||||||
assert '.create-issue-capture-actions[hidden] { display:none;' in html
|
assert '.create-issue-capture-actions[hidden] { display:none;' in html
|
||||||
|
attachment = html.index('class="create-issue-attachment"')
|
||||||
|
filing = html.index('class="create-issue-filing"')
|
||||||
|
assert attachment < filing
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user