Prevent silent mobile Draft loss at capacity #624
|
|
@ -10,6 +10,14 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
|||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
|
||||
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-capacity-actions button { min-height:44px; width:100%; }
|
||||
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
|
|
|
|||
|
|
@ -4070,6 +4070,7 @@
|
|||
body: qs('#create-issue-body').value.trim(),
|
||||
attachment: await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
if (showDraftCapacityDialog(unfiledCaptures)) return;
|
||||
const savedCapture = await unfiledCaptures.save(captureDraft);
|
||||
if (resumedUnfiledCaptureId) {
|
||||
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||
|
|
@ -4094,6 +4095,7 @@
|
|||
qs('#create-issue-title').focus();
|
||||
}
|
||||
});
|
||||
bindDraftCapacityDialog();
|
||||
qs('#use-shared-content').addEventListener('click', () => {
|
||||
issueCapture.acceptSharedContent();
|
||||
qs('#shared-content-conflict').hidden = true;
|
||||
|
|
|
|||
65
frontend/draft-capacity-dialog.js
Normal file
65
frontend/draft-capacity-dialog.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
function showDraftCapacityDialog(captures) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
|
||||
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
|
||||
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
|
||||
qs('#draft-capacity-status').textContent = '';
|
||||
qs('#draft-capacity-sheet').hidden = false;
|
||||
qs('#keep-editing-draft').focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
});
|
||||
qs('#review-full-drafts').addEventListener('click', () => {
|
||||
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
|
||||
saveIssueCaptureDraft();
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('drafts');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => {
|
||||
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
|
||||
card?.scrollIntoView({block:'nearest'});
|
||||
card?.focus({preventScroll:true});
|
||||
});
|
||||
});
|
||||
qs('#replace-oldest-draft').addEventListener('click', async () => {
|
||||
const oldest = unfiledCaptures.capacity().oldest;
|
||||
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
|
||||
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
|
||||
const button = qs('#replace-oldest-draft');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const draft = {
|
||||
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
|
||||
attachment:await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('drafts');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
|
||||
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
|
||||
} catch (error) {
|
||||
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
|
||||
|
|
@ -593,6 +593,20 @@
|
|||
</section>
|
||||
</div>
|
||||
|
||||
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
|
||||
<section class="draft-capacity-panel">
|
||||
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
|
||||
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
|
||||
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
|
||||
<div class="draft-capacity-actions">
|
||||
<button id="review-full-drafts" type="button">Review Drafts</button>
|
||||
<button id="keep-editing-draft" type="button">Keep editing</button>
|
||||
<button id="replace-oldest-draft" type="button">Replace oldest & save</button>
|
||||
</div>
|
||||
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
|
|
@ -844,6 +858,7 @@
|
|||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const SHELL = [
|
|||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ function createUnfiledCaptures({
|
|||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
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 prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
|
|
@ -43,32 +48,46 @@ function createUnfiledCaptures({
|
|||
if (hasAttachment && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
}
|
||||
|
||||
function persist(prepared, existing, removed = null) {
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin, title, body, savedAt:Number(now()),
|
||||
...(hasAttachment ? {hasAttachment:true} : {}),
|
||||
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||
savedAt:Number(now()), ...(prepared.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(() => {
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const finish = () => {
|
||||
try { write(items); }
|
||||
catch (error) {
|
||||
Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
|
||||
Promise.resolve(attachmentStore.delete(candidate.id)).catch(() => {}));
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -100,7 +119,7 @@ function createUnfiledCaptures({
|
|||
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, save, discard, resume, completeResume};
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ FEATURE_SOURCES = {
|
|||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/unfiled-captures.js",
|
||||
"static/today-rollover.js", "static/unfiled-captures.js", "static/draft-capacity-dialog.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
|
|
|||
|
|
@ -684,6 +684,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ def run_node(script: str):
|
|||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
|
||||
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
|
|
@ -32,25 +32,78 @@ const captures = createUnfiledCaptures({{
|
|||
createId:()=>String(++sequence),
|
||||
now:()=>1000 + sequence,
|
||||
}});
|
||||
for (let index = 1; index <= 22; index += 1) {{
|
||||
for (let index = 1; index <= 20; index += 1) {{
|
||||
captures.save({{title:'Note ' + index, body:'Context ' + index}});
|
||||
}}
|
||||
let fullError = '';
|
||||
try {{ captures.save({{title:'Note 21', body:'Context 21'}}); }} catch (error) {{
|
||||
fullError = error.message;
|
||||
}}
|
||||
const beforeReplace = captures.list();
|
||||
const oldest = captures.capacity().oldest;
|
||||
const replacement = captures.replaceOldest({{title:'Note 21', body:'Context 21'}}, oldest.id);
|
||||
const offline = captures.list();
|
||||
const restored = createUnfiledCaptures({{
|
||||
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
|
||||
}}).list();
|
||||
process.stdout.write(JSON.stringify({{offline, restored}}));
|
||||
process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement,offline,restored}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["fullError"] == "Drafts full — nothing was deleted."
|
||||
assert len(output["beforeReplace"]) == 20
|
||||
assert output["beforeReplace"][-1]["title"] == "Note 1"
|
||||
assert output["oldest"]["title"] == "Note 1"
|
||||
assert len(output["offline"]) == 20
|
||||
assert output["offline"][0]["title"] == "Note 22"
|
||||
assert output["offline"][-1]["title"] == "Note 3"
|
||||
assert output["offline"][0]["title"] == "Note 21"
|
||||
assert output["offline"][-1]["title"] == "Note 2"
|
||||
assert all(item["quarantined"] for item in output["offline"])
|
||||
assert all(not item["quarantined"] for item in output["restored"])
|
||||
assert output["restored"][0]["ownerLogin"] == "timmy"
|
||||
|
||||
|
||||
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const deleted = [];
|
||||
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 => {{ deleted.push(id); blobs.delete(id); }},
|
||||
}};
|
||||
(async () => {{
|
||||
let id = 0;
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, maxItems:2, 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 first = await captures.save({{title:'First',body:'one',attachment:image('first.png')}});
|
||||
const second = await captures.save({{title:'Second',body:'two',attachment:image('second.png')}});
|
||||
let mismatch = '';
|
||||
try {{ await captures.replaceOldest({{title:'Third',body:'three'}}, second.id); }}
|
||||
catch (error) {{ mismatch = error.message; }}
|
||||
const third = await captures.replaceOldest({{title:'Third',body:'three',attachment:image('third.png')}}, first.id);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
mismatch, titles:captures.list().map(item=>item.title), deleted,
|
||||
blobs:[...blobs.keys()], third:third.title,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"mismatch": "Drafts changed. Review them before replacing anything.",
|
||||
"titles": ["Third", "Second"],
|
||||
"deleted": ["1"],
|
||||
"blobs": ["2", "3"],
|
||||
"third": "Third",
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
|
|
@ -171,6 +224,22 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
|||
assert '@media(max-width:320px)' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="draft-capacity-sheet"' in html
|
||||
assert 'Drafts full — nothing was deleted.' in html
|
||||
assert 'id="review-full-drafts"' in html
|
||||
assert 'id="replace-oldest-draft"' in html
|
||||
assert 'id="keep-editing-draft"' in html
|
||||
assert "showDraftCapacityDialog(unfiledCaptures)" in html
|
||||
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
|
||||
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
|
||||
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
|
||||
assert '.draft-capacity-actions button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_fields():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user