diff --git a/README.md b/README.md index b5b9ddf..9fb2203 100644 --- a/README.md +++ b/README.md @@ -447,10 +447,12 @@ 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`. +"Download ePub" on a story page sends the book to your browser with a file name. A template gives +the name. The default template is `{title} - {authors}`, as in Calibre. For "The Book" by Ann +Writer, it gives `Book, The - Ann Writer.epub`. -An admin can set a template for the name under "Book Downloads" in Settings. The template follows +An admin can change the template under "Book Downloads" in Settings. To go back to the default, +clear the field and save. 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 diff --git a/bindery/filename_template.py b/bindery/filename_template.py index ac871ec..3a0ccc4 100644 --- a/bindery/filename_template.py +++ b/bindery/filename_template.py @@ -15,6 +15,8 @@ from typing import Any from bindery.ingest import sanitize_filename +# Calibre's default template for a file that it sends to a device. +DEFAULT_TEMPLATE = "{title} - {authors}" MAX_TEMPLATE_LENGTH = 1024 # The most characters that a format can ask for, as a width or a precision. MAX_WIDTH = 255 diff --git a/bindery/orchestrator.py b/bindery/orchestrator.py index 4a06716..e0648f7 100644 --- a/bindery/orchestrator.py +++ b/bindery/orchestrator.py @@ -380,7 +380,7 @@ 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 "", + "download_template": _download_template(), **job_list_settings(), } @@ -648,22 +648,24 @@ def download_fields() -> dict[str, str]: return fields +def _download_template() -> str: + return store.get_setting(None, "download_template") or filename_template.DEFAULT_TEMPLATE + + 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") + """The file name of a downloaded book, from the download template. If the template fails or gives no name for + this book, the name is the title.""" + 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"] 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}") + try: + name = filename_template.file_name(_download_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" diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js index 67498bb..b3e6954 100644 --- a/bindery/static/js/settings.js +++ b/bindery/static/js/settings.js @@ -162,7 +162,8 @@ export function settingsPage(main, _params, config) { '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. ' + + 'A field with no value becomes empty text. To go back to the default, {title} - {authors}, clear the field ' + + 'and save. ' + 'For example, ', code('{series:|| - }{series_index:0>2s|| - }{title} - {authors}'), ' gives "Saga, The - 02 - Book, The - Ann Writer.epub".', diff --git a/tests/test_api.py b/tests/test_api.py index bc25e10..b0ffbc0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -472,7 +472,7 @@ def test_settings(client, monkeypatch, tmp_path): "pause_uploads": False, "ingest_available": True, "personal_ini": "[defaults]\nis_adult:true\n", - "download_template": "", + "download_template": "{title} - {authors}", "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 e85129e..6a4f555 100644 --- a/tests/test_api_flows.py +++ b/tests/test_api_flows.py @@ -142,7 +142,7 @@ def test_download_builds_from_stored_chapters_without_changing_them(client, stor response = client.get(f"/api/stories/{sid}/epub") assert response.status_code == 200 assert response.data.startswith(b"PK") - assert "filename=A_B.epub" in response.headers["Content-Disposition"] + assert 'filename="A_B - Writer.epub"' in response.headers["Content-Disposition"] assert o.store.revision_html(cid, 1) == original response.close() @@ -166,11 +166,12 @@ def test_download_template_names_the_file(client, story): 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) + client.put("/api/settings", json={"download_template": "{author_sort}/{title}"}, headers=H) + assert "filename*=UTF-8''Caf%C3%A9.epub" in download_name(client, sid) + # An empty template restores the default. + saved = client.put("/api/settings", json={"download_template": " "}, headers=H).json + assert saved["download_template"] == "{title} - {authors}" 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):