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
+47
View File
@@ -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 |
+51 -6
View File
@@ -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
+79 -1
View File
@@ -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' },
+24
View File
@@ -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;
+2 -3
View File
@@ -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/<int:story_id>/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
+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)