Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece59c2558 | ||
|
|
dcdd8e4ac7 | ||
|
|
42e7287389 | ||
|
|
e38c8d655a | ||
|
|
2c8a1bb187 | ||
|
|
323bec3c04 | ||
|
|
3e29ab6bf0 | ||
|
|
9e7a488b50 |
@@ -28,6 +28,7 @@ The server has no reloader. If you change Python code, restart it. Flask reads `
|
|||||||
- `bindery/fff.py` is the only module that calls FanFicFare.
|
- `bindery/fff.py` is the only module that calls FanFicFare.
|
||||||
- `bindery/cwa.py` talks to CWA through OPDS. Bindery never opens the CWA database.
|
- `bindery/cwa.py` talks to CWA through OPDS. Bindery never opens the CWA database.
|
||||||
- `bindery/diffing.py` hashes and diffs text, not HTML.
|
- `bindery/diffing.py` hashes and diffs text, not HTML.
|
||||||
|
- `bindery/filename_template.py` expands Calibre's file name templates. It never runs template text as code.
|
||||||
- `bindery/jobs.py` keeps the job queue in SQLite, so jobs survive a restart.
|
- `bindery/jobs.py` keeps the job queue in SQLite, so jobs survive a restart.
|
||||||
- `tests/fake_cwa.py` is a fake CWA server.
|
- `tests/fake_cwa.py` is a fake CWA server.
|
||||||
|
|
||||||
|
|||||||
@@ -445,6 +445,66 @@ 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
|
"Check the site for new chapters after import", Bindery only rebuilds the book of a story that it
|
||||||
downloaded before.
|
downloaded before.
|
||||||
|
|
||||||
|
## File names of downloaded books
|
||||||
|
|
||||||
|
"Download ePub" on a story page sends the book to your browser with a file name. A template gives
|
||||||
|
the name. The default template is `{title_sort} - {authors}`. For "The Book" by Ann Writer, it gives
|
||||||
|
`Book, The - Ann Writer.epub`. That is the name that Calibre's default template gives.
|
||||||
|
|
||||||
|
An admin can change the template under "Book Downloads" in Settings. To go back to the default,
|
||||||
|
clear the field and save. The template follows
|
||||||
|
the rules of [Calibre's templates](https://manual.calibre-ebook.com/template_lang.html) for file
|
||||||
|
names. Bindery adds `.epub`. For example, `{series:|| - }{series_index:0>2s|| - }{title} - {authors}`
|
||||||
|
gives `The Saga - 02 - The Book - Ann Writer.epub`.
|
||||||
|
|
||||||
|
In Calibre, `{title}` and `{series}` give the title sort in a file name, for example `Book, The`.
|
||||||
|
In Bindery, they keep the article. This is the same as Calibre with the tweak
|
||||||
|
`save_template_title_series_sorting` set to `strictly_alphabetic`. Use `{title_sort}` and
|
||||||
|
`{series_sort}` for the title and the series with the article at the end. `{series_sort}` is a
|
||||||
|
Bindery field. Calibre does not have it, and gives empty text for it.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| `{title}` | The title: `The Book`. |
|
||||||
|
| `{title_sort}` | The title with a leading "The", "A" or "An" at the end: `Book, The`. |
|
||||||
|
| `{authors}`, `{author}` | The authors, with ` & ` between them. |
|
||||||
|
| `{author_sort}` | The authors with the last name first: `Writer, Ann`. |
|
||||||
|
| `{series}` | The series. |
|
||||||
|
| `{series_sort}` | The series with a leading article at the end: `Saga, The`. |
|
||||||
|
| `{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. |
|
||||||
|
| `{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.
|
||||||
|
- `{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.
|
||||||
|
|
||||||
|
"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 for English, with the tweak above. 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
|
## Download modes
|
||||||
|
|
||||||
| Mode | What it does |
|
| Mode | What it does |
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""Calibre's templates for file names, for the name of a downloaded book.
|
||||||
|
|
||||||
|
Bindery follows Calibre 9.2.1 with its default tweaks and the English articles. prepare() takes the tweak for
|
||||||
|
the order of titles and series as an argument. 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.
|
||||||
|
|
||||||
|
{series_sort} is a Bindery field. It is to {series} what {title_sort} is to {title}. Calibre has no such field.
|
||||||
|
|
||||||
|
The tags sort close to Calibre's ICU rules, but their order can differ for characters outside ASCII.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from datetime import datetime
|
||||||
|
from string import Formatter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bindery.ingest import sanitize_filename
|
||||||
|
|
||||||
|
# The name that Calibre's default template, {title} - {authors}, gives with its default tweaks.
|
||||||
|
DEFAULT_TEMPLATE = "{title_sort} - {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
|
||||||
|
|
||||||
|
# The fields that Bindery has, with the help text for each. Any other field is empty, as it is in Calibre for
|
||||||
|
# a book without that value.
|
||||||
|
FIELDS = {
|
||||||
|
"title": "The title",
|
||||||
|
"title_sort": 'The title with a leading "The", "A" or "An" at the end, such as "Book, The"',
|
||||||
|
"authors": "The authors",
|
||||||
|
"author": "The authors",
|
||||||
|
"author_sort": "The author sort string. To use only the first letter of the name, use {author_sort[0]}",
|
||||||
|
"series": "The series",
|
||||||
|
"series_sort": 'The series with a leading "The", "A" or "An" at the end, such as "Saga, The". '
|
||||||
|
"Calibre does not have this field",
|
||||||
|
"series_index": "The series number. To get leading zeros, use {series_index:0>3s}",
|
||||||
|
"tags": "The tags",
|
||||||
|
"languages": "The language",
|
||||||
|
"publisher": "The story site",
|
||||||
|
"pubdate": "The published date",
|
||||||
|
"timestamp": "The date when Bindery added the story",
|
||||||
|
"last_modified": "The date when the chapters last changed",
|
||||||
|
"id": "The Bindery story ID",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateError(ValueError):
|
||||||
|
"""A template that Bindery cannot use, or that fails for a book."""
|
||||||
|
|
||||||
|
|
||||||
|
# Metadata, as Calibre prepares it for a file name
|
||||||
|
|
||||||
|
_ARTICLE = re.compile(r"^(A\s+|The\s+|An\s+)", re.IGNORECASE)
|
||||||
|
# Calibre's quote_pairs: each opening quote, with the quotes that close it.
|
||||||
|
_QUOTE_PAIRS = {
|
||||||
|
'"': '"',
|
||||||
|
"'": "'",
|
||||||
|
"\u201c": "\u201d\u201c",
|
||||||
|
"\u201d": "\u201d",
|
||||||
|
"\u201e": "\u201d\u201c",
|
||||||
|
"\u201a": "\u2019\u2018",
|
||||||
|
"\u2019": "\u2019\u2018",
|
||||||
|
"\u2018": "\u2019\u2018",
|
||||||
|
"\u2039": "\u203a",
|
||||||
|
"\u203a": "\u2039",
|
||||||
|
"\u300a": "\u300b",
|
||||||
|
"\u3008": "\u3009",
|
||||||
|
"\u00bb": "\u00ab\u00bb",
|
||||||
|
"\u00ab": "\u00ab\u00bb",
|
||||||
|
"\u300c": "\u300d",
|
||||||
|
"\u300e": "\u300f",
|
||||||
|
}
|
||||||
|
# The ASCII punctuation in the order of Calibre's sort. Digits come next, then letters.
|
||||||
|
_PUNCTUATION = " _-,;:!?.'\"()[]{}@*/\\&#%`^+<=>|~$"
|
||||||
|
# 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"}
|
||||||
|
_COPY_WORDS = {
|
||||||
|
"agency",
|
||||||
|
"corporation",
|
||||||
|
"company",
|
||||||
|
"co.",
|
||||||
|
"council",
|
||||||
|
"committee",
|
||||||
|
"inc.",
|
||||||
|
"institute",
|
||||||
|
"national",
|
||||||
|
"society",
|
||||||
|
"club",
|
||||||
|
"team",
|
||||||
|
"software",
|
||||||
|
"games",
|
||||||
|
"entertainment",
|
||||||
|
"media",
|
||||||
|
"studios",
|
||||||
|
}
|
||||||
|
_BRACKETS = {"(": ")", "[": "]", "{": "}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _unquote(title: str) -> str:
|
||||||
|
closers = _QUOTE_PAIRS.get(title[:1])
|
||||||
|
if closers:
|
||||||
|
title = title[1:]
|
||||||
|
if title and title[-1] in closers:
|
||||||
|
title = title[:-1]
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
|
def title_sort(title: str) -> str:
|
||||||
|
"""Calibre's title_sort(): "The Book" gives "Book, The"."""
|
||||||
|
title = _unquote(title.strip())
|
||||||
|
match = _ARTICLE.search(title)
|
||||||
|
if match:
|
||||||
|
title = _unquote(f"{title[match.end() :]}, {match.group(1)}")
|
||||||
|
return title.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _without_brackets(text: str) -> str:
|
||||||
|
depth = dict.fromkeys(_BRACKETS, 0)
|
||||||
|
closers = {close: opener for opener, close in _BRACKETS.items()}
|
||||||
|
kept = []
|
||||||
|
for char in text:
|
||||||
|
if char in depth:
|
||||||
|
depth[char] += 1
|
||||||
|
elif char in closers:
|
||||||
|
depth[closers[char]] = max(0, depth[closers[char]] - 1)
|
||||||
|
elif not any(depth.values()):
|
||||||
|
kept.append(char)
|
||||||
|
return "".join(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def author_sort(author: str) -> str:
|
||||||
|
"""Calibre's author_to_author_sort() with its default tweaks: "Ann Writer" gives "Writer, Ann"."""
|
||||||
|
plain = _without_brackets(author).strip()
|
||||||
|
tokens = plain.split()
|
||||||
|
lower = [t.lower().removesuffix(".") for t in tokens]
|
||||||
|
if "," in plain or len(tokens) < 2 or _COPY_WORDS.intersection(t.lower() for t in tokens):
|
||||||
|
return author
|
||||||
|
first = next((n for n, t in enumerate(lower) if t not in _NAME_PREFIXES), None)
|
||||||
|
if first is None:
|
||||||
|
return author
|
||||||
|
last = next((n for n in range(len(tokens) - 1, first - 1, -1) if lower[n] not in _NAME_SUFFIXES), None)
|
||||||
|
if last is None:
|
||||||
|
return author
|
||||||
|
names = [tokens[last], *tokens[first:last]]
|
||||||
|
if len(names) > 1:
|
||||||
|
names[0] += ","
|
||||||
|
return " ".join(names + tokens[last + 1 :])
|
||||||
|
|
||||||
|
|
||||||
|
def series_index(index: Any) -> str:
|
||||||
|
"""Calibre's fmt_sidx(): 2.0 gives "2", and 2.345 gives "2.35"."""
|
||||||
|
try:
|
||||||
|
index = float(index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
index = 1.0
|
||||||
|
if index.is_integer():
|
||||||
|
return str(int(index))
|
||||||
|
return f"{index:.2f}".rstrip("0")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_key(tag: str) -> tuple[list[tuple[int, str]], str]:
|
||||||
|
"""Close to the key of Calibre's sort: no case, punctuation before digits before letters, accents last."""
|
||||||
|
text = unicodedata.normalize("NFKD", tag.casefold())
|
||||||
|
ranks = len(_PUNCTUATION)
|
||||||
|
letters = [
|
||||||
|
(_PUNCTUATION.index(c), "") if c in _PUNCTUATION else (ranks + c.isalpha(), c)
|
||||||
|
for c in text
|
||||||
|
if not unicodedata.combining(c)
|
||||||
|
]
|
||||||
|
return letters, text
|
||||||
|
|
||||||
|
|
||||||
|
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. `date_format` is Calibre's timefmt.
|
||||||
|
"""
|
||||||
|
library = order == "library_order"
|
||||||
|
values: dict[str, Any] = {"id": str(book_id), "publisher": meta.get("publisher") or ""}
|
||||||
|
title, authors, series = meta.get("title"), [a for a in meta.get("authors") or [] if a], meta.get("series")
|
||||||
|
if title:
|
||||||
|
values["title_sort"] = title_sort(title)
|
||||||
|
values["title"] = values["title_sort"] if library else title
|
||||||
|
if authors:
|
||||||
|
values["authors"] = values["author"] = " & ".join(a.replace("&", "&&") for a in authors)
|
||||||
|
values["author_sort"] = " & ".join(author_sort(a) for a in authors)
|
||||||
|
if series:
|
||||||
|
values["series_sort"] = title_sort(series)
|
||||||
|
values["series"] = values["series_sort"] if library else series.strip()
|
||||||
|
if meta.get("series_index") is not None:
|
||||||
|
values["series_index"] = series_index(meta["series_index"])
|
||||||
|
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), date_format)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
# Templates
|
||||||
|
|
||||||
|
_FIELD = re.compile(r"([^.\[\]]*)((?:\[\d+\])*)")
|
||||||
|
_AFFIXES = re.compile(r"^(.*)\|([^|]*)\|(.*)$", re.DOTALL)
|
||||||
|
_INT_CODES, _FLOAT_CODES = "bcdoxXn", "eEfFgG%"
|
||||||
|
|
||||||
|
|
||||||
|
def _format(value: str, fmt: str) -> str:
|
||||||
|
"""Calibre's _do_format(): a number code takes the text as a number."""
|
||||||
|
if not fmt or not value:
|
||||||
|
return value
|
||||||
|
code = fmt[-1]
|
||||||
|
try:
|
||||||
|
number = int(value) if code in _INT_CODES else float(value) if code in _FLOAT_CODES else value
|
||||||
|
return format(number, fmt)
|
||||||
|
except (ValueError, OverflowError) as e:
|
||||||
|
raise TemplateError(f"The format {fmt!r} cannot take the value {value!r}: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(template: str) -> list[tuple[str, str | None, list[int], str, str, str]]:
|
||||||
|
"""The parts of a template. Each part is the text before a field, then the name of the field, its indexes,
|
||||||
|
its format, its prefix and its suffix. The name is None for text with no field after it."""
|
||||||
|
if len(template) > MAX_TEMPLATE_LENGTH:
|
||||||
|
raise TemplateError(f"A template can have {MAX_TEMPLATE_LENGTH} characters at most")
|
||||||
|
if template.startswith(("program:", "python:")):
|
||||||
|
raise TemplateError("Bindery does not support the program modes of Calibre")
|
||||||
|
try:
|
||||||
|
fields = list(Formatter().parse(template))
|
||||||
|
except ValueError as e:
|
||||||
|
raise TemplateError(f"The braces of the template do not match: {e}") from e
|
||||||
|
parts = []
|
||||||
|
for text, name, spec, conversion in fields:
|
||||||
|
if name is None:
|
||||||
|
parts.append((text, None, [], "", "", ""))
|
||||||
|
continue
|
||||||
|
field = _FIELD.fullmatch(name)
|
||||||
|
if not field or conversion or "{" in spec:
|
||||||
|
raise TemplateError(f"Bindery does not support the field {{{name}}} in this form")
|
||||||
|
if not field.group(1) or field.group(1).isdigit():
|
||||||
|
raise TemplateError("A field needs a name, such as {title}")
|
||||||
|
affixes = _AFFIXES.match(spec)
|
||||||
|
fmt, prefix, suffix = affixes.groups() if affixes else (spec, "", "")
|
||||||
|
if fmt.startswith("'") or (":'" in fmt and fmt.endswith("'")) or ("(" in fmt and fmt.endswith(")")):
|
||||||
|
raise TemplateError("Bindery does not support template functions, such as {title:uppercase()}")
|
||||||
|
if any(int(n) > MAX_WIDTH for n in re.findall(r"\d+", fmt)):
|
||||||
|
raise TemplateError(f"A format can ask for {MAX_WIDTH} characters at most")
|
||||||
|
_format("1", fmt)
|
||||||
|
indexes = [int(n) for n in re.findall(r"\d+", field.group(2))]
|
||||||
|
parts.append((text, field.group(1).lower(), indexes, fmt, prefix, suffix))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def check(template: str) -> None:
|
||||||
|
"""Raise TemplateError if Bindery cannot use the template."""
|
||||||
|
_parse(template)
|
||||||
|
|
||||||
|
|
||||||
|
def expand(template: str, values: dict[str, Any]) -> str:
|
||||||
|
"""The text of a template for the prepared values of a book, as Calibre gives it before it makes a path."""
|
||||||
|
out = []
|
||||||
|
for text, name, indexes, fmt, prefix, suffix in _parse(template):
|
||||||
|
out.append(text)
|
||||||
|
if name is None:
|
||||||
|
continue
|
||||||
|
value = values.get(name, "")
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = ",".join(value)
|
||||||
|
value = value.replace("/", "_").replace("\\", "_")
|
||||||
|
try:
|
||||||
|
for index in indexes:
|
||||||
|
value = value[index]
|
||||||
|
except IndexError as e:
|
||||||
|
raise TemplateError(f"The value of {{{name}}} is too short for [{index}]") from e
|
||||||
|
if value:
|
||||||
|
value = _format(value, fmt)
|
||||||
|
if value:
|
||||||
|
out += prefix, value, suffix
|
||||||
|
return re.sub(r"\s+", " ", "".join(out)).strip(" ")
|
||||||
|
|
||||||
|
|
||||||
|
_RESERVED = re.compile(r"(?:CON|PRN|AUX|NUL|COM\d|LPT\d)(?:\.|$)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def file_name(template: str, values: dict[str, Any]) -> str:
|
||||||
|
"""The name of one file from a template, without the extension. Empty if the template gives no name.
|
||||||
|
|
||||||
|
A download has one name, so Bindery uses the last part of a template with folders. That is the name that
|
||||||
|
Calibre gives the file. Windows cannot use some names, such as CON. Those get a leading underscore.
|
||||||
|
"""
|
||||||
|
parts = [part.strip() for part in expand(template, values).split("/")]
|
||||||
|
name = next((part for part in reversed(parts) if part), "")
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
name = sanitize_filename(name)
|
||||||
|
if _RESERVED.match(name):
|
||||||
|
name = f"_{name}"
|
||||||
|
return name.encode()[:MAX_NAME_BYTES].decode(errors="ignore").rstrip(" .")
|
||||||
+69
-6
@@ -11,13 +11,13 @@ from datetime import datetime, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, BinaryIO
|
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.backup import daily_backup
|
||||||
from bindery.cwa import Book, BookFinder, CwaClient, CwaError
|
from bindery.cwa import Book, BookFinder, CwaClient, CwaError
|
||||||
from bindery.diffing import diff_html, text_hash
|
from bindery.diffing import diff_html, text_hash
|
||||||
from bindery.epub import EpubError, epub_metadata, epub_urls
|
from bindery.epub import EpubError, epub_metadata, epub_urls
|
||||||
from bindery.fff import StoryError
|
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.jobs import METADATA_FIELDS, DownloadMode, Job, QueueStatus, job_queue
|
||||||
from bindery.merge import merge_chapter_lists
|
from bindery.merge import merge_chapter_lists
|
||||||
from bindery.rules import RuleError, Step, apply_rules, compile_rules, normalize_rules
|
from bindery.rules import RuleError, Step, apply_rules, compile_rules, normalize_rules
|
||||||
@@ -380,12 +380,22 @@ def get_settings() -> dict[str, Any]:
|
|||||||
"linking_available": _linking_available(),
|
"linking_available": _linking_available(),
|
||||||
"hold_new_links": bool(store.get_setting(None, "hold_new_links")),
|
"hold_new_links": bool(store.get_setting(None, "hold_new_links")),
|
||||||
"personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "",
|
"personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "",
|
||||||
|
**{name: _download_setting(name) for name in DOWNLOAD_DEFAULTS},
|
||||||
**job_list_settings(),
|
**job_list_settings(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# The settings that save_settings takes.
|
# 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",
|
||||||
|
"download_date_format",
|
||||||
|
*JOB_LIST_DEFAULTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def save_settings(**changes: Any) -> dict[str, Any]:
|
def save_settings(**changes: Any) -> dict[str, Any]:
|
||||||
@@ -408,6 +418,16 @@ def save_settings(**changes: Any) -> dict[str, Any]:
|
|||||||
if name in changes:
|
if name in changes:
|
||||||
most = config.JOB_LIST_MAX_HOURS if name.endswith("hours") else config.JOB_LIST_MAX_COUNT
|
most = config.JOB_LIST_MAX_HOURS if name.endswith("hours") else config.JOB_LIST_MAX_COUNT
|
||||||
numbers[name] = whole_number(changes[name], name, most)
|
numbers[name] = whole_number(changes[name], name, most)
|
||||||
|
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 []
|
warnings = fff.check_personal_ini(personal_ini) if personal_ini is not None else []
|
||||||
|
|
||||||
released = False
|
released = False
|
||||||
@@ -422,6 +442,9 @@ def save_settings(**changes: Any) -> dict[str, Any]:
|
|||||||
store.set_setting(None, name, True if value else None)
|
store.set_setting(None, name, True if value else None)
|
||||||
for name, number in numbers.items():
|
for name, number in numbers.items():
|
||||||
store.set_setting(None, name, number)
|
store.set_setting(None, name, number)
|
||||||
|
# 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:
|
if personal_ini is not None:
|
||||||
# Write a new file, then replace the old one, so a job never reads half a file.
|
# 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")
|
partial = config.PERSONAL_INI.with_name("personal.ini.part")
|
||||||
@@ -623,15 +646,55 @@ 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]
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""The file name of a downloaded book, from the download template. If the template fails or gives no name for
|
||||||
|
this book, the name is the title."""
|
||||||
|
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"]
|
||||||
|
name = ""
|
||||||
|
try:
|
||||||
|
# {title} and {series} keep a leading article, as in Calibre with this value of its tweak
|
||||||
|
# save_template_title_series_sorting. {title_sort} moves it to the end.
|
||||||
|
date_format = _download_setting("download_date_format")
|
||||||
|
values = filename_template.prepare(meta, book_id, order="strictly_alphabetic", date_format=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"
|
||||||
|
|
||||||
|
|
||||||
def build_for_download(story_id: int) -> tuple[BinaryIO, str]:
|
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
|
"""The epub of a story for a browser download, as an open file with its file name. Bindery builds it only if
|
||||||
after the last build."""
|
the book changed after the last build."""
|
||||||
story = _require_story(story_id)
|
story = _require_story(story_id)
|
||||||
overrides = _epub_overrides(story["pinned"])
|
overrides = _epub_overrides(story["pinned"])
|
||||||
key = _build_key(story, overrides, story["site_values"])
|
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.
|
# The file is opened under the lock, so a new build cannot delete it first.
|
||||||
with _build_lock:
|
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
|
# Metadata locks
|
||||||
|
|||||||
@@ -227,9 +227,9 @@ export function globalRulesCard() {
|
|||||||
.catch(showError);
|
.catch(showError);
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
'section',
|
'details',
|
||||||
{ class: 'card' },
|
{ class: 'card', open: true },
|
||||||
h('h2', {}, 'Global replacement rules'),
|
h('summary', {}, h('h2', {}, 'Global replacement rules')),
|
||||||
h(
|
h(
|
||||||
'p',
|
'p',
|
||||||
{ class: 'hint' },
|
{ class: 'hint' },
|
||||||
|
|||||||
+139
-16
@@ -1,10 +1,17 @@
|
|||||||
// 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 { api, button, checkbox, externalLink, fill, h, showError, toast } from './lib.js';
|
||||||
import { globalRulesCard } from './rules.js';
|
import { globalRulesCard } from './rules.js';
|
||||||
|
|
||||||
const INI_DOCS = 'https://github.com/JimmXinu/FanFicFare/wiki/INI-File';
|
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 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);
|
||||||
|
// A card of settings that folds shut. It starts open.
|
||||||
|
const card = (title, ...children) =>
|
||||||
|
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, title)), ...children);
|
||||||
|
|
||||||
export function settingsPage(main, _params, config) {
|
export function settingsPage(main, _params, config) {
|
||||||
document.title = 'Settings · Bindery';
|
document.title = 'Settings · Bindery';
|
||||||
@@ -35,6 +42,22 @@ export function settingsPage(main, _params, config) {
|
|||||||
pauseUploads.setAttribute('aria-describedby', pauseUploads.disabled ? 'ingest-note' : '');
|
pauseUploads.setAttribute('aria-describedby', pauseUploads.disabled ? 'ingest-note' : '');
|
||||||
ingestNote.hidden = !pauseUploads.disabled;
|
ingestNote.hidden = !pauseUploads.disabled;
|
||||||
};
|
};
|
||||||
|
const template = h('input', {
|
||||||
|
id: 'download-template',
|
||||||
|
class: 'template',
|
||||||
|
type: 'text',
|
||||||
|
maxlength: 1024,
|
||||||
|
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 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 countField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_count, step: 1 });
|
||||||
const storyJobsHours = hoursField('story-jobs-hours');
|
const storyJobsHours = hoursField('story-jobs-hours');
|
||||||
@@ -73,6 +96,8 @@ export function settingsPage(main, _params, config) {
|
|||||||
recent.value = settings.recent_chapters;
|
recent.value = settings.recent_chapters;
|
||||||
showUploadSettings(settings);
|
showUploadSettings(settings);
|
||||||
ini.value = settings.personal_ini;
|
ini.value = settings.personal_ini;
|
||||||
|
template.value = settings.download_template;
|
||||||
|
dateFormat.value = settings.download_date_format;
|
||||||
storyJobsHours.value = settings.story_jobs_hours;
|
storyJobsHours.value = settings.story_jobs_hours;
|
||||||
storyJobsCount.value = settings.story_jobs_count;
|
storyJobsCount.value = settings.story_jobs_count;
|
||||||
mainJobsHours.value = settings.main_jobs_hours;
|
mainJobsHours.value = settings.main_jobs_hours;
|
||||||
@@ -82,11 +107,9 @@ export function settingsPage(main, _params, config) {
|
|||||||
|
|
||||||
fill(
|
fill(
|
||||||
main,
|
main,
|
||||||
h(
|
|
||||||
'section',
|
|
||||||
{ class: 'card' },
|
|
||||||
h('h1', {}, 'Settings'),
|
h('h1', {}, 'Settings'),
|
||||||
h('h2', {}, 'Recheck recent chapters'),
|
card(
|
||||||
|
'Recheck recent chapters',
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'controls' },
|
{ class: 'controls' },
|
||||||
@@ -102,10 +125,8 @@ export function settingsPage(main, _params, config) {
|
|||||||
),
|
),
|
||||||
saveButton(() => ({ recent_chapters: recent.value === '' ? 0 : Number(recent.value) })),
|
saveButton(() => ({ recent_chapters: recent.value === '' ? 0 : Number(recent.value) })),
|
||||||
),
|
),
|
||||||
config.cwa_enabled && h(
|
config.cwa_enabled && card(
|
||||||
'section',
|
'Uploads to CWA',
|
||||||
{ class: 'card' },
|
|
||||||
h('h2', {}, 'Uploads to CWA'),
|
|
||||||
h('label', {}, linkBooks, ' Link books to CWA library'),
|
h('label', {}, linkBooks, ' Link books to CWA library'),
|
||||||
h(
|
h(
|
||||||
'p',
|
'p',
|
||||||
@@ -139,10 +160,114 @@ export function settingsPage(main, _params, config) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
card(
|
||||||
|
'Book Downloads',
|
||||||
|
h('div', { class: 'controls' }, h('label', { for: 'download-template' }, 'File name template'), template),
|
||||||
h(
|
h(
|
||||||
'section',
|
'p',
|
||||||
{ class: 'card' },
|
{ class: 'hint' },
|
||||||
h('h2', {}, 'Jobs list'),
|
'This template gives the file name of a book that you download from a story page. To go back to the ' +
|
||||||
|
'default, ',
|
||||||
|
code('{title_sort} - {authors}'),
|
||||||
|
', clear the field and save.',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'details',
|
||||||
|
{ class: 'help' },
|
||||||
|
h('summary', {}, 'File name template help'),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'hint' },
|
||||||
|
'The template 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. For example, ',
|
||||||
|
code('{series:|| - }{series_index:0>2s|| - }{title} - {authors}'),
|
||||||
|
' gives "The Saga - 02 - The Book - 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',
|
||||||
|
{},
|
||||||
|
'{title} and {series} keep a leading "The", "A" or "An". {title_sort} and {series_sort} move it to the ' +
|
||||||
|
'end, so ' +
|
||||||
|
'"The Book" becomes "Book, The". Calibre puts the title sort in {title} as well, unless you set its ' +
|
||||||
|
'tweak save_template_title_series_sorting to strictly_alphabetic.',
|
||||||
|
),
|
||||||
|
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.'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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}. To go back to the default, ',
|
||||||
|
code('%b, %Y'),
|
||||||
|
', clear the field and save.',
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
'details',
|
||||||
|
{ class: 'help' },
|
||||||
|
h('summary', {}, 'Date format help'),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'hint' },
|
||||||
|
code('%d'),
|
||||||
|
' gives the day, ',
|
||||||
|
code('%b'),
|
||||||
|
' the month name, ',
|
||||||
|
code('%m'),
|
||||||
|
' the month number and ',
|
||||||
|
code('%Y'),
|
||||||
|
' the year. See the ',
|
||||||
|
externalLink(DATE_CODES, 'list of all codes'),
|
||||||
|
'. The default gives "May, 2020". A / in a date becomes _, as in Calibre.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
saveButton(
|
||||||
|
() => ({ download_template: template.value, download_date_format: dateFormat.value }),
|
||||||
|
(settings) => {
|
||||||
|
template.value = settings.download_template;
|
||||||
|
dateFormat.value = settings.download_date_format;
|
||||||
|
toast('Saved');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
card(
|
||||||
|
'Jobs list',
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'controls' },
|
{ class: 'controls' },
|
||||||
@@ -175,10 +300,8 @@ export function settingsPage(main, _params, config) {
|
|||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
globalRulesCard(),
|
globalRulesCard(),
|
||||||
h(
|
card(
|
||||||
'section',
|
'FanFicFare settings (personal.ini)',
|
||||||
{ class: 'card' },
|
|
||||||
h('h2', {}, 'FanFicFare settings (personal.ini)'),
|
|
||||||
h(
|
h(
|
||||||
'p',
|
'p',
|
||||||
{ class: 'hint' },
|
{ class: 'hint' },
|
||||||
|
|||||||
@@ -451,6 +451,25 @@ label:has(> input[type='checkbox']:disabled) {
|
|||||||
margin: 0;
|
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 {
|
.notice {
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -699,6 +718,11 @@ textarea {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template {
|
||||||
|
flex: 1 1 24em;
|
||||||
|
max-width: 48em;
|
||||||
|
}
|
||||||
|
|
||||||
.metadata input:not([type='checkbox']) {
|
.metadata input:not([type='checkbox']) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 32em;
|
max-width: 32em;
|
||||||
@@ -1039,6 +1063,16 @@ textarea {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Help that folds shut under a setting, such as the help for the download template. */
|
||||||
|
.help {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
|
||||||
|
> summary {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Toasts */
|
/* Toasts */
|
||||||
|
|
||||||
#toasts {
|
#toasts {
|
||||||
|
|||||||
+2
-3
@@ -20,7 +20,6 @@ from bindery import auth, config, orchestrator
|
|||||||
from bindery.cwa import CwaError
|
from bindery.cwa import CwaError
|
||||||
from bindery.epub import EpubError
|
from bindery.epub import EpubError
|
||||||
from bindery.fff import StoryError
|
from bindery.fff import StoryError
|
||||||
from bindery.ingest import sanitize_filename
|
|
||||||
from bindery.jobs import METADATA_FIELDS, MODE_LABELS, DownloadMode
|
from bindery.jobs import METADATA_FIELDS, MODE_LABELS, DownloadMode
|
||||||
from bindery.rules import RuleError
|
from bindery.rules import RuleError
|
||||||
|
|
||||||
@@ -269,6 +268,7 @@ def app_config() -> Any:
|
|||||||
"update_interval": config.UPDATE_INTERVAL_HOURS,
|
"update_interval": config.UPDATE_INTERVAL_HOURS,
|
||||||
"job_list_max_hours": config.JOB_LIST_MAX_HOURS,
|
"job_list_max_hours": config.JOB_LIST_MAX_HOURS,
|
||||||
"job_list_max_count": config.JOB_LIST_MAX_COUNT,
|
"job_list_max_count": config.JOB_LIST_MAX_COUNT,
|
||||||
|
"download_fields": orchestrator.download_fields(),
|
||||||
**orchestrator.job_list_settings(),
|
**orchestrator.job_list_settings(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,8 +399,7 @@ def delete_chapters(story_id: int) -> Any:
|
|||||||
|
|
||||||
@app.get("/api/stories/<int:story_id>/epub")
|
@app.get("/api/stories/<int:story_id>/epub")
|
||||||
def story_epub(story_id: int) -> Any:
|
def story_epub(story_id: int) -> Any:
|
||||||
file, title = orchestrator.build_for_download(story_id)
|
file, name = orchestrator.build_for_download(story_id)
|
||||||
name = f"{sanitize_filename(title)}.epub"
|
|
||||||
response = send_file(file, download_name=name, as_attachment=True, mimetype="application/epub+zip")
|
response = send_file(file, download_name=name, as_attachment=True, mimetype="application/epub+zip")
|
||||||
response.content_length = os.fstat(file.fileno()).st_size
|
response.content_length = os.fstat(file.fileno()).st_size
|
||||||
return response
|
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);
|
t.after(stopPolling);
|
||||||
const backend = server(t);
|
const backend = server(t);
|
||||||
backend.on('GET', 'auth/check', { authenticated: true, mode: 'proxy', user: 'bob', admin: false });
|
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', 'jobs', []);
|
||||||
backend.on('GET', 'stories', []);
|
backend.on('GET', 'stories', []);
|
||||||
await import('../../bindery/static/js/main.js');
|
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);
|
const backend = server(t);
|
||||||
let auth = { authenticated: false, mode: 'cwa', user: null };
|
let auth = { authenticated: false, mode: 'cwa', user: null };
|
||||||
backend.on('GET', 'auth/check', () => auth);
|
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', 'jobs', []);
|
||||||
backend.on('GET', 'stories', []);
|
backend.on('GET', 'stories', []);
|
||||||
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||||
|
|||||||
+48
-1
@@ -16,6 +16,7 @@ const config = {
|
|||||||
metadata_fields: { title: 'Title', authors: 'Authors', comments: 'Description', series: 'Series', pubdate: 'Published', tags: 'Tags', languages: 'Language' },
|
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,
|
mode_labels: { new: 'New chapters' }, library_available: true, cwa_enabled: true, update_interval: 2,
|
||||||
job_list_max_hours: 720, job_list_max_count: 500,
|
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 rule = { pattern: 'before', replacement: 'after', flags: 'i', note: '', enabled: true };
|
||||||
const emptyRules = { rules: [], paragraphs: null, global_paragraphs: true };
|
const emptyRules = { rules: [], paragraphs: null, global_paragraphs: true };
|
||||||
@@ -54,7 +55,7 @@ for (const value of ['7', '']) {
|
|||||||
await settle();
|
await settle();
|
||||||
api.on('PUT', 'settings', {});
|
api.on('PUT', 'settings', {});
|
||||||
field(main, 'recent-chapters').value = value;
|
field(main, 'recent-chapters').value = value;
|
||||||
await click(section(main, 'Settings'), 'Save');
|
await click(section(main, 'Recheck recent chapters'), 'Save');
|
||||||
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { recent_chapters: value === '' ? 0 : 7 });
|
assert.deepEqual(api.sent('PUT', 'settings')[0].body, { recent_chapters: value === '' ? 0 : 7 });
|
||||||
assert.match(ui.toasts.textContent, /Saved/);
|
assert.match(ui.toasts.textContent, /Saved/);
|
||||||
});
|
});
|
||||||
@@ -108,6 +109,52 @@ test('the jobs list settings load and save all four fields', async () => {
|
|||||||
assert.match(ui.toasts.textContent, /Saved/);
|
assert.match(ui.toasts.textContent, /Saved/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('each group of settings is an open card, and the download help starts closed', async () => {
|
||||||
|
settingsPage(main, {}, config);
|
||||||
|
await settle();
|
||||||
|
const cards = main.children.filter((el) => el.tagName !== 'h1');
|
||||||
|
assert.deepEqual(cards.map((el) => [el.tagName, el.className, el.open, el.children[0].textContent]), [
|
||||||
|
['details', 'card', true, 'Recheck recent chapters'],
|
||||||
|
['details', 'card', true, 'Uploads to CWA'],
|
||||||
|
['details', 'card', true, 'Book Downloads'],
|
||||||
|
['details', 'card', true, 'Jobs list'],
|
||||||
|
['details', 'card', true, 'Global replacement rules'],
|
||||||
|
['details', 'card', true, 'FanFicFare settings (personal.ini)'],
|
||||||
|
]);
|
||||||
|
const help = nodes(section(main, 'Book Downloads')).filter((el) => el.className === 'help');
|
||||||
|
assert.deepEqual(help.map((el) => [el.tagName, el.open, el.children[0].textContent]), [
|
||||||
|
['details', undefined, 'File name template help'],
|
||||||
|
['details', undefined, 'Date format help'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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}', 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} ';
|
||||||
|
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} ', 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');
|
||||||
|
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 () => {
|
test('adding stories keeps input on rejection and clears it after a mixed result', async () => {
|
||||||
await mountStories({ ...config, library_available: false });
|
await mountStories({ ...config, library_available: false });
|
||||||
const urls = nodes(main).find((el) => el.tagName === 'textarea');
|
const urls = nodes(main).find((el) => el.tagName === 'textarea');
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const settle = () => new Promise((resolve) => setImmediate(resolve));
|
|||||||
const config = {
|
const config = {
|
||||||
metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, cwa_enabled: true, update_interval: 0,
|
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,
|
job_list_max_hours: 720, job_list_max_count: 500,
|
||||||
|
download_fields: { title: 'The title', id: 'The story ID' },
|
||||||
};
|
};
|
||||||
const story = {
|
const story = {
|
||||||
id: 1, title: 'Story', author: 'Author', url: 'https://example.com/story', site: 'example.com',
|
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);
|
const backend = server(t);
|
||||||
let auth = { authenticated: false, mode: 'local', user: null };
|
let auth = { authenticated: false, mode: 'local', user: null };
|
||||||
backend.on('GET', 'auth/check', () => auth);
|
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', 'stories', []);
|
||||||
backend.on('GET', 'jobs', []);
|
backend.on('GET', 'jobs', []);
|
||||||
backend.on('GET', 'expired', { error: 'Session ended' }, 401);
|
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);
|
const backend = server(t);
|
||||||
backend.on('GET', 'auth/check', { authenticated: true, mode: 'proxy', user: 'alice' });
|
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', 'jobs', []);
|
||||||
backend.on('GET', 'stories', []);
|
backend.on('GET', 'stories', []);
|
||||||
backend.on('GET', 'expired', { error: 'Session ended' }, 401);
|
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);
|
t.after(stopPolling);
|
||||||
const backend = server(t);
|
const backend = server(t);
|
||||||
backend.on('GET', 'auth/check', { authenticated: true, mode: 'none', user: '' });
|
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', 'jobs', []);
|
||||||
backend.on('GET', 'stories', []);
|
backend.on('GET', 'stories', []);
|
||||||
backend.on('GET', 'settings', { recent_chapters: 0, personal_ini: '' });
|
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 { storyPage } = await import('../../bindery/static/js/story.js');
|
||||||
const { storiesPage } = await import('../../bindery/static/js/stories.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 book = { id: 7, uuid: 'book-7', title: 'Library Title', authors: ['Writer'] };
|
||||||
const details = (story, extra = {}) => ({
|
const details = (story, extra = {}) => ({
|
||||||
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
|
story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com',
|
||||||
|
|||||||
@@ -472,6 +472,8 @@ def test_settings(client, monkeypatch, tmp_path):
|
|||||||
"pause_uploads": False,
|
"pause_uploads": False,
|
||||||
"ingest_available": True,
|
"ingest_available": True,
|
||||||
"personal_ini": "[defaults]\nis_adult:true\n",
|
"personal_ini": "[defaults]\nis_adult:true\n",
|
||||||
|
"download_template": "{title_sort} - {authors}",
|
||||||
|
"download_date_format": "%b, %Y",
|
||||||
"story_jobs_hours": 36,
|
"story_jobs_hours": 36,
|
||||||
"story_jobs_count": 10,
|
"story_jobs_count": 10,
|
||||||
"main_jobs_hours": 6,
|
"main_jobs_hours": 6,
|
||||||
|
|||||||
+75
-1
@@ -142,11 +142,85 @@ def test_download_builds_from_stored_chapters_without_changing_them(client, stor
|
|||||||
response = client.get(f"/api/stories/{sid}/epub")
|
response = client.get(f"/api/stories/{sid}/epub")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data.startswith(b"PK")
|
assert response.data.startswith(b"PK")
|
||||||
assert "filename=A_B.epub" in response.headers["Content-Disposition"]
|
assert 'filename="A_B - Writer.epub"' in response.headers["Content-Disposition"]
|
||||||
assert o.store.revision_html(cid, 1) == original
|
assert o.store.revision_html(cid, 1) == original
|
||||||
response.close()
|
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="The Saga - The Book - {sid}.epub"' in download_name(client, sid)
|
||||||
|
# The default moves the article to the end, as Calibre's default does.
|
||||||
|
client.put("/api/settings", json={"download_template": ""}, headers=H)
|
||||||
|
assert 'filename="Book, The - Ann Writer.epub"' in download_name(client, sid)
|
||||||
|
client.put("/api/settings", json={"download_template": "{series_sort} - {title} - {id}"}, headers=H)
|
||||||
|
assert f'filename="Saga, The - The Book - {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]})
|
||||||
|
client.put("/api/settings", json={"download_template": "{series:|| - }{title} - {id}"}, headers=H)
|
||||||
|
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}"}, headers=H)
|
||||||
|
assert "filename*=UTF-8''Caf%C3%A9.epub" in download_name(client, sid)
|
||||||
|
# An empty template restores the default.
|
||||||
|
saved = client.put("/api/settings", json={"download_template": " "}, headers=H).json
|
||||||
|
assert saved["download_template"] == "{title_sort} - {authors}"
|
||||||
|
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())
|
||||||
|
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):
|
def test_unexpected_backend_error_returns_json(client, monkeypatch, caplog):
|
||||||
monkeypatch.setattr(o, "list_stories", Mock(side_effect=RuntimeError("broken")))
|
monkeypatch.setattr(o, "list_stories", Mock(side_effect=RuntimeError("broken")))
|
||||||
response = client.get("/api/stories")
|
response = client.get("/api/stories")
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""The file name templates, against the results of Calibre 9.2.1.
|
||||||
|
|
||||||
|
Each expected text is what Calibre gives for the same book and template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bindery import filename_template as ft
|
||||||
|
|
||||||
|
BOOK = {
|
||||||
|
"title": "The Book",
|
||||||
|
"authors": ["Ann Writer", "Ben Reader"],
|
||||||
|
"series": "The Saga",
|
||||||
|
"series_index": 2.345,
|
||||||
|
"languages": ["eng", "fra"],
|
||||||
|
"tags": ["Space", "Fiction"],
|
||||||
|
"pubdate": "2020-05-06T12:00:00+00:00",
|
||||||
|
"publisher": "site.example",
|
||||||
|
}
|
||||||
|
NO_SERIES = {**BOOK, "series": "", "series_index": None}
|
||||||
|
|
||||||
|
|
||||||
|
def expand(template, order="library_order", **changes):
|
||||||
|
return ft.expand(template, ft.prepare({**BOOK, **changes}, 42, order))
|
||||||
|
|
||||||
|
|
||||||
|
# name, template, text from Calibre, series index
|
||||||
|
REFERENCE = [
|
||||||
|
("title", "{title}", "Book, The", 2.345),
|
||||||
|
("series", "{series}", "Saga, The", 2.345),
|
||||||
|
("fraction", "{series_index}", "2.35", 2.345),
|
||||||
|
("languages", "{languages}", "eng,fra", 2.345),
|
||||||
|
("tags", "{tags}", "Fiction, Space", 2.345),
|
||||||
|
("send_date", "{pubdate}", "May, 2020", 2.345),
|
||||||
|
("space_padding", "x{series_index:>5s}x", "x 2x", 2),
|
||||||
|
("zero_padding", "{series_index:0>3s}", "002", 2),
|
||||||
|
("numeric_format", "{series_index:03d}", "002", 2),
|
||||||
|
("first_character", "{author_sort[0]}", "W", 2),
|
||||||
|
("conditional", "{series:|| - }{title}", "Saga, The - Book, The", 2),
|
||||||
|
("case_insensitive", "{TITLE}", "Book, The", 2),
|
||||||
|
("unknown", "x{not_a_field}x", "xx", 2),
|
||||||
|
("literal_braces", "{{title}}", "{title}", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,template,expected,index", REFERENCE, ids=[case[0] for case in REFERENCE])
|
||||||
|
def test_reference_cases(name, template, expected, index):
|
||||||
|
assert expand(template, series_index=index) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_cases_for_a_missing_series_and_for_alphabetic_order():
|
||||||
|
values = ft.prepare(NO_SERIES, 42)
|
||||||
|
assert ft.expand("{series}{series_index:| - | - }{title}", values) == "Book, The"
|
||||||
|
assert expand("{title}", "strictly_alphabetic") == "The Book"
|
||||||
|
assert expand("{series}", "strictly_alphabetic") == "The Saga"
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_sort_is_a_bindery_field():
|
||||||
|
assert expand("{series_sort}") == expand("{series_sort}", "strictly_alphabetic") == "Saga, The"
|
||||||
|
assert ft.expand("x{series_sort}x", ft.prepare(NO_SERIES, 42)) == "xx"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"template,expected",
|
||||||
|
[
|
||||||
|
("{authors}", "Ann Writer & Ben Reader"),
|
||||||
|
("{author}", "Ann Writer & Ben Reader"),
|
||||||
|
("{author_sort}", "Writer, Ann & Reader, Ben"),
|
||||||
|
("{title_sort}", "Book, The"),
|
||||||
|
("{publisher} {id}", "site.example 42"),
|
||||||
|
("{title:.3s|<|>}", "<Boo>"),
|
||||||
|
("{title:*^15}", "***Book, The***"),
|
||||||
|
("{id:05d} {id:x} {id:.2f}", "00042 2a 42.00"),
|
||||||
|
("{title[0]}{title[1]}", "Bo"),
|
||||||
|
("{title} {{{id}}}", "Book, The {42}"),
|
||||||
|
(" {title} -\t{authors} ", "Book, The - Ann Writer & Ben Reader"),
|
||||||
|
("{ title }{other}", ""),
|
||||||
|
("a|b|c{title:|-|}", "a|b|c-Book, The"),
|
||||||
|
("{series}/{series_index:0>2s| | - }{title}", "Saga, The/ 2.35 - Book, The"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_more_templates_give_the_text_of_calibre(template, expected):
|
||||||
|
assert expand(template) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_values_follow_the_rules_of_calibre():
|
||||||
|
assert expand("{title} - {authors}", title="A/B: C\\D", authors=["X/Y"]) == "A_B: C_D - X_Y"
|
||||||
|
assert expand("{authors}", authors=["Rock & Roll", "", "Solo"]) == "Rock && Roll & Solo"
|
||||||
|
assert expand("{tags}", tags=["/root", "alpha", "Beta", "1st", "_x"]) == "_x, _root, 1st, alpha, Beta"
|
||||||
|
assert expand("{tags}", tags=["F/M", "Hurt-Comfort", "hurt comfort", "Hurt", "x2", "x10", "é", "e", "f"]) == (
|
||||||
|
"e, é, f, F_M, Hurt, hurt comfort, Hurt-Comfort, x10, x2"
|
||||||
|
)
|
||||||
|
assert expand("{tags}|{languages}|{pubdate}", tags=[], languages="", pubdate="") == "||"
|
||||||
|
assert expand("{languages}", languages="en") == "en"
|
||||||
|
assert expand("{pubdate} {timestamp}", pubdate="1999-12-31T23:59:59", timestamp="2026-10-03") == (
|
||||||
|
"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)) == "|"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"title,expected",
|
||||||
|
[
|
||||||
|
("A Tale", "Tale, A"),
|
||||||
|
("An Apple", "Apple, An"),
|
||||||
|
("the lower", "lower, the"),
|
||||||
|
("Theory", "Theory"),
|
||||||
|
("The", "The"),
|
||||||
|
(" The Spaced ", "Spaced, The"),
|
||||||
|
("'The Quoted'", "Quoted, The"),
|
||||||
|
("“The Curly”", "Curly, The"),
|
||||||
|
("„The Low“", "Low, The"),
|
||||||
|
("The 'Inner", "Inner, The"),
|
||||||
|
('""', ""),
|
||||||
|
("Le Monde", "Le Monde"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_title_sort(title, expected):
|
||||||
|
assert ft.title_sort(title) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"author,expected",
|
||||||
|
[
|
||||||
|
("Ann Writer", "Writer, Ann"),
|
||||||
|
("Dr. John Smith Jr.", "Smith, John Jr."),
|
||||||
|
("John Smith MD PhD", "Smith, John MD PhD"),
|
||||||
|
("Mr Smith", "Smith"),
|
||||||
|
("Dr Mr", "Dr Mr"),
|
||||||
|
("Jr Sr", "Jr Sr"),
|
||||||
|
("xX_Dark_Xx", "xX_Dark_Xx"),
|
||||||
|
("Smith, John", "Smith, John"),
|
||||||
|
("John (Jack) Doe", "Doe, John"),
|
||||||
|
("A (b c", "A (b c"),
|
||||||
|
("Acme Media", "Acme Media"),
|
||||||
|
("Ludwig van Beethoven", "Beethoven, Ludwig van"),
|
||||||
|
("J. R. R. Tolkien", "Tolkien, J. R. R."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_author_sort(author, expected):
|
||||||
|
assert ft.author_sort(author) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"index,expected",
|
||||||
|
[(1, "1"), (2.0, "2"), (0, "0"), (2.5, "2.5"), (1.10, "1.1"), (3.14159, "3.14"), (2.001, "2."), (10.999, "11.")],
|
||||||
|
)
|
||||||
|
def test_series_index(index, expected):
|
||||||
|
assert ft.series_index(index) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"template,error",
|
||||||
|
[
|
||||||
|
("{title:uppercase()}", "does not support template functions"),
|
||||||
|
("{series:ifempty(none)}", "does not support template functions"),
|
||||||
|
("{title:'uppercase($)'}", "does not support template functions"),
|
||||||
|
("{title:.3s:'uppercase($)'}", "does not support template functions"),
|
||||||
|
("program: field('title')", "does not support the program modes"),
|
||||||
|
("python: def evaluate(book, context): return ''", "does not support the program modes"),
|
||||||
|
("{title!r}", "does not support the field"),
|
||||||
|
("{title.upper}", "does not support the field"),
|
||||||
|
("{title[x]}", "does not support the field"),
|
||||||
|
("{title:{id}}", "does not support the field"),
|
||||||
|
("{}", "A field needs a name"),
|
||||||
|
("{0}", "A field needs a name"),
|
||||||
|
("{title", "The braces of the template do not match"),
|
||||||
|
("title}", "The braces of the template do not match"),
|
||||||
|
("{title:5q}", "The format '5q' cannot take"),
|
||||||
|
("{series:|a|b|c}", "The format '|a' cannot take"),
|
||||||
|
("{id:.3d}", "The format '.3d' cannot take"),
|
||||||
|
("{title:>256s}", "A format can ask for 255 characters at most"),
|
||||||
|
("{id:.999f}", "A format can ask for 255 characters at most"),
|
||||||
|
("x" * 1025, "A template can have 1024 characters at most"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_check_refuses_what_bindery_does_not_support(template, error):
|
||||||
|
with pytest.raises(ft.TemplateError, match=error.replace("|", "\\|")):
|
||||||
|
ft.check(template)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_accepts_the_templates_of_calibre():
|
||||||
|
for template in ("", "{author_sort}/{title}/{title} - {authors}", "{title:>255s}", "x" * 1024):
|
||||||
|
ft.check(template)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("template", ["{series_index:03d}", "{title:d}", "{title[50]}", "{id:c}{title:c}"])
|
||||||
|
def test_a_template_can_fail_for_one_book(template):
|
||||||
|
ft.check(template)
|
||||||
|
with pytest.raises(ft.TemplateError):
|
||||||
|
expand(template)
|
||||||
|
|
||||||
|
|
||||||
|
def file_name(template, **changes):
|
||||||
|
return ft.file_name(template, ft.prepare({**BOOK, **changes}, 42))
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_name_is_the_last_part_of_the_path():
|
||||||
|
assert file_name("{author_sort}/{title}/{title} - {authors}") == "Book, The - Ann Writer & Ben Reader"
|
||||||
|
assert file_name("{series}//{title} /") == "Book, The"
|
||||||
|
assert file_name("{author_sort[0]}/{series}", series="") == "W"
|
||||||
|
assert file_name("{series}/{other}", series="") == ""
|
||||||
|
assert file_name(" / ") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_name_is_safe():
|
||||||
|
assert file_name("{title}?", title="A/B: C\\D") == "A_B_ C_D_"
|
||||||
|
assert file_name("<{id:c}>") == "___"
|
||||||
|
assert file_name("{title}", title="con") == "_con"
|
||||||
|
assert file_name("{title}.{id}", title="NUL") == "_NUL.42"
|
||||||
|
assert file_name("{title}", title="Console") == "Console"
|
||||||
|
assert file_name("{title}...") == "Book, The"
|
||||||
|
long = file_name("{title}", title="é" * 150)
|
||||||
|
assert long == "é" * 100
|
||||||
|
assert len(file_name("{title}", title="\U0001f600" * 150).encode()) == 200
|
||||||
@@ -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.")
|
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):
|
def test_link_setting_defaults_on_and_persists(client, monkeypatch, tmp_path, library):
|
||||||
assert client.get("/api/settings").json["link_books"] is True
|
assert client.get("/api/settings").json["link_books"] is True
|
||||||
response = client.put("/api/settings", json={"link_books": False}, headers=H)
|
response = client.put("/api/settings", json={"link_books": False}, headers=H)
|
||||||
|
|||||||
Reference in New Issue
Block a user