Make CWA linking and uploads optional
This commit is contained in:
@@ -130,6 +130,10 @@ test('sortable headings fill the column with one clickable highlight without shi
|
||||
assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/);
|
||||
});
|
||||
|
||||
test('disabled checkbox labels use muted text', () => {
|
||||
assert.equal(declarations("label:has(> input[type='checkbox']:disabled)").color, 'var(--muted)');
|
||||
});
|
||||
|
||||
function luminance(hex) {
|
||||
const channels = hex.slice(1).match(/../g).map((channel) => {
|
||||
const value = parseInt(channel, 16) / 255;
|
||||
|
||||
@@ -258,7 +258,7 @@ test('date columns keep each timestamp on one line, with last change before last
|
||||
|
||||
test('status messages move into accessible pill descriptions without hiding held or chapter counts', async (t) => {
|
||||
await mount(t, [
|
||||
story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true }),
|
||||
story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true, approval_held: true }),
|
||||
story(2, 'Beta', { status: 'error', message: '<script>Connection failed</script>' }),
|
||||
story(3, 'Gamma'),
|
||||
story(4, 'New', { status: '', message: '' }),
|
||||
|
||||
+239
-12
@@ -31,24 +31,131 @@ async function renderStory(t, data) {
|
||||
t.after(() => cleanup?.());
|
||||
await settle();
|
||||
const summary = nodes(main).find((el) => el.className === 'card story-summary');
|
||||
return { backend, notice: nodes(summary).find((el) => el.className === 'notice') };
|
||||
return { backend, summary, notice: nodes(summary).find((el) => el.className === 'notice') };
|
||||
}
|
||||
|
||||
test('the upload switches load and save on the settings page', async (t) => {
|
||||
const backend = server(t);
|
||||
backend.on('GET', 'settings', { recent_chapters: 0, hold_new_links: true, pause_uploads: false, personal_ini: '' });
|
||||
backend.on('PUT', 'settings', {});
|
||||
backend.on('GET', 'settings', { recent_chapters: 0, link_books: true, hold_new_links: true, pause_uploads: false,
|
||||
ingest_available: true, personal_ini: '' });
|
||||
backend.on('PUT', 'settings', { link_books: false, hold_new_links: true, pause_uploads: true, ingest_available: true });
|
||||
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||
backend.on('GET', 'stories', []);
|
||||
const main = new Element('main');
|
||||
settingsPage(main, {}, config);
|
||||
await settle();
|
||||
const card = section(main, 'Uploads to CWA');
|
||||
const [hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||
assert.deepEqual([hold.checked, pause.checked], [true, false]);
|
||||
const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||
assert.deepEqual([link.checked, hold.checked, pause.checked], [true, true, false]);
|
||||
assert.equal(find(card, 'label', ' Link books to CWA library').children[0], link);
|
||||
assert.match(card.textContent, /send a plain ePub to INGEST_DIR/);
|
||||
const warning = nodes(card).find((el) => el.attributes.id === 'link-note');
|
||||
assert.equal(warning.className, 'notice');
|
||||
assert.equal(warning.hidden, true);
|
||||
assert.equal(link.attributes['aria-describedby'], '');
|
||||
assert.match(warning.textContent, /Updates without a saved CWA book ID create duplicate books if CWA imports them/);
|
||||
assert.equal(nodes(card).find((el) => el.attributes.id === 'ingest-note').hidden, true);
|
||||
assert.equal(pause.disabled, false);
|
||||
assert.equal(hold.disabled, false);
|
||||
assert.equal(link.disabled, false);
|
||||
assert.equal(nodes(card).find((el) => el.attributes.id === 'library-note').hidden, true);
|
||||
change(link, false);
|
||||
assert.equal(warning.hidden, false);
|
||||
assert.equal(link.attributes['aria-describedby'], warning.attributes.id);
|
||||
assert.equal(hold.disabled, true);
|
||||
assert.equal(hold.checked, true);
|
||||
change(link, true);
|
||||
assert.equal(warning.hidden, true);
|
||||
assert.equal(hold.disabled, false);
|
||||
assert.equal(hold.checked, true);
|
||||
change(link, false);
|
||||
change(pause, true);
|
||||
await click(card, 'Save');
|
||||
assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { hold_new_links: true, pause_uploads: true });
|
||||
assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { link_books: false, hold_new_links: true, pause_uploads: true });
|
||||
assert.deepEqual([link.checked, hold.checked, pause.checked], [false, true, true]);
|
||||
assert.equal(hold.disabled, true);
|
||||
assert.equal(warning.hidden, false);
|
||||
});
|
||||
|
||||
for (const ingestAvailable of [true, false]) {
|
||||
test(`missing library sources disable linking and approval with ingest available ${ingestAvailable}`, async (t) => {
|
||||
const backend = server(t);
|
||||
const saved = { link_books: true, linking_available: false, hold_new_links: true,
|
||||
pause_uploads: false, ingest_available: ingestAvailable };
|
||||
backend.on('GET', 'settings', saved);
|
||||
backend.on('PUT', 'settings', saved);
|
||||
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||
backend.on('GET', 'stories', []);
|
||||
const main = new Element('main');
|
||||
settingsPage(main, {}, config);
|
||||
await settle();
|
||||
const card = section(main, 'Uploads to CWA');
|
||||
const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||
const note = nodes(card).find((el) => el.attributes.id === 'library-note');
|
||||
assert.equal(link.checked, false);
|
||||
assert.equal(link.disabled, true);
|
||||
assert.equal(hold.checked, true);
|
||||
assert.equal(hold.disabled, true);
|
||||
assert.equal(pause.checked, !ingestAvailable);
|
||||
assert.equal(pause.disabled, !ingestAvailable);
|
||||
assert.equal(note.hidden, false);
|
||||
assert.equal(note.className, 'notice');
|
||||
assert.match(note.textContent, /CWA_URL and CALIBRE_DB_PATH are not set/);
|
||||
assert.match(note.textContent, /Set one or both and restart Bindery/);
|
||||
assert.equal(link.attributes['aria-describedby'], 'library-note link-note');
|
||||
assert.equal(nodes(card).find((el) => el.attributes.id === 'link-note').hidden, false);
|
||||
await click(card, 'Save');
|
||||
assert.equal(backend.sent('PUT', 'settings')[0].body.link_books, false);
|
||||
assert.equal(link.checked, false);
|
||||
assert.equal(link.disabled, true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const linkBooks of [true, false]) {
|
||||
test(`missing INGEST_DIR forces pause and shows a highlighted note with linking ${linkBooks}`, async (t) => {
|
||||
const backend = server(t);
|
||||
const saved = { link_books: linkBooks, hold_new_links: false, pause_uploads: true, ingest_available: false };
|
||||
backend.on('GET', 'settings', saved);
|
||||
backend.on('PUT', 'settings', saved);
|
||||
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||
backend.on('GET', 'stories', []);
|
||||
const main = new Element('main');
|
||||
settingsPage(main, {}, config);
|
||||
await settle();
|
||||
const card = section(main, 'Uploads to CWA');
|
||||
const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||
assert.equal(link.checked, linkBooks);
|
||||
assert.equal(hold.disabled, !linkBooks);
|
||||
assert.equal(nodes(card).find((el) => el.attributes.id === 'link-note').hidden, linkBooks);
|
||||
assert.equal(pause.checked, true);
|
||||
assert.equal(pause.disabled, true);
|
||||
const note = nodes(card).find((el) => el.attributes.id === 'ingest-note');
|
||||
assert.equal(note.hidden, false);
|
||||
assert.equal(note.className, 'notice');
|
||||
assert.equal(pause.attributes['aria-describedby'], note.attributes.id);
|
||||
assert.match(note.textContent, /INGEST_DIR is not set/);
|
||||
await click(card, 'Save');
|
||||
assert.equal(pause.checked, true);
|
||||
assert.equal(pause.disabled, true);
|
||||
assert.equal(backend.sent('PUT', 'settings')[0].body.pause_uploads, true);
|
||||
});
|
||||
}
|
||||
|
||||
test('a saved approval preference stays checked but disabled while linking is off', async (t) => {
|
||||
const backend = server(t);
|
||||
backend.on('GET', 'settings', { link_books: false, hold_new_links: true, pause_uploads: false, ingest_available: true });
|
||||
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||
backend.on('GET', 'stories', []);
|
||||
const main = new Element('main');
|
||||
settingsPage(main, {}, config);
|
||||
await settle();
|
||||
const card = section(main, 'Uploads to CWA');
|
||||
const [link, hold] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||
assert.equal(hold.disabled, true);
|
||||
assert.equal(hold.checked, true);
|
||||
change(link, true);
|
||||
assert.equal(hold.disabled, false);
|
||||
assert.equal(hold.checked, true);
|
||||
});
|
||||
|
||||
test('a held book with a new link names the book and sends it on approval', async (t) => {
|
||||
@@ -57,12 +164,12 @@ test('a held book with a new link names the book and sends it on approval', asyn
|
||||
{ book },
|
||||
);
|
||||
const { backend, notice } = await renderStory(t, data);
|
||||
assert.match(notice.textContent, /waits for your approval\. The next upload replaces the ePub of library book 7: Library Title · Writer\./);
|
||||
assert.match(notice.textContent, /^Approve replacing the ePub of library book 7: Library Title · Writer\./);
|
||||
backend.on('POST', 'stories/1/approve', { story: data.story, job: { id: 'j1' } });
|
||||
await click(notice, 'Approve and send');
|
||||
await settle();
|
||||
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: 'book-7' });
|
||||
assert.match(ui.toasts.textContent, /Approved\. Bindery rebuilds the book and sends it\./);
|
||||
assert.match(ui.toasts.textContent, /Approved\. Rebuild queued\./);
|
||||
});
|
||||
|
||||
test('a wrong link can go to CWA as a new book instead', async (t) => {
|
||||
@@ -81,9 +188,50 @@ test('a wrong link can go to CWA as a new book instead', async (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
for (const uuid of [null, 'book-7']) {
|
||||
test(`approval while paused saves the decision without promising an upload: ${uuid}`, async (t) => {
|
||||
const data = details(
|
||||
{ calibre_id: uuid ? 7 : null, calibre_uuid: uuid, upload_held: true, needs_approval: true },
|
||||
{ link_books: true, uploads_paused: true },
|
||||
);
|
||||
const { backend, notice } = await renderStory(t, data);
|
||||
assert.ok(find(notice, 'button', 'Approve'));
|
||||
assert.equal(find(notice, 'button', 'Approve and send'), undefined);
|
||||
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
|
||||
backend.on('POST', 'stories/1/approve', { story: data.story, job: null });
|
||||
await click(notice, 'Approve');
|
||||
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid });
|
||||
assert.equal(ui.toasts.children.at(-1).textContent, 'Approved');
|
||||
});
|
||||
}
|
||||
|
||||
test('approval as a new book while paused keeps the pause in the confirmation', async (t) => {
|
||||
const data = details(
|
||||
{ calibre_id: 7, calibre_uuid: 'book-7', upload_held: true, needs_approval: true },
|
||||
{ link_books: true, uploads_paused: true },
|
||||
);
|
||||
const { backend, notice } = await renderStory(t, data);
|
||||
const confirm = t.mock.method(window, 'confirm', () => true);
|
||||
backend.on('POST', 'stories/1/link', data.story);
|
||||
backend.on('POST', 'stories/1/approve', { story: data.story, job: null });
|
||||
await click(notice, 'Approve as a new book instead');
|
||||
assert.match(confirm.mock.calls[0].arguments[0], /approve this story as a new book\? Uploads remain paused/);
|
||||
assert.match(confirm.mock.calls[0].arguments[0], /you get a duplicate/);
|
||||
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: null });
|
||||
assert.equal(ui.toasts.children.at(-1).textContent, 'Approved');
|
||||
});
|
||||
|
||||
test('a saved ID without a UUID still offers approval', async (t) => {
|
||||
const { notice } = await renderStory(t, details(
|
||||
{ calibre_id: 7, ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
|
||||
));
|
||||
assert.ok(find(notice, 'button', 'Approve and send'));
|
||||
assert.match(notice.textContent, /library book 7/);
|
||||
});
|
||||
|
||||
test('a new story asks for approval to go to CWA as a new book', async (t) => {
|
||||
const { notice } = await renderStory(t, details({ needs_approval: true }));
|
||||
assert.match(notice.textContent, /The next upload goes to CWA as a new book\./);
|
||||
assert.match(notice.textContent, /^Approve this story as a new CWA book\./);
|
||||
assert.ok(find(notice, 'button', 'Approve'));
|
||||
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
|
||||
});
|
||||
@@ -98,18 +246,96 @@ test('a paused upload points to the settings', async (t) => {
|
||||
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||
});
|
||||
|
||||
for (const savedId of [null, 7]) {
|
||||
for (const paused of [false, true]) {
|
||||
for (const needsApproval of [false, true]) {
|
||||
test(`linking off hides approval with saved ID ${savedId}, pause ${paused}, and approval ${needsApproval}`, async (t) => {
|
||||
const { notice } = await renderStory(t, details(
|
||||
{ calibre_id: savedId, calibre_uuid: savedId ? 'book-7' : null,
|
||||
upload_held: true, needs_approval: needsApproval },
|
||||
{ link_books: false, uploads_paused: paused },
|
||||
));
|
||||
if (savedId !== null && !paused) {
|
||||
assert.equal(notice, undefined);
|
||||
return;
|
||||
}
|
||||
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||
assert.doesNotMatch(notice.textContent, /waits for your approval|as a new book|Bindery sends this book/);
|
||||
if (savedId === null) {
|
||||
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
|
||||
assert.equal(notice.className, 'notice');
|
||||
} else {
|
||||
assert.doesNotMatch(notice.textContent, /duplicate books/);
|
||||
}
|
||||
if (paused) {
|
||||
assert.match(notice.textContent, /Uploads to CWA are paused/);
|
||||
assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('linking off warns that repeated exports without a saved ID create duplicates in CWA', async (t) => {
|
||||
const { notice, summary } = await renderStory(t, details(
|
||||
{ ingested_at: '2026-01-01', needs_approval: true, upload_held: true },
|
||||
{ link_books: false },
|
||||
));
|
||||
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
|
||||
assert.match(summary.textContent, /Sent to ingest folder/);
|
||||
assert.doesNotMatch(summary.textContent, /Sent to CWA, unlinked/);
|
||||
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||
});
|
||||
|
||||
test('a story that went to CWA without a link shows no approval until Bindery finds its book', async (t) => {
|
||||
const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true }));
|
||||
assert.equal(notice, undefined);
|
||||
});
|
||||
|
||||
test('the story list marks a held book', async (t) => {
|
||||
test('missing INGEST_DIR points to the environment rather than the disabled pause switch', async (t) => {
|
||||
const { notice } = await renderStory(t, details(
|
||||
{ upload_held: true }, { uploads_paused: true, ingest_available: false },
|
||||
));
|
||||
assert.equal(notice.textContent, 'Set INGEST_DIR and restart Bindery to enable uploads.');
|
||||
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||
});
|
||||
|
||||
test('missing library sources leave uploads enabled with the duplicate warning', async (t) => {
|
||||
const { notice } = await renderStory(t, details(
|
||||
{ ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
|
||||
{ link_books: false, library_matching_available: false },
|
||||
));
|
||||
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
|
||||
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||
});
|
||||
|
||||
test('a new book asks for approval when library linking is enabled', async (t) => {
|
||||
const { notice } = await renderStory(t, details(
|
||||
{ upload_held: true, needs_approval: true },
|
||||
{ link_books: true, library_matching_available: true },
|
||||
));
|
||||
assert.match(notice.textContent, /^Approve this story as a new CWA book\./);
|
||||
assert.ok(find(notice, 'button', 'Approve and send'));
|
||||
});
|
||||
|
||||
test('approval before a build still shows the pause and never offers to send', async (t) => {
|
||||
const { notice } = await renderStory(t, details(
|
||||
{ needs_approval: true }, { uploads_paused: true },
|
||||
));
|
||||
assert.match(notice.textContent, /Uploads to CWA are paused\. Resume them in Settings\./);
|
||||
assert.ok(find(notice, 'button', 'Approve'));
|
||||
assert.equal(find(notice, 'button', 'Approve and send'), undefined);
|
||||
});
|
||||
|
||||
test('the story list marks only an approval hold, not every unsent book', async (t) => {
|
||||
const backend = server(t);
|
||||
backend.on('GET', 'stories', [
|
||||
{ id: 1, url: 'u1', title: 'Held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
|
||||
message: '', upload_held: true },
|
||||
message: '', upload_held: true, approval_held: true },
|
||||
{ id: 2, url: 'u2', title: 'Sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
|
||||
message: '', upload_held: false },
|
||||
message: '', upload_held: false, approval_held: false },
|
||||
{ id: 3, url: 'u3', title: 'Paused', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
|
||||
message: '', upload_held: true, approval_held: false },
|
||||
]);
|
||||
backend.on('GET', 'jobs', []);
|
||||
const main = new Element('main');
|
||||
@@ -119,4 +345,5 @@ test('the story list marks a held book', async (t) => {
|
||||
const held = nodes(main).filter((el) => el.tagName === 'tr').filter((row) => find(row, 'span', 'held'));
|
||||
assert.equal(held.length, 1);
|
||||
assert.match(held[0].textContent, /^Held/);
|
||||
assert.match(find(held[0], 'span', 'held').attributes.title, /waits for your approval/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user