Allow queued confirmation during source pause
This commit is contained in:
1 parent
a22913129a
commit
b96ab42add
4 files changed
+51
-5
No files matched your search
@@ -98,7 +98,11 @@ delays. No image or cover requests occur during inspection. Successful inspectio
|
||||
sets `status:"ready"`, displays the canonical `sourceTitle` and source availability,
|
||||
and waits for **Confirm download**. Ready items survive daemon restarts and suppress
|
||||
duplicate nightly checks. Other jobs may proceed while confirmation is pending.
|
||||
Confirmation does not resume a deliberately paused queue or clear a source block.
|
||||
Confirmation can save an inspected edition even while its source lane is paused;
|
||||
the job then remains queued and makes no source requests until that lane is
|
||||
reviewed and explicitly resumed. Confirmation does not resume a deliberately
|
||||
paused queue or clear a source block. **Inspect again** remains unavailable
|
||||
while the source lane is paused.
|
||||
The download rediscovers the catalog; an edition title change requires re-inspection
|
||||
and confirmation. Direct API clients can still enqueue authorized downloads without
|
||||
inspection by omitting the flag. Existing download/discovery jobs are unchanged.
|
||||
|
||||
+24
-3
@@ -128,7 +128,17 @@
|
||||
const summary = event.target.closest('summary');
|
||||
const details = summary?.parentElement;
|
||||
const key = details?.dataset.scraperDisclosure;
|
||||
if (key && Object.hasOwn(disclosureOpen, key)) disclosureOpen[key] = !details.open;
|
||||
if (key && Object.hasOwn(disclosureOpen, key)) {
|
||||
const intended = !details.open;
|
||||
disclosureOpen[key] = intended;
|
||||
// A filter click can redraw the panel while this summary click is
|
||||
// bubbling. Apply the click to the replacement node as well; the
|
||||
// browser's native toggle will only affect the detached original.
|
||||
if (!details.isConnected) {
|
||||
const replacement = card.querySelector(`[data-scraper-disclosure="${key}"]`);
|
||||
if (replacement) replacement.open = intended;
|
||||
}
|
||||
}
|
||||
});
|
||||
card.addEventListener('toggle', event => {
|
||||
const details = event.target;
|
||||
@@ -227,9 +237,12 @@
|
||||
const expected = availability?.expectedLastChapter;
|
||||
const sparse = /^\d+$/.test(String(expected || '')) && Number(availability?.numberedChapters) < Number(expected);
|
||||
const partialCoverage = availability?.coverage === 'partial' || sparse;
|
||||
const sourceIsPaused = !!state.sourcePaused?.[item.source];
|
||||
const outcome = item.status === 'complete'
|
||||
? `${item.publishedCount ?? 0} chapter${item.publishedCount === 1 ? '' : 's'} published · finished ${when(item.completed)}`
|
||||
: item.status === 'ready' ? 'Awaiting edition confirmation' : item.status.replaceAll('_', ' ');
|
||||
: item.status === 'ready' ? 'Awaiting edition confirmation'
|
||||
: item.status === 'queued' && sourceIsPaused ? 'Confirmed · waiting for source access review'
|
||||
: item.status.replaceAll('_', ' ');
|
||||
return h('div', { class: 'scraper-item' + (running ? ' active' : ''), 'data-source': item.source },
|
||||
h('div', { class: 'grow' }, h('b', {}, item.title),
|
||||
item.status === 'complete' || availability ? h('div', { class: 'coverage-indicators' },
|
||||
@@ -241,11 +254,17 @@
|
||||
+ (item.existingPolicy === 'add-source' ? ' · alternate source' : '')),
|
||||
item.error ? h('div', { class: 'small error' }, item.error) : null,
|
||||
item.sourceTitle ? h('div', { class: 'small' }, `Source edition: ${item.sourceTitle}`) : null,
|
||||
item.status === 'ready' && sourceIsPaused ? h('div', { class: 'small error' },
|
||||
'This source is paused after an access response. Confirming saves your choice, but downloading will not start until access is reviewed and the source is resumed.') : null,
|
||||
availability ? h('div', { class: 'muted small' }, `${availability.selectedChapters} hosted selections${availability.specialReleases ? ` · ${availability.specialReleases} unnumbered releases` : ''}${availability.expectedLastChapter ? ` · metadata last chapter: ${availability.expectedLastChapter}` : ''}`) : null),
|
||||
movable ? h('button', { class: 'btn small', disabled: running || pending[0]?.id === item.id, title: 'Move up', onclick: () => move(item, -1) }, '↑') : null,
|
||||
movable ? h('button', { class: 'btn small', disabled: running || pending.at(-1)?.id === item.id, title: 'Move down', onclick: () => move(item, 1) }, '↓') : null,
|
||||
item.status === 'error' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'retry', id: item.id }) }, 'Retry') : null,
|
||||
['unavailable', 'quarantined', 'complete', 'ready'].includes(item.status) ? h('button', { class: 'btn small', onclick: () => command({ command: 'control', action: 'inspect', id: item.id }) }, 'Inspect again') : null,
|
||||
['unavailable', 'quarantined', 'complete', 'ready'].includes(item.status) ? h('button', {
|
||||
class: 'btn small', disabled: sourceIsPaused,
|
||||
title: sourceIsPaused ? 'Source access must be reviewed before another inspection' : '',
|
||||
onclick: () => command({ command: 'control', action: 'inspect', id: item.id }),
|
||||
}, 'Inspect again') : null,
|
||||
item.status === 'ready' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'confirm', id: item.id }) }, 'Confirm download') : null,
|
||||
h('button', { class: 'btn small', disabled: running, onclick: async () => {
|
||||
if (confirm(`Remove ${item.title} from the queue? Its library chapters will stay.`)) await command({ command: 'remove', id: item.id });
|
||||
@@ -273,6 +292,8 @@
|
||||
h('div', { class: 'row', style: 'margin-top:8px' }, state.paused
|
||||
? h('button', { class: 'btn primary', onclick: () => command({ command: 'control', action: 'resume' }) }, 'Resume queue')
|
||||
: h('button', { class: 'btn', onclick: () => command({ command: 'control', action: 'pause' }) }, 'Pause queue'))),
|
||||
Object.entries(state.sourcePaused || {}).filter(([, paused]) => paused).map(([source]) =>
|
||||
h('p', { class: 'small error', role: 'status' }, `${source} is paused after a source-access problem. Confirmed jobs wait; confirmation does not resume this source.`)),
|
||||
h('div', { class: 'field' }, h('span', {}, `Needs attention · ${attention.length}`),
|
||||
attention.length ? h('div', { class: 'scraper-queue' }, attention.map(item => jobRow(item)))
|
||||
: h('p', { class: 'muted small' }, 'Nothing needs action on this source.'),
|
||||
|
||||
+6
-1
@@ -654,7 +654,12 @@ class ScraperService {
|
||||
if (action === 'inspect' || action === 'confirm') {
|
||||
const item = this.state.queue.find(entry => entry.id === id);
|
||||
if (!item || Object.values(this.state.currentIds || {}).includes(item.id)) throw new Error('Job not found or already active');
|
||||
if (this.state.sourcePaused[item.source] || item.failure?.kind === 'access') throw new Error('Review the source access block before inspecting or confirming');
|
||||
// Confirmation only changes the persisted queue. The work loop still
|
||||
// refuses to start jobs for a paused source, so an admin can approve an
|
||||
// already inspected edition without accidentally retrying blocked access.
|
||||
if (item.failure?.kind === 'access' || (action === 'inspect' && this.state.sourcePaused[item.source])) {
|
||||
throw new Error('Review the source access block before inspecting');
|
||||
}
|
||||
if (action === 'confirm') {
|
||||
if (item.status !== 'ready' || item.kind !== 'inspection') throw new Error('Inspect the source first');
|
||||
item.confirmedSourceTitle = item.sourceTitle;
|
||||
|
||||
@@ -24,3 +24,19 @@ test('an operator can pause one source while other lanes continue', async t => {
|
||||
await service.control('resume-source', 'weebcentral');
|
||||
assert.equal(service.state.sourcePaused.weebcentral, undefined);
|
||||
});
|
||||
|
||||
test('confirmation can queue an inspected job without unpausing its source', async t => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mc-source-confirm-'));
|
||||
const service = await new ScraperService({ libraryRoot: path.join(root, 'library'),
|
||||
dataRoot: path.join(root, 'data') }).init();
|
||||
t.after(async () => { await service.close(); await fs.rm(root, { recursive: true, force: true }); });
|
||||
const item = { id: 'inspected', source: 'weebcentral', title: 'Sample', sourceTitle: 'Sample',
|
||||
kind: 'inspection', status: 'ready' };
|
||||
service.state.queue.push(item);
|
||||
await service.control('pause-source', 'weebcentral');
|
||||
await service.control('confirm', item.id);
|
||||
assert.equal(item.kind, 'download');
|
||||
assert.equal(item.status, 'queued');
|
||||
assert.equal(service.state.sourcePaused.weebcentral, true);
|
||||
await assert.rejects(service.control('inspect', item.id), /source access block/);
|
||||
});
|
||||
Reference in new issue
Block a user