Save photo-only mobile captures as untitled Drafts #940
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=[])
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user