Compare commits
No commits in common. "735653031837fbed66acb5b1be25ab262da27563" and "de8227f4f46bb0515981cc14492da015fb6a56a8" have entirely different histories.
7356530318
...
de8227f4f4
|
|
@ -24,12 +24,7 @@ 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. On a cold offline launch, **Save for filing** stores up to 20
|
lane immediately. Issue capture and authored mobile actions (issue
|
||||||
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,7 +81,6 @@ 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,11 +168,6 @@
|
||||||
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();
|
||||||
|
|
@ -624,21 +619,8 @@
|
||||||
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 = listDrafts();
|
lastDrafts = draftInbox.list();
|
||||||
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),
|
||||||
});
|
});
|
||||||
|
|
@ -705,7 +687,6 @@
|
||||||
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>' :
|
||||||
|
|
@ -717,12 +698,11 @@
|
||||||
'<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">' +
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</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 || isUnfiled) ?
|
const state = isOutbox ?
|
||||||
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
'<span class="pill">' + (item.quarantined ? 'Identity protected' :
|
||||||
(isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
|
(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>' +
|
||||||
|
|
@ -735,15 +715,7 @@
|
||||||
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 === 'unfiled-issue') {
|
if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||||
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -779,11 +751,10 @@
|
||||||
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 === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
|
if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_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 = listDrafts();
|
lastDrafts = draftInbox.list();
|
||||||
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();
|
||||||
|
|
@ -808,7 +779,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMyWork() {
|
function renderMyWork() {
|
||||||
lastDrafts = listDrafts();
|
lastDrafts = draftInbox.list();
|
||||||
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') {
|
||||||
|
|
@ -2131,24 +2102,6 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
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,7 +335,6 @@
|
||||||
</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>
|
||||||
|
|
@ -507,7 +506,6 @@
|
||||||
<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-v29';
|
const CACHE = 'stackchain-dashboard-shell-v28';
|
||||||
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,7 +15,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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-v29" in source
|
assert "stackchain-dashboard-shell-v28" 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,7 +217,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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