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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)) == "|"
|
||||
|
||||
Reference in New Issue
Block a user