From 285fdff86945d63894e1a93246c4bf6a5bad36ea Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 05:54:19 +0000 Subject: [PATCH] fix: preserve Drafts when cleanup fails (Closes #625) --- frontend/unfiled-captures.js | 47 ++++++++++--- tests/test_unfiled_captures.py | 125 +++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 10 deletions(-) diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js index e88a994..af780e2 100644 --- a/frontend/unfiled-captures.js +++ b/frontend/unfiled-captures.js @@ -57,20 +57,36 @@ function createUnfiledCaptures({ savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}), }; const items = [item, ...existing.filter(candidate => candidate.id !== item.id)]; - const finish = () => { + const writeItems = () => { try { write(items); } catch (error) { if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {}); throw error; } - if (removed?.hasAttachment) return Promise.resolve(attachmentStore?.delete(removed.id)).then(() => item); return item; }; - if (!prepared.hasAttachment) return finish(); - return Promise.resolve(attachmentStore.put(item.id, { - filename:String(prepared.attachment.filename).slice(0, 255), - contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob, - })).then(finish); + const stage = prepared.hasAttachment + ? Promise.resolve(attachmentStore.put(item.id, { + 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) { @@ -94,10 +110,21 @@ function createUnfiledCaptures({ const items = read(); const remaining = items.filter(item => item.id !== id); if (remaining.length === items.length) return false; - write(remaining); const removed = items.find(item => item.id === id); - if (!removed?.hasAttachment) return true; - return Promise.resolve(attachmentStore?.delete(id)).then(() => true); + if (!removed?.hasAttachment) { + write(remaining); + return true; + } + return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment => + Promise.resolve(attachmentStore?.delete(id)).then(async () => { + try { write(remaining); } + catch (error) { + if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {}); + throw error; + } + return true; + }) + ); } function resume(id, confirmedLogin) { diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index 9c27713..2c88b77 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -104,6 +104,131 @@ const attachmentStore = {{ } +def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob(): + 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 => {{ + if (id === '1') throw new Error('IndexedDB delete failed'); + blobs.delete(id); + }}, +}}; +(async () => {{ + let id = 0; + const captures = createUnfiledCaptures({{ + storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', + createId:()=>String(++id), now:()=>id, + }}); + const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}}); + const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}}); + let error = ''; + try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }} + catch (caught) {{ error = caught.message; }} + const resumed = await captures.resume(original.id, 'timmy'); + process.stdout.write(JSON.stringify({{ + error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()], + resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename, + }})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + output = run_node(script) + + assert output == { + "error": "IndexedDB delete failed", + "titles": ["Original"], + "blobs": ["1"], + "resumedTitle": "Original", + "resumedFilename": "original.png", + } + + +def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable(): + 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 () => {{ throw new Error('IndexedDB delete failed'); }}, +}}; +(async () => {{ + const captures = createUnfiledCaptures({{ + storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', + createId:()=>'capture-1', now:()=>42, + }}); + await captures.save({{title:'Keep this',body:'Important',attachment:{{ + filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}), + }}}}); + let error = ''; + try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }} + const resumed = await captures.resume('capture-1', 'timmy'); + process.stdout.write(JSON.stringify({{ + error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title, + resumedFilename:resumed.attachment.filename, + }})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + output = run_node(script) + + assert output == { + "error": "IndexedDB delete failed", + "titles": ["Keep this"], + "resumedTitle": "Keep this", + "resumedFilename": "proof.png", + } + + +def test_discard_metadata_write_failure_restores_attachment_and_draft(): + script = f""" +const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); +const values = new Map(); +const blobs = new Map(); +let failWrites = false; +const storage = {{ + getItem:k=>values.get(k)||null, + setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); 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, + }}); + await captures.save({{title:'Keep this',body:'Important',attachment:{{ + filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}), + }}}}); + failWrites = true; + let error = ''; + try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }} + failWrites = false; + const resumed = await captures.resume('capture-1', 'timmy'); + process.stdout.write(JSON.stringify({{ + error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()], + resumedFilename:resumed.attachment.filename, + }})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + output = run_node(script) + + assert output == { + "error": "Storage write failed", + "titles": ["Keep this"], + "blobIds": ["capture-1"], + "resumedFilename": "proof.png", + } + + def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); -- 2.43.0