From caa0fbc2fd8bbf2dbaf1e23a7af77bf0563edd51 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 04:59:08 +0000 Subject: [PATCH] feat: save photo-only captures as drafts (Closes #939) --- frontend/dashboard.js | 9 ++- frontend/unfiled-captures.js | 23 +++++-- src/main.py | 9 ++- src/unfiled_draft_store.py | 4 +- .../e2e/test_mobile_offline_issue_release.py | 6 ++ tests/test_unfiled_captures.py | 67 +++++++++++++++++-- tests/test_unfiled_draft_store.py | 14 ++++ 7 files changed, 118 insertions(+), 14 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index b44773a..c9670b3 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -2651,7 +2651,8 @@ function listDrafts() { const unfiled = unfiledCaptures.list().map(item => ({ id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(item), - title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''), + title:unfiledDraftDisplayTitle(item), + preview:[item.body, item.hasAttachment ? 'Photo evidence attached' : ''].filter(Boolean).join(' · '), hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'), updated_at:item.savedAt, quarantined:item.quarantined, ownership:item.quarantined ? 'Saved by ' + item.ownerLogin + @@ -4315,7 +4316,11 @@ } clearSharedLaunchUrl(); } - qs('#create-issue-title').focus(); + const mobileCapture = window.matchMedia?.('(max-width: 600px)').matches === true; + const continuingCapture = Boolean(rUC || captureDraft.title || captureDraft.body || captureDraft.repository); + if (unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})) { + qs('#create-issue-title').focus(); + } } function setIssueFilingMode(enabled) { diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js index a6e71a1..f36b1c3 100644 --- a/frontend/unfiled-captures.js +++ b/frontend/unfiled-captures.js @@ -18,6 +18,14 @@ function unfiledDraftSummary(item) { }; } +function unfiledDraftDisplayTitle(item) { + return String(item?.title || '').trim() || (item?.hasAttachment ? 'Untitled photo draft' : 'Untitled draft'); +} + +function unfiledShouldFocusTitle({mobile = false, continuing = false} = {}) { + return !mobile || continuing; +} + function unfiledSavedMessage(item) { return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' : 'Saved to Drafts. Choose a repository when you’re ready to file it.'; @@ -49,7 +57,8 @@ function createUnfiledCaptures({ if (record?.version !== 1 || !Array.isArray(record.items)) return []; return record.items.filter(item => item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' && - typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string' + typeof item.title === 'string' && (item.title.trim() || item.hasAttachment === true) && + typeof item.body === 'string' ); } catch (_error) { return []; } } @@ -122,9 +131,6 @@ function createUnfiledCaptures({ 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.'); - 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) { @@ -139,6 +145,9 @@ function createUnfiledCaptures({ const hasAttachment = Boolean(attachment?.blob && attachment?.filename && ['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || ''))); if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.'); + if (!title && !hasAttachment && !validAttachments) throw new Error('Add a title or photo before saving.'); + const ownerLogin = String(getCaptureLogin() || '').trim(); + if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.'); if ((hasAttachment || validAttachments) && !attachmentStore) { throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.'); } @@ -344,7 +353,7 @@ function createUnfiledCaptures({ } : {}), ...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}), }; - if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue; + if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue; if (evidence.length) { await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({ filename:String(entry.filename), contentType:String(entry.content_type), @@ -382,7 +391,7 @@ function createUnfiledCaptures({ } : {}), ...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}), }; - if (!item.id || !item.title.trim() || !Number.isFinite(item.savedAt)) continue; + if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue; if (evidence.length) { stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({ filename:String(entry.filename), contentType:String(entry.content_type), @@ -422,4 +431,6 @@ function createUnfiledCaptures({ if (typeof module !== 'undefined' && module.exports) { module.exports = createUnfiledCaptures; module.exports.summary = unfiledDraftSummary; + module.exports.displayTitle = unfiledDraftDisplayTitle; + module.exports.shouldFocusTitle = unfiledShouldFocusTitle; } diff --git a/src/main.py b/src/main.py index 11d11ac..c0010cc 100644 --- a/src/main.py +++ b/src/main.py @@ -697,13 +697,20 @@ class UnfiledDraftFilingPlan(BaseModel): class UnfiledDraft(BaseModel): id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$") - title: str = Field(min_length=1, max_length=255) + title: str = Field(default="", max_length=255) body: str = Field(default="", max_length=10_000) saved_at: int = Field(ge=0) filing_plan: UnfiledDraftFilingPlan | None = None blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5) evidence: list[UnfiledDraftEvidence] = Field(default_factory=list, max_length=5) + @model_validator(mode="after") + def require_title_or_evidence(self): + self.title = self.title.strip() + if not self.title and not self.evidence: + raise ValueError("title or evidence is required") + return self + class UnfiledDraftCollection(BaseModel): revision: int = Field(ge=0) diff --git a/src/unfiled_draft_store.py b/src/unfiled_draft_store.py index e943c35..c8d385e 100644 --- a/src/unfiled_draft_store.py +++ b/src/unfiled_draft_store.py @@ -161,7 +161,7 @@ class UnfiledDraftStore: title = raw.get("title") body = raw.get("body", "") saved_at = raw.get("saved_at") - if not isinstance(title, str) or not title.strip() or len(title.strip()) > 255: + if not isinstance(title, str) or len(title.strip()) > 255: raise ValueError("title is invalid") if not isinstance(body, str) or len(body) > 10_000: raise ValueError("body is invalid") @@ -214,6 +214,8 @@ class UnfiledDraftStore: **({"note": note} if note else {}), "data": data, }) + if not title.strip() and not clean_evidence: + raise ValueError("title or evidence is required") seen.add(draft_id) normalized.append({ "id": draft_id, diff --git a/tests/e2e/test_mobile_offline_issue_release.py b/tests/e2e/test_mobile_offline_issue_release.py index 799b5d0..6fe6f85 100644 --- a/tests/e2e/test_mobile_offline_issue_release.py +++ b/tests/e2e/test_mobile_offline_issue_release.py @@ -186,6 +186,12 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo assert page.evaluate("() => navigator.serviceWorker.controller !== null") new_action.click() + expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open") + assert page.evaluate("document.activeElement?.id") != "create-issue-title" + for control in page.locator(".photo-evidence-actions .issue-attachment-trigger").all(): + bounds = control.bounding_box() + assert bounds and bounds["height"] >= 44 + assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") expect(page.locator("#voice-issue-capture")).to_be_visible() page.locator("#start-voice-issue-capture").click() page.evaluate("""([title, body]) => { diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index 58029a3..46e2d02 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -68,7 +68,11 @@ const planned=createUnfiledCaptures.summary({{ attachmentCount:2, }}); const minimal=createUnfiledCaptures.summary({{}}); -process.stdout.write(JSON.stringify({{planned,minimal}})); +const titles=[ + createUnfiledCaptures.displayTitle({{title:'Pump failure',hasAttachment:true}}), + createUnfiledCaptures.displayTitle({{title:'',hasAttachment:true}}), +]; +process.stdout.write(JSON.stringify({{planned,minimal,titles}})); """ assert run_node(script) == { @@ -86,6 +90,24 @@ process.stdout.write(JSON.stringify({{planned,minimal}})); "action": "Choose repository", "details": "Create issue · No screenshots", }, + "titles": ["Pump failure", "Untitled photo draft"], + } + + +def test_capture_title_focus_defers_phone_keyboard_until_draft_continuation(): + script = f""" +const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); +const shouldFocus=createUnfiledCaptures.shouldFocusTitle; +process.stdout.write(JSON.stringify({{ + freshPhone:shouldFocus({{mobile:true,continuing:false}}), + resumedPhoto:shouldFocus({{mobile:true,continuing:true}}), + freshDesktop:shouldFocus({{mobile:false,continuing:false}}), +}})); +""" + assert run_node(script) == { + "freshPhone": False, + "resumedPhoto": True, + "freshDesktop": True, } @@ -198,6 +220,41 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at assert output["stored"] == output["notes"] +def test_unfiled_capture_saves_untitled_photo_only_draft_but_rejects_empty_capture(): + 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 photo={{filename:'field.jpg',contentType:'image/jpeg',blob:new Blob(['photo'],{{type:'image/jpeg'}}),note:'Pump label'}}; +(async()=>{{ + const captures=createUnfiledCaptures({{storage,attachmentStore,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'photo-only'}}); + const saved=await captures.save({{title:' ',body:'',attachments:[photo]}}); + const listed=captures.list()[0]; + const resumed=await captures.resume(saved.id,'timmy'); + const remoteValues=new Map(),remoteBlobs=new Map(); + const remote=createUnfiledCaptures({{ + storage:{{getItem:k=>remoteValues.get(k)||null,setItem:(k,v)=>remoteValues.set(k,v)}}, + attachmentStore:{{put:async(id,value)=>remoteBlobs.set(id,value),get:async id=>remoteBlobs.get(id),delete:async id=>remoteBlobs.delete(id)}}, + getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'unused' + }}); + await remote.mergeRemote(await captures.exportOwned('timmy'),'timmy'); + const remoteResumed=await remote.resume(saved.id,'timmy'); + let emptyError=''; + try {{ await captures.save({{title:' ',body:''}}); }} catch(error) {{ emptyError=error.message; }} + process.stdout.write(JSON.stringify({{saved,listed,resumedTitle:resumed.title,resumedEvidence:resumed.attachments.map(item=>item.filename),remoteTitle:remoteResumed.title,remoteEvidence:remoteResumed.attachments.map(item=>item.filename),emptyError}})); +}})().catch(error=>{{console.error(error);process.exit(1)}}); +""" + output = run_node(script) + assert output["saved"]["title"] == "" + assert output["listed"]["title"] == "" + assert output["resumedTitle"] == "" + assert output["resumedEvidence"] == ["field.jpg"] + assert output["remoteTitle"] == "" + assert output["remoteEvidence"] == ["field.jpg"] + assert output["emptyError"] == "Add a title or photo before saving." + + def test_unfiled_captures_export_and_import_ordered_evidence_between_devices(): script = f""" const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); @@ -643,7 +700,7 @@ process.stdout.write(JSON.stringify({{errors,size:values.size}})); output = run_node(script) assert output == { - "errors": ["Add a title before saving.", "Offline identity is unavailable. Reconnect once before saving private work."], + "errors": ["Add a title or photo before saving.", "Offline identity is unavailable. Reconnect once before saving private work."], "size": 0, } @@ -670,7 +727,8 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume assert "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text() assert "await unfiledCaptures.completeResume(rUC)" in html assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html - assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html + assert "title:unfiledDraftDisplayTitle(item)" in html + assert "item.hasAttachment ? 'Photo evidence attached' : ''" in html assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text() assert "item.kind === 'unfiled-issue'" in html assert '.create-issue-actions button { min-height:44px;' in html @@ -759,7 +817,8 @@ async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_f assert 'id="file-new-issue" type="button">File now' in html assert "function setIssueFilingMode(enabled)" in html assert "setIssueFilingMode(Boolean(captureDraft.repository))" in html - assert "qs('#create-issue-title').focus();" in html + assert "window.matchMedia?.('(max-width: 600px)').matches === true" in html + assert "unfiledShouldFocusTitle({mobile:mobileCapture, continuing:continuingCapture})" 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[hidden] { display:none;' in html diff --git a/tests/test_unfiled_draft_store.py b/tests/test_unfiled_draft_store.py index 74d4323..e0d1448 100644 --- a/tests/test_unfiled_draft_store.py +++ b/tests/test_unfiled_draft_store.py @@ -51,6 +51,20 @@ def test_unfiled_drafts_bound_collection_and_decoded_evidence(tmp_path): }])]) +def test_unfiled_drafts_allow_untitled_photo_evidence_but_reject_empty_records(tmp_path): + store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3") + photo_only = draft(title="") + + validated = main.UnfiledDraft.model_validate(photo_only).model_dump() + assert store.replace("timmy", 0, [validated])["drafts"][0]["title"] == "" + + empty = draft(title="", evidence=[]) + with pytest.raises(ValueError, match="title or evidence is required"): + main.UnfiledDraft.model_validate(empty) + with pytest.raises(ValueError, match="title or evidence is required"): + store.replace("timmy", 1, [empty]) + + def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path): store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3") planned = draft(evidence=[]) -- 2.43.0