diff --git a/README.md b/README.md index 9fb2203..0898dd1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bindery/filename_template.py b/bindery/filename_template.py index 3a0ccc4..9a8797e 100644 --- a/bindery/filename_template.py +++ b/bindery/filename_template.py @@ -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 diff --git a/bindery/orchestrator.py b/bindery/orchestrator.py index e0648f7..594f7fe 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": _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" diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js index b3e6954..afcc51b 100644 --- a/bindery/static/js/settings.js +++ b/bindery/static/js/settings.js @@ -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'); }, ), diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index 5db7011..91124f2 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -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'); diff --git a/tests/test_api.py b/tests/test_api.py index b0ffbc0..e591f36 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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, diff --git a/tests/test_api_flows.py b/tests/test_api_flows.py index 6a4f555..9a9fed5 100644 --- a/tests/test_api_flows.py +++ b/tests/test_api_flows.py @@ -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()) diff --git a/tests/test_filename_template.py b/tests/test_filename_template.py index 896185e..40f99bf 100644 --- a/tests/test_filename_template.py +++ b/tests/test_filename_template.py @@ -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)) == "|"