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.
This commit is contained in:
tim
2026-10-03 08:09:41 +02:00
parent 2ce3de6b58
commit 6a1c92bc62
7 changed files with 37 additions and 48 deletions
+23 -36
View File
@@ -48,6 +48,13 @@ export function settingsPage(main, _params, config) {
'aria-label': 'personal.ini', 'aria-label': 'personal.ini',
}); });
const warnings = h('div'); 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) => const showWarnings = (list) =>
fill( 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. ' + '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.', 'Enter 0 to turn this off. A story with its own interval for full rechecks does not use this setting.',
), ),
h( saveButton(() => ({ recent_chapters: recent.value === '' ? 0 : Number(recent.value) })),
'div',
{ class: 'controls' },
button('Save', async () => {
await api('settings', 'PUT', { recent_chapters: recent.value === '' ? 0 : Number(recent.value) });
toast('Saved');
}, { class: 'primary' }),
),
), ),
config.cwa_enabled && h( config.cwa_enabled && h(
'section', 'section',
@@ -131,16 +131,12 @@ export function settingsPage(main, _params, config) {
'Bindery downloads chapters and builds books, but sends no book to CWA. ' + 'Bindery downloads chapters and builds books, but sends no book to CWA. ' +
'When you turn the pause off, Bindery sends the books that wait.', 'When you turn the pause off, Bindery sends the books that wait.',
), ),
h( saveButton(
'div', () => ({ link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked }),
{ class: 'controls' }, (settings) => {
button('Save', async () => { showUploadSettings(settings);
const saved = await api('settings', 'PUT', {
link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked,
});
showUploadSettings(saved);
toast('Saved'); toast('Saved');
}, { class: 'primary' }), },
), ),
), ),
h( 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 ` + `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.`, `and ${config.job_list_max_count} jobs.`,
), ),
h( saveButton(() => ({
'div', story_jobs_hours: storyJobsHours.value,
{ class: 'controls' }, story_jobs_count: storyJobsCount.value,
button('Save', async () => { main_jobs_hours: mainJobsHours.value,
await api('settings', 'PUT', { main_jobs_count: mainJobsCount.value,
story_jobs_hours: storyJobsHours.value, })),
story_jobs_count: storyJobsCount.value,
main_jobs_hours: mainJobsHours.value,
main_jobs_count: mainJobsCount.value,
});
toast('Saved');
}, { class: 'primary' }),
),
), ),
globalRulesCard(), globalRulesCard(),
h( h(
@@ -202,14 +191,12 @@ export function settingsPage(main, _params, config) {
), ),
ini, ini,
warnings, warnings,
h( saveButton(
'div', () => ({ personal_ini: ini.value }),
{ class: 'controls' }, (saved) => {
button('Save', async () => {
const saved = await api('settings', 'PUT', { personal_ini: ini.value });
showWarnings(saved.warnings); showWarnings(saved.warnings);
toast(saved.warnings.length ? 'Saved, with warnings' : 'Saved'); toast(saved.warnings.length ? 'Saved, with warnings' : 'Saved');
}, { class: 'primary' }), },
), ),
), ),
); );
+1 -5
View File
@@ -210,10 +210,6 @@ function storyRow(s) {
); );
} }
function sortTitle(title) {
return title.trim().replace(/^(?:the|an?)\s+/i, '');
}
export function storiesPage(main, _params, config) { export function storiesPage(main, _params, config) {
document.title = 'Stories · Bindery'; document.title = 'Stories · Bindery';
let stories = null; let stories = null;
@@ -253,7 +249,7 @@ export function storiesPage(main, _params, config) {
: (a[sort] || '').localeCompare(b[sort] || '', undefined, { sensitivity: 'base' }); : (a[sort] || '').localeCompare(b[sort] || '', undefined, { sensitivity: 'base' });
if (order) return order * direction; 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; return sort === 'title' ? order * direction : order;
}); });
if (!shown.length) { if (!shown.length) {
+1 -2
View File
@@ -24,7 +24,6 @@ import {
import { regexDocs, rulesEditor, rulesPreview } from './rules.js'; import { regexDocs, rulesEditor, rulesPreview } from './rules.js';
import { epubImport } from './stories.js'; import { epubImport } from './stories.js';
const SITE_MODES = ['new', 'recheck', 'recheck_all', 'full', 'metadata'];
const MODE_HINTS = { const MODE_HINTS = {
new: new:
@@ -556,7 +555,7 @@ export function storyPage(main, { storyId }, config) {
}; };
const modeHint = h('p', { class: 'hint' }, MODE_HINTS[mode]); const modeHint = h('p', { class: 'hint' }, MODE_HINTS[mode]);
const modes = SITE_MODES.map((m) => const modes = Object.keys(MODE_HINTS).map((m) =>
h( h(
'label', 'label',
{}, {},
+3 -1
View File
@@ -200,7 +200,9 @@ class Store:
(SELECT COUNT(*) FROM chapters c WHERE c.story_id = s.id AND c.included = 1 (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 AND (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1) AS revised_count
FROM stories s""").fetchall() 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: def update_story(self, story_id: int, **fields: Any) -> None:
if not fields: if not fields:
+4 -1
View File
@@ -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 { refreshJobs, startPolling, stopPolling } = await import('../../bindery/static/js/jobs.js');
const { fmtDate } = await import('../../bindery/static/js/lib.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 = {}) => ({ 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', chapter_count: 42, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes',
last_changed: null, last_checked: null, ...changes, last_changed: null, last_checked: null, ...changes,
}); });
+3 -3
View File
@@ -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) => { test('the story list marks only an approval hold, not every unsent book', async (t) => {
const backend = server(t); const backend = server(t);
backend.on('GET', 'stories', [ 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 }, 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 }, 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 }, message: '', upload_held: true, approval_held: false },
]); ]);
backend.on('GET', 'jobs', []); backend.on('GET', 'jobs', []);
+2
View File
@@ -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", []) store.create_story(f"https://example/s/{n}", "example", title, "A", [])
titles = [s["title"] for s in store.list_stories()] titles = [s["title"] for s in store.list_stories()]
assert titles == ["Anthem", "A Bee", "Cat", "an Owl", "Theory", "The Zebra"] 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): def test_cover_replaces_previous_cover(tmp_path):