Capture unfiled work from a cold offline mobile launch #302
|
|
@ -24,7 +24,12 @@ issue-comment API; mobile issue capture requires issue
|
||||||
creation and assignment permission. The New issue sheet can optionally select an open
|
creation and assignment permission. The New issue sheet can optionally select an open
|
||||||
repository milestone and due date; the dashboard validates both and sends them with
|
repository milestone and due date; the dashboard validates both and sends them with
|
||||||
self-assignment in the single create request, so planned work appears in its release
|
self-assignment in the single create request, so planned work appears in its release
|
||||||
lane immediately. Issue capture and authored mobile actions (issue
|
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||||
|
account-bound title/description captures without selecting a repository or entering the
|
||||||
|
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||||
|
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||||
|
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||||
|
the private content. Issue capture and authored mobile actions (issue
|
||||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||||
another worker replays a confirmed result instead of posting duplicate content. Results
|
another worker replays a confirmed result instead of posting duplicate content. Results
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||||
.draft-actions button { min-height:44px; width:100%; }
|
.draft-actions button { min-height:44px; width:100%; }
|
||||||
|
.create-issue-actions button { min-height:44px; max-width:100%; }
|
||||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,11 @@
|
||||||
loadMilestones: item => issueController.loadMilestones(item),
|
loadMilestones: item => issueController.loadMilestones(item),
|
||||||
});
|
});
|
||||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
|
const unfiledCaptures = createUnfiledCaptures({
|
||||||
|
storage: localStorage,
|
||||||
|
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||||
|
getCurrentLogin: () => activeFlushLogin,
|
||||||
|
});
|
||||||
let backgroundIssueSync = null;
|
let backgroundIssueSync = null;
|
||||||
if ('indexedDB' in window) {
|
if ('indexedDB' in window) {
|
||||||
const backgroundIssueStore = createIssueSyncStore();
|
const backgroundIssueStore = createIssueSyncStore();
|
||||||
|
|
@ -619,8 +624,21 @@
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listDrafts() {
|
||||||
|
const unfiled = unfiledCaptures.list().map(item => ({
|
||||||
|
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
||||||
|
title:item.title, preview:item.body, 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 +
|
||||||
|
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||||||
|
}));
|
||||||
|
return draftInbox.list().concat(unfiled).sort((left, right) =>
|
||||||
|
Number(right.updated_at || 0) - Number(left.updated_at || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function refreshMyWorkView() {
|
function refreshMyWorkView() {
|
||||||
lastDrafts = draftInbox.list();
|
lastDrafts = listDrafts();
|
||||||
const partitioned = laterWork.partition(lastMyWork, {
|
const partitioned = laterWork.partition(lastMyWork, {
|
||||||
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
|
||||||
});
|
});
|
||||||
|
|
@ -687,6 +705,7 @@
|
||||||
const list = qs('#my-work-list');
|
const list = qs('#my-work-list');
|
||||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
||||||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||||
|
const isUnfiled = item.kind === 'unfiled-issue';
|
||||||
const outboxActions = item.quarantined ?
|
const outboxActions = item.quarantined ?
|
||||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||||
|
|
@ -698,11 +717,12 @@
|
||||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
||||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
|
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' +
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
||||||
|
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||||||
const state = isOutbox ?
|
const state = (isOutbox || isUnfiled) ?
|
||||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||||
(item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
(isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
||||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||||
return '<article class="my-work-card draft-card">' +
|
return '<article class="my-work-card draft-card">' +
|
||||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||||
|
|
@ -715,7 +735,15 @@
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
if (item.kind === 'new-issue') openCreateIssueSheet();
|
if (item.kind === 'unfiled-issue') {
|
||||||
|
try {
|
||||||
|
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
||||||
|
issueCapture.saveDraft(resumed);
|
||||||
|
refreshMyWorkView();
|
||||||
|
openCreateIssueSheet();
|
||||||
|
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
|
||||||
|
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||||
|
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||||
else if (item.route) workRoute.open(item.route);
|
else if (item.route) workRoute.open(item.route);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -751,10 +779,11 @@
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
|
||||||
|
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||||
else if (item) draftInbox.discard(item.id);
|
else if (item) draftInbox.discard(item.id);
|
||||||
lastDrafts = draftInbox.list();
|
lastDrafts = listDrafts();
|
||||||
const count = qs('[data-work-count="draft"]');
|
const count = qs('[data-work-count="draft"]');
|
||||||
if (count) count.textContent = lastDrafts.length;
|
if (count) count.textContent = lastDrafts.length;
|
||||||
renderDrafts();
|
renderDrafts();
|
||||||
|
|
@ -779,7 +808,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMyWork() {
|
function renderMyWork() {
|
||||||
lastDrafts = draftInbox.list();
|
lastDrafts = listDrafts();
|
||||||
const draftCount = qs('[data-work-count="draft"]');
|
const draftCount = qs('[data-work-count="draft"]');
|
||||||
if (draftCount) draftCount.textContent = lastDrafts.length;
|
if (draftCount) draftCount.textContent = lastDrafts.length;
|
||||||
if (selectedWorkFilter === 'draft') {
|
if (selectedWorkFilter === 'draft') {
|
||||||
|
|
@ -2102,6 +2131,24 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||||
|
qs('#save-unfiled-issue').addEventListener('click', () => {
|
||||||
|
const captureDraft = {
|
||||||
|
title: qs('#create-issue-title').value.trim(),
|
||||||
|
body: qs('#create-issue-body').value.trim(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
unfiledCaptures.save(captureDraft);
|
||||||
|
issueCapture.clearDraft();
|
||||||
|
qs('#create-issue-title').value = '';
|
||||||
|
qs('#create-issue-body').value = '';
|
||||||
|
closeCreateIssueSheet();
|
||||||
|
refreshMyWorkView();
|
||||||
|
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
|
||||||
|
} catch (error) {
|
||||||
|
qs('#create-issue-status').textContent = error.message;
|
||||||
|
qs('#create-issue-title').focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
qs('#use-shared-content').addEventListener('click', () => {
|
qs('#use-shared-content').addEventListener('click', () => {
|
||||||
issueCapture.acceptSharedContent();
|
issueCapture.acceptSharedContent();
|
||||||
qs('#shared-content-conflict').hidden = true;
|
qs('#shared-content-conflict').hidden = true;
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="small">The issue will be assigned to you.</div>
|
<div class="small">The issue will be assigned to you.</div>
|
||||||
<div class="create-issue-actions">
|
<div class="create-issue-actions">
|
||||||
|
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -506,6 +507,7 @@
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
<script src="static/drafts.js"></script>
|
<script src="static/drafts.js"></script>
|
||||||
|
<script src="static/unfiled-captures.js"></script>
|
||||||
<script src="static/outbox-coordinator.js"></script>
|
<script src="static/outbox-coordinator.js"></script>
|
||||||
<script src="static/background-issue-sync.js"></script>
|
<script src="static/background-issue-sync.js"></script>
|
||||||
<script src="static/issue-outbox.js"></script>
|
<script src="static/issue-outbox.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v28';
|
const CACHE = 'stackchain-dashboard-shell-v29';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
@ -15,6 +15,7 @@ const SHELL = [
|
||||||
BASE + 'static/search-preview.js',
|
BASE + 'static/search-preview.js',
|
||||||
BASE + 'static/widgets.js',
|
BASE + 'static/widgets.js',
|
||||||
BASE + 'static/drafts.js',
|
BASE + 'static/drafts.js',
|
||||||
|
BASE + 'static/unfiled-captures.js',
|
||||||
BASE + 'static/outbox-coordinator.js',
|
BASE + 'static/outbox-coordinator.js',
|
||||||
BASE + 'static/issue-outbox.js',
|
BASE + 'static/issue-outbox.js',
|
||||||
BASE + 'static/authored-outbox.js',
|
BASE + 'static/authored-outbox.js',
|
||||||
|
|
|
||||||
65
frontend/unfiled-captures.js
Normal file
65
frontend/unfiled-captures.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
function createUnfiledCaptures({
|
||||||
|
storage,
|
||||||
|
getCaptureLogin = () => '',
|
||||||
|
getCurrentLogin = () => '',
|
||||||
|
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||||
|
now = () => Date.now(),
|
||||||
|
maxItems = 20,
|
||||||
|
}) {
|
||||||
|
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||||
|
|
||||||
|
function read() {
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
} catch (_error) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(items) {
|
||||||
|
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||||
|
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
|
||||||
|
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(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 item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||||
|
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||||
|
write(items);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard(id) {
|
||||||
|
const items = read();
|
||||||
|
const remaining = items.filter(item => item.id !== id);
|
||||||
|
if (remaining.length === items.length) return false;
|
||||||
|
write(remaining);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resume(id, confirmedLogin) {
|
||||||
|
const item = read().find(candidate => candidate.id === id);
|
||||||
|
if (!item) throw new Error('This capture is no longer available.');
|
||||||
|
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||||
|
throw new Error('Reconnect with the account that saved this capture.');
|
||||||
|
}
|
||||||
|
discard(id);
|
||||||
|
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, save, discard, resume};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||||
|
|
@ -95,7 +95,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_strict_browser_assets_ship_in_a_new_shell_cache():
|
def test_strict_browser_assets_ship_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v28" in source
|
assert "stackchain-dashboard-shell-v29" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -217,6 +217,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/search-preview.js",
|
"/dashboard/static/search-preview.js",
|
||||||
"/dashboard/static/widgets.js",
|
"/dashboard/static/widgets.js",
|
||||||
"/dashboard/static/drafts.js",
|
"/dashboard/static/drafts.js",
|
||||||
|
"/dashboard/static/unfiled-captures.js",
|
||||||
"/dashboard/static/outbox-coordinator.js",
|
"/dashboard/static/outbox-coordinator.js",
|
||||||
"/dashboard/static/issue-outbox.js",
|
"/dashboard/static/issue-outbox.js",
|
||||||
"/dashboard/static/authored-outbox.js",
|
"/dashboard/static/authored-outbox.js",
|
||||||
|
|
|
||||||
111
tests/test_unfiled_captures.py
Normal file
111
tests/test_unfiled_captures.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script: str):
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
|
||||||
|
script = f"""
|
||||||
|
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem:key => values.get(key) || null,
|
||||||
|
setItem:(key,value) => values.set(key,value),
|
||||||
|
removeItem:key => values.delete(key),
|
||||||
|
}};
|
||||||
|
let sequence = 0;
|
||||||
|
const captures = createUnfiledCaptures({{
|
||||||
|
storage,
|
||||||
|
getCaptureLogin:()=>'timmy',
|
||||||
|
getCurrentLogin:()=>'',
|
||||||
|
createId:()=>String(++sequence),
|
||||||
|
now:()=>1000 + sequence,
|
||||||
|
}});
|
||||||
|
for (let index = 1; index <= 22; index += 1) {{
|
||||||
|
captures.save({{title:'Note ' + index, body:'Context ' + index}});
|
||||||
|
}}
|
||||||
|
const offline = captures.list();
|
||||||
|
const restored = createUnfiledCaptures({{
|
||||||
|
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
|
||||||
|
}}).list();
|
||||||
|
process.stdout.write(JSON.stringify({{offline, restored}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert len(output["offline"]) == 20
|
||||||
|
assert output["offline"][0]["title"] == "Note 22"
|
||||||
|
assert output["offline"][-1]["title"] == "Note 3"
|
||||||
|
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_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
|
||||||
|
script = f"""
|
||||||
|
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
let id = 0;
|
||||||
|
const captures = createUnfiledCaptures({{
|
||||||
|
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id
|
||||||
|
}});
|
||||||
|
const first = captures.save({{title:'First',body:'One'}});
|
||||||
|
const second = captures.save({{title:'Second',body:'Two'}});
|
||||||
|
let mismatch = '';
|
||||||
|
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
|
||||||
|
const resumed = captures.resume(first.id, 'timmy');
|
||||||
|
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["mismatch"] == "Reconnect with the account that saved this capture."
|
||||||
|
assert output["resumed"] == {"repository": "", "title": "First", "body": "One", "labelIds": []}
|
||||||
|
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
|
||||||
|
script = f"""
|
||||||
|
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
const captures = createUnfiledCaptures({{storage,getCaptureLogin:()=>''}});
|
||||||
|
const errors = [];
|
||||||
|
for (const note of [{{title:'',body:'context'}},{{title:'Work',body:'context'}}]) {{
|
||||||
|
try {{ captures.save(note); }} catch (error) {{ errors.push(error.message); }}
|
||||||
|
}}
|
||||||
|
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."],
|
||||||
|
"size": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume_flow():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/unfiled-captures.js"></script>' in html
|
||||||
|
assert 'id="save-unfiled-issue"' in html
|
||||||
|
assert 'Save for filing' in html
|
||||||
|
assert "createUnfiledCaptures({" in html
|
||||||
|
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
|
||||||
|
assert "unfiledCaptures.save(captureDraft)" in html
|
||||||
|
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||||
|
assert "issueCapture.saveDraft(resumed)" in html
|
||||||
|
assert "item.kind === 'unfiled-issue'" in html
|
||||||
|
assert '.create-issue-actions button { min-height:44px;' in html
|
||||||
|
assert '@media(max-width:320px)' in html
|
||||||
Loading…
Reference in New Issue
Block a user