feat: save photo-only captures as drafts (Closes #939)
All checks were successful
CI / lint (pull_request) Successful in 2m9s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-16 04:59:08 +00:00
parent 8a85b344f8
commit caa0fbc2fd
7 changed files with 118 additions and 14 deletions

View File

@ -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,8 +4316,12 @@
}
clearSharedLaunchUrl();
}
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) {
applyIssueFilingMode(qs, enabled);

View File

@ -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 youre 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;
}

View File

@ -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)

View File

@ -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,

View File

@ -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]) => {

View File

@ -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

View File

@ -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=[])