Name downloaded books with a template

The new "Book Downloads" group in Settings holds a Calibre template for the file name of a
direct download. Without a template, the name stays the title. The name is also the title if
the template fails or gives no name for a book.
This commit is contained in:
tim
2026-10-03 08:09:41 +02:00
parent 9e7a488b50
commit 3e29ab6bf0
16 changed files with 290 additions and 16 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ test('a user who is not an admin gets no settings link and no settings page', as
t.after(stopPolling);
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: true, mode: 'proxy', user: 'bob', admin: false });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false, download_fields: {} });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
await import('../../bindery/static/js/main.js');
+1 -1
View File
@@ -11,7 +11,7 @@ test('login retry, navigation, session expiry and logout preserve the authentica
const backend = server(t);
let auth = { authenticated: false, mode: 'cwa', user: null };
backend.on('GET', 'auth/check', () => auth);
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false, download_fields: {} });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'rules', { rules: [], paragraphs: false });
+21
View File
@@ -16,6 +16,7 @@ const config = {
metadata_fields: { title: 'Title', authors: 'Authors', comments: 'Description', series: 'Series', pubdate: 'Published', tags: 'Tags', languages: 'Language' },
mode_labels: { new: 'New chapters' }, library_available: true, cwa_enabled: true, update_interval: 2,
job_list_max_hours: 720, job_list_max_count: 500,
download_fields: { title: 'The title', id: 'The story ID' },
};
const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: '', enabled: true };
const emptyRules = { rules: [], paragraphs: null, global_paragraphs: true };
@@ -108,6 +109,26 @@ test('the jobs list settings load and save all four fields', async () => {
assert.match(ui.toasts.textContent, /Saved/);
});
test('the download template loads, lists its fields, and saves the stored form', async () => {
api.on('GET', 'settings', { recent_chapters: 0, personal_ini: '', download_template: '{title}' });
settingsPage(main, {}, config);
await settle();
const card = section(main, 'Book Downloads');
assert.equal(field(main, 'download-template').value, '{title}');
assert.ok(find(card, 'code', '{id}'));
assert.match(card.textContent, /\{title\}The title\{id\}The story ID/);
field(main, 'download-template').value = ' {title} - {authors} ';
api.on('PUT', 'settings', { download_template: '{title} - {authors}' });
await click(card, 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { download_template: ' {title} - {authors} ' });
assert.equal(field(main, 'download-template').value, '{title} - {authors}');
api.on('PUT', 'settings', { error: 'Bindery does not support template functions' }, 400);
field(main, 'download-template').value = '{title:uppercase()}';
await click(card, 'Save');
assert.equal(field(main, 'download-template').value, '{title:uppercase()}');
assert.match(ui.toasts.textContent, /does not support template functions/);
});
test('adding stories keeps input on rejection and clears it after a mixed result', async () => {
await mountStories({ ...config, library_available: false });
const urls = nodes(main).find((el) => el.tagName === 'textarea');
+1
View File
@@ -48,6 +48,7 @@ const settle = () => new Promise((resolve) => setImmediate(resolve));
const config = {
metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, cwa_enabled: true, update_interval: 0,
job_list_max_hours: 720, job_list_max_count: 500,
download_fields: { title: 'The title', id: 'The story ID' },
};
const story = {
id: 1, title: 'Story', author: 'Author', url: 'https://example.com/story', site: 'example.com',
+1 -1
View File
@@ -11,7 +11,7 @@ test('local login, retry, session expiry and logout use the existing form', asyn
const backend = server(t);
let auth = { authenticated: false, mode: 'local', user: null };
backend.on('GET', 'auth/check', () => auth);
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false, download_fields: {} });
backend.on('GET', 'stories', []);
backend.on('GET', 'jobs', []);
backend.on('GET', 'expired', { error: 'Session ended' }, 401);
+1 -1
View File
@@ -15,7 +15,7 @@ test('a 401 while logged in through the proxy reloads the page', async (t) => {
});
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: true, mode: 'proxy', user: 'alice' });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false, download_fields: {} });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'expired', { error: 'Session ended' }, 401);
+1 -1
View File
@@ -31,7 +31,7 @@ test('an open story has a tab with its title, also on its chapter pages', async
t.after(stopPolling);
const backend = server(t);
backend.on('GET', 'auth/check', { authenticated: true, mode: 'none', user: '' });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false });
backend.on('GET', 'config', { metadata_fields: {}, mode_labels: {}, library_available: false, download_fields: {} });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
backend.on('GET', 'settings', { recent_chapters: 0, personal_ini: '' });
+1 -1
View File
@@ -7,7 +7,7 @@ const { settingsPage } = await import('../../bindery/static/js/settings.js');
const { storyPage } = await import('../../bindery/static/js/story.js');
const { storiesPage } = await import('../../bindery/static/js/stories.js');
const config = { cwa_enabled: true, job_list_max_hours: 720, job_list_max_count: 500 };
const config = { cwa_enabled: true, job_list_max_hours: 720, job_list_max_count: 500, download_fields: {} };
const book = { id: 7, uuid: 'book-7', title: 'Library Title', authors: ['Writer'] };
const details = (story, extra = {}) => ({
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
+1
View File
@@ -472,6 +472,7 @@ def test_settings(client, monkeypatch, tmp_path):
"pause_uploads": False,
"ingest_available": True,
"personal_ini": "[defaults]\nis_adult:true\n",
"download_template": "",
"story_jobs_hours": 36,
"story_jobs_count": 10,
"main_jobs_hours": 6,
+49
View File
@@ -147,6 +147,55 @@ def test_download_builds_from_stored_chapters_without_changing_them(client, stor
response.close()
def download_name(client, sid):
response = client.get(f"/api/stories/{sid}/epub")
response.close()
return response.headers["Content-Disposition"]
def test_download_template_names_the_file(client, story):
sid, _ = story
site = {"title": "The Book", "authors": ["Ann Writer"], "series": "The Saga", "series_index": 2.0}
o.store.update_story(sid, metahtml=metahtml(), title="The Book", site_values=site)
assert client.get("/api/config").json["download_fields"]["id"].startswith("The CWA book ID")
saved = client.put("/api/settings", json={"download_template": " {series:|| - }{title} - {id} "}, headers=H)
assert saved.json["download_template"] == "{series:|| - }{title} - {id}"
assert f'filename="Saga, The - Book, The - {sid}.epub"' in download_name(client, sid)
# The ID is that of the CWA book, when the story has one.
o.store.update_story(sid, calibre_id=77, pinned={"title": "Caf\u00e9", "series": ["", None]})
name = download_name(client, sid)
assert 'filename="Cafe - 77.epub"' in name and "filename*=UTF-8''Caf%C3%A9%20-%2077.epub" in name
# A template with folders gives the name of the file alone.
client.put("/api/settings", json={"download_template": "{author_sort}/{title} - {authors}"}, headers=H)
assert "filename*=UTF-8''Caf%C3%A9%20-%20Ann%20Writer.epub" in download_name(client, sid)
# Without a template, the name is the title of the story, with its article in front.
assert client.put("/api/settings", json={"download_template": ""}, headers=H).json["download_template"] == ""
assert 'filename="The Book.epub"' in download_name(client, sid)
def test_download_template_that_fails_gives_the_title(client, story, caplog):
sid, _ = story
o.store.update_story(sid, metahtml=metahtml())
for template in ("{title:d}", "{series}/{series_index}"):
client.put("/api/settings", json={"download_template": template}, headers=H)
assert "filename=Story.epub" in download_name(client, sid)
assert "The download template fails for https://www.royalroad.com/fiction/1234" in caplog.text
assert caplog.text.count("The download template fails") == 1
def test_bad_download_template_is_not_saved(client):
client.put("/api/settings", json={"download_template": "{title}"}, headers=H)
for template, error in [
("{title:uppercase()}", "Bindery does not support template functions, such as {title:uppercase()}"),
("{title", "The braces of the template do not match: expected '}' before end of string"),
(7, "The download template must be text"),
]:
response = client.put("/api/settings", json={"download_template": template, "recent_chapters": 9}, headers=H)
assert (response.status_code, response.json) == (400, {"error": error})
assert client.get("/api/settings").json["download_template"] == "{title}"
assert client.get("/api/settings").json["recent_chapters"] == 0
def test_unexpected_backend_error_returns_json(client, monkeypatch, caplog):
monkeypatch.setattr(o, "list_stories", Mock(side_effect=RuntimeError("broken")))
response = client.get("/api/stories")
+9
View File
@@ -137,6 +137,15 @@ def test_cwa_off_shows_in_the_config_and_refuses_cwa_logins(client, monkeypatch,
assert response.json["error"].startswith("AUTH_METHOD=cwa needs CWA, but CWA_ENABLED is false.")
def test_without_cwa_the_download_template_uses_the_story_id(client, cwa_off):
assert client.get("/api/config").json["download_fields"]["id"] == "The Bindery story ID"
story_id = add_story(calibre_id=77)
client.put("/api/settings", json={"download_template": "{title} {id}"}, headers=H)
response = client.get(f"/api/stories/{story_id}/epub")
response.close()
assert f'filename="My Title {story_id}.epub"' in response.headers["Content-Disposition"]
def test_link_setting_defaults_on_and_persists(client, monkeypatch, tmp_path, library):
assert client.get("/api/settings").json["link_books"] is True
response = client.put("/api/settings", json={"link_books": False}, headers=H)