feat: save photo-only captures as drafts (Closes #939)
This commit is contained in:
parent
8a85b344f8
commit
caa0fbc2fd
|
|
@ -2651,7 +2651,8 @@
|
||||||
function listDrafts() {
|
function listDrafts() {
|
||||||
const unfiled = unfiledCaptures.list().map(item => ({
|
const unfiled = unfiledCaptures.list().map(item => ({
|
||||||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', ...unfiledDraftSummary(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'),
|
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||||
updated_at:item.savedAt, quarantined:item.quarantined,
|
updated_at:item.savedAt, quarantined:item.quarantined,
|
||||||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||||
|
|
@ -4315,7 +4316,11 @@
|
||||||
}
|
}
|
||||||
clearSharedLaunchUrl();
|
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) {
|
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) {
|
function unfiledSavedMessage(item) {
|
||||||
return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' :
|
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.';
|
'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 [];
|
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||||
return record.items.filter(item =>
|
return record.items.filter(item =>
|
||||||
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
|
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 []; }
|
} catch (_error) { return []; }
|
||||||
}
|
}
|
||||||
|
|
@ -122,9 +131,6 @@ function createUnfiledCaptures({
|
||||||
function prepare(note) {
|
function prepare(note) {
|
||||||
const title = String(note?.title || '').trim().slice(0, 255);
|
const title = String(note?.title || '').trim().slice(0, 255);
|
||||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
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 attachment = note?.attachment;
|
||||||
const attachments = note?.attachments;
|
const attachments = note?.attachments;
|
||||||
if (Array.isArray(attachments) && attachments.length > 5) {
|
if (Array.isArray(attachments) && attachments.length > 5) {
|
||||||
|
|
@ -139,6 +145,9 @@ function createUnfiledCaptures({
|
||||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
['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 (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) {
|
if ((hasAttachment || validAttachments) && !attachmentStore) {
|
||||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
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} : {}),
|
...(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) {
|
if (evidence.length) {
|
||||||
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
|
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
|
||||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||||
|
|
@ -382,7 +391,7 @@ function createUnfiledCaptures({
|
||||||
} : {}),
|
} : {}),
|
||||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
...(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) {
|
if (evidence.length) {
|
||||||
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
|
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
|
||||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||||
|
|
@ -422,4 +431,6 @@ function createUnfiledCaptures({
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = createUnfiledCaptures;
|
module.exports = createUnfiledCaptures;
|
||||||
module.exports.summary = unfiledDraftSummary;
|
module.exports.summary = unfiledDraftSummary;
|
||||||
|
module.exports.displayTitle = unfiledDraftDisplayTitle;
|
||||||
|
module.exports.shouldFocusTitle = unfiledShouldFocusTitle;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -697,13 +697,20 @@ class UnfiledDraftFilingPlan(BaseModel):
|
||||||
|
|
||||||
class UnfiledDraft(BaseModel):
|
class UnfiledDraft(BaseModel):
|
||||||
id: str = Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
|
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)
|
body: str = Field(default="", max_length=10_000)
|
||||||
saved_at: int = Field(ge=0)
|
saved_at: int = Field(ge=0)
|
||||||
filing_plan: UnfiledDraftFilingPlan | None = None
|
filing_plan: UnfiledDraftFilingPlan | None = None
|
||||||
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
|
blockers: list[UnfiledDraftBlocker] = Field(default_factory=list, max_length=5)
|
||||||
evidence: list[UnfiledDraftEvidence] = 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):
|
class UnfiledDraftCollection(BaseModel):
|
||||||
revision: int = Field(ge=0)
|
revision: int = Field(ge=0)
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ class UnfiledDraftStore:
|
||||||
title = raw.get("title")
|
title = raw.get("title")
|
||||||
body = raw.get("body", "")
|
body = raw.get("body", "")
|
||||||
saved_at = raw.get("saved_at")
|
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")
|
raise ValueError("title is invalid")
|
||||||
if not isinstance(body, str) or len(body) > 10_000:
|
if not isinstance(body, str) or len(body) > 10_000:
|
||||||
raise ValueError("body is invalid")
|
raise ValueError("body is invalid")
|
||||||
|
|
@ -214,6 +214,8 @@ class UnfiledDraftStore:
|
||||||
**({"note": note} if note else {}),
|
**({"note": note} if note else {}),
|
||||||
"data": data,
|
"data": data,
|
||||||
})
|
})
|
||||||
|
if not title.strip() and not clean_evidence:
|
||||||
|
raise ValueError("title or evidence is required")
|
||||||
seen.add(draft_id)
|
seen.add(draft_id)
|
||||||
normalized.append({
|
normalized.append({
|
||||||
"id": draft_id,
|
"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")
|
assert page.evaluate("() => navigator.serviceWorker.controller !== null")
|
||||||
|
|
||||||
new_action.click()
|
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()
|
expect(page.locator("#voice-issue-capture")).to_be_visible()
|
||||||
page.locator("#start-voice-issue-capture").click()
|
page.locator("#start-voice-issue-capture").click()
|
||||||
page.evaluate("""([title, body]) => {
|
page.evaluate("""([title, body]) => {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,11 @@ const planned=createUnfiledCaptures.summary({{
|
||||||
attachmentCount:2,
|
attachmentCount:2,
|
||||||
}});
|
}});
|
||||||
const minimal=createUnfiledCaptures.summary({{}});
|
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) == {
|
assert run_node(script) == {
|
||||||
|
|
@ -86,6 +90,24 @@ process.stdout.write(JSON.stringify({{planned,minimal}}));
|
||||||
"action": "Choose repository",
|
"action": "Choose repository",
|
||||||
"details": "Create issue · No screenshots",
|
"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"]
|
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():
|
def test_unfiled_captures_export_and_import_ordered_evidence_between_devices():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
const createUnfiledCaptures=require({json.dumps(str(UNFILED))});
|
||||||
|
|
@ -643,7 +700,7 @@ process.stdout.write(JSON.stringify({{errors,size:values.size}}));
|
||||||
output = run_node(script)
|
output = run_node(script)
|
||||||
|
|
||||||
assert output == {
|
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,
|
"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 "dependencies.attachment[evidence ? 'restore' : 'clear'](evidence)" in DRAFT_SESSION.read_text()
|
||||||
assert "await unfiledCaptures.completeResume(rUC)" in html
|
assert "await unfiledCaptures.completeResume(rUC)" in html
|
||||||
assert "...(rUC ? { sourceCaptureId: 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 "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
|
||||||
assert "item.kind === 'unfiled-issue'" in html
|
assert "item.kind === 'unfiled-issue'" in html
|
||||||
assert '.create-issue-actions button { min-height:44px;' 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 'id="file-new-issue" type="button">File now' in html
|
||||||
assert "function setIssueFilingMode(enabled)" in html
|
assert "function setIssueFilingMode(enabled)" in html
|
||||||
assert "setIssueFilingMode(Boolean(captureDraft.repository))" 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 "qs('#file-new-issue').addEventListener('click'" in html
|
||||||
assert '.create-issue-capture-actions button { min-height:44px;' in html
|
assert '.create-issue-capture-actions button { min-height:44px;' in html
|
||||||
assert '.create-issue-capture-actions[hidden] { display:none;' 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):
|
def test_unfiled_drafts_validate_and_round_trip_complete_filing_plan(tmp_path):
|
||||||
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
store = UnfiledDraftStore(tmp_path / "unfiled.sqlite3")
|
||||||
planned = draft(evidence=[])
|
planned = draft(evidence=[])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user