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',
});
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' }),
},
),
),
);
+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) {
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) {
+1 -2
View File
@@ -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',
{},
+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
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:
+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 { 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,
});
+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) => {
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', []);
+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", [])
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):