From 3662372c38c1222d819a797b49c4561d9e5ea5a0 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sat, 3 Oct 2026 11:13:51 +0200 Subject: [PATCH] Add the OPDS settings to the settings page The Book Downloads card has an "Enable OPDS" box. In the proxy mode, it also shows the user name for the reader and a field to set the OPDS password. The field is grey until the box is checked. Disabled text fields now look grey. --- bindery/static/js/settings.js | 88 +++++++++++++++++++++- bindery/static/style.css | 29 ++++++++ tests/js/flows.test.mjs | 2 +- tests/js/opds-settings.test.mjs | 127 ++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 tests/js/opds-settings.test.mjs diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js index 0e55d1b..05cd2f1 100644 --- a/bindery/static/js/settings.js +++ b/bindery/static/js/settings.js @@ -9,6 +9,15 @@ const DEFAULTS_INI = 'https://github.com/JimmXinu/FanFicFare/blob/main/fanficfar const TEMPLATE_DOCS = 'https://manual.calibre-ebook.com/template_lang.html'; const DATE_CODES = 'https://docs.python.org/3/library/datetime.html#format-codes'; const code = (text) => h('code', {}, text); +// How a reader logs in to the catalog, for each login mode. +const OPDS_LOGIN = { + none: 'Bindery asks for no login, so the catalog needs none.', + local: 'The reader sends your Bindery user name and password with HTTP Basic authentication.', + cwa: 'The reader sends your CWA user name and password with HTTP Basic authentication.', + proxy: + 'The proxy skips its login for the catalog. Each user sets an OPDS password below. The reader sends that ' + + 'password and the user name from the proxy with HTTP Basic authentication.', +}; // A card of settings that folds shut. It starts open. const card = (title, ...children) => h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, title)), ...children); @@ -42,6 +51,47 @@ export function settingsPage(main, _params, config) { pauseUploads.setAttribute('aria-describedby', pauseUploads.disabled ? 'ingest-note' : ''); ingestNote.hidden = !pauseUploads.disabled; }; + const opdsEnabled = checkbox(false, (checked) => showPassword(passwordSet, checked), { + id: 'opds-enabled', + 'aria-describedby': 'opds-note', + }); + let passwordSet = false; + const opdsUser = h('input', { + id: 'opds-user', + type: 'text', + readonly: true, + spellcheck: 'false', + autocomplete: 'off', + 'aria-describedby': 'opds-password-status', + }); + const opdsPassword = h('input', { + id: 'opds-password', + type: 'password', + minlength: 8, + maxlength: 256, + autocomplete: 'new-password', + }); + const opdsPasswordStatus = h('p', { class: 'hint', id: 'opds-password-status' }); + const setPassword = button('Set password', async () => { + await api('settings/opds-password', 'PUT', { password: opdsPassword.value }); + opdsPassword.value = ''; + showPassword(true, opdsEnabled.checked); + toast('Saved'); + }); + // The password applies only when the catalog is on, so the field is grey while the box is not checked. + const showPassword = (isSet, enabled) => { + passwordSet = isSet; + opdsPassword.disabled = !enabled; + setPassword.disabled = !enabled; + setPassword.textContent = isSet ? 'Reset password' : 'Set password'; + fill( + opdsPasswordStatus, + isSet + ? 'You have an OPDS password. Bindery keeps only a hash of it. ' + + 'Enter the user name and the password in your reader.' + : 'You have no OPDS password yet. Set one, then enter the user name and the password in your reader.', + ); + }; const template = h('input', { id: 'download-template', class: 'template', @@ -96,6 +146,9 @@ export function settingsPage(main, _params, config) { recent.value = settings.recent_chapters; showUploadSettings(settings); ini.value = settings.personal_ini; + opdsEnabled.checked = settings.opds_enabled ?? false; + opdsUser.value = settings.opds_user ?? ''; + showPassword(settings.opds_password_set ?? false, opdsEnabled.checked); template.value = settings.download_template; dateFormat.value = settings.download_date_format; storyJobsHours.value = settings.story_jobs_hours; @@ -162,12 +215,33 @@ export function settingsPage(main, _params, config) { ), card( 'Book Downloads', + h('label', {}, opdsEnabled, ' Enable OPDS'), + h( + 'p', + { class: 'hint', id: 'opds-note' }, + 'OPDS lets a reader browse and download books. It is off by default. After you enable it and save, use ', + h('a', { href: 'opds' }, 'the OPDS catalog'), + '. ', + OPDS_LOGIN[config.auth_mode] ?? OPDS_LOGIN.local, + ' Use HTTPS outside a trusted local network.', + ), + config.auth_mode === 'proxy' && [ + h( + 'div', + { class: 'credentials' }, + h('label', { for: 'opds-user' }, 'OPDS user name'), + opdsUser, + h('label', { for: 'opds-password' }, 'OPDS password'), + h('div', { class: 'controls' }, opdsPassword, setPassword), + ), + opdsPasswordStatus, + ], h('div', { class: 'controls' }, h('label', { for: 'download-template' }, 'File name template'), template), h( 'p', { class: 'hint' }, - 'This template gives the file name of a book that you download from a story page. To go back to the ' + - 'default, ', + 'This template gives the file name of a book that you download from a story page or OPDS. ' + + 'To go back to the default, ', code('{title_sort} - {authors}'), ', clear the field and save.', ), @@ -234,7 +308,7 @@ export function settingsPage(main, _params, config) { h( 'p', { class: 'hint' }, - 'The form of the dates in {pubdate}, {timestamp} and {last_modified}. To go back to the default, ', + 'The form of template dates in {pubdate}, {timestamp} and {last_modified}. To go back to the default, ', code('%b, %Y'), ', clear the field and save.', ), @@ -258,8 +332,14 @@ export function settingsPage(main, _params, config) { ), ), saveButton( - () => ({ download_template: template.value, download_date_format: dateFormat.value }), + () => ({ + opds_enabled: opdsEnabled.checked, + download_template: template.value, + download_date_format: dateFormat.value, + }), (settings) => { + opdsEnabled.checked = settings.opds_enabled ?? false; + showPassword(passwordSet, opdsEnabled.checked); template.value = settings.download_template; dateFormat.value = settings.download_date_format; toast('Saved'); diff --git a/bindery/static/style.css b/bindery/static/style.css index 5b26594..627e51c 100644 --- a/bindery/static/style.css +++ b/bindery/static/style.css @@ -575,6 +575,11 @@ button.danger:hover:not(:disabled) { background: var(--err-bg); } +input:not([type='checkbox'], [type='radio']):disabled { + opacity: 0.55; + cursor: not-allowed; +} + button:disabled { opacity: 0.55; cursor: default; @@ -718,6 +723,30 @@ textarea { line-height: 1.45; } +/* A user name over a password, with the labels in one column. */ +.credentials { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + align-items: center; + gap: 8px 12px; + max-width: 48em; + margin-top: 8px; + + input { + width: 18em; + } + + input[readonly] { + background: var(--card-bg); + font-family: var(--mono); + cursor: default; + } + + .controls { + margin-top: 0; + } +} + .template { flex: 1 1 24em; max-width: 48em; diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index 9202f70..9b273cd 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -144,7 +144,7 @@ test('the download template loads, lists its fields, and saves the stored form', api.on('PUT', 'settings', { download_template: '{title} - {authors}', download_date_format: '%b, %Y' }); await click(card, 'Save'); assert.deepEqual(api.sent('PUT', 'settings')[0].body, { - download_template: ' {title} - {authors} ', download_date_format: '', + opds_enabled: false, download_template: ' {title} - {authors} ', download_date_format: '', }); assert.equal(field(main, 'download-template').value, '{title} - {authors}'); assert.equal(field(main, 'download-date-format').value, '%b, %Y'); diff --git a/tests/js/opds-settings.test.mjs b/tests/js/opds-settings.test.mjs new file mode 100644 index 0000000..f2a9d43 --- /dev/null +++ b/tests/js/opds-settings.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Element, change, click, field, find, install, nodes, section, server, settle } from './flow-helpers.mjs'; + +install(); +const { settingsPage } = await import('../../bindery/static/js/settings.js'); + +async function open(t, settings, authMode = 'proxy') { + install(); + t.mock.timers.enable({ apis: ['setTimeout'] }); + const backend = server(t); + const stored = { download_template: '{title}', download_date_format: '%Y', personal_ini: '', ...settings }; + backend.on('GET', 'settings', stored); + backend.on('GET', 'rules', { rules: [] }); + backend.on('GET', 'stories', []); + backend.on('PUT', 'settings', ({ body }) => ({ ...stored, ...body })); + backend.on('PUT', 'settings/opds-password', { opds_user: 'alice', opds_password_set: true }); + const main = new Element('main'); + settingsPage(main, {}, { cwa_enabled: false, download_fields: {}, auth_mode: authMode }); + await settle(); + return { backend, main, card: section(main, 'Book Downloads') }; +} + +for (const enabled of [false, true]) { + test(`OPDS loads ${enabled}, appears first in Book Downloads, and saves both states`, async (t) => { + const { backend, main, card } = await open(t, { opds_enabled: enabled }); + const toggle = field(card, 'opds-enabled'); + assert.equal(toggle.checked, enabled); + assert.equal(card.children[1].tagName, 'label'); + assert.equal(card.children[1].children[0], toggle); + assert.equal(card.children[1].textContent, ' Enable OPDS'); + assert.equal(toggle.getAttribute('aria-describedby'), 'opds-note'); + assert.match(field(card, 'opds-note').textContent, /off by default/); + assert.equal(find(field(card, 'opds-note'), 'a', 'the OPDS catalog').getAttribute('href'), 'opds'); + assert.equal(nodes(main).filter((node) => node.getAttribute('id') === 'opds-enabled').length, 1); + for (const value of [!enabled, enabled]) { + change(toggle, value); + await click(card, 'Save'); + assert.deepEqual(backend.sent('PUT', 'settings').at(-1).body, { + opds_enabled: value, download_template: '{title}', download_date_format: '%Y', + }); + assert.equal(toggle.checked, value); + } + backend.on('PUT', 'settings', { opds_enabled: false, download_template: '{title}', download_date_format: '%Y' }); + change(toggle, true); + await click(card, 'Save'); + assert.equal(toggle.checked, false, 'The saved response sets the checkbox state'); + }); +} + +const LOGIN = { + none: /Bindery asks for no login, so the catalog needs none/, + local: /sends your Bindery user name and password/, + cwa: /sends your CWA user name and password/, + proxy: /The proxy skips its login for the catalog.*Each user sets an OPDS password.*user name from the proxy/, +}; +for (const [mode, text] of Object.entries(LOGIN)) { + test(`the note names the login of the ${mode} mode, and only the proxy mode shows the password`, async (t) => { + const { card } = await open(t, { opds_enabled: true, opds_password_set: false, opds_user: 'alice' }, mode); + assert.match(field(card, 'opds-note').textContent, text); + assert.doesNotMatch(field(card, 'opds-note').textContent, /BINDERY_API_KEY|Bearer|X-Api-Key/); + for (const id of ['opds-user', 'opds-password', 'opds-password-status']) { + assert.equal(field(card, id) !== undefined, mode === 'proxy', id); + } + assert.equal(find(card, 'button', 'Set password') !== undefined, mode === 'proxy'); + }); +} + +test('the password field is grey until the box is checked, and follows the box at once', async (t) => { + const { card } = await open(t, { opds_enabled: false, opds_password_set: true, opds_user: 'alice' }); + const password = field(card, 'opds-password'); + const button = find(card, 'button', 'Reset password'); + assert.equal(password.disabled, true); + assert.equal(button.disabled, true); + change(field(card, 'opds-enabled'), true); + assert.equal(password.disabled, false); + assert.equal(button.disabled, false); + change(field(card, 'opds-enabled'), false); + assert.equal(password.disabled, true); + assert.equal(button.disabled, true); +}); + +for (const isSet of [false, true]) { + test(`the OPDS password ${isSet ? 'is set' : 'is not set'}, and the button sets or resets it`, async (t) => { + const { backend, card } = await open(t, { opds_enabled: true, opds_password_set: isSet, opds_user: 'alice' }); + const password = field(card, 'opds-password'); + const status = field(card, 'opds-password-status'); + const user = field(card, 'opds-user'); + assert.equal(user.value, 'alice'); + assert.equal(user.getAttribute('readonly'), ''); + assert.equal(user.disabled, false, 'The user name stays selectable'); + assert.equal(user.getAttribute('aria-describedby'), 'opds-password-status'); + assert.equal(find(card, 'label', 'OPDS user name').getAttribute('for'), 'opds-user'); + assert.equal(find(card, 'label', 'OPDS password').getAttribute('for'), 'opds-password'); + assert.equal(password.disabled, false); + assert.equal(password.getAttribute('type'), 'password'); + assert.equal(password.getAttribute('autocomplete'), 'new-password'); + assert.equal(password.getAttribute('minlength'), '8'); + assert.equal(password.value, ''); + assert.match(status.textContent, /enter the user name and the password in your reader/i); + assert.match(status.textContent, isSet ? /You have an OPDS password/ : /You have no OPDS password yet/); + assert.ok(find(card, 'button', isSet ? 'Reset password' : 'Set password')); + password.value = 'long enough'; + await click(card, isSet ? 'Reset password' : 'Set password'); + assert.deepEqual(backend.sent('PUT', 'settings/opds-password')[0].body, { password: 'long enough' }); + assert.equal(password.value, '', 'The field is empty after the save'); + assert.match(status.textContent, /You have an OPDS password/); + assert.equal(password.disabled, false); + assert.ok(find(card, 'button', 'Reset password')); + // The Save button of the card never sends the password. + password.value = 'not sent'; + await click(card, 'Save'); + assert.equal(backend.sent('PUT', 'settings/opds-password').length, 1); + assert.equal('password' in backend.sent('PUT', 'settings')[0].body, false); + assert.equal(password.disabled, false, 'A save keeps the field in step with the box'); + }); +} + +test('a rejected OPDS password keeps the field and the status', async (t) => { + const { backend, card } = await open(t, { opds_enabled: true, opds_password_set: false }); + backend.on('PUT', 'settings/opds-password', { error: 'The OPDS password must be text of 8 to 256 characters' }, 400); + field(card, 'opds-password').value = 'short'; + await click(card, 'Set password'); + assert.equal(field(card, 'opds-password').value, 'short'); + assert.match(field(card, 'opds-password-status').textContent, /You have no OPDS password yet/); + assert.ok(find(card, 'button', 'Set password')); +});