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