From 3e29ab6bf07a728006d3d60cfea9e20319b4d701 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sat, 3 Oct 2026 00:28:52 +0200 Subject: [PATCH] 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. --- README.md | 47 ++++++++++++++++++++ bindery/orchestrator.py | 57 +++++++++++++++++++++--- bindery/static/js/settings.js | 80 +++++++++++++++++++++++++++++++++- bindery/static/style.css | 24 ++++++++++ bindery/web.py | 5 +-- tests/js/admin.test.mjs | 2 +- tests/js/auth-flows.test.mjs | 2 +- tests/js/flows.test.mjs | 21 +++++++++ tests/js/frontend.test.mjs | 1 + tests/js/local-auth.test.mjs | 2 +- tests/js/proxy-reload.test.mjs | 2 +- tests/js/story-tab.test.mjs | 2 +- tests/js/upload-hold.test.mjs | 2 +- tests/test_api.py | 1 + tests/test_api_flows.py | 49 +++++++++++++++++++++ tests/test_optional_cwa.py | 9 ++++ 16 files changed, 290 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5373744..a0b0f90 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,53 @@ After the import, Bindery checks the site for new chapters and sends the book to "Check the site for new chapters after import", Bindery only rebuilds the book of a story that it downloaded before. +## File names of downloaded books + +"Download ePub" on a story page sends the book to your browser with a file name. Without a template, +the name is the title, for example `The Book.epub`. + +An admin can set a template for the name under "Book Downloads" in Settings. The template follows +the rules of [Calibre's templates](https://manual.calibre-ebook.com/template_lang.html) for file +names, so a template from Calibre gives the same name in Bindery. Bindery adds `.epub`. For example, +`{series:|| - }{series_index:0>2s|| - }{title} - {authors}` gives +`Saga, The - 02 - Book, The - Ann Writer.epub`. + +| Field | Value | +| --- | --- | +| `{title}` | The title. A leading "The", "A" or "An" moves to the end, as in Calibre: `Book, The`. | +| `{title_sort}` | The same value as `{title}`. | +| `{authors}`, `{author}` | The authors, with ` & ` between them. | +| `{author_sort}` | The authors with the last name first: `Writer, Ann`. | +| `{series}` | The series. A leading article moves to the end. | +| `{series_index}` | The series number: `2`, or `2.5`. | +| `{tags}` | The tags in sorted order, with `, ` between them. | +| `{languages}` | The language, as Bindery stores it. | +| `{publisher}` | The story site. | +| `{pubdate}` | The published date, in the form `May, 2020`. | +| `{timestamp}` | The date when Bindery added the story. | +| `{last_modified}` | The date when the chapters last changed. | +| `{id}` | The CWA book ID. For a story with no library book, and without CWA, the Bindery story ID. | + +- A field with no value becomes empty text. So does a field that Bindery does not have, such as + `{rating}` or a custom column. +- `{field:format|text before|text after}` adds the two texts only when the field has a value. Each + of the three parts can be empty. `{series:|| - }` adds ` - ` after the series. +- The format is a Python format. `{series_index:0>3s}` gives `002`, and `{title:.20s}` gives the + first 20 characters. `{author_sort[0]}` gives the first character. +- `{{` and `}}` give braces. Bindery makes each run of spaces one space. +- A download has one file name. If the template has folders, Bindery uses the part after the last + `/`. That is the name that Calibre gives the file. +- Bindery does not support template functions, such as `{title:uppercase()}`, or the program modes + of Calibre. It refuses such a template when you save it. +- If the template fails for a book, or gives an empty name, Bindery names the file after the title. + The log has a warning. +- Bindery replaces each character that a file name cannot have with `_`. A name has 100 characters + and 200 bytes at most. + +Bindery follows Calibre 9.2.1 with its default tweaks, for English. The order of tags with letters +outside ASCII can differ from Calibre. The template applies only to downloads in the browser. It +does not change the names of the files in `INGEST_DIR`. The browser can ignore the name. + ## Download modes | Mode | What it does | diff --git a/bindery/orchestrator.py b/bindery/orchestrator.py index 30da9a3..4a06716 100644 --- a/bindery/orchestrator.py +++ b/bindery/orchestrator.py @@ -11,13 +11,13 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any, BinaryIO -from bindery import config, fff +from bindery import config, fff, filename_template from bindery.backup import daily_backup from bindery.cwa import Book, BookFinder, CwaClient, CwaError from bindery.diffing import diff_html, text_hash from bindery.epub import EpubError, epub_metadata, epub_urls from bindery.fff import StoryError -from bindery.ingest import IngestError, IngestFolder +from bindery.ingest import IngestError, IngestFolder, sanitize_filename from bindery.jobs import METADATA_FIELDS, DownloadMode, Job, QueueStatus, job_queue from bindery.merge import merge_chapter_lists from bindery.rules import RuleError, Step, apply_rules, compile_rules, normalize_rules @@ -380,12 +380,21 @@ def get_settings() -> dict[str, Any]: "linking_available": _linking_available(), "hold_new_links": bool(store.get_setting(None, "hold_new_links")), "personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "", + "download_template": store.get_setting(None, "download_template") or "", **job_list_settings(), } # The settings that save_settings takes. -SETTINGS = ("recent_chapters", "personal_ini", "link_books", "hold_new_links", "pause_uploads", *JOB_LIST_DEFAULTS) +SETTINGS = ( + "recent_chapters", + "personal_ini", + "link_books", + "hold_new_links", + "pause_uploads", + "download_template", + *JOB_LIST_DEFAULTS, +) def save_settings(**changes: Any) -> dict[str, Any]: @@ -408,6 +417,12 @@ def save_settings(**changes: Any) -> dict[str, Any]: if name in changes: most = config.JOB_LIST_MAX_HOURS if name.endswith("hours") else config.JOB_LIST_MAX_COUNT numbers[name] = whole_number(changes[name], name, most) + template = changes.get("download_template") + if template is not None: + if not isinstance(template, str): + raise ValueError("The download template must be text") + template = template.strip() + filename_template.check(template) warnings = fff.check_personal_ini(personal_ini) if personal_ini is not None else [] released = False @@ -422,6 +437,8 @@ def save_settings(**changes: Any) -> dict[str, Any]: store.set_setting(None, name, True if value else None) for name, number in numbers.items(): store.set_setting(None, name, number) + if template is not None: + store.set_setting(None, "download_template", template or None) if personal_ini is not None: # Write a new file, then replace the old one, so a job never reads half a file. partial = config.PERSONAL_INI.with_name("personal.ini.part") @@ -623,15 +640,43 @@ def library_candidates() -> list[dict[str, Any]]: return [b.to_dict() for b in cwa().site_books(fff.is_supported_site) if b.id not in tracked_ids] +def download_fields() -> dict[str, str]: + """The fields of the download template, with the help text for each.""" + fields = dict(filename_template.FIELDS) + if config.CWA_ENABLED: + fields["id"] = "The CWA book ID. For a story with no library book, the Bindery story ID" + return fields + + +def _download_name(story: dict[str, Any]) -> str: + """The file name of a downloaded book. It comes from the download template. Without a template, or if the + template fails or gives no name for this book, it is the title.""" + template = store.get_setting(None, "download_template") + name = "" + if template: + meta = _book_metadata(story["site_values"], _epub_overrides(story["pinned"])) + meta["title"] = meta.get("title") or story["title"] + meta["authors"] = meta.get("authors") or [story["author"]] + meta.update(publisher=story["site"], timestamp=story["created_at"], last_modified=story["last_changed"]) + # Without CWA, the saved CWA book IDs do not apply. + book_id = story["calibre_id"] if config.CWA_ENABLED and story["calibre_id"] is not None else story["id"] + try: + name = filename_template.file_name(template, filename_template.prepare(meta, book_id)) + except filename_template.TemplateError as e: + logger.warning(f"The download template fails for {story['url']}: {e}") + return f"{name or sanitize_filename(story['title'])}.epub" + + def build_for_download(story_id: int) -> tuple[BinaryIO, str]: - """The epub of a story for a browser download, as an open file. Bindery builds it only if the book changed - after the last build.""" + """The epub of a story for a browser download, as an open file with its file name. Bindery builds it only if + the book changed after the last build.""" story = _require_story(story_id) overrides = _epub_overrides(story["pinned"]) key = _build_key(story, overrides, story["site_values"]) + name = _download_name(story) # The file is opened under the lock, so a new build cannot delete it first. with _build_lock: - return open(_built_epub(story, overrides, key, record=False), "rb"), story["title"] + return open(_built_epub(story, overrides, key, record=False), "rb"), name # Metadata locks diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js index 9abf059..67498bb 100644 --- a/bindery/static/js/settings.js +++ b/bindery/static/js/settings.js @@ -1,10 +1,13 @@ -// Settings: the recheck of recent chapters, the uploads to CWA, the global rules, and FanFicFare's personal.ini. +// Settings: the recheck of recent chapters, the uploads to CWA, the names of downloaded books, the global rules, +// and FanFicFare's personal.ini. import { api, button, checkbox, externalLink, fill, h, showError, toast } from './lib.js'; import { globalRulesCard } from './rules.js'; const INI_DOCS = 'https://github.com/JimmXinu/FanFicFare/wiki/INI-File'; const DEFAULTS_INI = 'https://github.com/JimmXinu/FanFicFare/blob/main/fanficfare/defaults.ini'; +const TEMPLATE_DOCS = 'https://manual.calibre-ebook.com/template_lang.html'; +const code = (text) => h('code', {}, text); export function settingsPage(main, _params, config) { document.title = 'Settings ยท Bindery'; @@ -35,6 +38,14 @@ export function settingsPage(main, _params, config) { pauseUploads.setAttribute('aria-describedby', pauseUploads.disabled ? 'ingest-note' : ''); ingestNote.hidden = !pauseUploads.disabled; }; + const template = h('input', { + id: 'download-template', + class: 'template', + type: 'text', + maxlength: 1024, + spellcheck: 'false', + autocomplete: 'off', + }); const hoursField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_hours, step: 1 }); const countField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_count, step: 1 }); const storyJobsHours = hoursField('story-jobs-hours'); @@ -73,6 +84,7 @@ export function settingsPage(main, _params, config) { recent.value = settings.recent_chapters; showUploadSettings(settings); ini.value = settings.personal_ini; + template.value = settings.download_template; storyJobsHours.value = settings.story_jobs_hours; storyJobsCount.value = settings.story_jobs_count; mainJobsHours.value = settings.main_jobs_hours; @@ -139,6 +151,72 @@ export function settingsPage(main, _params, config) { }, ), ), + h( + 'section', + { class: 'card' }, + h('h2', {}, 'Book Downloads'), + 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. It follows the ', + externalLink(TEMPLATE_DOCS, 'template rules of Calibre'), + ' for file names. Bindery replaces each field in braces with the value of the book, and adds .epub. ' + + 'A field with no value becomes empty text. Leave the template empty to name the file after the title. ' + + 'For example, ', + code('{series:|| - }{series_index:0>2s|| - }{title} - {authors}'), + ' gives "Saga, The - 02 - Book, The - Ann Writer.epub".', + ), + h('p', { class: 'hint' }, 'You can use these fields:'), + h( + 'dl', + { class: 'fields' }, + Object.entries(config.download_fields).map(([name, text]) => [h('dt', {}, code(`{${name}}`)), h('dd', {}, text)]), + ), + h( + 'ul', + { class: 'hint' }, + h('li', {}, code('{title:.20s}'), ' gives the first 20 characters of the title.'), + h('li', {}, code('{author_sort[0]}'), ' gives the first character of the author sort string.'), + h( + 'li', + {}, + code('{series:|| - }'), + ' adds " - " after the series, but only for a book with a series. The form is ', + code('{field:format|text before|text after}'), + '.', + ), + h('li', {}, code('{{'), ' and ', code('}}'), ' give braces.'), + h( + 'li', + {}, + 'The title and the series move a leading "The", "A" or "An" to the end, as Calibre does. ' + + '"The Book" becomes "Book, The".', + ), + h( + 'li', + {}, + 'A download has one file name. If the template has folders, Bindery uses the part after the last ', + code('/'), + '.', + ), + h( + 'li', + {}, + 'Bindery does not support template functions, such as ', + code('{title:uppercase()}'), + ', or the program modes of Calibre.', + ), + h('li', {}, 'If the template fails or gives no name for a book, Bindery names the file after the title.'), + ), + saveButton( + () => ({ download_template: template.value }), + (settings) => { + template.value = settings.download_template; + toast('Saved'); + }, + ), + ), h( 'section', { class: 'card' }, diff --git a/bindery/static/style.css b/bindery/static/style.css index 846a4cc..65215ef 100644 --- a/bindery/static/style.css +++ b/bindery/static/style.css @@ -451,6 +451,25 @@ label:has(> input[type='checkbox']:disabled) { margin: 0; } +ul.hint { + padding-left: 20px; +} + +/* The fields of the download template: each name with its meaning beside it. */ +.fields { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 12px; + max-width: 80ch; + margin: 4px 0 8px; + color: var(--muted); + font-size: var(--text-sm); + + dd { + margin: 0; + } +} + .notice { margin: 12px 0; padding: 10px 14px; @@ -699,6 +718,11 @@ textarea { line-height: 1.45; } +.template { + flex: 1 1 24em; + max-width: 48em; +} + .metadata input:not([type='checkbox']) { width: 100%; max-width: 32em; diff --git a/bindery/web.py b/bindery/web.py index f7ce899..ab99fe2 100644 --- a/bindery/web.py +++ b/bindery/web.py @@ -20,7 +20,6 @@ from bindery import auth, config, orchestrator from bindery.cwa import CwaError from bindery.epub import EpubError from bindery.fff import StoryError -from bindery.ingest import sanitize_filename from bindery.jobs import METADATA_FIELDS, MODE_LABELS, DownloadMode from bindery.rules import RuleError @@ -269,6 +268,7 @@ def app_config() -> Any: "update_interval": config.UPDATE_INTERVAL_HOURS, "job_list_max_hours": config.JOB_LIST_MAX_HOURS, "job_list_max_count": config.JOB_LIST_MAX_COUNT, + "download_fields": orchestrator.download_fields(), **orchestrator.job_list_settings(), } @@ -399,8 +399,7 @@ def delete_chapters(story_id: int) -> Any: @app.get("/api/stories//epub") def story_epub(story_id: int) -> Any: - file, title = orchestrator.build_for_download(story_id) - name = f"{sanitize_filename(title)}.epub" + file, name = orchestrator.build_for_download(story_id) response = send_file(file, download_name=name, as_attachment=True, mimetype="application/epub+zip") response.content_length = os.fstat(file.fileno()).st_size return response diff --git a/tests/js/admin.test.mjs b/tests/js/admin.test.mjs index d801182..c71922a 100644 --- a/tests/js/admin.test.mjs +++ b/tests/js/admin.test.mjs @@ -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'); diff --git a/tests/js/auth-flows.test.mjs b/tests/js/auth-flows.test.mjs index 12313e7..5faed81 100644 --- a/tests/js/auth-flows.test.mjs +++ b/tests/js/auth-flows.test.mjs @@ -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 }); diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index adef628..5db7011 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -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'); diff --git a/tests/js/frontend.test.mjs b/tests/js/frontend.test.mjs index c6376ab..2687965 100644 --- a/tests/js/frontend.test.mjs +++ b/tests/js/frontend.test.mjs @@ -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', diff --git a/tests/js/local-auth.test.mjs b/tests/js/local-auth.test.mjs index f041f82..17b24f5 100644 --- a/tests/js/local-auth.test.mjs +++ b/tests/js/local-auth.test.mjs @@ -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); diff --git a/tests/js/proxy-reload.test.mjs b/tests/js/proxy-reload.test.mjs index e0df5a1..8006761 100644 --- a/tests/js/proxy-reload.test.mjs +++ b/tests/js/proxy-reload.test.mjs @@ -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); diff --git a/tests/js/story-tab.test.mjs b/tests/js/story-tab.test.mjs index eeeaeff..0bd1308 100644 --- a/tests/js/story-tab.test.mjs +++ b/tests/js/story-tab.test.mjs @@ -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: '' }); diff --git a/tests/js/upload-hold.test.mjs b/tests/js/upload-hold.test.mjs index 3890903..0cec324 100644 --- a/tests/js/upload-hold.test.mjs +++ b/tests/js/upload-hold.test.mjs @@ -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', diff --git a/tests/test_api.py b/tests/test_api.py index 01d470c..bc25e10 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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, diff --git a/tests/test_api_flows.py b/tests/test_api_flows.py index be8fea1..e85129e 100644 --- a/tests/test_api_flows.py +++ b/tests/test_api_flows.py @@ -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") diff --git a/tests/test_optional_cwa.py b/tests/test_optional_cwa.py index a70412f..f5cfffe 100644 --- a/tests/test_optional_cwa.py +++ b/tests/test_optional_cwa.py @@ -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)