Add a date format for download names

The new setting gives the strftime format of {pubdate}, {timestamp} and {last_modified}.
The default is Calibre's "%b, %Y".
This commit is contained in:
tim
2026-10-03 08:09:41 +02:00
parent 2c8a1bb187
commit e38c8d655a
8 changed files with 114 additions and 26 deletions
+7 -1
View File
@@ -469,7 +469,7 @@ names, so a template from Calibre gives the same name in Bindery. Bindery adds `
| `{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`. |
| `{pubdate}` | The published date. |
| `{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. |
@@ -489,6 +489,12 @@ names, so a template from Calibre gives the same name in Bindery. Bindery adds `
- Bindery replaces each character that a file name cannot have with `_`. A name has 100 characters
and 200 bytes at most.
"Date format" sets the form of `{pubdate}`, `{timestamp}` and `{last_modified}`. It takes the
[codes of Python's strftime](https://docs.python.org/3/library/datetime.html#format-codes). `%d`
gives the day, `%b` the month name, `%m` the month number and `%Y` the year. The default is
`%b, %Y`, as in Calibre. It gives `May, 2020`. To go back to the default, clear the field and save.
A `/` in a date becomes `_`, as in Calibre.
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.
+22 -9
View File
@@ -1,8 +1,9 @@
"""Calibre's templates for file names, for the name of a downloaded book.
Bindery follows Calibre 9.2.1 with its default tweaks: titles and series in library order with the English
articles, and dates in the form "%b, %Y". It takes {field}, {field[0]}, {field:format|prefix|suffix} and {{ }}.
It refuses template functions and the program modes. It never runs template text as code.
articles. Dates take a strftime format, with Calibre's "%b, %Y" as the default. Bindery takes {field},
{field[0]}, {field:format|prefix|suffix} and {{ }}. It refuses template functions and the program modes. It never
runs template text as code.
The tags sort close to Calibre's ICU rules, but their order can differ for characters outside ASCII.
"""
@@ -17,7 +18,9 @@ from bindery.ingest import sanitize_filename
# Calibre's default template for a file that it sends to a device.
DEFAULT_TEMPLATE = "{title} - {authors}"
DEFAULT_DATE_FORMAT = "%b, %Y"
MAX_TEMPLATE_LENGTH = 1024
MAX_DATE_FORMAT_LENGTH = 100
# The most characters that a format can ask for, as a width or a precision.
MAX_WIDTH = 255
MAX_NAME_BYTES = 200
@@ -70,7 +73,6 @@ _QUOTE_PAIRS = {
}
# The ASCII punctuation in the order of Calibre's sort. Digits come next, then letters.
_PUNCTUATION = " _-,;:!?.'\"()[]{}@*/\\&#%`^+<=>|~$"
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
# Calibre's default tweaks for author names.
_NAME_PREFIXES = {"mr", "mrs", "ms", "dr", "prof"}
_NAME_SUFFIXES = {"jr", "sr", "inc", "ph.d", "phd", "md", "m.d", "i", "ii", "iii", "iv", "junior", "senior"}
@@ -158,12 +160,21 @@ def series_index(index: Any) -> str:
return f"{index:.2f}".rstrip("0")
def _date(value: Any) -> str:
def check_date_format(date_format: str) -> None:
"""Raise TemplateError if Bindery cannot use the date format."""
if len(date_format) > MAX_DATE_FORMAT_LENGTH:
raise TemplateError(f"A date format can have {MAX_DATE_FORMAT_LENGTH} characters at most")
try:
date = datetime.fromisoformat(value)
datetime(2020, 5, 6).strftime(date_format)
except ValueError as e:
raise TemplateError(f"The date format is not valid: {e}") from e
def _date(value: Any, date_format: str) -> str:
try:
return datetime.fromisoformat(value).strftime(date_format)
except (TypeError, ValueError):
return ""
return f"{_MONTHS[date.month - 1]}, {date.year:04}"
def _tag_key(tag: str) -> tuple[list[tuple[int, str]], str]:
@@ -178,11 +189,13 @@ def _tag_key(tag: str) -> tuple[list[tuple[int, str]], str]:
return letters, text
def prepare(meta: dict[str, Any], book_id: int, order: str = "library_order") -> dict[str, Any]:
def prepare(
meta: dict[str, Any], book_id: int, order: str = "library_order", date_format: str = DEFAULT_DATE_FORMAT
) -> dict[str, Any]:
"""Calibre's get_component_metadata(): the values of a book in the form that a file name takes.
`order` is Calibre's tweak save_template_title_series_sorting. With "strictly_alphabetic", the title and the
series keep a leading article.
series keep a leading article. `date_format` is Calibre's timefmt.
"""
library = order == "library_order"
values: dict[str, Any] = {"id": str(book_id), "publisher": meta.get("publisher") or ""}
@@ -200,7 +213,7 @@ def prepare(meta: dict[str, Any], book_id: int, order: str = "library_order") ->
values["tags"] = ", ".join(sorted(meta.get("tags") or [], key=_tag_key)).removeprefix("/")
values["languages"] = meta.get("languages") or ""
for name in ("pubdate", "timestamp", "last_modified"):
values[name] = _date(meta.get(name))
values[name] = _date(meta.get(name), date_format)
return values
+25 -12
View File
@@ -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": _download_template(),
**{name: _download_setting(name) for name in DOWNLOAD_DEFAULTS},
**job_list_settings(),
}
@@ -393,6 +393,7 @@ SETTINGS = (
"hold_new_links",
"pause_uploads",
"download_template",
"download_date_format",
*JOB_LIST_DEFAULTS,
)
@@ -417,12 +418,16 @@ 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)
texts: dict[str, str] = {}
for name, label, check in (
("download_template", "The download template", filename_template.check),
("download_date_format", "The date format", filename_template.check_date_format),
):
if name in changes:
if not isinstance(changes[name], str):
raise ValueError(f"{label} must be text")
texts[name] = changes[name].strip()
check(texts[name])
warnings = fff.check_personal_ini(personal_ini) if personal_ini is not None else []
released = False
@@ -437,8 +442,9 @@ 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)
# An empty value restores the default.
for name, text in texts.items():
store.set_setting(None, name, text 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")
@@ -648,8 +654,14 @@ def download_fields() -> dict[str, str]:
return fields
def _download_template() -> str:
return store.get_setting(None, "download_template") or filename_template.DEFAULT_TEMPLATE
DOWNLOAD_DEFAULTS = {
"download_template": filename_template.DEFAULT_TEMPLATE,
"download_date_format": filename_template.DEFAULT_DATE_FORMAT,
}
def _download_setting(name: str) -> str:
return store.get_setting(None, name) or DOWNLOAD_DEFAULTS[name]
def _download_name(story: dict[str, Any]) -> str:
@@ -663,7 +675,8 @@ def _download_name(story: dict[str, Any]) -> str:
book_id = story["calibre_id"] if config.CWA_ENABLED and story["calibre_id"] is not None else story["id"]
name = ""
try:
name = filename_template.file_name(_download_template(), filename_template.prepare(meta, book_id))
values = filename_template.prepare(meta, book_id, date_format=_download_setting("download_date_format"))
name = filename_template.file_name(_download_setting("download_template"), values)
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"
+21 -1
View File
@@ -7,6 +7,7 @@ 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 DATE_CODES = 'https://docs.python.org/3/library/datetime.html#format-codes';
const code = (text) => h('code', {}, text);
export function settingsPage(main, _params, config) {
@@ -46,6 +47,14 @@ export function settingsPage(main, _params, config) {
spellcheck: 'false',
autocomplete: 'off',
});
const dateFormat = h('input', {
id: 'download-date-format',
class: 'template',
type: 'text',
maxlength: 100,
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');
@@ -85,6 +94,7 @@ export function settingsPage(main, _params, config) {
showUploadSettings(settings);
ini.value = settings.personal_ini;
template.value = settings.download_template;
dateFormat.value = settings.download_date_format;
storyJobsHours.value = settings.story_jobs_hours;
storyJobsCount.value = settings.story_jobs_count;
mainJobsHours.value = settings.main_jobs_hours;
@@ -168,6 +178,15 @@ export function settingsPage(main, _params, config) {
code('{series:|| - }{series_index:0>2s|| - }{title} - {authors}'),
' gives "Saga, The - 02 - Book, The - Ann Writer.epub".',
),
h('div', { class: 'controls' }, h('label', { for: 'download-date-format' }, 'Date format'), dateFormat),
h(
'p',
{ class: 'hint' },
'The form of the dates in {pubdate}, {timestamp} and {last_modified}. %d gives the day, %b the month name, ' +
'%m the month number and %Y the year. See the ',
externalLink(DATE_CODES, 'list of all codes'),
'. To go back to the default, %b, %Y, clear the field and save. The default gives "May, 2020".',
),
h('p', { class: 'hint' }, 'You can use these fields:'),
h(
'dl',
@@ -211,9 +230,10 @@ export function settingsPage(main, _params, config) {
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 }),
() => ({ download_template: template.value, download_date_format: dateFormat.value }),
(settings) => {
template.value = settings.download_template;
dateFormat.value = settings.download_date_format;
toast('Saved');
},
),
+10 -3
View File
@@ -110,18 +110,25 @@ test('the jobs list settings load and save all four fields', async () => {
});
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}' });
api.on('GET', 'settings', {
recent_chapters: 0, personal_ini: '', download_template: '{title}', download_date_format: '%b, %Y',
});
settingsPage(main, {}, config);
await settle();
const card = section(main, 'Book Downloads');
assert.equal(field(main, 'download-template').value, '{title}');
assert.equal(field(main, 'download-date-format').value, '%b, %Y');
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}' });
field(main, 'download-date-format').value = '';
api.on('PUT', 'settings', { download_template: '{title} - {authors}', download_date_format: '%b, %Y' });
await click(card, 'Save');
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { download_template: ' {title} - {authors} ' });
assert.deepEqual(api.sent('PUT', 'settings')[0].body, {
download_template: ' {title} - {authors} ', download_date_format: '',
});
assert.equal(field(main, 'download-template').value, '{title} - {authors}');
assert.equal(field(main, 'download-date-format').value, '%b, %Y');
api.on('PUT', 'settings', { error: 'Bindery does not support template functions' }, 400);
field(main, 'download-template').value = '{title:uppercase()}';
await click(card, 'Save');
+1
View File
@@ -473,6 +473,7 @@ def test_settings(client, monkeypatch, tmp_path):
"ingest_available": True,
"personal_ini": "[defaults]\nis_adult:true\n",
"download_template": "{title} - {authors}",
"download_date_format": "%b, %Y",
"story_jobs_hours": 36,
"story_jobs_count": 10,
"main_jobs_hours": 6,
+18
View File
@@ -174,6 +174,24 @@ def test_download_template_names_the_file(client, story):
assert "filename*=UTF-8''Caf%C3%A9%20-%20Ann%20Writer.epub" in download_name(client, sid)
def test_date_format_of_download_names(client, story):
sid, _ = story
o.store.update_story(sid, metahtml=metahtml(), site_values={"pubdate": "2020-05-06T00:00:00"})
client.put("/api/settings", json={"download_template": "{title} {pubdate}"}, headers=H)
assert 'filename="Story May, 2020.epub"' in download_name(client, sid)
saved = client.put("/api/settings", json={"download_date_format": " %Y-%m-%d "}, headers=H).json
assert saved["download_date_format"] == "%Y-%m-%d"
assert "filename=Story 2020-05-06.epub" in download_name(client, sid).replace('"', "")
response = client.put("/api/settings", json={"download_date_format": 5}, headers=H)
assert (response.status_code, response.json) == (400, {"error": "The date format must be text"})
response = client.put("/api/settings", json={"download_date_format": "%Y" * 51}, headers=H)
assert (response.status_code, response.json) == (400, {"error": "A date format can have 100 characters at most"})
# An empty format restores the default.
assert client.put("/api/settings", json={"download_date_format": ""}, headers=H).json["download_date_format"] == (
"%b, %Y"
)
def test_download_template_that_fails_gives_the_title(client, story, caplog):
sid, _ = story
o.store.update_story(sid, metahtml=metahtml())
+10
View File
@@ -91,6 +91,16 @@ def test_values_follow_the_rules_of_calibre():
"Dec, 1999 Oct, 2026"
)
assert expand("{pubdate}", pubdate="not a date") == ""
def test_date_format():
# Calibre's helper_date reference case.
assert ft.expand("{pubdate}", ft.prepare(BOOK, 42, date_format="%b %Y")) == "May 2020"
assert ft.expand("{pubdate}", ft.prepare(BOOK, 42, date_format="%d/%m/%Y")) == "06_05_2020"
assert ft.expand("x{pubdate}x", ft.prepare(BOOK, 42, date_format="")) == "xx"
ft.check_date_format("%Y-%m-%d " * 10)
with pytest.raises(ft.TemplateError, match="A date format can have 100 characters at most"):
ft.check_date_format("x" * 101)
# A series without a number has no series_index, and a book without a series has neither.
assert expand("{series}{series_index:| [|]}", series_index=None) == "Saga, The"
assert ft.expand("{series_index:03d}|{series}", ft.prepare(NO_SERIES, 42)) == "|"