From 6a1c92bc62f4acdd95e2e2ea5966364e00fd3fed Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Fri, 2 Oct 2026 23:56:48 +0200 Subject: [PATCH] Remove duplicates in the web interface The settings page has one Save button helper. The story list sorts by the sort key that the server sends, so the rule for a leading article exists once. The story page takes its list of download modes from the mode hints. --- bindery/static/js/settings.js | 59 ++++++++++++++--------------------- bindery/static/js/stories.js | 6 +--- bindery/static/js/story.js | 3 +- bindery/store.py | 4 ++- tests/js/story-list.test.mjs | 5 ++- tests/js/upload-hold.test.mjs | 6 ++-- tests/test_store.py | 2 ++ 7 files changed, 37 insertions(+), 48 deletions(-) diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js index a24761f..9abf059 100644 --- a/bindery/static/js/settings.js +++ b/bindery/static/js/settings.js @@ -48,6 +48,13 @@ export function settingsPage(main, _params, config) { 'aria-label': 'personal.ini', }); const warnings = h('div'); + // A Save button for some of the settings. `values` gives the settings to send, and `saved` gets the answer. + const saveButton = (values, saved = () => toast('Saved')) => + h( + 'div', + { class: 'controls' }, + button('Save', async () => saved(await api('settings', 'PUT', values())), { class: 'primary' }), + ); const showWarnings = (list) => fill( @@ -93,14 +100,7 @@ export function settingsPage(main, _params, config) { 'Authors most often fix their latest chapters. Each rechecked chapter is one more request to the site. ' + 'Enter 0 to turn this off. A story with its own interval for full rechecks does not use this setting.', ), - h( - 'div', - { class: 'controls' }, - button('Save', async () => { - await api('settings', 'PUT', { recent_chapters: recent.value === '' ? 0 : Number(recent.value) }); - toast('Saved'); - }, { class: 'primary' }), - ), + saveButton(() => ({ recent_chapters: recent.value === '' ? 0 : Number(recent.value) })), ), config.cwa_enabled && h( 'section', @@ -131,16 +131,12 @@ export function settingsPage(main, _params, config) { 'Bindery downloads chapters and builds books, but sends no book to CWA. ' + 'When you turn the pause off, Bindery sends the books that wait.', ), - h( - 'div', - { class: 'controls' }, - button('Save', async () => { - const saved = await api('settings', 'PUT', { - link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked, - }); - showUploadSettings(saved); + saveButton( + () => ({ link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked }), + (settings) => { + showUploadSettings(settings); toast('Saved'); - }, { class: 'primary' }), + }, ), ), h( @@ -171,19 +167,12 @@ export function settingsPage(main, _params, config) { `A queued or running job always shows. The most either field allows is ${config.job_list_max_hours} hours ` + `and ${config.job_list_max_count} jobs.`, ), - h( - 'div', - { class: 'controls' }, - button('Save', async () => { - await api('settings', 'PUT', { - story_jobs_hours: storyJobsHours.value, - story_jobs_count: storyJobsCount.value, - main_jobs_hours: mainJobsHours.value, - main_jobs_count: mainJobsCount.value, - }); - toast('Saved'); - }, { class: 'primary' }), - ), + saveButton(() => ({ + story_jobs_hours: storyJobsHours.value, + story_jobs_count: storyJobsCount.value, + main_jobs_hours: mainJobsHours.value, + main_jobs_count: mainJobsCount.value, + })), ), globalRulesCard(), h( @@ -202,14 +191,12 @@ export function settingsPage(main, _params, config) { ), ini, warnings, - h( - 'div', - { class: 'controls' }, - button('Save', async () => { - const saved = await api('settings', 'PUT', { personal_ini: ini.value }); + saveButton( + () => ({ personal_ini: ini.value }), + (saved) => { showWarnings(saved.warnings); toast(saved.warnings.length ? 'Saved, with warnings' : 'Saved'); - }, { class: 'primary' }), + }, ), ), ); diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js index af822de..093a744 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -210,10 +210,6 @@ function storyRow(s) { ); } -function sortTitle(title) { - return title.trim().replace(/^(?:the|an?)\s+/i, ''); -} - export function storiesPage(main, _params, config) { document.title = 'Stories ยท Bindery'; let stories = null; @@ -253,7 +249,7 @@ export function storiesPage(main, _params, config) { : (a[sort] || '').localeCompare(b[sort] || '', undefined, { sensitivity: 'base' }); if (order) return order * direction; } - const order = sortTitle(a.title || a.url).localeCompare(sortTitle(b.title || b.url), undefined, { sensitivity: 'base' }) || a.id - b.id; + const order = a.sort_title.localeCompare(b.sort_title, undefined, { sensitivity: 'base' }) || a.id - b.id; return sort === 'title' ? order * direction : order; }); if (!shown.length) { diff --git a/bindery/static/js/story.js b/bindery/static/js/story.js index cce2b1b..9c8ca00 100644 --- a/bindery/static/js/story.js +++ b/bindery/static/js/story.js @@ -24,7 +24,6 @@ import { import { regexDocs, rulesEditor, rulesPreview } from './rules.js'; import { epubImport } from './stories.js'; -const SITE_MODES = ['new', 'recheck', 'recheck_all', 'full', 'metadata']; const MODE_HINTS = { new: @@ -556,7 +555,7 @@ export function storyPage(main, { storyId }, config) { }; const modeHint = h('p', { class: 'hint' }, MODE_HINTS[mode]); - const modes = SITE_MODES.map((m) => + const modes = Object.keys(MODE_HINTS).map((m) => h( 'label', {}, diff --git a/bindery/store.py b/bindery/store.py index af63993..9653bb9 100644 --- a/bindery/store.py +++ b/bindery/store.py @@ -200,7 +200,9 @@ class Store: (SELECT COUNT(*) FROM chapters c WHERE c.story_id = s.id AND c.included = 1 AND (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1) AS revised_count FROM stories s""").fetchall() - return [_story(r) for r in sorted(rows, key=lambda r: sort_title(r["title"]))] + # The story list of the web interface sorts by the same key. + stories = [dict(_story(r), sort_title=sort_title(r["title"] or r["url"])) for r in rows] + return sorted(stories, key=lambda s: s["sort_title"]) def update_story(self, story_id: int, **fields: Any) -> None: if not fields: diff --git a/tests/js/story-list.test.mjs b/tests/js/story-list.test.mjs index 5572384..0ffe8df 100644 --- a/tests/js/story-list.test.mjs +++ b/tests/js/story-list.test.mjs @@ -16,8 +16,11 @@ const { storiesPage } = await import('../../bindery/static/js/stories.js'); const { refreshJobs, startPolling, stopPolling } = await import('../../bindery/static/js/jobs.js'); const { fmtDate } = await import('../../bindery/static/js/lib.js'); +// The server gives each story its sort key: the title in lower case, without a leading article. const story = (id, title, changes = {}) => ({ - id, title, url: `https://example.com/${id}`, author: 'Reader', site: 'example.com', + id, title, url: `https://example.com/${id}`, + sort_title: (title || `https://example.com/${id}`).trim().replace(/^(?:the|an?)\s+/i, '').toLowerCase(), + author: 'Reader', site: 'example.com', chapter_count: 42, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes', last_changed: null, last_checked: null, ...changes, }); diff --git a/tests/js/upload-hold.test.mjs b/tests/js/upload-hold.test.mjs index 761f0f6..3890903 100644 --- a/tests/js/upload-hold.test.mjs +++ b/tests/js/upload-hold.test.mjs @@ -330,11 +330,11 @@ test('approval before a build still shows the pause and never offers to send', a 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', + { id: 1, url: 'u1', title: 'Held', sort_title: 'held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok', 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', + { id: 2, url: 'u2', title: 'Sent', sort_title: 'sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok', 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', + { id: 3, url: 'u3', title: 'Paused', sort_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', []); diff --git a/tests/test_store.py b/tests/test_store.py index cf76179..03a0c44 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -55,6 +55,8 @@ def test_list_stories_sorts_titles_without_leading_articles(tmp_path): store.create_story(f"https://example/s/{n}", "example", title, "A", []) titles = [s["title"] for s in store.list_stories()] assert titles == ["Anthem", "A Bee", "Cat", "an Owl", "Theory", "The Zebra"] + # The web interface sorts by the same key. + assert [s["sort_title"] for s in store.list_stories()] == ["anthem", "bee", "cat", "owl", "theory", "zebra"] def test_cover_replaces_previous_cover(tmp_path):