36 Commits
Author SHA1 Message Date
tim ece59c2558 Add {series_sort} to the download template
It gives the series with a leading article at the end, as Calibre's default does for {series}.
2026-10-03 08:09:41 +02:00
tim dcdd8e4ac7 Keep the article in {title} and {series}
The default template is now {title_sort} - {authors}. It gives the same name as Calibre's default.
2026-10-03 08:09:41 +02:00
tim 42e7287389 Fold each group of settings into an open card
The long help for the download template and the date format now folds shut.
2026-10-03 08:09:41 +02:00
tim e38c8d655a Add a date format for download names
The new setting gives the strftime format of {pubdate}, {timestamp} and {last_modified}.
The default is Calibre's "%b, %Y".
2026-10-03 08:09:41 +02:00
tim 2c8a1bb187 Use {title} - {authors} as the default download template
This is Calibre's default. An empty template now restores the default.
2026-10-03 08:09:41 +02:00
tim 323bec3c04 Name no template fields that Bindery does not have 2026-10-03 08:09:41 +02:00
tim 3e29ab6bf0 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.
2026-10-03 08:09:41 +02:00
tim 9e7a488b50 Add Calibre's file name templates
The module prepares the metadata of a book and expands a template as Calibre 9.2.1 does with
its default tweaks. It takes fields, formats, indexes, and text before and after a field. It
refuses template functions and the program modes.
2026-10-03 08:09:41 +02:00
tim ccd3b18791 Share the folder setup of the configuration tests 2026-10-03 08:09:41 +02:00
tim 6a1c92bc62 Remove duplicates in the web interface
The settings page has one Save button helper. The story list sorts by
the sort key that the server sends, so the rule for a leading article
exists once. The story page takes its list of download modes from the
mode hints.
2026-10-03 08:09:41 +02:00
tim 2ce3de6b58 Remove unused store and CWA client code
The two functions that add chapters share one insert. Nothing used
Store.current_revisions and CwaClient.check.
2026-10-03 08:09:41 +02:00
tim 5f682d209b Remove small duplicates in the settings, the logins and the names
One function reads a true or false value. The download name uses the
file name cleaning of the ingest folder. The login code reads the login
mode from the configuration, and has one lockout message. The title
helper of FanFicFare is now unescape_title, because cwa.py has another
plain_title.
2026-10-03 08:09:41 +02:00
tim 96cf1e4f03 Split the site job into smaller functions
A new story, the chapter list of the site and the choice of chapters to
download each have a function. One helper downloads a chapter for both
loops.
2026-10-03 08:09:41 +02:00
tim 2ada3b3d7e Share the checks and the job builders of the orchestrator
One function checks a whole number of a request, and one checks the
metadata field names. save_settings takes the settings by name. A rebuild
job and a story from an epub each have one builder.
2026-10-03 08:09:41 +02:00
tim 89709f875a Give a library book one form in the API
A book from the search, from the list of library candidates and from a
lookup now has the same fields, with the authors as a list.
2026-10-03 08:09:41 +02:00
tim 22478c3a56 Simplify the upload state and the story page data
The pause of the user and a missing ingest folder are separate checks.
One function gives the upload state to the settings and the story page.
A route that returns only a story no longer loads its chapters or asks
CWA for its book. The story page data loses the values that no page reads.
2026-10-03 08:09:41 +02:00
tim 2b1a26d1da Name Bindery alone in the default personal.ini 2026-10-03 08:09:41 +02:00
tim 05ef1dbfad Add CWA_ENABLED to run Bindery without CWA
With CWA_ENABLED=false, Bindery ignores the CWA settings, makes no request
to CWA, and shows nothing about CWA in the log and the web interface. A
set INGEST_DIR still gets each changed book as a plain epub.

Without it, the log names each CWA function that a missing setting turns
off.
2026-10-02 23:39:58 +02:00
tim bb6c87cdd5 Let only admins change the global settings
BINDERY_ADMIN_USERS lists the admins of the cwa and proxy modes. Only an
admin can change the global settings and the global rules, and read or
change personal.ini. Without a list, each user is an admin.
2026-10-02 23:37:33 +02:00
tim 751b52da77 Answer only to the host names of Bindery
Without a login, a web page that the user visits could reach Bindery
under a name of its own that points at the address of Bindery, and read
and change everything. The "none" mode now answers only to addresses
and to names of a private network. ALLOWED_HOSTS sets the names for
each mode.
2026-10-02 23:37:33 +02:00
tim 2d9796d77c Accept the proxy user header only from trusted addresses
In proxy mode, each client that reached the port could send the user
header. PROXY_AUTH_TRUSTED_IPS now lists the addresses and networks that
can send it. The default is the loopback and private addresses, so a
request that comes straight from the internet cannot log in.
2026-10-02 23:37:33 +02:00
tim 9196dd75a8 Refuse OPDS logins when CWA accepts every password
A CWA with anonymous browsing can answer an OPDS request whatever the
password, so each password logged a user in to Bindery. Bindery now
tests CWA with a login that cannot exist, and refuses OPDS logins if
CWA accepts it. A login check no longer shares the session of the
account of Bindery. The CWA account stays optional for a CWA that asks
for no login, and a missing account now gets its own message.
2026-10-02 23:37:33 +02:00
tim 869e56eaf0 Do not make the ingest folder
A folder that Bindery made hid a missing volume mount. The books went
to a folder inside the container, and CWA never saw them. A missing
folder is now an error in the log at the start, and each upload fails
with a message that names the mount.
2026-10-02 23:37:33 +02:00
tim 679c1ede9f Serve a download from the built book, and delete stale uploads
A download built the book again each time. It now sends the kept build,
and builds only if the book changed after it. Stop tracking deletes the
built book. A cancelled import deletes its uploaded file, and the start
deletes the uploads that no job needs.
2026-10-02 23:37:33 +02:00
tim ff2fa0bf90 Keep a move or a delete of chapters that the user makes during a job
A job placed each new chapter with the chapter order from its start, so
a move by hand during the job went back. The store now places a new
chapter against the order that the story has at that moment, in one
transaction. A recheck of a chapter that the user deleted no longer
stops the job.
2026-10-02 23:37:33 +02:00
tim 81efc03b75 Keep the job thread alive after an error of the queue
An error outside a job, such as a full disk, stopped the thread, and no
job ran until a restart. The health check now answers 503 when a job
thread stopped.
2026-10-02 23:37:33 +02:00
tim c0504135b2 Follow the writing rules in the README and two messages
Remove "should" from the README and the semicolons from two messages.
Correct the note about a local run without CWA.
2026-10-02 23:37:33 +02:00
tim 9c9c5b7dac Load a job record that holds unknown fields
A downgrade of Bindery no longer stops at the start.
2026-10-02 23:37:33 +02:00
tim 98c4a66da1 Answer 400 for a request value of the wrong type
A value such as chapter_ids that is not a list of numbers gave a 500.
2026-10-02 23:37:33 +02:00
tim 90457610f6 Wait only 5 seconds for CWA on the story page
A job still waits for the full timeout.
2026-10-02 23:37:33 +02:00
tim e196d1688e Keep server details out of API answers
An unexpected error now sends a general message, and the log keeps the
text. A job no longer shows the path of its uploaded file. A cover is
cached only by the browser of the user.
2026-10-02 23:37:33 +02:00
tim 0edfa1308c Stop the start with a clear message for a bad setting
A bad UPDATE_INTERVAL_HOURS or LOG_LEVEL no longer ends in a traceback.
Warn about a CWA_PASSWORD without a CWA_USERNAME.
2026-10-02 23:37:33 +02:00
tim 0506086533 Check every setting before the first one is stored
A bad value in a settings request no longer leaves the other values of
that request saved.
2026-10-02 23:37:33 +02:00
tim 7a36e26751 Build and send a book only when it changed
A key of the chapters, rules and metadata of a story tells if its book
changed. A check without a change no longer builds the book again, also
for a book that waits for an upload. A held book goes out without a new
build. The Rebuild button still builds and sends in each case.
2026-10-02 23:37:33 +02:00
tim f961e4781e Warn when the ingest folder has no library to match
Without CWA_URL and CALIBRE_DB_PATH, each update goes to CWA as a new book.
2026-10-02 21:04:49 +02:00
tim c188795852 Make CWA linking and uploads optional 2026-10-02 20:25:45 +02:00
48 changed files with 3929 additions and 611 deletions
+3 -1
View File
@@ -17,7 +17,8 @@ Keep the quotes around the glob in the node command. Without them, node reads `t
Start a development server with no CWA: Start a development server with no CWA:
```bash ```bash
CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery mkdir -p dev/ingest
CWA_ENABLED=false CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery
``` ```
The server has no reloader. If you change Python code, restart it. Flask reads `bindery/static` from disk on each request, so a server that is not restarted sends new JavaScript to old Python routes. The server has no reloader. If you change Python code, restart it. Flask reads `bindery/static` from disk on each request, so a server that is not restarted sends new JavaScript to old Python routes.
@@ -27,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.
+190 -21
View File
@@ -4,6 +4,7 @@ Bindery downloads web fiction into a
[Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated) (CWA) library and [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated) (CWA) library and
keeps the books up to date. It uses [FanFicFare](https://github.com/JimmXinu/FanFicFare), so it keeps the books up to date. It uses [FanFicFare](https://github.com/JimmXinu/FanFicFare), so it
supports the sites that FanFicFare supports. The tested site is RoyalRoad. supports the sites that FanFicFare supports. The tested site is RoyalRoad.
CWA is optional. You can download ePubs from Bindery without it.
## How it differs from FanFicFare ## How it differs from FanFicFare
@@ -21,7 +22,7 @@ builds each ePub from that database.
## Quick start ## Quick start
**You need CWA v4.0.1 or later.** Older versions can import an update as a new book. See For uploads to CWA, use CWA v4.0.1 or later. Older versions can import an update as a new book. See
"Limitations". "Limitations".
1. Get the code, and make the folder for Bindery's data. 1. Get the code, and make the folder for Bindery's data.
@@ -52,8 +53,9 @@ Environment variables set the configuration. **Restart the container after a cha
| Variable | Description | Default value | | Variable | Description | Default value |
| --- | --- | --- | | --- | --- | --- |
| `CWA_ENABLED` | `false` if you have no CWA. See "Without CWA". | `true` |
| `CWA_URL` | The address of CWA, for example `http://calibre-web-automated:8083` | None | | `CWA_URL` | The address of CWA, for example `http://calibre-web-automated:8083` | None |
| `CWA_USERNAME`, `CWA_PASSWORD` | The CWA account of Bindery | None | | `CWA_USERNAME`, `CWA_PASSWORD` | The CWA account of Bindery. Leave them unset if CWA asks for no login. | None |
| `CWA_DB_PATH` | Calibre-Web's `app.db`, for logins. See "CWA logins". | None | | `CWA_DB_PATH` | Calibre-Web's `app.db`, for logins. See "CWA logins". | None |
| `CALIBRE_DB_PATH` | The calibre `metadata.db`, for book ids. See "How books reach the library". | None | | `CALIBRE_DB_PATH` | The calibre `metadata.db`, for book ids. See "How books reach the library". | None |
| `UPDATE_INTERVAL_HOURS` | Hours between checks for new chapters. `0` turns them off. | `24` | | `UPDATE_INTERVAL_HOURS` | Hours between checks for new chapters. `0` turns them off. | `24` |
@@ -62,19 +64,55 @@ Environment variables set the configuration. **Restart the container after a cha
| `BINDERY_USERNAME` | The user name for `AUTH_METHOD=local`. Case-sensitive. | None | | `BINDERY_USERNAME` | The user name for `AUTH_METHOD=local`. Case-sensitive. | None |
| `BINDERY_PASSWORD_HASH` | The password hash for `AUTH_METHOD=local`. See "Local login". | None | | `BINDERY_PASSWORD_HASH` | The password hash for `AUTH_METHOD=local`. See "Local login". | None |
| `CWA_ALLOWED_USERS` | The CWA users that can log in. Empty allows every CWA user. | Empty | | `CWA_ALLOWED_USERS` | The CWA users that can log in. Empty allows every CWA user. | Empty |
| `BINDERY_ADMIN_USERS` | The users that can change the global settings. See "Admins". | Empty |
| `PROXY_AUTH_USER_HEADER` | The header in which a reverse proxy sends the user name | `X-Remote-User` | | `PROXY_AUTH_USER_HEADER` | The header in which a reverse proxy sends the user name | `X-Remote-User` |
| `PROXY_AUTH_TRUSTED_IPS` | The addresses and networks that can send the user header. See "Logins". | Loopback and private addresses |
| `PROXY_AUTH_LOGOUT_URL` | Where the Log out button goes in proxy mode | None | | `PROXY_AUTH_LOGOUT_URL` | Where the Log out button goes in proxy mode | None |
| `BINDERY_API_KEY` | A key for scripts. See "Scripts". | None | | `BINDERY_API_KEY` | A key for scripts. See "Scripts". | None |
| `ALLOWED_HOSTS` | The host names that Bindery answers to. See "Host names". | None |
| `SESSION_COOKIE_SECURE` | `true` if users reach Bindery only over HTTPS | `false` | | `SESSION_COOKIE_SECURE` | `true` if users reach Bindery only over HTTPS | `false` |
| `TRUST_PROXY` | `true` if a reverse proxy sends the client address. See "Lockouts and sessions". | `false` | | `TRUST_PROXY` | `true` if a reverse proxy sends the client address. See "Lockouts and sessions". | `false` |
| `URL_BASE` | A path prefix, such as `/bindery`. See "Reverse proxy". | None | | `URL_BASE` | A path prefix, such as `/bindery`. See "Reverse proxy". | None |
| `INGEST_DIR` | The CWA ingest folder | `/cwa-book-ingest` | | `INGEST_DIR` | The CWA ingest folder. If unset, all uploads stay paused. Bindery does not make this folder. Without CWA, a folder for the built ePubs. | None |
| `CONFIG_DIR` | The story database and `personal.ini` | `/config` | | `CONFIG_DIR` | The story database and `personal.ini` | `/config` |
| `TMP_DIR` | The folder for ePubs while Bindery builds them | `/tmp/bindery` | | `TMP_DIR` | The folder for the built ePubs. Bindery keeps the last build of each story. | `/tmp/bindery` |
| `LOG_LEVEL` | The log level | `INFO` | | `LOG_LEVEL` | The log level | `INFO` |
Separate the values in a list with commas. Separate the values in a list with commas.
### Without CWA
Set `CWA_ENABLED=false` to run Bindery without CWA. Bindery still downloads chapters and builds
ePubs. Download the books from each story page.
With `CWA_ENABLED=false`:
- Bindery makes no request to CWA. It ignores `CWA_URL`, `CWA_USERNAME`, `CWA_PASSWORD`,
`CWA_DB_PATH` and `CALIBRE_DB_PATH`.
- The web interface and the log show nothing about CWA. The pause, the library links and the
approvals do not apply.
- If `INGEST_DIR` is set, Bindery puts each changed book in that folder as a plain ePub. Use this to
import the books into other software. Each file gets a new name, so Bindery never replaces a file
in the folder.
- `AUTH_METHOD=cwa` does not work. Use `local` or `proxy`. On a private network, you can use `none`.
If you use the example Compose file, remove its CWA connection values. Keep `INGEST_DIR` and its
volume mount only if you want the ePubs in a folder.
Without `CWA_ENABLED=false`, Bindery expects CWA. At each start, the log then has a warning for each
function that a missing `INGEST_DIR`, `CWA_URL` or `CALIBRE_DB_PATH` turns off. Bindery still
downloads chapters and builds ePubs.
Without `INGEST_DIR`, Settings shows "Pause all uploads" checked and a highlighted explanation.
You cannot clear that checkbox until you set `INGEST_DIR` and restart Bindery.
If neither `CWA_URL` nor `CALIBRE_DB_PATH` is set, library linking is off and its checkbox is disabled.
Set either value and restart Bindery to enable the checkbox. Bindery keeps your saved linking preference.
Saved CWA book IDs still work for uploads if you configure an ingest folder.
To export ePubs for other software, set `INGEST_DIR` and turn off "Link books to CWA library" in Settings.
Each build without a saved CWA book ID then puts a plain ePub in that folder, including updates.
"Pause all uploads" still stops these exports. If CWA imports the files, updates will create duplicate books.
### Logins ### Logins
`AUTH_METHOD` sets how users log in: `AUTH_METHOD` sets how users log in:
@@ -90,6 +128,13 @@ Separate the values in a list with commas.
because Bindery trusts this header. If a request gets a 401 because the proxy session ended, because Bindery trusts this header. If a request gets a 401 because the proxy session ended,
Bindery reloads the page, so the proxy can step in again. Bindery reloads the page, so the proxy can step in again.
Bindery accepts the header only from the addresses in `PROXY_AUTH_TRUSTED_IPS`. The default is the
loopback and private addresses, such as `127.0.0.1`, `10.x`, `172.16.x` to `172.31.x` and
`192.168.x`. This stops a request that comes straight from the internet, but not one from your own
network. **Set it to the address of the proxy**, for example `PROXY_AUTH_TRUSTED_IPS=172.18.0.3`,
or publish the port of Bindery only on the host, as `127.0.0.1:8085:8085` in the Compose file.
`TRUST_PROXY` does not change this check. It is only for the lockouts.
**If the login settings are incomplete or wrong, Bindery refuses every request**, also with the API **If the login settings are incomplete or wrong, Bindery refuses every request**, also with the API
key, and shows the reason. key, and shows the reason.
@@ -139,13 +184,18 @@ open sessions.
`AUTH_METHOD=cwa` needs `CWA_URL`, `CWA_DB_PATH` or both. Without `CWA_DB_PATH`, Bindery sends the `AUTH_METHOD=cwa` needs `CWA_URL`, `CWA_DB_PATH` or both. Without `CWA_DB_PATH`, Bindery sends the
user name and password to `CWA_URL/opds` as an HTTP Basic login. This is the OPDS login. user name and password to `CWA_URL/opds` as an HTTP Basic login. This is the OPDS login.
**The OPDS login works only if CWA refuses a wrong password.** A CWA with anonymous browsing can
accept every OPDS login. Bindery tests this with a login that cannot exist. If CWA accepts it,
Bindery refuses each OPDS login, and the log names the reason. Set `CWA_DB_PATH` then, or use
another `AUTH_METHOD`.
With `CWA_DB_PATH`, Bindery checks the password against the account in Calibre-Web's `app.db`. A With `CWA_DB_PATH`, Bindery checks the password against the account in Calibre-Web's `app.db`. A
wrong password or an unknown account fails at once. If Bindery cannot read the file or its password wrong password or an unknown account fails at once. If Bindery cannot read the file or its password
hash, it uses the OPDS login. hash, it uses the OPDS login.
`CWA_DB_PATH` is the path of `app.db` inside the container. To use it, mount the CWA config folder `CWA_DB_PATH` is the path of `app.db` inside the container. To use it, mount the CWA config folder
read-only into the container. Mount the whole folder, so that SQLite can read the journal files read-only into the container. Mount the whole folder, so that SQLite can read the journal files
beside the database. **Bindery does not write this database**, and access should ideally be read-only. beside the database. **Bindery does not write this database.**
**Database logins check only the password.** CWA's external login providers and multi-factor **Database logins check only the password.** CWA's external login providers and multi-factor
authentication do not apply. If they must apply, use the `proxy` mode. authentication do not apply. If they must apply, use the `proxy` mode.
@@ -177,6 +227,43 @@ open session until that session ends. To stop a CWA user at once, remove the use
`CWA_ALLOWED_USERS` and restart Bindery. To end all sessions, delete `/config/.flask_secret` and `CWA_ALLOWED_USERS` and restart Bindery. To end all sessions, delete `/config/.flask_secret` and
restart Bindery. restart Bindery.
#### Admins
In the `cwa` and `proxy` modes, `BINDERY_ADMIN_USERS` lists the users that are admins, for example
`BINDERY_ADMIN_USERS=alice, bob`. Only an admin can do these things:
- Open the Settings page, and change the settings on it.
- Change the global replacement rules.
- Read and change `personal.ini`, which can hold passwords for story sites.
Each other user can still add, update and delete stories, and change the settings and the rules of a
story. Bindery ignores the case of the names.
If the list is empty, each user is an admin. The `none` and `local` modes have only one user, who is
always an admin. The API key is always an admin too.
### Host names
Without a login, a web page that you visit can try to reach Bindery from your browser, under a host
name of its own that points at the address of Bindery. To stop this, the `none` login mode answers
only to these host names:
- An address, such as `192.168.1.5`.
- `localhost`, and a name without a dot, such as `nas` or the name of a container.
- A name of a private network. These end in `.local`, `.lan`, `.home`, `.home.arpa`, `.internal`,
`.localhost` or `.localdomain`.
For each other name, Bindery answers with an error that shows the name. If you use such a name, for
example behind a reverse proxy, list it in `ALLOWED_HOSTS`:
- `ALLOWED_HOSTS=bindery.example.com` allows that name. Bindery then answers only to the names in
the list, in each login mode. Write the names without a port.
- A leading dot, as in `.example.com`, also allows the subdomains.
- `ALLOWED_HOSTS=*` allows each name.
The other login modes answer to each name, unless you set a list. The health check at `/api/health`
works under each name.
### Scripts ### Scripts
Scripts can use the JSON API of the web interface. Scripts can use the JSON API of the web interface.
@@ -222,7 +309,7 @@ setting. Bindery reads the file for each job, so a change applies to the next jo
You can edit the file on the Settings page. Bindery refuses a file that FanFicFare cannot read, and You can edit the file on the Settings page. Bindery refuses a file that FanFicFare cannot read, and
shows FanFicFare's warnings for other problems. **The file can hold passwords for story sites, and shows FanFicFare's warnings for other problems. **The file can hold passwords for story sites, and
every user who can log in to Bindery can read them.** each admin can read them.** If `BINDERY_ADMIN_USERS` is not set, each user is an admin. See "Admins".
Bindery always sets `always_reload_first_chapter`, `mark_new_chapters`, `continue_on_chapter_error`, Bindery always sets `always_reload_first_chapter`, `mark_new_chapters`, `continue_on_chapter_error`,
`make_directories`, `zip_output` and `calibre_series_meta`, because it needs fixed values for them. `make_directories`, `zip_output` and `calibre_series_meta`, because it needs fixed values for them.
@@ -244,6 +331,10 @@ The health check at `/api/health` works with and without the prefix.
Bindery gives every book to CWA through the ingest folder. CWA makes all changes to the library. Bindery gives every book to CWA through the ingest folder. CWA makes all changes to the library.
**Bindery does not make the ingest folder.** If the folder in `INGEST_DIR` does not exist, most often
because its volume mount is missing, the log shows an error at the start and each upload fails. The
books go to CWA with the next update after you fix the mount.
1. Bindery puts the ePub of a new story into the ingest folder. CWA imports it as a new book. 1. Bindery puts the ePub of a new story into the ingest folder. CWA imports it as a new book.
2. At the next update, Bindery finds the CWA book of the story. If you set `CALIBRE_DB_PATH`, it 2. At the next update, Bindery finds the CWA book of the story. If you set `CALIBRE_DB_PATH`, it
first looks for the story URL in the library database. Without a single match there, it searches first looks for the story URL in the library database. Without a single match there, it searches
@@ -261,20 +352,20 @@ set `CALIBRE_DB_PATH`, then through CWA. If you deleted the book in CWA, Bindery
`CALIBRE_DB_PATH` is optional. It is the path of the calibre `metadata.db` inside the container, and `CALIBRE_DB_PATH` is optional. It is the path of the calibre `metadata.db` inside the container, and
it lets Bindery find a book by its story URL instead of its title. To use it, mount the library it lets Bindery find a book by its story URL instead of its title. To use it, mount the library
folder read-only into the container. Mount the whole folder, so that SQLite can read the journal folder read-only into the container. Mount the whole folder, so that SQLite can read the journal
files beside the database. The Compose file has an example. **Bindery does not write to this database**, files beside the database. The Compose file has an example. **Bindery does not write to this database.**
and access should ideally be read-only.
Be sure to use the library that gets the ingest files. A different library can make Bindery update the Be sure to use the library that gets the ingest files. A different library can make Bindery update the
wrong book. Database lookups also find books that the CWA account of Bindery cannot see. If Bindery wrong book. Database lookups also find books that the CWA account of Bindery cannot see. If Bindery
cannot read the database, it uses CWA for every lookup. Keep `CWA_URL`, `CWA_USERNAME` and cannot read the database, it uses CWA for every lookup. Keep `CWA_URL` and the CWA account set,
`CWA_PASSWORD` set, because library imports and CWA lookups use them. because library imports and CWA lookups use them.
A book that the FanFicFare calibre plugin made holds its story URL. If you add its URL, or import it A book that the FanFicFare calibre plugin made holds its story URL. If you add its URL, or import it
from the library, Bindery links that book and imports its chapters from the ePub. from the library, Bindery links that book and imports its chapters from the ePub.
### If Bindery cannot find the book ### If Bindery cannot find the book
The update fails, and the story page shows the error "Bindery cannot find this story in CWA". The If library matching is on and configured, an update fails when Bindery cannot find the book.
The story page shows the error "Cannot find this story in CWA. Link to library book, or send as new book." The
chapters stay stored, and the next successful update includes them. This happens if: chapters stay stored, and the next successful update includes them. This happens if:
- CWA did not import the ePub. - CWA did not import the ePub.
@@ -288,18 +379,26 @@ To fix it, go to "Library link" on the story page, and do one of these:
- Click "Send to CWA as a new book". Use this if CWA did not import the first version, or if you - Click "Send to CWA as a new book". Use this if CWA did not import the first version, or if you
deleted the book. deleted the book.
### Hold uploads ### Upload settings
Two settings on the Settings page stop books from going to CWA. Bindery still downloads the chapters The "Uploads to CWA" section controls library matching and uploads. Bindery still downloads chapters
and builds each book. The story list marks a book that waits with "held". and builds books when uploads stop. The story list marks a built book with "held" only when it waits
for approval, library linking is on, and uploads are not paused. Bindery keeps track of all unsent books.
- **Hold new links for approval.** Use this to catch a story that is linked to the wrong CWA book. - Link books to CWA library. This defaults to on when `CWA_URL` or `CALIBRE_DB_PATH` is set.
Without either value, it stays off and disabled. Turn it off to skip matching with a configured library connection.
Uploads use saved CWA book IDs when available. Without an ID, every build goes to `INGEST_DIR` as a plain ePub.
If CWA imports these files, updates will create duplicate books. Settings and the story page show a highlighted warning.
A change of this setting sends the unsent books, unless uploads are paused.
- **Hold new links for approval.** This setting applies only when library linking is on.
Use this to catch a story that is linked to the wrong CWA book.
The first upload of a new story waits for your approval. The first upload after a change of the The first upload of a new story waits for your approval. The first upload after a change of the
library link also waits. This includes a link that you make, and a book that Bindery finds after library link also waits. This includes a link that you make, and a book that Bindery finds after
the linked book is gone. The story page names the book that gets the update, or says that the the linked book is gone. The story page names the book that gets the update, or says that the
story goes to CWA as a new book. story goes to CWA as a new book.
- **Pause all uploads.** No book goes to CWA. When you turn the pause off, Bindery rebuilds each - **Pause all uploads.** No book goes to CWA. This is always checked when `INGEST_DIR` is unset.
book that waits and sends it. When you turn the pause off, Bindery sends the unsent books. The other upload settings still apply.
While paused, use "Approve" or "Approve as a new book instead" to save a decision without sending the book.
To approve a link, open the story page and do one of these: To approve a link, open the story page and do one of these:
@@ -324,6 +423,9 @@ Make a CWA user for Bindery with these settings:
Bindery sends the password of this account with each OPDS request. **Use a password that no other Bindery sends the password of this account with each OPDS request. **Use a password that no other
service uses.** service uses.**
If CWA allows anonymous browsing, Bindery needs no account. Leave `CWA_USERNAME` and `CWA_PASSWORD`
unset, and give the guest user of CWA the settings above.
## Import an ePub file ## Import an ePub file
If the site removed chapters before Bindery tracked the story, import an ePub that you made earlier If the site removed chapters before Bindery tracked the story, import an ePub that you made earlier
@@ -343,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 |
@@ -352,7 +514,11 @@ downloaded before.
| Recheck all chapters | Downloads every chapter on the site again, and new chapters too. | | Recheck all chapters | Downloads every chapter on the site again, and new chapters too. |
| Download and replace entire book | Downloads every chapter again. Chapters that the site no longer lists leave the book. They stay stored, and you can put them back. | | Download and replace entire book | Downloads every chapter again. Chapters that the site no longer lists leave the book. They stay stored, and you can put them back. |
| Update metadata from site | Updates only the metadata in the ePub. | | Update metadata from site | Updates only the metadata in the ePub. |
| Rebuild book | Builds the book again from the stored chapters, for example after you change a rule. | | Rebuild book | Builds the book again from the stored chapters, and sends it. |
**Bindery builds and sends a book only if the book changed.** A change is a new, changed, moved or
renamed chapter, a changed rule, or changed metadata. A job that finds no change answers "No changes"
and sends nothing to CWA. Only "Rebuild book" builds and sends a book that did not change.
### Find edited chapters ### Find edited chapters
@@ -395,6 +561,9 @@ After a restart, the waiting jobs run. A job that was running starts again at th
queue, and skips the chapters that it stored. If Bindery stops during the same job three times, the queue, and skips the chapters that it stored. If Bindery stops during the same job three times, the
job fails. job fails.
If a job thread stops, `/api/health` answers 503, so the container shows as unhealthy. Restart
Bindery to start the thread again.
### Backups ### Backups
Once a day, Bindery copies its database and `personal.ini` to `/config/backups`, and keeps the Once a day, Bindery copies its database and `personal.ini` to `/config/backups`, and keeps the
@@ -492,12 +661,12 @@ the pages in a browser.
### Run locally without CWA ### Run locally without CWA
Without CWA, Bindery puts the first download of each story into its ingest folder as an ePub. Later Without CWA, Bindery puts each new build of a story into its ingest folder as an ePub. This is
downloads fail, because Bindery needs CWA to find the book, but their chapters stay stored. This is
enough to work on downloads, chapters, rules and the web interface. enough to work on downloads, chapters, rules and the web interface.
```bash ```bash
CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery mkdir -p dev/ingest
CWA_ENABLED=false CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery
``` ```
Open `http://127.0.0.1:8085`. The folders go in `dev/`, which git ignores. To look at an ePub, use Open `http://127.0.0.1:8085`. The folders go in `dev/`, which git ignores. To look at an ePub, use
+88 -14
View File
@@ -1,11 +1,13 @@
"""Logins: none, one local account, CWA accounts, or a proxy. API keys work in every mode. """Logins: none, one local account, CWA accounts, or a proxy. API keys work in every mode.
CWA accounts use an optional read-only app.db, with OPDS as the fallback when CWA accounts use an optional read-only app.db, with OPDS as the fallback when
the database cannot answer. A rejected database login never falls back. the database cannot answer. A rejected database login never falls back. The
OPDS login works only with a CWA that refuses a wrong password.
""" """
import hashlib import hashlib
import hmac import hmac
import ipaddress
import logging import logging
import threading import threading
import time import time
@@ -13,6 +15,7 @@ from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Any from typing import Any
from urllib.parse import urlsplit
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
@@ -25,6 +28,9 @@ AUTH_NONE, AUTH_LOCAL, AUTH_CWA, AUTH_PROXY = "none", "local", "cwa", "proxy"
MAX_FAILURES = 5 # for one user name from one address MAX_FAILURES = 5 # for one user name from one address
MAX_ADDRESS_FAILURES = 20 # for all user names from one address MAX_ADDRESS_FAILURES = 20 # for all user names from one address
LOCKOUT_SECONDS = 15 * 60 LOCKOUT_SECONDS = 15 * 60
LOCKED = "Too many wrong passwords. Try again in 15 minutes."
# Name endings that only a private network can resolve.
LOCAL_SUFFIXES = (".local", ".lan", ".home", ".home.arpa", ".internal", ".localhost", ".localdomain")
class AuthError(Exception): class AuthError(Exception):
@@ -53,6 +59,48 @@ def _valid_local_hash(value: str) -> bool:
return False return False
@lru_cache(maxsize=8)
def _networks(values: tuple[str, ...]) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
"""The networks of a list of addresses and networks. Raises ValueError for a value that is neither."""
return tuple(ipaddress.ip_network(v, strict=False) for v in values)
def _trusted_proxy(address: str | None) -> bool:
"""Whether the request comes from an address that can send the user header."""
try:
ip = ipaddress.ip_address(address or "")
except ValueError:
return False
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped
return any(ip.version == n.version and ip in n for n in _networks(tuple(config.PROXY_AUTH_TRUSTED_IPS)))
def host_allowed(host: str) -> bool:
"""Whether Bindery answers a request with this Host header.
Without a login, each web page that the user visits could reach Bindery under
a name of its own that points at the address of Bindery (DNS rebinding), and
read and change everything. Such a name is a public name, so the "none" mode
answers only to addresses and to names of a private network.
"""
try:
name = (urlsplit(f"//{host}").hostname or "").rstrip(".")
except ValueError:
return False
if config.ALLOWED_HOSTS:
return any(
h == "*" or name == h.lstrip(".") or (h.startswith(".") and name.endswith(h)) for h in config.ALLOWED_HOSTS
)
if config.AUTH_METHOD != AUTH_NONE:
return True
try:
ipaddress.ip_address(name)
return True
except ValueError:
return bool(name) and ("." not in name or name.endswith(LOCAL_SUFFIXES))
def local_password_version() -> str: def local_password_version() -> str:
"""Invalidate local sessions when the configured password hash changes.""" """Invalidate local sessions when the configured password hash changes."""
return hashlib.sha256(config.LOCAL_PASSWORD_HASH.encode()).hexdigest() return hashlib.sha256(config.LOCAL_PASSWORD_HASH.encode()).hexdigest()
@@ -71,22 +119,38 @@ def config_error() -> str | None:
return "AUTH_METHOD=local needs BINDERY_USERNAME and BINDERY_PASSWORD_HASH" return "AUTH_METHOD=local needs BINDERY_USERNAME and BINDERY_PASSWORD_HASH"
if not _valid_local_hash(config.LOCAL_PASSWORD_HASH): if not _valid_local_hash(config.LOCAL_PASSWORD_HASH):
return "BINDERY_PASSWORD_HASH must be a Werkzeug scrypt or PBKDF2 hash of a nonempty password" return "BINDERY_PASSWORD_HASH must be a Werkzeug scrypt or PBKDF2 hash of a nonempty password"
if method == AUTH_CWA and not config.CWA_ENABLED:
return "AUTH_METHOD=cwa needs CWA, but CWA_ENABLED is false"
if method == AUTH_CWA and not (config.CWA_URL or config.CWA_DB_PATH): if method == AUTH_CWA and not (config.CWA_URL or config.CWA_DB_PATH):
return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH" return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"
if method == AUTH_PROXY and not config.PROXY_AUTH_USER_HEADER: if method == AUTH_PROXY:
return "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER" if not config.PROXY_AUTH_USER_HEADER:
return "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER"
if not config.PROXY_AUTH_TRUSTED_IPS:
return "AUTH_METHOD=proxy needs PROXY_AUTH_TRUSTED_IPS, the addresses of the proxy"
try:
_networks(tuple(config.PROXY_AUTH_TRUSTED_IPS))
except ValueError as e:
return f"PROXY_AUTH_TRUSTED_IPS must be a list of addresses and networks: {e}"
return None return None
def active_mode() -> str:
return config.AUTH_METHOD
def _allowed(name: str) -> bool: def _allowed(name: str) -> bool:
names = {n.casefold() for n in config.CWA_ALLOWED_USERS} names = {n.casefold() for n in config.CWA_ALLOWED_USERS}
return not names or name.casefold() in names return not names or name.casefold() in names
def is_admin(identity: Identity) -> bool:
"""Whether this user can change the global settings, the global rules and personal.ini.
Only the cwa and proxy modes have more than one user. The API key is always an admin.
"""
names = {n.casefold() for n in config.ADMIN_USERS}
if not names or identity.via in ("api_key", "none") or config.AUTH_METHOD == AUTH_LOCAL:
return True
return identity.name.casefold() in names
def _api_key_identity(headers: Mapping[str, str]) -> Identity | None: def _api_key_identity(headers: Mapping[str, str]) -> Identity | None:
if not config.API_KEY: if not config.API_KEY:
return None return None
@@ -99,18 +163,23 @@ def _api_key_identity(headers: Mapping[str, str]) -> Identity | None:
return None return None
def current_identity(headers: Mapping[str, str], session: Mapping[str, Any]) -> Identity | None: def current_identity(
"""Who makes this request, or None when a login is needed.""" headers: Mapping[str, str], session: Mapping[str, Any], remote_addr: str | None = None
) -> Identity | None:
"""Who makes this request, or None when a login is needed. `remote_addr` is the address that connects."""
if config_error(): if config_error():
return None return None
identity = _api_key_identity(headers) identity = _api_key_identity(headers)
if identity: if identity:
return identity return identity
mode = active_mode() mode = config.AUTH_METHOD
if mode == AUTH_NONE: if mode == AUTH_NONE:
return Identity("", "none") return Identity("", "none")
if mode == AUTH_PROXY: if mode == AUTH_PROXY:
name = (headers.get(config.PROXY_AUTH_USER_HEADER) or "").strip() name = (headers.get(config.PROXY_AUTH_USER_HEADER) or "").strip()
if name and not _trusted_proxy(remote_addr):
logger.warning(f"Ignored the user header from {remote_addr}, which is not in PROXY_AUTH_TRUSTED_IPS")
return None
return Identity(name, "proxy") if name else None return Identity(name, "proxy") if name else None
name = session.get("user") name = session.get("user")
if not name or session.get("mode") != mode: if not name or session.get("mode") != mode:
@@ -174,17 +243,22 @@ def _login_ok(username: str, password: str, client: CwaClient) -> bool:
return calibre_db.login_ok(config.CWA_DB_PATH, username, password) return calibre_db.login_ok(config.CWA_DB_PATH, username, password)
except calibre_db.DatabaseUnavailable as e: except calibre_db.DatabaseUnavailable as e:
logger.warning(f"Cannot read the Calibre-Web login database. Trying OPDS: {e}") logger.warning(f"Cannot read the Calibre-Web login database. Trying OPDS: {e}")
if client.accepts_any_login():
raise CwaError(
"CWA accepts every OPDS login, most often because anonymous browsing is on, so Bindery cannot check "
"a password through OPDS. Set CWA_DB_PATH, or use another AUTH_METHOD."
)
return client.login_ok(username, password) return client.login_ok(username, password)
def login_local(username: str, password: str, address: str) -> Identity: def login_local(username: str, password: str, address: str) -> Identity:
username = username.strip() username = username.strip()
if active_mode() != AUTH_LOCAL: if config.AUTH_METHOD != AUTH_LOCAL:
raise AuthError("Bindery does not use local logins") raise AuthError("Bindery does not use local logins")
if error := config_error(): if error := config_error():
raise AuthError(error) raise AuthError(error)
if limiter.locked(username, address): if limiter.locked(username, address):
raise AuthError("Too many wrong passwords. Try again in 15 minutes.") raise AuthError(LOCKED)
# Check the password even for a wrong user name, so both failures take the same work. # Check the password even for a wrong user name, so both failures take the same work.
password_ok = check_password_hash(config.LOCAL_PASSWORD_HASH, password) password_ok = check_password_hash(config.LOCAL_PASSWORD_HASH, password)
if not (password_ok and password and hmac.compare_digest(username.encode(), config.LOCAL_USERNAME.encode())): if not (password_ok and password and hmac.compare_digest(username.encode(), config.LOCAL_USERNAME.encode())):
@@ -197,10 +271,10 @@ def login_local(username: str, password: str, address: str) -> Identity:
def login_cwa(username: str, password: str, client: CwaClient, address: str) -> Identity: def login_cwa(username: str, password: str, client: CwaClient, address: str) -> Identity:
"""Check a CWA login. Raises AuthError with a message for the user.""" """Check a CWA login. Raises AuthError with a message for the user."""
username = username.strip() username = username.strip()
if active_mode() != AUTH_CWA: if config.AUTH_METHOD != AUTH_CWA:
raise AuthError("Bindery does not use CWA logins") raise AuthError("Bindery does not use CWA logins")
if limiter.locked(username, address): if limiter.locked(username, address):
raise AuthError("Too many wrong passwords. Try again in 15 minutes.") raise AuthError(LOCKED)
try: try:
ok = ( ok = (
_allowed(username) _allowed(username)
+84 -9
View File
@@ -8,8 +8,18 @@ from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _bool(name: str, default: str = "false") -> bool: def flag(value: str, default: bool = False) -> bool:
return os.getenv(name, default).strip().lower() in ("true", "yes", "1", "y", "on") """A text value as true or false. A value that is neither gives the default."""
value = value.strip().lower()
if value in ("true", "yes", "1", "y", "on"):
return True
if value in ("false", "no", "0", "n", "off"):
return False
return default
def _bool(name: str, default: bool = False) -> bool:
return flag(os.getenv(name, ""), default)
def _list(name: str, default: str = "") -> list[str]: def _list(name: str, default: str = "") -> list[str]:
@@ -27,6 +37,22 @@ def _optional_path(name: str) -> Path | None:
return Path(value).absolute() if value else None return Path(value).absolute() if value else None
# A bad value stops the start with one clear line, not with a traceback.
def _number(name: str, default: str) -> float:
value = os.getenv(name, default).strip()
try:
return float(value)
except ValueError:
raise SystemExit(f"{name} must be a number, not {value!r}") from None
def _choice(name: str, default: str, choices: tuple[str, ...]) -> str:
value = os.getenv(name, default).strip().upper()
if value not in choices:
raise SystemExit(f"{name} must be one of {', '.join(choices)}, not {value!r}")
return value
def _base_path(value: str) -> str: def _base_path(value: str) -> str:
path = value.strip().strip("/") path = value.strip().strip("/")
return f"/{path}" if path else "" return f"/{path}" if path else ""
@@ -35,9 +61,9 @@ def _base_path(value: str) -> str:
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
CONFIG_DIR = _path("CONFIG_DIR", "/config") CONFIG_DIR = _path("CONFIG_DIR", "/config")
INGEST_DIR = _path("INGEST_DIR", "/cwa-book-ingest") INGEST_DIR = _optional_path("INGEST_DIR")
TMP_DIR = _path("TMP_DIR", "/tmp/bindery") TMP_DIR = _path("TMP_DIR", "/tmp/bindery")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").strip().upper() LOG_LEVEL = _choice("LOG_LEVEL", "INFO", ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"))
# A path prefix, such as /bindery, for a reverse proxy that forwards the full path. # A path prefix, such as /bindery, for a reverse proxy that forwards the full path.
URL_BASE = _base_path(os.getenv("URL_BASE", "")) URL_BASE = _base_path(os.getenv("URL_BASE", ""))
@@ -48,8 +74,16 @@ CWA_PASSWORD = os.getenv("CWA_PASSWORD", "")
# Independent, optional read-only databases. HTTP remains the fallback. # Independent, optional read-only databases. HTTP remains the fallback.
CWA_DB_PATH = _optional_path("CWA_DB_PATH") # Calibre-Web app.db, for logins CWA_DB_PATH = _optional_path("CWA_DB_PATH") # Calibre-Web app.db, for logins
CALIBRE_DB_PATH = _optional_path("CALIBRE_DB_PATH") # calibre metadata.db, for book IDs CALIBRE_DB_PATH = _optional_path("CALIBRE_DB_PATH") # calibre metadata.db, for book IDs
# false if the user has no CWA. Bindery then ignores the CWA settings above, and shows nothing about CWA. INGEST_DIR
# still gets each built book, as a plain epub. Only a clear "false" turns CWA off.
CWA_ENABLED = _bool("CWA_ENABLED", True)
CWA_SETTINGS = ("CWA_URL", "CWA_USERNAME", "CWA_PASSWORD", "CWA_DB_PATH", "CALIBRE_DB_PATH")
IGNORED_SETTINGS = [] if CWA_ENABLED else [name for name in CWA_SETTINGS if os.getenv(name, "").strip()]
if not CWA_ENABLED:
CWA_URL = CWA_USERNAME = CWA_PASSWORD = ""
CWA_DB_PATH = CALIBRE_DB_PATH = None
UPDATE_INTERVAL_HOURS = float(os.getenv("UPDATE_INTERVAL_HOURS", "24")) UPDATE_INTERVAL_HOURS = _number("UPDATE_INTERVAL_HOURS", "24")
DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title") DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title")
# Logins: none, local, cwa or proxy. An API key works in every mode. # Logins: none, local, cwa or proxy. An API key works in every mode.
@@ -57,15 +91,25 @@ AUTH_METHOD = os.getenv("AUTH_METHOD", "none").strip().lower()
LOCAL_USERNAME = os.getenv("BINDERY_USERNAME", "").strip() LOCAL_USERNAME = os.getenv("BINDERY_USERNAME", "").strip()
LOCAL_PASSWORD_HASH = os.getenv("BINDERY_PASSWORD_HASH", "").strip() LOCAL_PASSWORD_HASH = os.getenv("BINDERY_PASSWORD_HASH", "").strip()
CWA_ALLOWED_USERS = _list("CWA_ALLOWED_USERS") CWA_ALLOWED_USERS = _list("CWA_ALLOWED_USERS")
# The users of the cwa and proxy modes that can change the global settings. Without a list, each user can.
ADMIN_USERS = _list("BINDERY_ADMIN_USERS")
PROXY_AUTH_USER_HEADER = os.getenv("PROXY_AUTH_USER_HEADER", "X-Remote-User").strip() PROXY_AUTH_USER_HEADER = os.getenv("PROXY_AUTH_USER_HEADER", "X-Remote-User").strip()
PROXY_AUTH_LOGOUT_URL = os.getenv("PROXY_AUTH_LOGOUT_URL", "").strip() PROXY_AUTH_LOGOUT_URL = os.getenv("PROXY_AUTH_LOGOUT_URL", "").strip()
# The addresses and networks that can send the user header. The default is each loopback and private address, so
# a request that comes straight from the internet cannot send it.
PROXY_AUTH_TRUSTED_IPS = _list(
"PROXY_AUTH_TRUSTED_IPS", "127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7, fe80::/10"
)
API_KEY = os.getenv("BINDERY_API_KEY", "").strip() API_KEY = os.getenv("BINDERY_API_KEY", "").strip()
# The host names that Bindery answers to. A leading dot also allows the subdomains, and * allows every name.
# Without a list, the "none" login mode answers only to addresses and local names. The other modes answer to all.
ALLOWED_HOSTS = [h.lower() for h in _list("ALLOWED_HOSTS")]
SESSION_COOKIE_SECURE = _bool("SESSION_COOKIE_SECURE") SESSION_COOKIE_SECURE = _bool("SESSION_COOKIE_SECURE")
# Take the client address from the last entry of X-Forwarded-For, which a reverse proxy adds. # Take the client address from the last entry of X-Forwarded-For, which a reverse proxy adds.
TRUST_PROXY = _bool("TRUST_PROXY") TRUST_PROXY = _bool("TRUST_PROXY")
# Tests turn off the job threads. # Tests turn off the job threads.
START_THREADS = _bool("BINDERY_START_THREADS", "true") START_THREADS = _bool("BINDERY_START_THREADS", True)
SITE_DELAY = 5 # seconds between two jobs that go to a story site SITE_DELAY = 5 # seconds between two jobs that go to a story site
JOB_LIST_MAX_HOURS = 24 * 30 # the highest allowed value of a "jobs from the last N hours" setting JOB_LIST_MAX_HOURS = 24 * 30 # the highest allowed value of a "jobs from the last N hours" setting
@@ -83,10 +127,41 @@ BACKUP_COUNT = 7 # daily backups to keep
def prepare() -> None: def prepare() -> None:
"""Make the folders, and give the user a personal.ini to edit on first start.""" """Make the folders, and give the user a personal.ini to edit on first start."""
for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR, INGEST_DIR): for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR):
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
# The ingest folder belongs to CWA. A folder that Bindery makes hides a missing volume mount, and CWA
# never sees its files.
if INGEST_DIR and not INGEST_DIR.is_dir():
logger.error(
f"The ingest folder {INGEST_DIR} does not exist. Check INGEST_DIR and the volume mount. "
"Uploads fail until the folder exists."
)
if not PERSONAL_INI.exists(): if not PERSONAL_INI.exists():
shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI) shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI)
logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}") logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}")
if not CWA_URL: if not CWA_ENABLED:
logger.warning("CWA_URL is not set. OPDS imports and HTTP fallbacks are unavailable.") if IGNORED_SETTINGS:
logger.info(f"CWA_ENABLED is false. Bindery ignores {', '.join(IGNORED_SETTINGS)}.")
return
if CWA_PASSWORD and not CWA_USERNAME:
logger.warning("CWA_PASSWORD is set, but CWA_USERNAME is not. Bindery sends no login to CWA.")
missing = _missing_cwa()
for warning in missing:
logger.warning(warning)
if missing:
logger.warning("If you do not use CWA, set CWA_ENABLED=false. Bindery then shows no messages about CWA.")
def _missing_cwa() -> list[str]:
"""What Bindery cannot do with CWA, because a setting is not set."""
missing = []
if INGEST_DIR is None:
missing.append("INGEST_DIR is not set. Bindery sends no book to CWA.")
if not (CWA_URL or CALIBRE_DB_PATH):
effect = ", so each update adds a new book to CWA." if INGEST_DIR else "."
missing.append(
"CWA_URL and CALIBRE_DB_PATH are not set. Bindery cannot find the library book of a story" + effect
)
elif not CWA_URL:
missing.append("CWA_URL is not set. Bindery cannot search the CWA library, or import books from it.")
return missing
+32 -10
View File
@@ -7,7 +7,9 @@ by title, then reads the story URL inside each candidate epub.
import logging import logging
import re import re
import secrets
import threading import threading
import time
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -30,6 +32,8 @@ ACQUISITION = "http://opds-spec.org/acquisition"
# The most epubs that one search downloads to find a story. # The most epubs that one search downloads to find a story.
MAX_DOWNLOADS = 5 MAX_DOWNLOADS = 5
MAX_PAGES = 100 MAX_PAGES = 100
# The seconds that Bindery trusts its last test of the logins of CWA.
LOGIN_TEST_SECONDS = 10 * 60
class CwaError(Exception): class CwaError(Exception):
@@ -52,11 +56,12 @@ class Book:
formats: dict[str, str] = field(default_factory=dict) # "epub" -> download href formats: dict[str, str] = field(default_factory=dict) # "epub" -> download href
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""The book in the form of get_book, with its publisher."""
return { return {
"id": self.id, "id": self.id,
"uuid": self.uuid, "uuid": self.uuid,
"title": self.title, "title": self.title,
"authors": " & ".join(self.authors), "authors": self.authors,
"publisher": self.publisher, "publisher": self.publisher,
"formats": sorted(self.formats), "formats": sorted(self.formats),
} }
@@ -71,30 +76,35 @@ class CwaClient:
self.auth = (username, password or "") if username else None self.auth = (username, password or "") if username else None
self.timeout = timeout self.timeout = timeout
self.session = requests.Session() self.session = requests.Session()
self._login_test: tuple[float, bool] | None = None # (time, CWA accepted a login that cannot exist)
def available(self) -> bool: def available(self) -> bool:
return bool(self.base_url) return bool(self.base_url)
def _get(self, href: str, auth: Any = None, **kwargs: Any) -> requests.Response: def _get(self, href: str, auth: Any = None, **kwargs: Any) -> requests.Response:
"""Get a page as the account of Bindery, or with `auth` as a user who logs in."""
if not self.available(): if not self.available():
raise CwaError("CWA address is not set. Set CWA_URL.") raise CwaError("CWA address is not set. Set CWA_URL.")
# hrefs from the feed are absolute paths, and include any path prefix of CWA. # hrefs from the feed are absolute paths, and include any path prefix of CWA.
url = urljoin(self.base_url, href) url = urljoin(self.base_url, href)
timeout = kwargs.pop("timeout", self.timeout)
try: try:
response = self.session.get(url, auth=auth or self.auth, timeout=self.timeout, **kwargs) if auth:
# Without the session, so a cookie of the account of Bindery cannot stand in for the password.
response = requests.get(url, auth=auth, timeout=timeout, **kwargs)
else:
response = self.session.get(url, auth=self.auth, timeout=timeout, **kwargs)
except requests.RequestException as e: except requests.RequestException as e:
raise CwaError(f"Cannot reach CWA at {self.base_url}: {e}") from e raise CwaError(f"Cannot reach CWA at {self.base_url}: {e}") from e
if response.status_code == 401: if response.status_code == 401:
if not (auth or self.auth):
# A CWA with anonymous browsing needs no account. This one does.
raise CwaLoginError("CWA asks for a login. Set CWA_USERNAME and CWA_PASSWORD.")
raise CwaLoginError("CWA refused the login") raise CwaLoginError("CWA refused the login")
if response.status_code >= 400: if response.status_code >= 400:
raise CwaError(f"CWA answered {response.status_code} for {url}") raise CwaError(f"CWA answered {response.status_code} for {url}")
return response return response
def check(self) -> str:
"""Log in to the OPDS feed. Returns the catalog title."""
root = ElementTree.fromstring(self._get("opds").content)
return (root.findtext(f"{ATOM}title") or "CWA").strip()
def login_ok(self, username: str, password: str) -> bool: def login_ok(self, username: str, password: str) -> bool:
"""True if CWA accepts this user name and password.""" """True if CWA accepts this user name and password."""
try: try:
@@ -103,6 +113,18 @@ class CwaClient:
return False return False
return True return True
def accepts_any_login(self) -> bool:
"""True if CWA accepts a login that cannot exist.
A CWA with anonymous browsing can answer every OPDS request, whatever the
password. Its OPDS feed then cannot check the password of a user.
"""
test = self._login_test
if test is None or time.monotonic() - test[0] > LOGIN_TEST_SECONDS:
accepted = self.login_ok(f"bindery-test-{secrets.token_hex(8)}", secrets.token_hex(16))
test = self._login_test = (time.monotonic(), accepted)
return test[1]
def _feed(self, href: str, params: dict[str, str] | None = None) -> tuple[list[Book], list[tuple[str, str]]]: def _feed(self, href: str, params: dict[str, str] | None = None) -> tuple[list[Book], list[tuple[str, str]]]:
"""Books and navigation entries (title, href) of an OPDS feed, with all its pages.""" """Books and navigation entries (title, href) of an OPDS feed, with all its pages."""
books: list[Book] = [] books: list[Book] = []
@@ -153,8 +175,8 @@ class CwaClient:
return [] return []
return self._feed("opds/search", params={"query": query.strip()})[0] return self._feed("opds/search", params={"query": query.strip()})[0]
def get_book(self, book_uuid: str) -> dict[str, Any] | None: def get_book(self, book_uuid: str, timeout: int | None = None) -> dict[str, Any] | None:
"""A book by its calibre uuid, or None if CWA has no such book.""" """A book by its calibre uuid, or None if CWA has no such book. `timeout` is for a page that waits."""
# The uuid can come from a request. Other characters could change the path. # The uuid can come from a request. Other characters could change the path.
if not re.fullmatch(r"[0-9A-Za-z-]+", book_uuid): if not re.fullmatch(r"[0-9A-Za-z-]+", book_uuid):
return None return None
@@ -165,7 +187,7 @@ class CwaClient:
return book return book
except calibre_db.DatabaseUnavailable as e: except calibre_db.DatabaseUnavailable as e:
logger.warning(f"Cannot read the calibre library database. Trying CWA: {e}") logger.warning(f"Cannot read the calibre library database. Trying CWA: {e}")
response = self._get(f"ajax/book/{book_uuid}") response = self._get(f"ajax/book/{book_uuid}", timeout=timeout or self.timeout)
if not response.text.strip(): if not response.text.strip():
return None return None
try: try:
+11 -6
View File
@@ -90,7 +90,7 @@ def escape_title(text: str) -> str:
return html.escape(text, quote=False) return html.escape(text, quote=False)
def plain_title(title: str) -> str: def unescape_title(title: str) -> str:
"""A stored chapter title as text, for a message.""" """A stored chapter title as text, for a message."""
return html.unescape(title) return html.unescape(title)
@@ -148,7 +148,7 @@ def check_personal_ini(text: str) -> list[str]:
try: try:
configuration.read_file(io.StringIO(text)) configuration.read_file(io.StringIO(text))
except configparser.ParsingError as e: except configparser.ParsingError as e:
lines = "; ".join(f"line {n}: {line.strip()}" for n, line in e.errors) lines = ", ".join(f"line {n}: {line.strip()}" for n, line in e.errors)
raise ValueError(f"FanFicFare cannot read this personal.ini. Check {lines}") from e raise ValueError(f"FanFicFare cannot read this personal.ini. Check {lines}") from e
except configparser.Error as e: except configparser.Error as e:
raise ValueError(f"FanFicFare cannot read this personal.ini: {e}") from e raise ValueError(f"FanFicFare cannot read this personal.ini: {e}") from e
@@ -238,16 +238,21 @@ def word_count(chapters: list[dict[str, Any]]) -> int:
return sum(len(block.split()) for c in chapters for block in html_to_blocks(c["html"])) return sum(len(block.split()) for c in chapters for block in html_to_blocks(c["html"]))
def effective_overrides(overrides: dict[str, Any]) -> dict[str, Any]:
"""The kept values that replace a site value. An empty title, author list or language does not."""
return {k: v for k, v in overrides.items() if v or k not in ("title", "authors", "languages")}
def apply_overrides(story, overrides: dict[str, Any]) -> None: def apply_overrides(story, overrides: dict[str, Any]) -> None:
"""Put the kept values of locked fields into FanFicFare's story metadata. """Put the kept values of locked fields into FanFicFare's story metadata.
The epub writer takes the OPF metadata from these entries, and CWA takes The epub writer takes the OPF metadata from these entries, and CWA takes
the book record from the OPF on the first import. the book record from the OPF on the first import.
""" """
for key, value in overrides.items(): for key, value in effective_overrides(overrides).items():
if key == "title" and value: if key == "title":
story.setMetadata("title", value) story.setMetadata("title", value)
elif key == "authors" and value: elif key == "authors":
for n, author in enumerate(value): for n, author in enumerate(value):
story.addToList("author", author, clear=(n == 0)) story.addToList("author", author, clear=(n == 0))
elif key == "comments": elif key == "comments":
@@ -263,7 +268,7 @@ def apply_overrides(story, overrides: dict[str, Any]) -> None:
story.setMetadata("datePublished", datetime.fromisoformat(value) if value else None) story.setMetadata("datePublished", datetime.fromisoformat(value) if value else None)
except ValueError: except ValueError:
logger.warning(f"Ignoring a kept publication date that is not a date: {value!r}") logger.warning(f"Ignoring a kept publication date that is not a date: {value!r}")
elif key == "languages" and value: elif key == "languages":
story.setMetadata("langcode", value) story.setMetadata("langcode", value)
elif key == "tags": elif key == "tags":
tags = list(value or []) tags = list(value or [])
+321
View File
@@ -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(" .")
+11 -2
View File
@@ -26,10 +26,19 @@ def sanitize_filename(name: str) -> str:
return name[:MAX_NAME_LENGTH].strip(" .") or "story" return name[:MAX_NAME_LENGTH].strip(" .") or "story"
class IngestError(Exception):
"""The ingest folder cannot take a book."""
class IngestFolder: class IngestFolder:
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
self.path = Path(path) self.path = Path(path)
def _check(self) -> None:
# Bindery does not make the folder. A missing folder is most often a missing volume mount.
if not self.path.is_dir():
raise IngestError(f"The ingest folder {self.path} does not exist. Check INGEST_DIR and the volume mount.")
def _place(self, epub: Path, final_path: Path) -> Path: def _place(self, epub: Path, final_path: Path) -> Path:
"""Copy an epub in under a temporary .part name, then rename it. """Copy an epub in under a temporary .part name, then rename it.
@@ -43,7 +52,7 @@ class IngestFolder:
def ingest(self, epub: Path, name: str) -> Path: def ingest(self, epub: Path, name: str) -> Path:
"""Send a new book to CWA.""" """Send a new book to CWA."""
self.path.mkdir(parents=True, exist_ok=True) self._check()
# A short unique suffix, so a file that CWA has not imported yet is never replaced. # A short unique suffix, so a file that CWA has not imported yet is never replaced.
stem = sanitize_filename(f"{name[:80]} {uuid.uuid4().hex[:6]}") stem = sanitize_filename(f"{name[:80]} {uuid.uuid4().hex[:6]}")
final_path = self._place(epub, self.path / f"{stem}.epub") final_path = self._place(epub, self.path / f"{stem}.epub")
@@ -52,7 +61,7 @@ class IngestFolder:
def send_update(self, epub: Path, book_id: int, name: str) -> Path: def send_update(self, epub: Path, book_id: int, name: str) -> Path:
"""Send a new epub for an existing book. CWA replaces the book's epub.""" """Send a new epub for an existing book. CWA replaces the book's epub."""
self.path.mkdir(parents=True, exist_ok=True) self._check()
# A unique name, so two updates of one book never share a sidecar file. # A unique name, so two updates of one book never share a sidecar file.
stem = sanitize_filename(f"{name[:60]} {book_id}-{uuid.uuid4().hex[:8]}") stem = sanitize_filename(f"{name[:60]} {book_id}-{uuid.uuid4().hex[:8]}")
final_path = self.path / f"{stem}.epub" final_path = self.path / f"{stem}.epub"
+15 -2
View File
@@ -4,7 +4,7 @@ import json
import sqlite3 import sqlite3
import uuid import uuid
from collections import deque from collections import deque
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field, fields
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
@@ -87,6 +87,8 @@ class Job:
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
d = asdict(self) d = asdict(self)
# The path of an uploaded file is for the job only.
d["options"] = {k: v for k, v in self.options.items() if k != "path"}
d["mode"] = self.mode.value d["mode"] = self.mode.value
d["mode_label"] = MODE_LABELS[self.mode.value] d["mode_label"] = MODE_LABELS[self.mode.value]
d["status"] = self.status.value d["status"] = self.status.value
@@ -102,7 +104,9 @@ class Job:
@classmethod @classmethod
def from_record(cls, record: str) -> "Job": def from_record(cls, record: str) -> "Job":
d = json.loads(record) # A record of a newer Bindery can hold fields that this one does not know.
known = {f.name for f in fields(cls)}
d = {k: v for k, v in json.loads(record).items() if k in known}
d["mode"] = DownloadMode(d["mode"]) d["mode"] = DownloadMode(d["mode"])
d["status"] = QueueStatus(d["status"]) d["status"] = QueueStatus(d["status"])
d["created"] = datetime.fromisoformat(d["created"]) d["created"] = datetime.fromisoformat(d["created"])
@@ -221,6 +225,15 @@ class JobQueue:
self._save(job) self._save(job)
return True return True
def get(self, job_id: str) -> Job | None:
with self._lock:
return self._jobs.get(job_id)
def active(self) -> list[Job]:
"""The jobs that wait or run."""
with self._lock:
return [j for j in self._jobs.values() if j.status in (QueueStatus.QUEUED, QueueStatus.RUNNING)]
def has_active(self, story_id: int) -> bool: def has_active(self, story_id: int) -> bool:
with self._lock: with self._lock:
return any( return any(
+575 -294
View File
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -11,7 +11,7 @@ const ROUTES = [
[/^\/$/, storiesPage, []], [/^\/$/, storiesPage, []],
[/^\/story\/(\d+)$/, storyPage, ['storyId']], [/^\/story\/(\d+)$/, storyPage, ['storyId']],
[/^\/story\/(\d+)\/chapter\/(\d+)$/, chapterPage, ['storyId', 'chapterId']], [/^\/story\/(\d+)\/chapter\/(\d+)$/, chapterPage, ['storyId', 'chapterId']],
[/^\/settings$/, settingsPage, []], [/^\/settings$/, settingsPage, [], true],
]; ];
const app = document.getElementById('bindery-app'); const app = document.getElementById('bindery-app');
@@ -23,6 +23,9 @@ let nav = null;
let storyTab = null; let storyTab = null;
let storyTabShown = false; let storyTabShown = false;
// The server refuses the changes of a user who is not an admin. The interface only hides what that user cannot use.
const isAdmin = () => auth.admin !== false;
const currentPath = () => window.location.hash.replace(/^#/, '') || '/'; const currentPath = () => window.location.hash.replace(/^#/, '') || '/';
// The story of #/story/3 and of its chapter pages, such as #/story/3/chapter/5. // The story of #/story/3 and of its chapter pages, such as #/story/3/chapter/5.
const currentStoryId = () => currentPath().match(/^\/story\/(\d+)(?:\/|$)/)?.[1]; const currentStoryId = () => currentPath().match(/^\/story\/(\d+)(?:\/|$)/)?.[1];
@@ -44,6 +47,14 @@ function notFound(main) {
fill(main, h('section', { class: 'card' }, h('h1', {}, 'Page not found'), h('p', {}, h('a', { href: '#/' }, 'Go to the story list')))); fill(main, h('section', { class: 'card' }, h('h1', {}, 'Page not found'), h('p', {}, h('a', { href: '#/' }, 'Go to the story list'))));
} }
function adminOnly(main) {
document.title = 'Settings · Bindery';
fill(
main,
h('section', { class: 'card' }, h('h1', {}, 'Settings'), h('p', {}, 'Only an admin can open the settings. BINDERY_ADMIN_USERS lists the admins.')),
);
}
function route() { function route() {
const main = document.getElementById('main'); const main = document.getElementById('main');
if (!main) return; if (!main) return;
@@ -54,9 +65,10 @@ function route() {
if (storyTab?.getAttribute('href') !== `#/story/${currentStoryId()}`) hideStoryTab(); if (storyTab?.getAttribute('href') !== `#/story/${currentStoryId()}`) hideStoryTab();
const found = ROUTES.find(([pattern]) => pattern.test(path)); const found = ROUTES.find(([pattern]) => pattern.test(path));
if (found) { if (found) {
const [pattern, page, names] = found; const [pattern, page, names, admin] = found;
const match = path.match(pattern); const match = path.match(pattern);
cleanup = page(main, Object.fromEntries(names.map((name, i) => [name, match[i + 1]])), config) || null; if (admin && !isAdmin()) adminOnly(main);
else cleanup = page(main, Object.fromEntries(names.map((name, i) => [name, match[i + 1]])), config) || null;
} else { } else {
notFound(main); notFound(main);
} }
@@ -81,7 +93,7 @@ function shell() {
(auth.mode === 'cwa' || auth.mode === 'local' || auth.logout_url) && (auth.mode === 'cwa' || auth.mode === 'local' || auth.logout_url) &&
h('button', { type: 'button', onclick: () => logout().catch(showError) }, 'Log out'), h('button', { type: 'button', onclick: () => logout().catch(showError) }, 'Log out'),
]; ];
nav = h('nav', { 'aria-label': 'Main navigation' }, h('a', { href: '#/' }, 'Stories'), h('a', { href: '#/settings' }, 'Settings')); nav = h('nav', { 'aria-label': 'Main navigation' }, h('a', { href: '#/' }, 'Stories'), isAdmin() && h('a', { href: '#/settings' }, 'Settings'));
storyTab = h('a', { class: 'story-tab' }); storyTab = h('a', { class: 'story-tab' });
storyTabShown = false; storyTabShown = false;
fill( fill(
+3 -3
View File
@@ -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' },
+201 -54
View File
@@ -1,16 +1,63 @@
// 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';
const recent = h('input', { id: 'recent-chapters', type: 'number', min: 0, step: 1 }); const recent = h('input', { id: 'recent-chapters', type: 'number', min: 0, step: 1 });
const holdNewLinks = checkbox(false, () => {}); const holdNewLinks = checkbox(false, () => {});
const libraryNote = h('p', { class: 'notice', id: 'library-note', hidden: true },
'CWA_URL and CALIBRE_DB_PATH are not set. Set one or both and restart Bindery to enable library linking.');
const linkNote = h('p', { class: 'notice', id: 'link-note', hidden: true },
'Updates without a saved CWA book ID create duplicate books if CWA imports them.');
const showLinkState = (enabled) => {
holdNewLinks.disabled = !enabled;
linkNote.hidden = enabled;
linkBooks.setAttribute('aria-describedby',
[linkBooks.disabled && 'library-note', !enabled && 'link-note'].filter(Boolean).join(' '));
};
const linkBooks = checkbox(true, showLinkState);
const pauseUploads = checkbox(false, () => {}); const pauseUploads = checkbox(false, () => {});
const ingestNote = h('p', { class: 'notice', id: 'ingest-note', hidden: true },
'INGEST_DIR is not set. Uploads stay paused until you set it and restart Bindery.');
const showUploadSettings = (settings) => {
linkBooks.disabled = settings.linking_available === false;
linkBooks.checked = !linkBooks.disabled && (settings.link_books ?? true);
libraryNote.hidden = !linkBooks.disabled;
holdNewLinks.checked = settings.hold_new_links;
showLinkState(linkBooks.checked);
pauseUploads.disabled = settings.ingest_available === false;
pauseUploads.checked = pauseUploads.disabled || settings.pause_uploads;
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 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');
@@ -24,6 +71,13 @@ export function settingsPage(main, _params, config) {
'aria-label': 'personal.ini', 'aria-label': 'personal.ini',
}); });
const warnings = h('div'); const warnings = h('div');
// A Save button for some of the settings. `values` gives the settings to send, and `saved` gets the answer.
const saveButton = (values, saved = () => toast('Saved')) =>
h(
'div',
{ class: 'controls' },
button('Save', async () => saved(await api('settings', 'PUT', values())), { class: 'primary' }),
);
const showWarnings = (list) => const showWarnings = (list) =>
fill( fill(
@@ -40,9 +94,10 @@ export function settingsPage(main, _params, config) {
api('settings') api('settings')
.then((settings) => { .then((settings) => {
recent.value = settings.recent_chapters; recent.value = settings.recent_chapters;
holdNewLinks.checked = settings.hold_new_links; showUploadSettings(settings);
pauseUploads.checked = settings.pause_uploads;
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;
@@ -52,11 +107,9 @@ export function settingsPage(main, _params, config) {
fill( fill(
main, main,
h( h('h1', {}, 'Settings'),
'section', card(
{ class: 'card' }, 'Recheck recent chapters',
h('h1', {}, 'Settings'),
h('h2', {}, 'Recheck recent chapters'),
h( h(
'div', 'div',
{ class: 'controls' }, { class: 'controls' },
@@ -70,19 +123,19 @@ export function settingsPage(main, _params, config) {
'Authors most often fix their latest chapters. Each rechecked chapter is one more request to the site. ' + 'Authors most often fix their latest chapters. Each rechecked chapter is one more request to the site. ' +
'Enter 0 to turn this off. A story with its own interval for full rechecks does not use this setting.', 'Enter 0 to turn this off. A story with its own interval for full rechecks does not use this setting.',
), ),
h( saveButton(() => ({ recent_chapters: recent.value === '' ? 0 : Number(recent.value) })),
'div',
{ class: 'controls' },
button('Save', async () => {
await api('settings', 'PUT', { recent_chapters: recent.value === '' ? 0 : Number(recent.value) });
toast('Saved');
}, { class: 'primary' }),
),
), ),
h( config.cwa_enabled && card(
'section', 'Uploads to CWA',
{ class: 'card' }, h('label', {}, linkBooks, ' Link books to CWA library'),
h('h2', {}, 'Uploads to CWA'), h(
'p',
{ class: 'hint' },
'Find library books through CWA_URL or CALIBRE_DB_PATH. When this is off, uploads use saved CWA book IDs ' +
'or send a plain ePub to INGEST_DIR.',
),
libraryNote,
linkNote,
h('label', {}, holdNewLinks, ' Hold new links for approval'), h('label', {}, holdNewLinks, ' Hold new links for approval'),
h( h(
'p', 'p',
@@ -92,25 +145,129 @@ export function settingsPage(main, _params, config) {
'Later updates of an approved book go to CWA as usual.', 'Later updates of an approved book go to CWA as usual.',
), ),
h('label', {}, pauseUploads, ' Pause all uploads'), h('label', {}, pauseUploads, ' Pause all uploads'),
ingestNote,
h( h(
'p', 'p',
{ class: 'hint' }, { class: 'hint' },
'Bindery downloads chapters and builds books, but sends no book to CWA. ' + 'Bindery downloads chapters and builds books, but sends no book to CWA. ' +
'When you turn the pause off, Bindery sends the books that wait.', 'When you turn the pause off, Bindery sends the books that wait.',
), ),
h( saveButton(
'div', () => ({ link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked }),
{ class: 'controls' }, (settings) => {
button('Save', async () => { showUploadSettings(settings);
await api('settings', 'PUT', { hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked });
toast('Saved'); toast('Saved');
}, { class: 'primary' }), },
), ),
), ),
h( card(
'section', 'Book Downloads',
{ class: 'card' }, h('div', { class: 'controls' }, h('label', { for: 'download-template' }, 'File name template'), template),
h('h2', {}, 'Jobs list'), h(
'p',
{ class: 'hint' },
'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' },
@@ -135,25 +292,16 @@ export function settingsPage(main, _params, config) {
`A queued or running job always shows. The most either field allows is ${config.job_list_max_hours} hours ` + `A queued or running job always shows. The most either field allows is ${config.job_list_max_hours} hours ` +
`and ${config.job_list_max_count} jobs.`, `and ${config.job_list_max_count} jobs.`,
), ),
h( saveButton(() => ({
'div', story_jobs_hours: storyJobsHours.value,
{ class: 'controls' }, story_jobs_count: storyJobsCount.value,
button('Save', async () => { main_jobs_hours: mainJobsHours.value,
await api('settings', 'PUT', { main_jobs_count: mainJobsCount.value,
story_jobs_hours: storyJobsHours.value, })),
story_jobs_count: storyJobsCount.value,
main_jobs_hours: mainJobsHours.value,
main_jobs_count: mainJobsCount.value,
});
toast('Saved');
}, { class: 'primary' }),
),
), ),
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' },
@@ -161,18 +309,17 @@ export function settingsPage(main, _params, config) {
externalLink(INI_DOCS, 'personal.ini documentation'), externalLink(INI_DOCS, 'personal.ini documentation'),
' and the ', ' and the ',
externalLink(DEFAULTS_INI, 'list of all settings with their defaults'), externalLink(DEFAULTS_INI, 'list of all settings with their defaults'),
'. The file can hold passwords for story sites. Each user who can log in to Bindery can read them here.', '. The file can hold passwords for story sites. Each admin can read them here. If BINDERY_ADMIN_USERS is not ' +
'set, each user is an admin.',
), ),
ini, ini,
warnings, warnings,
h( saveButton(
'div', () => ({ personal_ini: ini.value }),
{ class: 'controls' }, (saved) => {
button('Save', async () => {
const saved = await api('settings', 'PUT', { personal_ini: ini.value });
showWarnings(saved.warnings); showWarnings(saved.warnings);
toast(saved.warnings.length ? 'Saved, with warnings' : 'Saved'); toast(saved.warnings.length ? 'Saved, with warnings' : 'Saved');
}, { class: 'primary' }), },
), ),
), ),
); );
+12 -15
View File
@@ -50,7 +50,7 @@ function libraryImport(config) {
'th', 'th',
{}, {},
checkbox(selected.size === candidates.length, (on) => { checkbox(selected.size === candidates.length, (on) => {
selected = new Set(on ? candidates.map((c) => c.calibre_id) : []); selected = new Set(on ? candidates.map((c) => c.id) : []);
render(); render();
}, { 'aria-label': 'Select all' }), }, { 'aria-label': 'Select all' }),
), ),
@@ -66,10 +66,10 @@ function libraryImport(config) {
h( h(
'tr', 'tr',
{}, {},
h('td', {}, checkbox(selected.has(c.calibre_id), (on) => toggle(c.calibre_id, on), { 'aria-label': c.title })), h('td', {}, checkbox(selected.has(c.id), (on) => toggle(c.id, on), { 'aria-label': c.title })),
h('td', {}, c.title), h('td', {}, c.title),
h('td', {}, c.authors), h('td', {}, c.authors.join(' & ')),
h('td', {}, c.site), h('td', {}, c.publisher),
), ),
), ),
), ),
@@ -79,8 +79,8 @@ function libraryImport(config) {
'Import selected', 'Import selected',
async () => { async () => {
const books = candidates const books = candidates
.filter((c) => selected.has(c.calibre_id)) .filter((c) => selected.has(c.id))
.map((c) => ({ id: c.calibre_id, uuid: c.uuid, title: c.title })); .map(({ id, uuid, title }) => ({ id, uuid, title }));
if (!books.length) return; if (!books.length) return;
const jobs = await api('library/import', 'POST', { books, check }); const jobs = await api('library/import', 'POST', { books, check });
toast(`Queued ${jobs.length} imports`); toast(`Queued ${jobs.length} imports`);
@@ -106,7 +106,7 @@ function libraryImport(config) {
{ class: 'controls' }, { class: 'controls' },
button('Find stories in the library', async () => { button('Find stories in the library', async () => {
candidates = await api('library/candidates'); candidates = await api('library/candidates');
selected = new Set(candidates.map((c) => c.calibre_id)); selected = new Set(candidates.map((c) => c.id));
render(); render();
}), }),
h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'), h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'),
@@ -203,17 +203,13 @@ function storyRow(s) {
'td', 'td',
{ 'data-label': 'Status' }, { 'data-label': 'Status' },
status, status,
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')], s.approval_held && [' ', badge('warn', 'held', 'The book waits for your approval. See the story page.')],
), ),
storyDate('Last change', s.last_changed), storyDate('Last change', s.last_changed),
storyDate('Last check', s.last_checked), storyDate('Last check', s.last_checked),
); );
} }
function sortTitle(title) {
return title.trim().replace(/^(?:the|an?)\s+/i, '');
}
export function storiesPage(main, _params, config) { export function storiesPage(main, _params, config) {
document.title = 'Stories · Bindery'; document.title = 'Stories · Bindery';
let stories = null; let stories = null;
@@ -253,7 +249,7 @@ export function storiesPage(main, _params, config) {
: (a[sort] || '').localeCompare(b[sort] || '', undefined, { sensitivity: 'base' }); : (a[sort] || '').localeCompare(b[sort] || '', undefined, { sensitivity: 'base' });
if (order) return order * direction; if (order) return order * direction;
} }
const order = sortTitle(a.title || a.url).localeCompare(sortTitle(b.title || b.url), undefined, { sensitivity: 'base' }) || a.id - b.id; const order = a.sort_title.localeCompare(b.sort_title, undefined, { sensitivity: 'base' }) || a.id - b.id;
return sort === 'title' ? order * direction : order; return sort === 'title' ? order * direction : order;
}); });
if (!shown.length) { if (!shown.length) {
@@ -341,7 +337,7 @@ export function storiesPage(main, _params, config) {
'div', 'div',
{ class: 'controls' }, { class: 'controls' },
add, add,
h( config.cwa_enabled && h(
'span', 'span',
{ class: 'hint' }, { class: 'hint' },
'If CWA already has this story, Bindery links that book and keeps its chapters, then downloads only the chapters that are missing.', 'If CWA already has this story, Bindery links that book and keeps its chapters, then downloads only the chapters that are missing.',
@@ -371,7 +367,8 @@ export function storiesPage(main, _params, config) {
), ),
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Jobs')), jobs.el), h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Jobs')), jobs.el),
epubImport(), epubImport(),
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)), config.cwa_enabled &&
h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)),
); );
load(); load();
+46 -28
View File
@@ -24,7 +24,6 @@ import {
import { regexDocs, rulesEditor, rulesPreview } from './rules.js'; import { regexDocs, rulesEditor, rulesPreview } from './rules.js';
import { epubImport } from './stories.js'; import { epubImport } from './stories.js';
const SITE_MODES = ['new', 'recheck', 'recheck_all', 'full', 'metadata'];
const MODE_HINTS = { const MODE_HINTS = {
new: new:
@@ -55,7 +54,7 @@ const editText = (key, value) => {
return formatField(key, value); return formatField(key, value);
}; };
const authorsText = (authors) => (Array.isArray(authors) ? authors.join(' & ') : authors); const authorsText = (authors) => authors.join(' & ');
// On a narrow screen, the rows stack instead of scrolling sideways. `extra` names the wrapper class // On a narrow screen, the rows stack instead of scrolling sideways. `extra` names the wrapper class
// that gives a table's stacked rows their own layout, such as picking out the row's first cell. // that gives a table's stacked rows their own layout, such as picking out the row's first cell.
@@ -69,6 +68,9 @@ const table = (headings, rows, extra = '') =>
export function storyPage(main, { storyId }, config) { export function storyPage(main, { storyId }, config) {
const id = Number(storyId); const id = Number(storyId);
const fields = config.metadata_fields; const fields = config.metadata_fields;
// With CWA_ENABLED=false, the page shows nothing about CWA.
const cwa = config.cwa_enabled;
const libraryStays = cwa ? ' The library book stays.' : '';
let details = null; let details = null;
let selected = new Set(); let selected = new Set();
let mode = 'new'; let mode = 'new';
@@ -133,7 +135,7 @@ export function storyPage(main, { storyId }, config) {
let library = 'Not in the library yet'; let library = 'Not in the library yet';
if (book) library = `Library book ${book.id}`; if (book) library = `Library book ${book.id}`;
else if (story.calibre_id) library = `Library book ${story.calibre_id}`; else if (story.calibre_id) library = `Library book ${story.calibre_id}`;
else if (story.ingested_at) library = 'Sent to CWA, unlinked'; else if (story.ingested_at) library = details.link_books === false ? 'Sent to ingest folder' : 'Sent to CWA, unlinked';
fill( fill(
summary, summary,
// The version in the URL changes with the image, so the browser keeps the cover until the site changes it. // The version in the URL changes with the image, so the browser keeps the cover until the site changes it.
@@ -147,7 +149,7 @@ export function storyPage(main, { storyId }, config) {
{ class: 'byline' }, { class: 'byline' },
`${story.author} · `, `${story.author} · `,
externalLink(story.url, story.site), externalLink(story.url, story.site),
` · ${library}`, cwa && ` · ${library}`,
), ),
h( h(
'p', 'p',
@@ -165,15 +167,18 @@ export function storyPage(main, { storyId }, config) {
const approve = async (uuid) => { const approve = async (uuid) => {
const result = await api(`stories/${id}/approve`, 'POST', { uuid }); const result = await api(`stories/${id}/approve`, 'POST', { uuid });
toast(result.job ? 'Approved. Bindery rebuilds the book and sends it.' : 'Approved'); toast(result.job ? 'Approved. Rebuild queued.' : 'Approved');
if (result.job) refreshJobs(); if (result.job) refreshJobs();
await load(); await load();
}; };
const approveAsNew = async () => { const approveAsNew = async () => {
const message = const action = details.pause_uploads
'Unlink the library book, and send this story to CWA as a new book? If CWA already has the book, you get a ' + ? 'Unlink the library book, and approve this story as a new book? Uploads remain paused. '
'duplicate. In that case, search for the book under "Library link" and link it instead.'; : 'Unlink the library book, and send this story to CWA as a new book? ';
const message = action +
'If CWA already has the book, you get a duplicate. ' +
'In that case, search for the book under "Library link" and link it instead.';
if (!window.confirm(message)) return; if (!window.confirm(message)) return;
await api(`stories/${id}/link`, 'POST', { uuid: null }); await api(`stories/${id}/link`, 'POST', { uuid: null });
await approve(null); await approve(null);
@@ -181,16 +186,19 @@ export function storyPage(main, { storyId }, config) {
// Why the book did not go to CWA, and the approval of a new library link. // Why the book did not go to CWA, and the approval of a new library link.
function holdNotice() { function holdNotice() {
if (!cwa) return null;
const { story, book } = details; const { story, book } = details;
const paused = story.upload_held && details.uploads_paused; const linking = details.link_books !== false;
// A story that went to CWA without a link gets its book at the next update, and the approval comes then. const unlinked = !linking && story.calibre_id == null;
const approval = story.needs_approval && (story.calibre_uuid || !story.ingested_at); // A previously uploaded book needs a known target before approval.
if (!paused && !approval) return null; const approval = linking && story.needs_approval && (story.calibre_id != null || !story.ingested_at);
let target = 'The next upload goes to CWA as a new book.'; const paused = details.pause_uploads && (story.upload_held || approval);
if (!paused && !approval && !unlinked) return null;
let target = 'Approve this story as a new CWA book.';
if (book) { if (book) {
target = `The next upload replaces the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`; target = `Approve replacing the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
} else if (story.calibre_id) { } else if (story.calibre_id != null) {
target = `The next upload replaces the ePub of library book ${story.calibre_id}.`; target = `Approve replacing the ePub of library book ${story.calibre_id}.`;
} }
return h( return h(
'div', 'div',
@@ -199,19 +207,27 @@ export function storyPage(main, { storyId }, config) {
h( h(
'p', 'p',
{}, {},
'Uploads to CWA are paused. Bindery sends this book when you turn the pause off in ', details.ingest_available === false
link('/settings', 'Settings'), ? 'Set INGEST_DIR and restart Bindery to enable uploads.'
'.', : ['Uploads to CWA are paused. Resume them in ', link('/settings', 'Settings'), '.'],
),
unlinked &&
h(
'p',
{},
'Uploads of this book have no CWA book ID. If CWA imports them, updates will create duplicate books.',
), ),
approval && [ approval && [
h('p', {}, `${story.upload_held ? 'The book waits for your approval. ' : ''}${target} Make sure that this is right.`), h('p', {}, target),
h( h(
'div', 'div',
{ class: 'controls' }, { class: 'controls' },
button(story.upload_held ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), { button(story.upload_held && !details.pause_uploads ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
class: 'primary', class: 'primary',
}), }),
story.calibre_uuid && button('Send as a new book instead', approveAsNew), story.calibre_uuid && button(
details.pause_uploads ? 'Approve as a new book instead' : 'Send as a new book instead', approveAsNew,
),
), ),
], ],
); );
@@ -539,7 +555,7 @@ export function storyPage(main, { storyId }, config) {
}; };
const modeHint = h('p', { class: 'hint' }, MODE_HINTS[mode]); const modeHint = h('p', { class: 'hint' }, MODE_HINTS[mode]);
const modes = SITE_MODES.map((m) => const modes = Object.keys(MODE_HINTS).map((m) =>
h( h(
'label', 'label',
{}, {},
@@ -650,8 +666,10 @@ export function storyPage(main, { storyId }, config) {
{ class: 'hint' }, { class: 'hint' },
'Bindery writes this metadata into the ePub. You can edit each field. A locked field keeps its value on ' + 'Bindery writes this metadata into the ePub. You can edit each field. A locked field keeps its value on ' +
'update, unless you take it from the site above. An unlocked field keeps an edited value until the site ' + 'update, unless you take it from the site above. An unlocked field keeps an edited value until the site ' +
"changes that field. CWA reads the ePub's metadata only when it first imports a book, so later changes " + 'changes that field.',
'show in the ePub but not in the CWA catalog.', cwa &&
" CWA reads the ePub's metadata only when it first imports a book, so later changes show in the ePub " +
'but not in the CWA catalog.',
), ),
metadata, metadata,
), ),
@@ -693,7 +711,7 @@ export function storyPage(main, { storyId }, config) {
), ),
epubImport(id, 'Import chapters from an ePub'), epubImport(id, 'Import chapters from an ePub'),
h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el), h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el),
h( cwa && h(
'details', 'details',
{ class: 'card' }, { class: 'card' },
h('summary', {}, h('h2', {}, 'Library link')), h('summary', {}, h('h2', {}, 'Library link')),
@@ -725,11 +743,11 @@ export function storyPage(main, { storyId }, config) {
'details', 'details',
{ class: 'card card-danger' }, { class: 'card card-danger' },
h('summary', {}, h('h2', {}, 'Stop tracking')), h('summary', {}, h('h2', {}, 'Stop tracking')),
h('p', { class: 'hint' }, 'Deletes the stored chapters, revisions and rules for this story. The library book stays.'), h('p', { class: 'hint' }, `Deletes the stored chapters, revisions and rules for this story.${libraryStays}`),
button( button(
'Stop tracking this story', 'Stop tracking this story',
async () => { async () => {
const message = 'Stop tracking this story? Stored chapters, revisions and rules are deleted. The library book stays.'; const message = `Stop tracking this story? Stored chapters, revisions and rules are deleted.${libraryStays}`;
if (!window.confirm(message)) return; if (!window.confirm(message)) return;
await api(`stories/${id}`, 'DELETE'); await api(`stories/${id}`, 'DELETE');
window.location.hash = '#/'; window.location.hash = '#/';
+38
View File
@@ -427,6 +427,10 @@ label:has(> input[type='radio']) {
display: block; display: block;
} }
label:has(> input[type='checkbox']:disabled) {
color: var(--muted);
}
.library-results { .library-results {
list-style: none; list-style: none;
margin: 8px 0 0; margin: 8px 0 0;
@@ -447,6 +451,25 @@ label:has(> input[type='radio']) {
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;
@@ -695,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;
@@ -1035,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 {
+58 -40
View File
@@ -8,7 +8,7 @@ site without losing text.
import json import json
import re import re
import sqlite3 import sqlite3
from collections.abc import Iterator from collections.abc import Callable, Iterator
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -200,7 +200,9 @@ class Store:
(SELECT COUNT(*) FROM chapters c WHERE c.story_id = s.id AND c.included = 1 (SELECT COUNT(*) FROM chapters c WHERE c.story_id = s.id AND c.included = 1
AND (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1) AS revised_count AND (SELECT COUNT(*) FROM revisions r WHERE r.chapter_id = c.id) > 1) AS revised_count
FROM stories s""").fetchall() FROM stories s""").fetchall()
return [_story(r) for r in sorted(rows, key=lambda r: sort_title(r["title"]))] # The story list of the web interface sorts by the same key.
stories = [dict(_story(r), sort_title=sort_title(r["title"] or r["url"])) for r in rows]
return sorted(stories, key=lambda s: s["sort_title"])
def update_story(self, story_id: int, **fields: Any) -> None: def update_story(self, story_id: int, **fields: Any) -> None:
if not fields: if not fields:
@@ -242,18 +244,6 @@ class Store:
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def current_revisions(self, story_id: int) -> dict[int, dict[str, Any]]:
"""The rev, text_hash and source of the current revision of each chapter, by chapter id."""
with self._connect() as conn:
rows = conn.execute(
"""
SELECT c.id, r.rev, r.text_hash, r.source FROM chapters c
JOIN revisions r ON r.chapter_id = c.id AND r.rev = c.current_rev
WHERE c.story_id = ?""",
(story_id,),
).fetchall()
return {r["id"]: {"rev": r["rev"], "text_hash": r["text_hash"], "source": r["source"]} for r in rows}
def replace_revision(self, chapter_id: int, rev: int, html: str, text_hash: str) -> None: def replace_revision(self, chapter_id: int, rev: int, html: str, text_hash: str) -> None:
with self._connect() as conn: with self._connect() as conn:
conn.execute( conn.execute(
@@ -266,41 +256,52 @@ class Store:
row = conn.execute("SELECT * FROM chapters WHERE id = ?", (chapter_id,)).fetchone() row = conn.execute("SELECT * FROM chapters WHERE id = ?", (chapter_id,)).fetchone()
return dict(row) if row else None return dict(row) if row else None
@staticmethod
def _insert_chapter(
conn: sqlite3.Connection, story_id: int, chapter: dict[str, str], source: str, position: int
) -> int:
"""Store a chapter with its first revision. The chapter needs url, title, html and text_hash."""
cur = conn.execute(
"INSERT INTO chapters (story_id, position, url, title, added_at) VALUES (?, ?, ?, ?, ?)",
(story_id, position, chapter["url"], chapter["title"], now()),
)
chapter_id = int(cur.lastrowid)
conn.execute(
"INSERT INTO revisions (chapter_id, rev, html, text_hash, source, fetched_at) VALUES (?, 1, ?, ?, ?, ?)",
(chapter_id, chapter["html"], chapter["text_hash"], source, now()),
)
return chapter_id
def add_chapter( def add_chapter(
self, story_id: int, url: str, title: str, html: str, text_hash: str, source: str, position: int self, story_id: int, url: str, title: str, html: str, text_hash: str, source: str, position: int
) -> int: ) -> int:
"""Add one chapter at a position. The jobs use add_chapters, which also places the chapters."""
chapter = {"url": url, "title": title, "html": html, "text_hash": text_hash}
with self._connect() as conn: with self._connect() as conn:
cur = conn.execute( return self._insert_chapter(conn, story_id, chapter, source, position)
"INSERT INTO chapters (story_id, position, url, title, added_at) VALUES (?, ?, ?, ?, ?)",
(story_id, position, url, title, now()),
)
chapter_id = int(cur.lastrowid)
conn.execute(
"INSERT INTO revisions (chapter_id, rev, html, text_hash, source, fetched_at) "
"VALUES (?, 1, ?, ?, ?, ?)",
(chapter_id, html, text_hash, source, now()),
)
return chapter_id
def add_chapters(self, story_id: int, chapters: list[dict[str, str]], source: str, order: list[str]) -> list[int]: def add_chapters(
self,
story_id: int,
chapters: list[dict[str, str]],
source: str,
order: list[str] | Callable[[list[str]], list[str]],
) -> list[int]:
"""Add chapters and put the story in the order of `order`, a list of chapter URLs, in one transaction. """Add chapters and put the story in the order of `order`, a list of chapter URLs, in one transaction.
Each chapter needs url, title, html and text_hash. Chapters that `order` Each chapter needs url, title, html and text_hash. Chapters that `order`
leaves out go after the others and keep their order. leaves out go after the others and keep their order.
`order` can be a function. It gets the URLs of the stored chapters in book
order, and returns the list. It runs in the transaction, so it sees a move or
a delete that the user made a moment before.
""" """
with self._connect() as conn: with self._connect() as conn:
ids = [] conn.execute("BEGIN IMMEDIATE")
for c in chapters: if callable(order):
cur = conn.execute( stored = conn.execute("SELECT url FROM chapters WHERE story_id = ? ORDER BY position, id", (story_id,))
"INSERT INTO chapters (story_id, position, url, title, added_at) VALUES (?, 0, ?, ?, ?)", order = order([r["url"] for r in stored])
(story_id, c["url"], c["title"], now()), ids = [self._insert_chapter(conn, story_id, c, source, 0) for c in chapters]
)
ids.append(int(cur.lastrowid))
conn.execute(
"INSERT INTO revisions (chapter_id, rev, html, text_hash, source, fetched_at) "
"VALUES (?, 1, ?, ?, ?, ?)",
(ids[-1], c["html"], c["text_hash"], source, now()),
)
rows = conn.execute( rows = conn.execute(
"SELECT id, url FROM chapters WHERE story_id = ? ORDER BY position, id", (story_id,) "SELECT id, url FROM chapters WHERE story_id = ? ORDER BY position, id", (story_id,)
).fetchall() ).fetchall()
@@ -338,11 +339,20 @@ class Store:
(*fields.values(), chapter_id), (*fields.values(), chapter_id),
) )
def set_positions(self, story_id: int, chapter_ids: list[int]) -> None: def reorder_chapters(self, story_id: int, arrange: Callable[[list[int]], list[int]]) -> None:
"""Give the chapters of a story a new order.
`arrange` gets the chapter ids in book order, and returns them in the new
order. The read and the write are one transaction, so a job that adds a
chapter at the same time cannot come between them.
"""
with self._connect() as conn: with self._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
rows = conn.execute("SELECT id FROM chapters WHERE story_id = ? ORDER BY position, id", (story_id,))
order = arrange([r["id"] for r in rows])
conn.executemany( conn.executemany(
"UPDATE chapters SET position = ? WHERE id = ? AND story_id = ?", "UPDATE chapters SET position = ? WHERE id = ? AND story_id = ?",
[(i, cid, story_id) for i, cid in enumerate(chapter_ids)], [(i, cid, story_id) for i, cid in enumerate(order)],
) )
def delete_chapters(self, story_id: int, chapter_ids: list[int]) -> None: def delete_chapters(self, story_id: int, chapter_ids: list[int]) -> None:
@@ -388,6 +398,14 @@ class Store:
rows = conn.execute("SELECT url FROM images WHERE story_id = ? AND is_cover = 0", (story_id,)) rows = conn.execute("SELECT url FROM images WHERE story_id = ? AND is_cover = 0", (story_id,))
return {r["url"] for r in rows} return {r["url"] for r in rows}
def image_sizes(self, story_id: int) -> list[list[Any]]:
"""The URL, the cover mark and the size of each image, without its data."""
with self._connect() as conn:
rows = conn.execute(
"SELECT url, is_cover, LENGTH(data) FROM images WHERE story_id = ? ORDER BY url, is_cover", (story_id,)
)
return [list(r) for r in rows]
def images(self, story_id: int) -> list[dict[str, Any]]: def images(self, story_id: int) -> list[dict[str, Any]]:
with self._connect() as conn: with self._connect() as conn:
rows = conn.execute( rows = conn.execute(
+103 -45
View File
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import Any from typing import Any
from flask import Flask, Response, g, jsonify, request, send_file, session from flask import Flask, Response, g, jsonify, request, send_file, session
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import Forbidden, HTTPException
from bindery import auth, config, orchestrator from bindery import auth, config, orchestrator
from bindery.cwa import CwaError from bindery.cwa import CwaError
@@ -26,8 +26,8 @@ from bindery.rules import RuleError
logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# API paths that need no login. # API paths that need no login. The login gate also lets /api/health through, before it reads a setting.
PUBLIC_PATHS = {"/api/health", "/api/auth/check", "/api/auth/login", "/api/auth/logout"} PUBLIC_PATHS = {"/api/auth/check", "/api/auth/login", "/api/auth/logout"}
# The browser sends this header with every change. Another site cannot send it # The browser sends this header with every change. Another site cannot send it
# without a CORS preflight, which Bindery never allows, so it stops cross-site requests. # without a CORS preflight, which Bindery never allows, so it stops cross-site requests.
CSRF_HEADER = "X-Requested-With" CSRF_HEADER = "X-Requested-With"
@@ -92,11 +92,60 @@ def _json() -> dict[str, Any]:
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
# Values of a request body. A value of the wrong type is an error of the request, so it gets a 400.
def _list(data: dict[str, Any], name: str) -> list[Any]:
value = data.get(name)
if value is None:
return []
if not isinstance(value, list):
raise ValueError(f"{name} must be a list")
return value
def _int(value: Any, name: str) -> int:
return orchestrator.whole_number(value, name, negative=True)
def _ids(data: dict[str, Any], name: str = "chapter_ids") -> list[int]:
return [_int(v, name) for v in _list(data, name)]
def _rules(data: dict[str, Any]) -> list[dict[str, Any]]:
rules = _list(data, "rules")
if not all(isinstance(r, dict) for r in rules):
raise ValueError("rules must be a list of rules")
return rules
def _names(data: dict[str, Any], name: str) -> list[str] | None:
"""A list of text values, or None if the request leaves it out."""
if data.get(name) is None:
return None
values = _list(data, name)
if not all(isinstance(v, str) for v in values):
raise ValueError(f"{name} must be a list of names")
return values
# Login gate and errors # Login gate and errors
def _identity() -> auth.Identity | None: def _identity() -> auth.Identity | None:
return auth.current_identity(request.headers, session) return auth.current_identity(request.headers, session, request.remote_addr)
def _admin_only() -> None:
if not auth.is_admin(g.identity):
raise Forbidden("Only an admin can do this. BINDERY_ADMIN_USERS lists the admins.")
@app.before_request
def check_host() -> Any:
if request.path == "/api/health" or auth.host_allowed(request.host):
return None
return jsonify({"error": f"Bindery does not answer to the host name {request.host}. Add it to ALLOWED_HOSTS."}), 400
@app.before_request @app.before_request
@@ -151,8 +200,9 @@ def bad_request(e: Exception) -> Any:
def server_error(e: Exception) -> Any: def server_error(e: Exception) -> Any:
if isinstance(e, HTTPException): if isinstance(e, HTTPException):
return {"error": e.description}, e.code return {"error": e.description}, e.code
# The text of an unexpected error can hold paths and addresses, so it goes only to the log.
logger.exception(f"Error in {request.path}: {e}") logger.exception(f"Error in {request.path}: {e}")
return {"error": f"{type(e).__name__}: {e}"}, 500 return {"error": "Bindery hit an unexpected error. The log has the details."}, 500
# Login # Login
@@ -160,17 +210,21 @@ def server_error(e: Exception) -> Any:
@app.get("/api/health") @app.get("/api/health")
def health() -> Any: def health() -> Any:
stopped = orchestrator.stopped_threads()
if stopped:
return {"status": "error", "error": f"Stopped: {', '.join(stopped)}. Restart Bindery."}, 503
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/auth/check") @app.get("/api/auth/check")
def auth_check() -> Any: def auth_check() -> Any:
identity = _identity() identity = _identity()
mode = auth.active_mode() mode = config.AUTH_METHOD
return { return {
"authenticated": identity is not None, "authenticated": identity is not None,
"mode": mode, "mode": mode,
"user": identity.name if identity else None, "user": identity.name if identity else None,
"admin": identity is not None and auth.is_admin(identity),
"logout_url": config.PROXY_AUTH_LOGOUT_URL or None if mode == auth.AUTH_PROXY else None, "logout_url": config.PROXY_AUTH_LOGOUT_URL or None if mode == auth.AUTH_PROXY else None,
} }
@@ -182,7 +236,7 @@ def auth_login() -> Any:
username = str(data.get("username", "")) username = str(data.get("username", ""))
password = str(data.get("password", "")) password = str(data.get("password", ""))
address = auth.client_address(request.headers, request.remote_addr) address = auth.client_address(request.headers, request.remote_addr)
if auth.active_mode() == auth.AUTH_LOCAL: if config.AUTH_METHOD == auth.AUTH_LOCAL:
identity = auth.login_local(username, password, address) identity = auth.login_local(username, password, address)
else: else:
identity = auth.login_cwa(username, password, orchestrator.cwa(), address) identity = auth.login_cwa(username, password, orchestrator.cwa(), address)
@@ -190,8 +244,8 @@ def auth_login() -> Any:
return {"error": str(e)}, 401 return {"error": str(e)}, 401
session.clear() session.clear()
session["user"] = identity.name session["user"] = identity.name
session["mode"] = auth.active_mode() session["mode"] = config.AUTH_METHOD
if auth.active_mode() == auth.AUTH_LOCAL: if config.AUTH_METHOD == auth.AUTH_LOCAL:
session["local_password_version"] = auth.local_password_version() session["local_password_version"] = auth.local_password_version()
session.permanent = bool(data.get("remember")) session.permanent = bool(data.get("remember"))
logger.info(f"{identity.name} logged in") logger.info(f"{identity.name} logged in")
@@ -210,9 +264,11 @@ def app_config() -> Any:
"metadata_fields": METADATA_FIELDS, "metadata_fields": METADATA_FIELDS,
"mode_labels": MODE_LABELS, "mode_labels": MODE_LABELS,
"library_available": orchestrator.library_available(), "library_available": orchestrator.library_available(),
"cwa_enabled": config.CWA_ENABLED,
"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(),
} }
@@ -220,18 +276,13 @@ def app_config() -> Any:
@app.route("/api/settings", methods=["GET", "PUT"]) @app.route("/api/settings", methods=["GET", "PUT"])
def settings() -> Any: def settings() -> Any:
if request.method == "PUT": if request.method == "PUT":
_admin_only()
data = _json() data = _json()
return orchestrator.save_settings( return orchestrator.save_settings(**{name: data.get(name) for name in orchestrator.SETTINGS})
data.get("recent_chapters"), settings = orchestrator.get_settings()
data.get("personal_ini"), if not auth.is_admin(g.identity):
data.get("hold_new_links"), del settings["personal_ini"] # it can hold passwords for story sites
data.get("pause_uploads"), return settings
data.get("story_jobs_hours"),
data.get("story_jobs_count"),
data.get("main_jobs_hours"),
data.get("main_jobs_count"),
)
return orchestrator.get_settings()
# Jobs # Jobs
@@ -257,9 +308,8 @@ def stories() -> Any:
@app.post("/api/stories") @app.post("/api/stories")
def add_stories() -> Any: def add_stories() -> Any:
urls = _json().get("urls", []) data = _json()
if isinstance(urls, str): urls = re.split(r"\s+", data["urls"]) if isinstance(data.get("urls"), str) else _list(data, "urls")
urls = re.split(r"\s+", urls)
queued, errors = orchestrator.queue_urls([str(u) for u in urls]) queued, errors = orchestrator.queue_urls([str(u) for u in urls])
return {"jobs": queued, "errors": errors} return {"jobs": queued, "errors": errors}
@@ -272,7 +322,7 @@ def import_epub(story_id: int | None = None) -> Any:
raise ValueError("Choose an epub file") raise ValueError("Choose an epub file")
path = config.UPLOAD_DIR / f"{uuid.uuid4().hex}.epub" path = config.UPLOAD_DIR / f"{uuid.uuid4().hex}.epub"
upload.save(path) upload.save(path)
check = request.form.get("check", "true").lower() not in ("0", "false", "no", "off") check = config.flag(request.form.get("check", ""), True)
return orchestrator.queue_epub_file(path, os.path.basename(upload.filename), story_id, check) return orchestrator.queue_epub_file(path, os.path.basename(upload.filename), story_id, check)
@@ -290,18 +340,23 @@ def delete_story(story_id: int) -> Any:
@app.post("/api/stories/<int:story_id>/download") @app.post("/api/stories/<int:story_id>/download")
def download(story_id: int) -> Any: def download(story_id: int) -> Any:
data = _json() data = _json()
# A rebuild that the user asks for also builds and sends a book that did not change.
return orchestrator.queue_story( return orchestrator.queue_story(
story_id, data.get("mode", DownloadMode.NEW.value), data.get("chapter_ids"), data.get("force_fields") story_id, str(data.get("mode", DownloadMode.NEW.value)), _ids(data), _names(data, "force_fields"), True
) )
@app.put("/api/stories/<int:story_id>/settings") @app.put("/api/stories/<int:story_id>/settings")
def story_settings(story_id: int) -> Any: def story_settings(story_id: int) -> Any:
data = _json() data = _json()
days = data.get("recheck_days")
orchestrator.update_story_settings( orchestrator.update_story_settings(
story_id, data.get("locked_fields"), data.get("auto_update"), data.get("recheck_days") story_id,
_names(data, "locked_fields"),
data.get("auto_update"),
None if days is None else _int(days, "recheck_days"),
) )
return orchestrator.story_details(story_id)["story"] return orchestrator.story_view(story_id)
@app.put("/api/stories/<int:story_id>/metadata") @app.put("/api/stories/<int:story_id>/metadata")
@@ -315,42 +370,39 @@ def edit_metadata(story_id: int) -> Any:
@app.post("/api/stories/<int:story_id>/link") @app.post("/api/stories/<int:story_id>/link")
def link(story_id: int) -> Any: def link(story_id: int) -> Any:
orchestrator.link_story(story_id, _json().get("uuid") or None) orchestrator.link_story(story_id, _json().get("uuid") or None)
return orchestrator.story_details(story_id)["story"] return orchestrator.story_view(story_id)
@app.post("/api/stories/<int:story_id>/approve") @app.post("/api/stories/<int:story_id>/approve")
def approve_upload(story_id: int) -> Any: def approve_upload(story_id: int) -> Any:
job = orchestrator.approve_upload(story_id, _json().get("uuid") or None) job = orchestrator.approve_upload(story_id, _json().get("uuid") or None)
return {"story": orchestrator.story_details(story_id)["story"], "job": job} return {"story": orchestrator.story_view(story_id), "job": job}
@app.post("/api/stories/<int:story_id>/chapters") @app.post("/api/stories/<int:story_id>/chapters")
def chapters_included(story_id: int) -> Any: def chapters_included(story_id: int) -> Any:
data = _json() data = _json()
return orchestrator.set_chapters_included( return orchestrator.set_chapters_included(story_id, _ids(data), bool(data.get("included", True)))
story_id, [int(c) for c in data.get("chapter_ids", [])], bool(data.get("included", True))
)
@app.post("/api/stories/<int:story_id>/chapters/move") @app.post("/api/stories/<int:story_id>/chapters/move")
def move_chapters(story_id: int) -> Any: def move_chapters(story_id: int) -> Any:
data = _json() data = _json()
after = data.get("after") after = data.get("after")
return orchestrator.move_chapters( return orchestrator.move_chapters(story_id, _ids(data), None if after is None else _int(after, "after"))
story_id, [int(c) for c in data.get("chapter_ids", [])], None if after is None else int(after)
)
@app.post("/api/stories/<int:story_id>/chapters/delete") @app.post("/api/stories/<int:story_id>/chapters/delete")
def delete_chapters(story_id: int) -> Any: def delete_chapters(story_id: int) -> Any:
return orchestrator.delete_chapters(story_id, [int(c) for c in _json().get("chapter_ids", [])]) return orchestrator.delete_chapters(story_id, _ids(_json()))
@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:
path, title = orchestrator.build_for_download(story_id) file, name = orchestrator.build_for_download(story_id)
name = re.sub(r'[\\/:*?"<>|]', "_", title.strip())[:200] or "story" response = send_file(file, download_name=name, as_attachment=True, mimetype="application/epub+zip")
return send_file(path, download_name=f"{name}.epub", as_attachment=True) response.content_length = os.fstat(file.fileno()).st_size
return response
@app.get("/api/stories/<int:story_id>/cover") @app.get("/api/stories/<int:story_id>/cover")
@@ -359,7 +411,11 @@ def story_cover(story_id: int) -> Any:
if not cover: if not cover:
raise KeyError("This story has no cover") raise KeyError("This story has no cover")
# The page puts the cover version in the URL, so the browser can keep the image for a year. # The page puts the cover version in the URL, so the browser can keep the image for a year.
return send_file(io.BytesIO(cover["data"]), mimetype=cover["mime"], max_age=365 * 24 * 60 * 60) response = send_file(io.BytesIO(cover["data"]), mimetype=cover["mime"], max_age=365 * 24 * 60 * 60)
# A cover needs a login, so a shared cache must not keep it.
response.cache_control.public = False
response.cache_control.private = True
return response
# Replacement rules. Story rules run after the global rules. # Replacement rules. Story rules run after the global rules.
@@ -369,8 +425,10 @@ def story_cover(story_id: int) -> Any:
@app.route("/api/rules", methods=["GET", "PUT"]) @app.route("/api/rules", methods=["GET", "PUT"])
def rules(story_id: int | None = None) -> Any: def rules(story_id: int | None = None) -> Any:
if request.method == "PUT": if request.method == "PUT":
if story_id is None:
_admin_only()
data = _json() data = _json()
return orchestrator.save_rules(story_id, data.get("rules", []), data.get("paragraphs", ...)) return orchestrator.save_rules(story_id, _rules(data), data.get("paragraphs", ...))
return orchestrator.get_rules(story_id) return orchestrator.get_rules(story_id)
@@ -380,8 +438,8 @@ def rules_preview(story_id: int | None = None) -> Any:
data = _json() data = _json()
return orchestrator.preview_rules( return orchestrator.preview_rules(
story_id, story_id,
data.get("rules", []), _rules(data),
int(data.get("chapter_id", 0)), _int(data.get("chapter_id", 0), "chapter_id"),
bool(data.get("source")), bool(data.get("source")),
data.get("paragraphs"), data.get("paragraphs"),
) )
@@ -399,7 +457,7 @@ def chapter_diff(chapter_id: int) -> Any:
@app.post("/api/chapters/<int:chapter_id>/revision") @app.post("/api/chapters/<int:chapter_id>/revision")
def chapter_revision(chapter_id: int) -> Any: def chapter_revision(chapter_id: int) -> Any:
return orchestrator.set_chapter_revision(chapter_id, int(_json().get("rev", 0))) return orchestrator.set_chapter_revision(chapter_id, _int(_json().get("rev", 0), "rev"))
@app.delete("/api/chapters/<int:chapter_id>/revisions/<int:rev>") @app.delete("/api/chapters/<int:chapter_id>/revisions/<int:rev>")
@@ -423,7 +481,7 @@ def library_search() -> Any:
@app.post("/api/library/import") @app.post("/api/library/import")
def library_import() -> Any: def library_import() -> Any:
data = _json() data = _json()
books = [b for b in data.get("books", []) if isinstance(b, dict) and "id" in b] books = [{**b, "id": _int(b["id"], "id")} for b in _list(data, "books") if isinstance(b, dict) and "id" in b]
return orchestrator.queue_import(books, bool(data.get("check", True))) return orchestrator.queue_import(books, bool(data.get("check", True)))
+1 -1
View File
@@ -1,4 +1,4 @@
## FanFicFare configuration for CWA Bindery. ## FanFicFare configuration for Bindery.
## ##
## This file has the same format as personal.ini in the FanFicFare calibre ## This file has the same format as personal.ini in the FanFicFare calibre
## plugin and CLI. You can copy settings from your plugin configuration. ## plugin and CLI. You can copy settings from your plugin configuration.
+13 -1
View File
@@ -5,7 +5,12 @@ services:
# The user and group that own ./config and can write CWA's ingest folder. Usually the same as CWA's. # The user and group that own ./config and can write CWA's ingest folder. Usually the same as CWA's.
user: "1000:1000" user: "1000:1000"
environment: environment:
# Without CWA: set this, and remove the CWA values below. INGEST_DIR then gets the built ePubs.
# CWA_ENABLED: "false"
# Omit this and its volume mount to keep uploads paused.
INGEST_DIR: /cwa-book-ingest
# The address of CWA, as the Bindery container reaches it, and the CWA account that Bindery uses. # The address of CWA, as the Bindery container reaches it, and the CWA account that Bindery uses.
# Leave the account out if CWA asks for no login.
CWA_URL: http://calibre-web-automated:8083 CWA_URL: http://calibre-web-automated:8083
CWA_USERNAME: bindery CWA_USERNAME: bindery
CWA_PASSWORD: change-me CWA_PASSWORD: change-me
@@ -18,10 +23,17 @@ services:
# CWA_DB_PATH: /cwa-config/app.db # CWA_DB_PATH: /cwa-config/app.db
# CALIBRE_DB_PATH: /calibre-library/metadata.db # CALIBRE_DB_PATH: /calibre-library/metadata.db
# URL_BASE: /bindery # URL_BASE: /bindery
# The host names that Bindery answers to. AUTH_METHOD=none needs this for a public name such as this one.
# ALLOWED_HOSTS: bindery.example.com
# true only if clients reach Bindery through a reverse proxy alone. The login lockout then uses X-Forwarded-For. # true only if clients reach Bindery through a reverse proxy alone. The login lockout then uses X-Forwarded-For.
# TRUST_PROXY: "true" # TRUST_PROXY: "true"
# For AUTH_METHOD=proxy: the address of the proxy. The default trusts each private address.
# PROXY_AUTH_TRUSTED_IPS: 172.18.0.3
# For the cwa and proxy modes: the users that can change the global settings. Without a list, each user can.
# BINDERY_ADMIN_USERS: alice, bob
# BINDERY_API_KEY: a-long-random-string # BINDERY_API_KEY: a-long-random-string
ports: ports:
# If a reverse proxy on this host is the only way in, publish the port only on the host: 127.0.0.1:8085:8085
- 8085:8085 - 8085:8085
restart: unless-stopped restart: unless-stopped
# /api/health needs no login, and works with and without URL_BASE. # /api/health needs no login, and works with and without URL_BASE.
@@ -32,7 +44,7 @@ services:
start_period: 30s start_period: 30s
retries: 3 retries: 3
volumes: volumes:
# CWA's ingest folder. New stories and updates go here. # CWA's ingest folder. New stories and updates go here. Bindery does not make the folder.
- /path/to/cwa/ingest:/cwa-book-ingest - /path/to/cwa/ingest:/cwa-book-ingest
# The story database and personal.ini. Create the folder before the first start. # The story database and personal.ini. Create the folder before the first start.
- ./config:/config - ./config:/config
+2 -2
View File
@@ -304,7 +304,7 @@ def remove_added_chapters(db_store: Store, story: dict, existing: list[dict]) ->
return return
print(f"{story['title']} has {_count(len(added), 'chapter')} that this script added:") print(f"{story['title']} has {_count(len(added), 'chapter')} that this script added:")
for c in added: for c in added:
print(f" - {fff.plain_title(c['title'])}") print(f" - {fff.unescape_title(c['title'])}")
if not _confirm("Delete them and all their revisions?"): if not _confirm("Delete them and all their revisions?"):
return return
conn = sqlite3.connect(config.DB_PATH) conn = sqlite3.connect(config.DB_PATH)
@@ -408,7 +408,7 @@ def main(argv: list[str] | None = None) -> None:
raise ScriptError(f"{story['title']} has no stored chapters yet. Download it in Bindery first.") raise ScriptError(f"{story['title']} has no stored chapters yet. Download it in Bindery first.")
epub_chapters = read_epub(args.epub) epub_chapters = read_epub(args.epub)
titles = [fff.plain_title(c["title"]) for c in existing] titles = [fff.unescape_title(c["title"]) for c in existing]
labels = [t + ("" if c["on_site"] else " (not on site)") for t, c in zip(titles, existing, strict=True)] labels = [t + ("" if c["on_site"] else " (not on site)") for t, c in zip(titles, existing, strict=True)]
epub_titles = [c["title"] for c in epub_chapters] epub_titles = [c["title"] for c in epub_chapters]
+1
View File
@@ -7,6 +7,7 @@ from pathlib import Path
_tmp = Path(tempfile.mkdtemp(prefix="bindery-tests-")) _tmp = Path(tempfile.mkdtemp(prefix="bindery-tests-"))
for name, sub in [("CONFIG_DIR", "config"), ("INGEST_DIR", "ingest"), ("TMP_DIR", "tmp")]: for name, sub in [("CONFIG_DIR", "config"), ("INGEST_DIR", "ingest"), ("TMP_DIR", "tmp")]:
os.environ[name] = str(_tmp / sub) os.environ[name] = str(_tmp / sub)
(_tmp / "ingest").mkdir() # Bindery does not make the ingest folder
os.environ["BINDERY_START_THREADS"] = "false" os.environ["BINDERY_START_THREADS"] = "false"
os.environ["UPDATE_INTERVAL_HOURS"] = "0" os.environ["UPDATE_INTERVAL_HOURS"] = "0"
for name in ( for name in (
+3 -1
View File
@@ -26,6 +26,8 @@ class FakeCwa:
self.requests: list[str] = [] self.requests: list[str] = []
# CWA's "Show Publisher Section" setting. Without it, the publisher feed is 404. # CWA's "Show Publisher Section" setting. Without it, the publisher feed is 404.
self.publisher_view = True self.publisher_view = True
# CWA's anonymous browsing. With it, the fake answers every request, with or without a login.
self.anonymous = False
fake = self fake = self
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -87,7 +89,7 @@ class FakeCwa:
self._send(h, 200, self._feed([self._book_entry(b) for b in page], next_href), "application/atom+xml") self._send(h, 200, self._feed([self._book_entry(b) for b in page], next_href), "application/atom+xml")
def handle(self, h) -> None: def handle(self, h) -> None:
if not self._authorized(h): if not self.anonymous and not self._authorized(h):
self._send(h, 401, b"Unauthorized", "text/plain") self._send(h, 401, b"Unauthorized", "text/plain")
return return
url = urlparse(h.path) url = urlparse(h.path)
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { find, install, server, settle } from './flow-helpers.mjs';
const ui = install();
const { stopPolling } = await import('../../bindery/static/js/jobs.js');
test('a user who is not an admin gets no settings link and no settings page', async (t) => {
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, download_fields: {} });
backend.on('GET', 'jobs', []);
backend.on('GET', 'stories', []);
await import('../../bindery/static/js/main.js');
await settle();
assert.ok(find(ui.app, 'a', 'Stories'));
assert.equal(find(ui.app, 'a', 'Settings'), undefined);
window.location.hash = '#/settings';
ui.windowEvents.hashchange();
await settle();
assert.match(ui.app.textContent, /Only an admin can open the settings/);
// The page asks the server for no settings and no rules.
assert.deepEqual(backend.sent('GET', 'settings'), []);
assert.deepEqual(backend.sent('GET', 'rules'), []);
});
+1 -1
View File
@@ -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 });
+73 -4
View File
@@ -14,8 +14,9 @@ const { setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js'
let api, main, details, cleanups; let api, main, details, cleanups;
const config = { 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, 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');
@@ -188,7 +235,7 @@ test('library import sends only selected books and handles an empty candidate li
api.on('GET', 'library/candidates', []); api.on('GET', 'library/candidates', []);
await click(main, 'Find stories in the library'); await click(main, 'Find stories in the library');
assert.match(main.textContent, /tracked already/); assert.match(main.textContent, /tracked already/);
const books = [{ calibre_id: 8, uuid: 'a', title: 'A' }, { calibre_id: 9, uuid: 'b', title: 'B' }]; const books = [{ id: 8, uuid: 'a', title: 'A', authors: ['W'] }, { id: 9, uuid: 'b', title: 'B', authors: [] }];
api.on('GET', 'library/candidates', books); api.on('GET', 'library/candidates', books);
await click(main, 'Find stories in the library'); await click(main, 'Find stories in the library');
change(field(main, 'Select all'), false); change(field(main, 'Select all'), false);
@@ -479,7 +526,7 @@ test('library search trims the query and links by UUID', async () => {
await settle(); await settle();
assert.match(main.textContent, /No library books match/); assert.match(main.textContent, /No library books match/);
api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] }, api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] },
{ id: 8, uuid: 'book-8', title: 'Tracked', authors: 'Writer', tracked_as: 'Other' }]); { id: 8, uuid: 'book-8', title: 'Tracked', authors: ['Writer'], tracked_as: 'Other' }]);
emit(form, 'submit'); emit(form, 'submit');
await settle(); await settle();
const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link'); const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link');
@@ -641,3 +688,25 @@ test('chapter titles show the entities that FanFicFare keeps as text', async ()
render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A &amp; B' }] } }]); render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A &amp; B' }] } }]);
assert.ok(find(el, 'a', 'A & B')); assert.ok(find(el, 'a', 'A & B'));
}); });
test('with CWA off, the pages show nothing about CWA', async () => {
await mountStory();
assert.ok(section(main, 'Library link'));
const off = { ...config, cwa_enabled: false, library_available: false };
// Values that show a notice with CWA on.
Object.assign(details, { pause_uploads: true, ingest_available: false, link_books: false });
details.story.upload_held = true;
await mountStory(off);
assert.ok(section(main, 'Metadata'));
assert.equal(section(main, 'Library link'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
await mountStories(off);
assert.ok(section(main, 'Add stories'));
assert.equal(section(main, 'Import from the library'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
settingsPage(main, {}, off);
await settle();
assert.ok(section(main, 'Jobs list'));
assert.equal(section(main, 'Uploads to CWA'), undefined);
assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i);
});
+2 -1
View File
@@ -46,8 +46,9 @@ const text = (node) => node instanceof Element ? node.children.map(text).join(''
const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label); const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label);
const settle = () => new Promise((resolve) => setImmediate(resolve)); const settle = () => new Promise((resolve) => setImmediate(resolve));
const config = { const config = {
metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: 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',
+4
View File
@@ -130,6 +130,10 @@ test('sortable headings fill the column with one clickable highlight without shi
assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/); assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/);
}); });
test('disabled checkbox labels use muted text', () => {
assert.equal(declarations("label:has(> input[type='checkbox']:disabled)").color, 'var(--muted)');
});
function luminance(hex) { function luminance(hex) {
const channels = hex.slice(1).match(/../g).map((channel) => { const channels = hex.slice(1).match(/../g).map((channel) => {
const value = parseInt(channel, 16) / 255; const value = parseInt(channel, 16) / 255;
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+5 -2
View File
@@ -16,8 +16,11 @@ const { storiesPage } = await import('../../bindery/static/js/stories.js');
const { refreshJobs, startPolling, stopPolling } = await import('../../bindery/static/js/jobs.js'); const { refreshJobs, startPolling, stopPolling } = await import('../../bindery/static/js/jobs.js');
const { fmtDate } = await import('../../bindery/static/js/lib.js'); const { fmtDate } = await import('../../bindery/static/js/lib.js');
// The server gives each story its sort key: the title in lower case, without a leading article.
const story = (id, title, changes = {}) => ({ const story = (id, title, changes = {}) => ({
id, title, url: `https://example.com/${id}`, author: 'Reader', site: 'example.com', id, title, url: `https://example.com/${id}`,
sort_title: (title || `https://example.com/${id}`).trim().replace(/^(?:the|an?)\s+/i, '').toLowerCase(),
author: 'Reader', site: 'example.com',
chapter_count: 42, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes', chapter_count: 42, offsite_count: 0, revised_count: 0, status: 'ok', message: 'No changes',
last_changed: null, last_checked: null, ...changes, last_changed: null, last_checked: null, ...changes,
}); });
@@ -258,7 +261,7 @@ test('date columns keep each timestamp on one line, with last change before last
test('status messages move into accessible pill descriptions without hiding held or chapter counts', async (t) => { test('status messages move into accessible pill descriptions without hiding held or chapter counts', async (t) => {
await mount(t, [ await mount(t, [
story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true }), story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true, approval_held: true }),
story(2, 'Beta', { status: 'error', message: '<script>Connection failed</script>' }), story(2, 'Beta', { status: 'error', message: '<script>Connection failed</script>' }),
story(3, 'Gamma'), story(3, 'Gamma'),
story(4, 'New', { status: '', message: '' }), story(4, 'New', { status: '', message: '' }),
+1 -1
View File
@@ -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: '' });
+245 -18
View File
@@ -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 = { 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',
@@ -15,7 +15,7 @@ const details = (story, extra = {}) => ({
calibre_id: null, calibre_uuid: null, ingested_at: null, upload_held: false, needs_approval: false, ...story }, calibre_id: null, calibre_uuid: null, ingested_at: null, upload_held: false, needs_approval: false, ...story },
chapters: [], chapters: [],
recent_chapters: 0, recent_chapters: 0,
uploads_paused: false, pause_uploads: false,
book: null, book: null,
...extra, ...extra,
}); });
@@ -27,28 +27,135 @@ async function renderStory(t, data) {
backend.on('GET', 'jobs?story_id=1', []); backend.on('GET', 'jobs?story_id=1', []);
backend.on('GET', 'jobs', []); backend.on('GET', 'jobs', []);
const main = new Element('main'); const main = new Element('main');
const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} }); const cleanup = storyPage(main, { storyId: '1' }, { cwa_enabled: true, metadata_fields: {}, mode_labels: {} });
t.after(() => cleanup?.()); t.after(() => cleanup?.());
await settle(); await settle();
const summary = nodes(main).find((el) => el.className === 'card story-summary'); const summary = nodes(main).find((el) => el.className === 'card story-summary');
return { backend, notice: nodes(summary).find((el) => el.className === 'notice') }; return { backend, summary, notice: nodes(summary).find((el) => el.className === 'notice') };
} }
test('the upload switches load and save on the settings page', async (t) => { test('the upload switches load and save on the settings page', async (t) => {
const backend = server(t); const backend = server(t);
backend.on('GET', 'settings', { recent_chapters: 0, hold_new_links: true, pause_uploads: false, personal_ini: '' }); backend.on('GET', 'settings', { recent_chapters: 0, link_books: true, hold_new_links: true, pause_uploads: false,
backend.on('PUT', 'settings', {}); ingest_available: true, personal_ini: '' });
backend.on('PUT', 'settings', { link_books: false, hold_new_links: true, pause_uploads: true, ingest_available: true });
backend.on('GET', 'rules', { rules: [], paragraphs: false }); backend.on('GET', 'rules', { rules: [], paragraphs: false });
backend.on('GET', 'stories', []); backend.on('GET', 'stories', []);
const main = new Element('main'); const main = new Element('main');
settingsPage(main, {}, config); settingsPage(main, {}, config);
await settle(); await settle();
const card = section(main, 'Uploads to CWA'); const card = section(main, 'Uploads to CWA');
const [hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox'); const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
assert.deepEqual([hold.checked, pause.checked], [true, false]); assert.deepEqual([link.checked, hold.checked, pause.checked], [true, true, false]);
assert.equal(find(card, 'label', ' Link books to CWA library').children[0], link);
assert.match(card.textContent, /send a plain ePub to INGEST_DIR/);
const warning = nodes(card).find((el) => el.attributes.id === 'link-note');
assert.equal(warning.className, 'notice');
assert.equal(warning.hidden, true);
assert.equal(link.attributes['aria-describedby'], '');
assert.match(warning.textContent, /Updates without a saved CWA book ID create duplicate books if CWA imports them/);
assert.equal(nodes(card).find((el) => el.attributes.id === 'ingest-note').hidden, true);
assert.equal(pause.disabled, false);
assert.equal(hold.disabled, false);
assert.equal(link.disabled, false);
assert.equal(nodes(card).find((el) => el.attributes.id === 'library-note').hidden, true);
change(link, false);
assert.equal(warning.hidden, false);
assert.equal(link.attributes['aria-describedby'], warning.attributes.id);
assert.equal(hold.disabled, true);
assert.equal(hold.checked, true);
change(link, true);
assert.equal(warning.hidden, true);
assert.equal(hold.disabled, false);
assert.equal(hold.checked, true);
change(link, false);
change(pause, true); change(pause, true);
await click(card, 'Save'); await click(card, 'Save');
assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { hold_new_links: true, pause_uploads: true }); assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { link_books: false, hold_new_links: true, pause_uploads: true });
assert.deepEqual([link.checked, hold.checked, pause.checked], [false, true, true]);
assert.equal(hold.disabled, true);
assert.equal(warning.hidden, false);
});
for (const ingestAvailable of [true, false]) {
test(`missing library sources disable linking and approval with ingest available ${ingestAvailable}`, async (t) => {
const backend = server(t);
const saved = { link_books: true, linking_available: false, hold_new_links: true,
pause_uploads: false, ingest_available: ingestAvailable };
backend.on('GET', 'settings', saved);
backend.on('PUT', 'settings', saved);
backend.on('GET', 'rules', { rules: [], paragraphs: false });
backend.on('GET', 'stories', []);
const main = new Element('main');
settingsPage(main, {}, config);
await settle();
const card = section(main, 'Uploads to CWA');
const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
const note = nodes(card).find((el) => el.attributes.id === 'library-note');
assert.equal(link.checked, false);
assert.equal(link.disabled, true);
assert.equal(hold.checked, true);
assert.equal(hold.disabled, true);
assert.equal(pause.checked, !ingestAvailable);
assert.equal(pause.disabled, !ingestAvailable);
assert.equal(note.hidden, false);
assert.equal(note.className, 'notice');
assert.match(note.textContent, /CWA_URL and CALIBRE_DB_PATH are not set/);
assert.match(note.textContent, /Set one or both and restart Bindery/);
assert.equal(link.attributes['aria-describedby'], 'library-note link-note');
assert.equal(nodes(card).find((el) => el.attributes.id === 'link-note').hidden, false);
await click(card, 'Save');
assert.equal(backend.sent('PUT', 'settings')[0].body.link_books, false);
assert.equal(link.checked, false);
assert.equal(link.disabled, true);
});
}
for (const linkBooks of [true, false]) {
test(`missing INGEST_DIR forces pause and shows a highlighted note with linking ${linkBooks}`, async (t) => {
const backend = server(t);
const saved = { link_books: linkBooks, hold_new_links: false, pause_uploads: true, ingest_available: false };
backend.on('GET', 'settings', saved);
backend.on('PUT', 'settings', saved);
backend.on('GET', 'rules', { rules: [], paragraphs: false });
backend.on('GET', 'stories', []);
const main = new Element('main');
settingsPage(main, {}, config);
await settle();
const card = section(main, 'Uploads to CWA');
const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
assert.equal(link.checked, linkBooks);
assert.equal(hold.disabled, !linkBooks);
assert.equal(nodes(card).find((el) => el.attributes.id === 'link-note').hidden, linkBooks);
assert.equal(pause.checked, true);
assert.equal(pause.disabled, true);
const note = nodes(card).find((el) => el.attributes.id === 'ingest-note');
assert.equal(note.hidden, false);
assert.equal(note.className, 'notice');
assert.equal(pause.attributes['aria-describedby'], note.attributes.id);
assert.match(note.textContent, /INGEST_DIR is not set/);
await click(card, 'Save');
assert.equal(pause.checked, true);
assert.equal(pause.disabled, true);
assert.equal(backend.sent('PUT', 'settings')[0].body.pause_uploads, true);
});
}
test('a saved approval preference stays checked but disabled while linking is off', async (t) => {
const backend = server(t);
backend.on('GET', 'settings', { link_books: false, hold_new_links: true, pause_uploads: false, ingest_available: true });
backend.on('GET', 'rules', { rules: [], paragraphs: false });
backend.on('GET', 'stories', []);
const main = new Element('main');
settingsPage(main, {}, config);
await settle();
const card = section(main, 'Uploads to CWA');
const [link, hold] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
assert.equal(hold.disabled, true);
assert.equal(hold.checked, true);
change(link, true);
assert.equal(hold.disabled, false);
assert.equal(hold.checked, true);
}); });
test('a held book with a new link names the book and sends it on approval', async (t) => { test('a held book with a new link names the book and sends it on approval', async (t) => {
@@ -57,12 +164,12 @@ test('a held book with a new link names the book and sends it on approval', asyn
{ book }, { book },
); );
const { backend, notice } = await renderStory(t, data); const { backend, notice } = await renderStory(t, data);
assert.match(notice.textContent, /waits for your approval\. The next upload replaces the ePub of library book 7: Library Title · Writer\./); assert.match(notice.textContent, /^Approve replacing the ePub of library book 7: Library Title · Writer\./);
backend.on('POST', 'stories/1/approve', { story: data.story, job: { id: 'j1' } }); backend.on('POST', 'stories/1/approve', { story: data.story, job: { id: 'j1' } });
await click(notice, 'Approve and send'); await click(notice, 'Approve and send');
await settle(); await settle();
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: 'book-7' }); assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: 'book-7' });
assert.match(ui.toasts.textContent, /Approved\. Bindery rebuilds the book and sends it\./); assert.match(ui.toasts.textContent, /Approved\. Rebuild queued\./);
}); });
test('a wrong link can go to CWA as a new book instead', async (t) => { test('a wrong link can go to CWA as a new book instead', async (t) => {
@@ -81,9 +188,50 @@ test('a wrong link can go to CWA as a new book instead', async (t) => {
); );
}); });
for (const uuid of [null, 'book-7']) {
test(`approval while paused saves the decision without promising an upload: ${uuid}`, async (t) => {
const data = details(
{ calibre_id: uuid ? 7 : null, calibre_uuid: uuid, upload_held: true, needs_approval: true },
{ link_books: true, pause_uploads: true },
);
const { backend, notice } = await renderStory(t, data);
assert.ok(find(notice, 'button', 'Approve'));
assert.equal(find(notice, 'button', 'Approve and send'), undefined);
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
backend.on('POST', 'stories/1/approve', { story: data.story, job: null });
await click(notice, 'Approve');
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid });
assert.equal(ui.toasts.children.at(-1).textContent, 'Approved');
});
}
test('approval as a new book while paused keeps the pause in the confirmation', async (t) => {
const data = details(
{ calibre_id: 7, calibre_uuid: 'book-7', upload_held: true, needs_approval: true },
{ link_books: true, pause_uploads: true },
);
const { backend, notice } = await renderStory(t, data);
const confirm = t.mock.method(window, 'confirm', () => true);
backend.on('POST', 'stories/1/link', data.story);
backend.on('POST', 'stories/1/approve', { story: data.story, job: null });
await click(notice, 'Approve as a new book instead');
assert.match(confirm.mock.calls[0].arguments[0], /approve this story as a new book\? Uploads remain paused/);
assert.match(confirm.mock.calls[0].arguments[0], /you get a duplicate/);
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: null });
assert.equal(ui.toasts.children.at(-1).textContent, 'Approved');
});
test('a saved ID without a UUID still offers approval', async (t) => {
const { notice } = await renderStory(t, details(
{ calibre_id: 7, ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
));
assert.ok(find(notice, 'button', 'Approve and send'));
assert.match(notice.textContent, /library book 7/);
});
test('a new story asks for approval to go to CWA as a new book', async (t) => { test('a new story asks for approval to go to CWA as a new book', async (t) => {
const { notice } = await renderStory(t, details({ needs_approval: true })); const { notice } = await renderStory(t, details({ needs_approval: true }));
assert.match(notice.textContent, /The next upload goes to CWA as a new book\./); assert.match(notice.textContent, /^Approve this story as a new CWA book\./);
assert.ok(find(notice, 'button', 'Approve')); assert.ok(find(notice, 'button', 'Approve'));
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined); assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
}); });
@@ -91,25 +239,103 @@ test('a new story asks for approval to go to CWA as a new book', async (t) => {
test('a paused upload points to the settings', async (t) => { test('a paused upload points to the settings', async (t) => {
const { notice } = await renderStory( const { notice } = await renderStory(
t, t,
details({ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true }, { book, uploads_paused: true }), details({ calibre_id: 7, calibre_uuid: 'book-7', ingested_at: '2026-01-01', upload_held: true }, { book, pause_uploads: true }),
); );
assert.match(notice.textContent, /Uploads to CWA are paused/); assert.match(notice.textContent, /Uploads to CWA are paused/);
assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings'); assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings');
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0); assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
}); });
for (const savedId of [null, 7]) {
for (const paused of [false, true]) {
for (const needsApproval of [false, true]) {
test(`linking off hides approval with saved ID ${savedId}, pause ${paused}, and approval ${needsApproval}`, async (t) => {
const { notice } = await renderStory(t, details(
{ calibre_id: savedId, calibre_uuid: savedId ? 'book-7' : null,
upload_held: true, needs_approval: needsApproval },
{ link_books: false, pause_uploads: paused },
));
if (savedId !== null && !paused) {
assert.equal(notice, undefined);
return;
}
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
assert.doesNotMatch(notice.textContent, /waits for your approval|as a new book|Bindery sends this book/);
if (savedId === null) {
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
assert.equal(notice.className, 'notice');
} else {
assert.doesNotMatch(notice.textContent, /duplicate books/);
}
if (paused) {
assert.match(notice.textContent, /Uploads to CWA are paused/);
assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings');
}
});
}
}
}
test('linking off warns that repeated exports without a saved ID create duplicates in CWA', async (t) => {
const { notice, summary } = await renderStory(t, details(
{ ingested_at: '2026-01-01', needs_approval: true, upload_held: true },
{ link_books: false },
));
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
assert.match(summary.textContent, /Sent to ingest folder/);
assert.doesNotMatch(summary.textContent, /Sent to CWA, unlinked/);
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
});
test('a story that went to CWA without a link shows no approval until Bindery finds its book', async (t) => { test('a story that went to CWA without a link shows no approval until Bindery finds its book', async (t) => {
const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true })); const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true }));
assert.equal(notice, undefined); assert.equal(notice, undefined);
}); });
test('the story list marks a held book', async (t) => { test('missing INGEST_DIR points to the environment rather than the disabled pause switch', async (t) => {
const { notice } = await renderStory(t, details(
{ upload_held: true }, { pause_uploads: true, ingest_available: false },
));
assert.equal(notice.textContent, 'Set INGEST_DIR and restart Bindery to enable uploads.');
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
});
test('missing library sources leave uploads enabled with the duplicate warning', async (t) => {
const { notice } = await renderStory(t, details(
{ ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
{ link_books: false },
));
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
});
test('a new book asks for approval when library linking is enabled', async (t) => {
const { notice } = await renderStory(t, details(
{ upload_held: true, needs_approval: true },
{ link_books: true },
));
assert.match(notice.textContent, /^Approve this story as a new CWA book\./);
assert.ok(find(notice, 'button', 'Approve and send'));
});
test('approval before a build still shows the pause and never offers to send', async (t) => {
const { notice } = await renderStory(t, details(
{ needs_approval: true }, { pause_uploads: true },
));
assert.match(notice.textContent, /Uploads to CWA are paused\. Resume them in Settings\./);
assert.ok(find(notice, 'button', 'Approve'));
assert.equal(find(notice, 'button', 'Approve and send'), undefined);
});
test('the story list marks only an approval hold, not every unsent book', async (t) => {
const backend = server(t); const backend = server(t);
backend.on('GET', 'stories', [ backend.on('GET', 'stories', [
{ id: 1, url: 'u1', title: 'Held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok', { id: 1, url: 'u1', title: 'Held', sort_title: 'held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
message: '', upload_held: true }, message: '', upload_held: true, approval_held: true },
{ id: 2, url: 'u2', title: 'Sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok', { id: 2, url: 'u2', title: 'Sent', sort_title: 'sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
message: '', upload_held: false }, message: '', upload_held: false, approval_held: false },
{ id: 3, url: 'u3', title: 'Paused', sort_title: 'paused', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
message: '', upload_held: true, approval_held: false },
]); ]);
backend.on('GET', 'jobs', []); backend.on('GET', 'jobs', []);
const main = new Element('main'); const main = new Element('main');
@@ -119,4 +345,5 @@ test('the story list marks a held book', async (t) => {
const held = nodes(main).filter((el) => el.tagName === 'tr').filter((row) => find(row, 'span', 'held')); const held = nodes(main).filter((el) => el.tagName === 'tr').filter((row) => find(row, 'span', 'held'));
assert.equal(held.length, 1); assert.equal(held.length, 1);
assert.match(held[0].textContent, /^Held/); assert.match(held[0].textContent, /^Held/);
assert.match(find(held[0], 'span', 'held').attributes.title, /waits for your approval/);
}); });
+185
View File
@@ -10,6 +10,7 @@ import pytest
from fake_cwa import FakeCwa from fake_cwa import FakeCwa
from itsdangerous import TimestampSigner from itsdangerous import TimestampSigner
from test_build import PNG from test_build import PNG
from werkzeug.security import generate_password_hash
from bindery import auth, config, orchestrator, web from bindery import auth, config, orchestrator, web
from bindery.store import Store from bindery.store import Store
@@ -70,6 +71,7 @@ def test_cwa_login(client, cwa):
"authenticated": False, "authenticated": False,
"mode": "cwa", "mode": "cwa",
"user": None, "user": None,
"admin": False,
"logout_url": None, "logout_url": None,
} }
assert login(client, password="wrong").status_code == 401 assert login(client, password="wrong").status_code == 401
@@ -81,6 +83,20 @@ def test_cwa_login(client, cwa):
assert client.get("/api/stories").status_code == 401 assert client.get("/api/stories").status_code == 401
def test_cwa_that_accepts_every_login_cannot_check_a_password(client, cwa, caplog):
cwa.anonymous = True
for password in ("pw", "wrong"):
response = login(client, password=password)
assert response.status_code == 401
assert response.json["error"] == "Cannot check the login with CWA. The log has the details."
assert "CWA accepts every OPDS login" in caplog.text
assert client.get("/api/stories").status_code == 401
# These are not wrong passwords, so they lock no one out.
cwa.anonymous = False
orchestrator.cwa()._login_test = None
assert login(client).status_code == 200
def test_a_session_ends_after_one_day(client, cwa, monkeypatch): def test_a_session_ends_after_one_day(client, cwa, monkeypatch):
response = client.post( response = client.post(
"/api/auth/login", json={"username": "reader", "password": "pw", "remember": True}, headers=H "/api/auth/login", json={"username": "reader", "password": "pw", "remember": True}, headers=H
@@ -155,16 +171,105 @@ def test_proxy_header(client, monkeypatch):
"authenticated": True, "authenticated": True,
"mode": "proxy", "mode": "proxy",
"user": "alice", "user": "alice",
"admin": True,
"logout_url": "https://auth.example.com/logout", "logout_url": "https://auth.example.com/logout",
} }
@pytest.mark.parametrize(("user", "admin"), [("alice", True), ("Alice", True), ("bob", False)])
def test_only_an_admin_changes_the_global_settings(client, monkeypatch, tmp_path, user, admin):
monkeypatch.setattr(config, "PERSONAL_INI", tmp_path / "personal.ini")
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
monkeypatch.setattr(config, "ADMIN_USERS", ["alice"])
sid = orchestrator.store.create_story("https://www.royalroad.com/fiction/1", "www.royalroad.com", "S", "W", [])
headers = {**H, "X-Remote-User": user}
status = 200 if admin else 403
assert client.get("/api/auth/check", headers=headers).json["admin"] is admin
assert ("personal_ini" in client.get("/api/settings", headers=headers).json) is admin
for data in ({"recent_chapters": 5}, {"personal_ini": "[defaults]\n"}):
assert client.put("/api/settings", json=data, headers=headers).status_code == status
assert (orchestrator.get_settings()["recent_chapters"] == 5) is admin
assert config.PERSONAL_INI.exists() is admin
rules = {"rules": [{"pattern": "a", "replacement": "b"}]}
response = client.put("/api/rules", json=rules, headers=headers)
assert response.status_code == status
assert bool(orchestrator.store.rules(None)) is admin
if not admin:
assert response.json == {"error": "Only an admin can do this. BINDERY_ADMIN_USERS lists the admins."}
# Each user can read the global rules, and change the rules and the settings of a story.
assert client.get("/api/rules", headers=headers).status_code == 200
assert client.put(f"/api/stories/{sid}/rules", json=rules, headers=headers).status_code == 200
assert client.put(f"/api/stories/{sid}/settings", json={"recheck_days": 3}, headers=headers).status_code == 200
def test_the_api_key_and_single_user_modes_are_admins(client, monkeypatch):
monkeypatch.setattr(config, "ADMIN_USERS", ["alice"])
assert client.get("/api/auth/check").json["admin"] # the none mode has no users
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
monkeypatch.setattr(config, "API_KEY", "secret")
key = {"X-Api-Key": "secret"}
assert client.get("/api/auth/check", headers=key).json["admin"]
assert client.put("/api/settings", json={"recent_chapters": 2}, headers=key).status_code == 200
assert auth.is_admin(auth.Identity("bob", "proxy")) is False
monkeypatch.setattr(config, "AUTH_METHOD", "local")
assert auth.is_admin(auth.Identity("bob", "session"))
# Without a list, each user is an admin.
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
monkeypatch.setattr(config, "ADMIN_USERS", [])
assert auth.is_admin(auth.Identity("bob", "proxy"))
@pytest.mark.parametrize(
("trusted", "address", "accepted"),
[
(None, "127.0.0.1", True),
(None, "::1", True),
(None, "172.18.0.3", True),
(None, "192.168.1.20", True),
(None, "fd00::5", True),
(None, "::ffff:10.1.2.3", True),
(None, "203.0.113.9", False),
(None, "2001:db8::1", False),
(None, "", False),
(["172.18.0.3"], "172.18.0.3", True),
(["172.18.0.3"], "172.18.0.4", False),
(["172.18.0.3"], "127.0.0.1", False),
(["10.0.0.0/8", "fd00::/64"], "fd00::7", True),
(["10.0.0.0/8", "fd00::/64"], "192.168.1.20", False),
],
)
def test_only_a_trusted_address_can_send_the_proxy_header(client, monkeypatch, caplog, trusted, address, accepted):
monkeypatch.setattr(config, "AUTH_METHOD", "proxy")
if trusted is not None:
monkeypatch.setattr(config, "PROXY_AUTH_TRUSTED_IPS", trusted)
environ = {"REMOTE_ADDR": address}
response = client.get("/api/stories", headers={"X-Remote-User": "alice"}, environ_base=environ)
assert response.status_code == (200 if accepted else 401)
assert (
client.get("/api/auth/check", headers={"X-Remote-User": "alice"}, environ_base=environ).json["authenticated"]
is accepted
)
assert ("which is not in PROXY_AUTH_TRUSTED_IPS" in caplog.text) is not accepted
# A change needs the header too, from the same address.
change = client.post(
"/api/stories", json={"urls": []}, headers={**H, "X-Remote-User": "alice"}, environ_base=environ
)
assert change.status_code == (200 if accepted else 401)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("setting", "value", "error"), ("setting", "value", "error"),
[ [
("AUTH_METHOD", "cwa", "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"), ("AUTH_METHOD", "cwa", "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH"),
("AUTH_METHOD", "cwa-login", "AUTH_METHOD must be none, local, cwa or proxy, not 'cwa-login'"), ("AUTH_METHOD", "cwa-login", "AUTH_METHOD must be none, local, cwa or proxy, not 'cwa-login'"),
("PROXY_AUTH_USER_HEADER", "", "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER"), ("PROXY_AUTH_USER_HEADER", "", "AUTH_METHOD=proxy needs PROXY_AUTH_USER_HEADER"),
("PROXY_AUTH_TRUSTED_IPS", [], "AUTH_METHOD=proxy needs PROXY_AUTH_TRUSTED_IPS, the addresses of the proxy"),
(
"PROXY_AUTH_TRUSTED_IPS",
["10.0.0.5", "proxy.local"],
"PROXY_AUTH_TRUSTED_IPS must be a list of addresses and networks: "
"'proxy.local' does not appear to be an IPv4 or IPv6 network",
),
], ],
) )
def test_incomplete_login_settings_refuse_every_request(client, monkeypatch, setting, value, error): def test_incomplete_login_settings_refuse_every_request(client, monkeypatch, setting, value, error):
@@ -183,6 +288,51 @@ def test_incomplete_login_settings_refuse_every_request(client, monkeypatch, set
assert client.get("/").status_code == 200 assert client.get("/").status_code == 200
@pytest.mark.parametrize(
("mode", "allowed", "host", "accepted"),
[
# Without a login, only an address or a name of a private network.
("none", [], "localhost", True),
("none", [], "127.0.0.1:8085", True),
("none", [], "192.168.1.5:8085", True),
("none", [], "[fd00::5]:8085", True),
("none", [], "bindery:8085", True),
("none", [], "nas.local", True),
("none", [], "NAS.Home.Arpa.", True),
("none", [], "bindery.example.com", False),
("none", [], "rebind.attacker.example:8085", False),
("none", [], "notlocal", True),
("none", [], "evil.com.local.attacker.example", False),
# With a login, each name.
("local", [], "bindery.example.com", True),
("proxy", [], "bindery.example.com", True),
# A list counts in each mode.
("none", ["bindery.example.com"], "bindery.example.com:443", True),
("none", ["bindery.example.com"], "localhost", False),
("none", ["bindery.example.com"], "x.bindery.example.com", False),
("local", [".example.com"], "bindery.example.com", True),
("local", [".example.com"], "example.com", True),
("local", [".example.com"], "badexample.com", False),
("local", ["example.com"], "attacker.example", False),
("none", ["*"], "anything.example", True),
],
)
def test_bindery_answers_only_to_its_host_names(client, monkeypatch, mode, allowed, host, accepted):
monkeypatch.setattr(config, "AUTH_METHOD", mode)
monkeypatch.setattr(config, "LOCAL_USERNAME", "reader")
monkeypatch.setattr(config, "LOCAL_PASSWORD_HASH", generate_password_hash("pw", method="pbkdf2:sha256:1000"))
monkeypatch.setattr(config, "ALLOWED_HOSTS", allowed)
for path in ("/", "/api/auth/check", "/static/style.css"):
response = client.get(path, headers={"Host": host})
assert response.status_code == (200 if accepted else 400), path
if not accepted:
assert response.json == {
"error": f"Bindery does not answer to the host name {host}. Add it to ALLOWED_HOSTS."
}
# The health check of the container works under each name.
assert client.get("/api/health", headers={"Host": host}).status_code == 200
def test_api_key(client, cwa, monkeypatch): def test_api_key(client, cwa, monkeypatch):
monkeypatch.setattr(config, "API_KEY", "k" * 32) monkeypatch.setattr(config, "API_KEY", "k" * 32)
assert client.get("/api/stories", headers={"X-Api-Key": "wrong"}).status_code == 401 assert client.get("/api/stories", headers={"X-Api-Key": "wrong"}).status_code == 401
@@ -222,6 +372,7 @@ def test_the_cover_of_a_story(client):
response = client.get(f"/api/stories/{story_id}/cover?v={version}") response = client.get(f"/api/stories/{story_id}/cover?v={version}")
assert (response.status_code, response.mimetype, response.data) == (200, "image/png", PNG) assert (response.status_code, response.mimetype, response.data) == (200, "image/png", PNG)
assert response.cache_control.max_age == 365 * 24 * 60 * 60 assert response.cache_control.max_age == 365 * 24 * 60 * 60
assert response.cache_control.private and not response.cache_control.public
# A new cover gets a new version, so the browser loads it. # A new cover gets a new version, so the browser loads it.
store.put_image(story_id, "https://example.com/new.jpg", "jpg", "image/jpeg", b"new", is_cover=True) store.put_image(story_id, "https://example.com/new.jpg", "jpg", "image/jpeg", b"new", is_cover=True)
@@ -280,15 +431,49 @@ def test_delete_revisions(client):
assert [r["rev"] for r in client.get(f"/api/chapters/{chapter_id}/diff").json["revisions"]] == [1, 3] assert [r["rev"] for r in client.get(f"/api/chapters/{chapter_id}/diff").json["revisions"]] == [1, 3]
@pytest.mark.parametrize(
"bad",
[
{"recent_chapters": "many"},
{"main_jobs_count": 100000},
{"personal_ini": "[defaults\nfoo"},
{"link_books": "yes"},
],
)
def test_a_bad_setting_changes_no_setting(client, monkeypatch, tmp_path, bad):
ini = tmp_path / "personal.ini"
ini.write_text("[defaults]\n")
monkeypatch.setattr(config, "PERSONAL_INI", ini)
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
before = client.get("/api/settings").json
good = {
"link_books": False,
"hold_new_links": True,
"pause_uploads": True,
"recent_chapters": 4,
"story_jobs_hours": 1,
"personal_ini": "[defaults]\nis_adult:true\n",
}
assert client.put("/api/settings", json={**good, **bad}, headers=H).status_code == 400
assert client.get("/api/settings").json == before
def test_settings(client, monkeypatch, tmp_path): def test_settings(client, monkeypatch, tmp_path):
ini = tmp_path / "personal.ini" ini = tmp_path / "personal.ini"
ini.write_text("[defaults]\nis_adult:true\n") ini.write_text("[defaults]\nis_adult:true\n")
monkeypatch.setattr(config, "PERSONAL_INI", ini) monkeypatch.setattr(config, "PERSONAL_INI", ini)
monkeypatch.setattr(config, "CWA_URL", "")
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
assert client.get("/api/settings").json == { assert client.get("/api/settings").json == {
"recent_chapters": 0, "recent_chapters": 0,
"link_books": False,
"linking_available": False,
"hold_new_links": False, "hold_new_links": False,
"pause_uploads": False, "pause_uploads": False,
"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,
+141 -4
View File
@@ -46,6 +46,7 @@ def test_add_list_download_and_cancel_jobs(client, story):
assert result.json["mode"] == "metadata" assert result.json["mode"] == "metadata"
app_config = client.get("/api/config").json app_config = client.get("/api/config").json
assert app_config["metadata_fields"]["title"] assert app_config["metadata_fields"]["title"]
assert app_config["cwa_enabled"] is True
assert (app_config["story_jobs_hours"], app_config["story_jobs_count"]) == (36, 10) assert (app_config["story_jobs_hours"], app_config["story_jobs_count"]) == (36, 10)
assert (app_config["main_jobs_hours"], app_config["main_jobs_count"]) == (6, 25) assert (app_config["main_jobs_hours"], app_config["main_jobs_count"]) == (6, 25)
assert (app_config["job_list_max_hours"], app_config["job_list_max_count"]) == (720, 500) assert (app_config["job_list_max_hours"], app_config["job_list_max_count"]) == (720, 500)
@@ -90,7 +91,7 @@ def test_library_search_link_import_and_unlink(client, story, monkeypatch):
cwa.get_book.return_value = book.to_dict() cwa.get_book.return_value = book.to_dict()
monkeypatch.setattr(o, "cwa", lambda: cwa) monkeypatch.setattr(o, "cwa", lambda: cwa)
monkeypatch.setattr(config, "CWA_URL", "https://cwa.example") monkeypatch.setattr(config, "CWA_URL", "https://cwa.example")
assert client.get("/api/library/candidates").json[0]["calibre_id"] == 7 assert client.get("/api/library/candidates").json[0]["id"] == 7
assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] is None assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] is None
cwa.search.assert_called_once_with("Title") cwa.search.assert_called_once_with("Title")
linked = client.post(f"/api/stories/{sid}/link", json={"uuid": "book-7"}, headers=H) linked = client.post(f"/api/stories/{sid}/link", json={"uuid": "book-7"}, headers=H)
@@ -99,6 +100,7 @@ def test_library_search_link_import_and_unlink(client, story, monkeypatch):
assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] == "Story" assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] == "Story"
cwa.get_book.side_effect = CwaError("offline") cwa.get_book.side_effect = CwaError("offline")
assert client.get(f"/api/stories/{sid}").json["library_error"] == "offline" assert client.get(f"/api/stories/{sid}").json["library_error"] == "offline"
cwa.get_book.assert_called_with("book-7", timeout=5) # the page does not wait long for CWA
cwa.get_book.side_effect = None cwa.get_book.side_effect = None
cwa.get_book.return_value = None cwa.get_book.return_value = None
assert client.post(f"/api/stories/{sid}/link", json={"uuid": "missing"}, headers=H).status_code == 404 assert client.post(f"/api/stories/{sid}/link", json={"uuid": "missing"}, headers=H).status_code == 404
@@ -140,13 +142,148 @@ 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 test_unexpected_backend_error_returns_json(client, monkeypatch): 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):
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")
assert response.status_code == 500 assert response.status_code == 500
assert response.json == {"error": "RuntimeError: broken"} assert response.json == {"error": "Bindery hit an unexpected error. The log has the details."}
assert "RuntimeError: broken" in caplog.text
@pytest.mark.parametrize(
("method", "path", "body", "error"),
[
("post", "stories", {"urls": 5}, "urls must be a list"),
("post", "stories/{sid}/chapters", {"chapter_ids": 5}, "chapter_ids must be a list"),
("post", "stories/{sid}/chapters/move", {"chapter_ids": [[1]]}, "chapter_ids must be a whole number"),
("post", "stories/{sid}/chapters/move", {"chapter_ids": [1], "after": [2]}, "after must be a whole number"),
("post", "stories/{sid}/chapters/delete", {"chapter_ids": "all"}, "chapter_ids must be a list"),
("post", "stories/{sid}/download", {"mode": "recheck", "chapter_ids": ["x"]}, "chapter_ids must be a whole"),
("post", "stories/{sid}/download", {"force_fields": "title"}, "force_fields must be a list"),
("post", "stories/{sid}/download", {"force_fields": [["title"]]}, "force_fields must be a list of names"),
("put", "stories/{sid}/settings", {"locked_fields": [{}]}, "locked_fields must be a list of names"),
("put", "stories/{sid}/settings", {"recheck_days": [7]}, "recheck_days must be a whole number"),
("put", "rules", {"rules": ["a"]}, "rules must be a list of rules"),
("post", "rules/preview", {"rules": [], "chapter_id": None}, "chapter_id must be a whole number"),
("post", "chapters/{cid}/revision", {"rev": {}}, "rev must be a whole number"),
("post", "library/import", {"books": 7}, "books must be a list"),
("post", "library/import", {"books": [{"id": "x"}]}, "id must be a whole number"),
],
)
def test_a_value_of_the_wrong_type_is_a_bad_request(client, story, method, path, body, error):
sid, cid = story
response = getattr(client, method)("/api/" + path.format(sid=sid, cid=cid), json=body, headers=H)
assert response.status_code == 400
assert error in response.json["error"]
def test_chapter_requests_without_ids_name_what_to_select(client, story):
sid, _ = story
for body in ({}, {"chapter_ids": None}):
response = client.post(f"/api/stories/{sid}/chapters/delete", json=body, headers=H)
assert (response.status_code, response.json["error"]) == (400, "Select the chapters to delete")
def test_a_story_change_does_not_ask_cwa_for_the_book(client, story, monkeypatch):
sid, _ = story
o.store.update_story(sid, calibre_id=7, calibre_uuid="book-7")
monkeypatch.setattr(config, "CWA_URL", "https://cwa.example")
cwa = Mock()
monkeypatch.setattr(o, "cwa", lambda: cwa)
response = client.put(f"/api/stories/{sid}/settings", json={"recheck_days": 4}, headers=H)
assert response.json["recheck_days"] == 4
assert "chapters" not in response.json
cwa.get_book.assert_not_called()
def test_settings_are_saved_by_name(client):
with pytest.raises(ValueError, match="Unknown settings: \\['recent'\\]"):
o.save_settings(recent=3)
# The route passes only the known settings on.
response = client.put("/api/settings", json={"recent": 3, "recent_chapters": 4, "main_jobs_count": 9}, headers=H)
assert (response.status_code, response.json["recent_chapters"], response.json["main_jobs_count"]) == (200, 4, 9)
for data, error in [
({"recent_chapters": -1}, "The number of recent chapters cannot be negative"),
({"main_jobs_count": 501}, "main_jobs_count cannot be more than 500"),
({"story_jobs_hours": "x"}, "story_jobs_hours must be a whole number"),
]:
assert client.put("/api/settings", json=data, headers=H).json == {"error": error}
+1 -1
View File
@@ -76,7 +76,7 @@ def test_a_title_with_entities_builds_and_imports_in_fanficfare_form(tmp_path):
ElementTree.fromstring(z.read(name)) ElementTree.fromstring(z.read(name))
# An import stores the same title as a download, so the next update finds no rename. # An import stores the same title as a download, so the next update finds no rename.
assert fff.read_epub(out).chapters[0]["title"] == title assert fff.read_epub(out).chapters[0]["title"] == title
assert fff.plain_title(title) == "Folly & Magic <1>" assert fff.unescape_title(title) == "Folly & Magic <1>"
assert fff.escape_title("Folly & Magic <1>") == title assert fff.escape_title("Folly & Magic <1>") == title
+296
View File
@@ -0,0 +1,296 @@
"""A book is built and sent only when its chapters, rules or metadata changed."""
import dataclasses
from unittest.mock import Mock
import pytest
from test_api import H
from test_api import client as client
from test_deliver import STORY_URL, add_story, ingest_files, linked_story, site, update
from test_deliver import fake as fake
from test_deliver import jobs as jobs
from test_deliver import story_epub as story_epub
from bindery import config, orchestrator
from bindery.jobs import DownloadMode, Job
ONE = [(1, "One", "a")]
@pytest.fixture(autouse=True)
def builds(fake, jobs, monkeypatch, tmp_path):
"""Count the builds, in a folder of built books that starts empty."""
monkeypatch.setattr(config, "TMP_DIR", tmp_path / "tmp")
config.TMP_DIR.mkdir()
spy = Mock(side_effect=orchestrator._build)
monkeypatch.setattr(orchestrator, "_build", spy)
return spy
def story(story_id):
return orchestrator.store.get_story(story_id)
def test_an_unchanged_story_is_not_built_or_sent_again(builds, monkeypatch):
story_id = linked_story(ONE)
site(monkeypatch, ONE)
assert "library book 1" in update(story_id)
changed = story(story_id)["last_changed"]
orchestrator.store.update_story(story_id, last_changed="2020-01-01T00:00:00")
for mode in (DownloadMode.NEW, DownloadMode.RECHECK_ALL, DownloadMode.METADATA, DownloadMode.FULL):
assert update(story_id, mode).startswith("No changes")
assert changed and story(story_id)["last_changed"] == "2020-01-01T00:00:00"
assert builds.call_count == 1
assert len(ingest_files()) == 2 # one epub and its sidecar
@pytest.mark.parametrize("change", ["chapter", "rule", "global rule", "paragraphs", "metadata", "included"])
def test_each_change_builds_and_sends_the_book(builds, monkeypatch, change):
story_id = linked_story([(1, "One", "a"), (2, "Two", "b")])
site(monkeypatch, [(1, "One", "a"), (2, "Two", "b")])
update(story_id)
if change == "chapter":
site(monkeypatch, [(1, "One", "a"), (2, "Two", "b"), (3, "Three", "c")])
elif change == "rule":
orchestrator.save_rules(story_id, [{"pattern": "a", "replacement": "b"}])
elif change == "global rule":
orchestrator.save_rules(None, [{"pattern": "a", "replacement": "b"}])
elif change == "paragraphs":
orchestrator.save_rules(story_id, [], paragraphs=True)
elif change == "metadata":
orchestrator.store.update_story(story_id, pinned={"title": "Edited"})
else:
chapter = orchestrator.store.list_chapters(story_id)[0]
orchestrator.store.update_chapter(chapter["id"], included=0)
assert "library book 1" in update(story_id)
assert builds.call_count == 2
assert len(ingest_files()) == 4
assert update(story_id).startswith("No changes")
def test_a_held_book_that_did_not_change_is_not_built_again(builds, monkeypatch):
monkeypatch.setattr(config, "INGEST_DIR", None)
site(monkeypatch, ONE)
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
assert "Not sent. INGEST_DIR is not set." in orchestrator._run_site_job(job)
changed = story(job.story_id)["last_changed"]
orchestrator.store.update_story(job.story_id, last_changed="2020-01-01T00:00:00")
assert "Not sent. INGEST_DIR is not set." in update(job.story_id)
assert changed and story(job.story_id)["last_changed"] == "2020-01-01T00:00:00"
assert builds.call_count == 1
# The book changes. Bindery builds it, also while it cannot send it.
site(monkeypatch, [*ONE, (2, "Two", "b")])
update(job.story_id)
assert builds.call_count == 2
assert story(job.story_id)["last_changed"] != "2020-01-01T00:00:00"
def test_a_released_book_goes_out_without_another_build(builds, jobs):
story_id = add_story(ingested_at="2026-01-01T00:00:00", calibre_id=1, calibre_uuid="uuid-1")
orchestrator.save_settings(pause_uploads=True)
orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id))
assert builds.call_count == 1 and ingest_files() == []
orchestrator.save_settings(pause_uploads=False)
assert "library book 1" in orchestrator._run_rebuild(jobs.get_next())
assert builds.call_count == 1
assert len(ingest_files()) == 2
def test_a_lost_file_is_built_again_only_to_send_it(builds):
story_id = add_story(ingested_at="2026-01-01T00:00:00", calibre_id=1, calibre_uuid="uuid-1")
orchestrator.save_settings(pause_uploads=True)
job = Job(mode=DownloadMode.REBUILD, story_id=story_id)
orchestrator._run_rebuild(job)
for path in config.TMP_DIR.iterdir():
path.unlink()
assert "pause" in orchestrator._run_rebuild(job)
assert builds.call_count == 1
orchestrator.store.set_setting(None, "pause_uploads", None)
assert "library book 1" in orchestrator._run_rebuild(job)
assert builds.call_count == 2
def test_a_rebuild_without_a_change_does_nothing_unless_the_user_asks_for_it(builds, client, jobs):
story_id = add_story(ingested_at="2026-01-01T00:00:00", calibre_id=1, calibre_uuid="uuid-1")
job = Job(mode=DownloadMode.REBUILD, story_id=story_id)
assert "library book 1" in orchestrator._run_rebuild(job)
orchestrator.store.update_story(story_id, last_changed=None, message="before")
assert orchestrator._run_rebuild(job) == "No changes"
assert (story(story_id)["last_changed"], story(story_id)["message"]) == (None, "before")
assert builds.call_count == 1
# The Rebuild button of the story page.
queued = client.post(f"/api/stories/{story_id}/download", json={"mode": "rebuild"}, headers=H).json
assert queued["options"] == {"force": True}
assert "library book 1" in orchestrator._run_rebuild(jobs.get_next())
assert builds.call_count == 2
assert len(ingest_files()) == 4
assert story(story_id)["last_changed"]
# Other modes take no such option.
queued = client.post(f"/api/stories/{story_id}/download", json={"mode": "new"}, headers=H).json
assert queued["options"] == {}
def test_a_changed_link_gets_the_book_without_a_build(builds, fake):
story_id = add_story(ingested_at="2026-01-01T00:00:00", calibre_id=1, calibre_uuid="uuid-1")
job = Job(mode=DownloadMode.REBUILD, story_id=story_id)
orchestrator._run_rebuild(job)
fake.books.append(dataclasses.replace(fake.books[0], id=2, uuid="uuid-2", title="Other"))
orchestrator.link_story(story_id, "uuid-2")
assert "library book 2" in orchestrator._run_rebuild(job)
assert builds.call_count == 1
def test_a_failed_delivery_is_tried_again_without_a_build(builds, fake, monkeypatch):
story_id = linked_story(ONE)
site(monkeypatch, ONE)
send = orchestrator.IngestFolder.send_update
monkeypatch.setattr(orchestrator.IngestFolder, "send_update", Mock(side_effect=OSError("disk full")))
with pytest.raises(OSError, match="disk full"):
update(story_id)
monkeypatch.setattr(orchestrator.IngestFolder, "send_update", send)
assert "library book 1" in update(story_id)
assert builds.call_count == 1
def test_a_new_build_removes_the_older_file(builds, monkeypatch):
story_id = linked_story(ONE)
site(monkeypatch, ONE)
update(story_id)
(first,) = config.TMP_DIR.iterdir()
site(monkeypatch, [*ONE, (2, "Two", "b")])
update(story_id)
(second,) = config.TMP_DIR.iterdir()
assert first != second and second.name.startswith(f"story-{story_id}-")
@pytest.mark.parametrize(
("changes", "held", "built", "sent"),
[
({"calibre_id": 1, "calibre_uuid": "uuid-1"}, False, True, True),
({"ingested_at": "2026-01-01"}, False, True, True),
({"ingested_at": "2026-01-01"}, True, True, False),
({}, False, True, False),
({"ingested_at": "2026-01-01", "status": "error"}, False, False, False),
({"ingested_at": "2026-01-01", "no_metadata": True}, False, False, False),
],
)
def test_stories_of_an_older_bindery_get_their_keys(builds, changes, held, built, sent):
no_metadata = changes.pop("no_metadata", False)
story_id = add_story(**changes)
if no_metadata:
orchestrator.store.update_story(story_id, metahtml=None)
orchestrator.store.set_setting(story_id, "upload_held", held or None)
orchestrator.adopt_build_keys()
settings = orchestrator.store
assert bool(settings.get_setting(story_id, "built_key")) is built
assert bool(settings.get_setting(story_id, "sent_key")) is sent
if sent:
assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes"
assert builds.call_count == 0
def test_a_story_with_a_waiting_job_gets_no_key(builds, jobs):
story_id = add_story(ingested_at="2026-01-01")
jobs.add(Job(mode=DownloadMode.NEW, story_id=story_id))
orchestrator.adopt_build_keys()
assert orchestrator.store.get_setting(story_id, "built_key") is None
def test_the_first_check_after_an_upgrade_sends_nothing(builds, monkeypatch):
story_id = linked_story(ONE)
orchestrator.store.update_story(story_id, pinned={"title": "My Title"}, site_values={})
orchestrator.adopt_build_keys()
site(monkeypatch, ONE)
assert update(story_id).startswith("No changes")
assert builds.call_count == 0 and ingest_files() == []
# Downloads in the browser
def download(client, story_id):
response = client.get(f"/api/stories/{story_id}/epub")
assert response.status_code == 200 and response.data.startswith(b"PK")
assert response.content_length == len(response.data)
response.close()
return response.data
def test_a_download_uses_the_built_book(builds, client, monkeypatch):
story_id = linked_story(ONE)
site(monkeypatch, ONE)
update(story_id)
(built,) = config.TMP_DIR.iterdir()
assert download(client, story_id) == download(client, story_id) == built.read_bytes()
assert builds.call_count == 1
def test_a_download_builds_a_book_that_has_no_file_or_that_changed(builds, client, monkeypatch):
story_id = add_story()
first = download(client, story_id)
download(client, story_id)
assert builds.call_count == 1
orchestrator.save_rules(story_id, [{"pattern": "Zorian", "replacement": "Zach"}])
assert download(client, story_id) != first
assert builds.call_count == 2
assert len(list(config.TMP_DIR.iterdir())) == 1
# The build of a download is not the build of a job. The job sends the book, and marks the change.
assert orchestrator.store.get_setting(story_id, "built_key") is None
job = Job(mode=DownloadMode.REBUILD, story_id=story_id)
assert "library book 1" in orchestrator._run_rebuild(job)
assert builds.call_count == 2
assert story(story_id)["last_changed"]
def test_stop_tracking_deletes_the_built_book(builds, client):
story_id, other = add_story(), orchestrator.store.create_story("https://other", "s", "Other", "W", [])
download(client, story_id)
kept = config.TMP_DIR / f"story-{other}-0123456789abcdef.epub"
kept.write_bytes(b"other")
orchestrator.delete_story(story_id)
assert list(config.TMP_DIR.iterdir()) == [kept]
# Uploaded files
@pytest.fixture
def uploads(monkeypatch, tmp_path):
monkeypatch.setattr(config, "UPLOAD_DIR", tmp_path / "uploads")
config.UPLOAD_DIR.mkdir()
return config.UPLOAD_DIR
def test_a_cancelled_import_deletes_its_uploaded_file(builds, jobs, uploads, story_epub):
path = uploads / "one.epub"
path.write_bytes(story_epub)
queued = orchestrator.queue_epub_file(path, "story.epub")
assert "path" not in queued["options"]
assert orchestrator.cancel_job(queued["id"]) is True
assert not path.exists()
assert orchestrator.cancel_job(queued["id"]) is False
assert orchestrator.cancel_job("no-such-job") is False
def test_the_start_deletes_uploads_that_no_job_needs(builds, jobs, uploads, story_epub):
waiting, stale = uploads / "waiting.epub", uploads / "stale.epub"
for path in (waiting, stale):
path.write_bytes(story_epub)
orchestrator.queue_epub_file(waiting, "story.epub")
orchestrator.remove_stale_uploads()
assert list(uploads.iterdir()) == [waiting]
def test_a_missing_ingest_folder_fails_the_job_until_the_folder_exists(builds, jobs, monkeypatch, tmp_path):
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "not-mounted")
story_id = linked_story(ONE)
site(monkeypatch, ONE)
monkeypatch.setattr(orchestrator.time, "sleep", Mock())
jobs.add(Job(mode=DownloadMode.NEW, story_id=story_id))
job = orchestrator._run_next_job()
assert job.status == "error" and "does not exist. Check INGEST_DIR and the volume mount." in job.message
assert story(story_id)["status"] == "error"
assert not config.INGEST_DIR.exists()
config.INGEST_DIR.mkdir()
assert "library book 1" in update(story_id)
assert builds.call_count == 1
+3 -2
View File
@@ -14,7 +14,6 @@ from werkzeug.security import generate_password_hash
from bindery import auth, calibre_db, config, orchestrator from bindery import auth, calibre_db, config, orchestrator
from bindery.cwa import CwaClient from bindery.cwa import CwaClient
from bindery.ingest import IngestFolder
@pytest.fixture @pytest.fixture
@@ -54,6 +53,7 @@ def db_login(client, user_db, monkeypatch):
monkeypatch.setattr(config, "CWA_DB_PATH", user_db) monkeypatch.setattr(config, "CWA_DB_PATH", user_db)
remote = Mock(spec=CwaClient) remote = Mock(spec=CwaClient)
remote.login_ok.return_value = True remote.login_ok.return_value = True
remote.accepts_any_login.return_value = False
monkeypatch.setattr(orchestrator, "cwa", lambda: remote) monkeypatch.setattr(orchestrator, "cwa", lambda: remote)
return remote return remote
@@ -250,7 +250,8 @@ def test_library_database_failure_keeps_the_http_lookup(library_db, monkeypatch,
@pytest.mark.parametrize("saved_id,saved_uuid", [(None, None), (1, "uuid-42"), (42, None)]) @pytest.mark.parametrize("saved_id,saved_uuid", [(None, None), (1, "uuid-42"), (42, None)])
def test_database_book_id_reaches_the_epub_sidecar(client, library_db, monkeypatch, tmp_path, saved_id, saved_uuid): def test_database_book_id_reaches_the_epub_sidecar(client, library_db, monkeypatch, tmp_path, saved_id, saved_uuid):
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db) monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
monkeypatch.setattr(orchestrator, "ingest_folder", IngestFolder(tmp_path / "ingest")) monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
config.INGEST_DIR.mkdir(exist_ok=True)
remote = CwaClient("") remote = CwaClient("")
remote.session.get = Mock(side_effect=AssertionError("No HTTP lookup needed")) remote.session.get = Mock(side_effect=AssertionError("No HTTP lookup needed"))
monkeypatch.setattr(orchestrator, "cwa", lambda: remote) monkeypatch.setattr(orchestrator, "cwa", lambda: remote)
+173 -1
View File
@@ -16,6 +16,14 @@ def reload_config(monkeypatch):
importlib.reload(config) importlib.reload(config)
@pytest.fixture
def folders(monkeypatch, tmp_path):
"""Put the folders of Bindery in a temporary folder. The ingest folder does not exist yet."""
for name, folder in [("CONFIG_DIR", "config"), ("UPLOAD_DIR", "uploads"), ("TMP_DIR", "tmp"), ("INGEST_DIR", "in")]:
monkeypatch.setattr(config, name, tmp_path / folder)
monkeypatch.setattr(config, "PERSONAL_INI", tmp_path / "config" / "personal.ini")
def test_bool(monkeypatch): def test_bool(monkeypatch):
for value in ("true", "Yes", " 1 ", "on", "y"): for value in ("true", "Yes", " 1 ", "on", "y"):
monkeypatch.setenv("X_TEST", value) monkeypatch.setenv("X_TEST", value)
@@ -24,7 +32,10 @@ def test_bool(monkeypatch):
monkeypatch.setenv("X_TEST", value) monkeypatch.setenv("X_TEST", value)
assert not config._bool("X_TEST") assert not config._bool("X_TEST")
monkeypatch.delenv("X_TEST") monkeypatch.delenv("X_TEST")
assert config._bool("X_TEST", "true") assert config._bool("X_TEST", True)
# A value that is not clear gives the default.
monkeypatch.setenv("X_TEST", "maybe")
assert config._bool("X_TEST", True) and not config._bool("X_TEST")
def test_list(monkeypatch): def test_list(monkeypatch):
@@ -69,18 +80,22 @@ def test_values_from_the_environment(monkeypatch, reload_config):
monkeypatch.setenv("UPDATE_INTERVAL_HOURS", "1.5") monkeypatch.setenv("UPDATE_INTERVAL_HOURS", "1.5")
monkeypatch.setenv("DEFAULT_LOCKED_FIELDS", "title, authors") monkeypatch.setenv("DEFAULT_LOCKED_FIELDS", "title, authors")
monkeypatch.setenv("CWA_ALLOWED_USERS", "alice,bob") monkeypatch.setenv("CWA_ALLOWED_USERS", "alice,bob")
monkeypatch.setenv("BINDERY_ADMIN_USERS", "alice")
monkeypatch.setenv("CONFIG_DIR", "/data/bindery") monkeypatch.setenv("CONFIG_DIR", "/data/bindery")
monkeypatch.setenv("TRUST_PROXY", "yes") monkeypatch.setenv("TRUST_PROXY", "yes")
monkeypatch.setenv("CWA_DB_PATH", " /cwa/app.db ") monkeypatch.setenv("CWA_DB_PATH", " /cwa/app.db ")
monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db") monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db")
monkeypatch.setenv("BINDERY_USERNAME", " reader ") monkeypatch.setenv("BINDERY_USERNAME", " reader ")
monkeypatch.setenv("BINDERY_PASSWORD_HASH", " hash ") monkeypatch.setenv("BINDERY_PASSWORD_HASH", " hash ")
monkeypatch.setenv("ALLOWED_HOSTS", "Bindery.Example.com, .home.example")
reload_config() reload_config()
assert config.ALLOWED_HOSTS == ["bindery.example.com", ".home.example"]
assert config.URL_BASE == "/bindery" assert config.URL_BASE == "/bindery"
assert config.AUTH_METHOD == "cwa" assert config.AUTH_METHOD == "cwa"
assert config.UPDATE_INTERVAL_HOURS == 1.5 assert config.UPDATE_INTERVAL_HOURS == 1.5
assert config.DEFAULT_LOCKED_FIELDS == ["title", "authors"] assert config.DEFAULT_LOCKED_FIELDS == ["title", "authors"]
assert config.CWA_ALLOWED_USERS == ["alice", "bob"] assert config.CWA_ALLOWED_USERS == ["alice", "bob"]
assert config.ADMIN_USERS == ["alice"]
assert str(config.DB_PATH) == "/data/bindery/bindery.db" assert str(config.DB_PATH) == "/data/bindery/bindery.db"
assert config.TRUST_PROXY is True assert config.TRUST_PROXY is True
assert str(config.CWA_DB_PATH) == "/cwa/app.db" assert str(config.CWA_DB_PATH) == "/cwa/app.db"
@@ -97,10 +112,15 @@ def test_defaults(monkeypatch, reload_config):
"DEFAULT_LOCKED_FIELDS", "DEFAULT_LOCKED_FIELDS",
"LOG_LEVEL", "LOG_LEVEL",
"PROXY_AUTH_USER_HEADER", "PROXY_AUTH_USER_HEADER",
"PROXY_AUTH_TRUSTED_IPS",
"ALLOWED_HOSTS",
"BINDERY_ADMIN_USERS",
"CWA_ENABLED",
"SESSION_COOKIE_SECURE", "SESSION_COOKIE_SECURE",
"TRUST_PROXY", "TRUST_PROXY",
"CWA_DB_PATH", "CWA_DB_PATH",
"CALIBRE_DB_PATH", "CALIBRE_DB_PATH",
"INGEST_DIR",
"BINDERY_USERNAME", "BINDERY_USERNAME",
"BINDERY_PASSWORD_HASH", "BINDERY_PASSWORD_HASH",
): ):
@@ -112,9 +132,161 @@ def test_defaults(monkeypatch, reload_config):
assert config.DEFAULT_LOCKED_FIELDS == ["title"] assert config.DEFAULT_LOCKED_FIELDS == ["title"]
assert config.LOG_LEVEL == "INFO" assert config.LOG_LEVEL == "INFO"
assert config.PROXY_AUTH_USER_HEADER == "X-Remote-User" assert config.PROXY_AUTH_USER_HEADER == "X-Remote-User"
assert config.PROXY_AUTH_TRUSTED_IPS == [
"127.0.0.0/8",
"::1",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
"fe80::/10",
]
assert config.ALLOWED_HOSTS == []
assert config.ADMIN_USERS == []
assert config.CWA_ENABLED is True
assert config.IGNORED_SETTINGS == []
assert config.SESSION_COOKIE_SECURE is False assert config.SESSION_COOKIE_SECURE is False
assert config.TRUST_PROXY is False assert config.TRUST_PROXY is False
assert config.CWA_DB_PATH is None assert config.CWA_DB_PATH is None
assert config.CALIBRE_DB_PATH is None assert config.CALIBRE_DB_PATH is None
assert config.INGEST_DIR is None
assert config.LOCAL_USERNAME == "" assert config.LOCAL_USERNAME == ""
assert config.LOCAL_PASSWORD_HASH == "" assert config.LOCAL_PASSWORD_HASH == ""
@pytest.mark.parametrize("value", ["", " "])
def test_empty_ingest_directory_disables_uploads(monkeypatch, reload_config, value):
monkeypatch.setenv("INGEST_DIR", value)
reload_config()
assert config.INGEST_DIR is None
@pytest.mark.parametrize("value", ["false", " No ", "0", "off"])
def test_cwa_off_ignores_the_cwa_settings_but_not_the_ingest_folder(monkeypatch, reload_config, value):
monkeypatch.setenv("CWA_ENABLED", value)
monkeypatch.setenv("CWA_URL", "https://cwa.invalid")
monkeypatch.setenv("CWA_USERNAME", "bindery")
monkeypatch.setenv("CWA_PASSWORD", "secret")
monkeypatch.setenv("CWA_DB_PATH", "/cwa/app.db")
monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db")
monkeypatch.setenv("INGEST_DIR", "/books")
reload_config()
assert config.CWA_ENABLED is False
assert (config.CWA_URL, config.CWA_USERNAME, config.CWA_PASSWORD) == ("", "", "")
assert (config.CWA_DB_PATH, config.CALIBRE_DB_PATH) == (None, None)
assert config.IGNORED_SETTINGS == ["CWA_URL", "CWA_USERNAME", "CWA_PASSWORD", "CWA_DB_PATH", "CALIBRE_DB_PATH"]
assert str(config.INGEST_DIR) == "/books"
@pytest.mark.parametrize("value", ["", "true", "maybe"])
def test_only_a_clear_false_turns_cwa_off(monkeypatch, reload_config, value):
monkeypatch.setenv("CWA_ENABLED", value)
monkeypatch.setenv("CWA_URL", "https://cwa.invalid")
reload_config()
assert config.CWA_ENABLED is True
assert config.CWA_URL == "https://cwa.invalid"
@pytest.mark.parametrize("ignored", [[], ["CWA_URL", "CALIBRE_DB_PATH"]])
def test_prepare_with_cwa_off_logs_nothing_about_missing_cwa(folders, monkeypatch, tmp_path, caplog, ignored):
monkeypatch.setattr(config, "CWA_ENABLED", False)
monkeypatch.setattr(config, "IGNORED_SETTINGS", ignored)
monkeypatch.setattr(config, "INGEST_DIR", None)
monkeypatch.setattr(config, "CWA_URL", "")
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
with caplog.at_level("INFO"):
config.prepare()
lines = [r.getMessage() for r in caplog.records]
expected = ["CWA_ENABLED is false. Bindery ignores CWA_URL, CALIBRE_DB_PATH."] if ignored else []
assert [line for line in lines if "CWA" in line or "INGEST" in line] == expected
@pytest.mark.parametrize(
("ingest", "url", "database", "expected"),
[
(False, "", None, ["INGEST_DIR is not set", "cannot find the library book of a story.", "CWA_ENABLED=false"]),
(True, "", None, ["so each update adds a new book to CWA.", "CWA_ENABLED=false"]),
(True, "", Path("/library/metadata.db"), ["CWA_URL is not set. Bindery cannot search", "CWA_ENABLED=false"]),
(False, "https://cwa.invalid", None, ["INGEST_DIR is not set", "CWA_ENABLED=false"]),
(True, "https://cwa.invalid", None, []),
],
)
def test_prepare_warns_about_each_missing_cwa_function(
folders, monkeypatch, tmp_path, caplog, ingest, url, database, expected
):
monkeypatch.setattr(config, "CWA_URL", url)
monkeypatch.setattr(config, "CALIBRE_DB_PATH", database)
monkeypatch.setattr(config, "INGEST_DIR", tmp_path if ingest else None)
with caplog.at_level("WARNING"):
config.prepare()
warnings = [r.getMessage() for r in caplog.records]
assert len(warnings) == len(expected)
for warning, text in zip(warnings, expected, strict=True):
assert text in warning
def test_prepare_without_cwa_creates_only_bindery_folders(folders, monkeypatch, tmp_path, caplog):
monkeypatch.setattr(config, "CWA_ENABLED", False)
monkeypatch.setattr(config, "INGEST_DIR", None)
monkeypatch.setattr(config, "CWA_URL", "")
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
config.prepare()
assert config.PERSONAL_INI.exists()
assert {p.name for p in tmp_path.iterdir()} == {"config", "uploads", "tmp"}
assert caplog.records == []
@pytest.mark.parametrize(
("url", "database", "warns"),
[("", None, True), ("https://cwa.invalid", None, False), ("", Path("/library/metadata.db"), False)],
)
def test_prepare_warns_about_an_ingest_folder_without_a_library(
folders, monkeypatch, tmp_path, caplog, url, database, warns
):
monkeypatch.setattr(config, "CWA_URL", url)
monkeypatch.setattr(config, "CALIBRE_DB_PATH", database)
with caplog.at_level("WARNING"):
config.prepare()
assert ("each update adds a new book" in caplog.text) is warns
@pytest.mark.parametrize(
("name", "value", "error"),
[
("UPDATE_INTERVAL_HOURS", "daily", "UPDATE_INTERVAL_HOURS must be a number, not 'daily'"),
("LOG_LEVEL", "loud", "LOG_LEVEL must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL, not 'LOUD'"),
],
)
def test_a_bad_value_stops_the_start_with_a_clear_message(monkeypatch, reload_config, name, value, error):
monkeypatch.setenv(name, value)
with pytest.raises(SystemExit) as stop:
reload_config()
assert str(stop.value) == error
def test_log_level_in_lower_case(monkeypatch, reload_config):
monkeypatch.setenv("LOG_LEVEL", " debug ")
reload_config()
assert config.LOG_LEVEL == "DEBUG"
@pytest.mark.parametrize(("username", "warns"), [("", True), ("bindery", False)])
def test_prepare_warns_about_a_cwa_password_without_a_user_name(
folders, monkeypatch, tmp_path, caplog, username, warns
):
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
monkeypatch.setattr(config, "CWA_USERNAME", username)
monkeypatch.setattr(config, "CWA_PASSWORD", "secret")
with caplog.at_level("WARNING"):
config.prepare()
assert ("CWA_PASSWORD is set" in caplog.text) is warns
@pytest.mark.parametrize("exists", [False, True])
def test_prepare_does_not_make_the_ingest_folder(folders, monkeypatch, tmp_path, caplog, exists):
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
if exists:
config.INGEST_DIR.mkdir()
config.prepare()
assert config.INGEST_DIR.exists() is exists
assert ("does not exist. Check INGEST_DIR and the volume mount" in caplog.text) is not exists
+56 -2
View File
@@ -1,15 +1,17 @@
"""The OPDS client, the book finder, the login check, the ingest folder and the epub reader.""" """The OPDS client, the book finder, the login check, the ingest folder and the epub reader."""
import json import json
import time
from unittest.mock import Mock
import pytest import pytest
from fake_cwa import FakeBook, FakeCwa from fake_cwa import FakeBook, FakeCwa
from test_build import URL, chapters, metahtml from test_build import URL, chapters, metahtml
from bindery import fff from bindery import fff
from bindery.cwa import BookFinder, CwaClient, CwaError, plain_title, search_text, title_rank from bindery.cwa import LOGIN_TEST_SECONDS, BookFinder, CwaClient, CwaError, plain_title, search_text, title_rank
from bindery.epub import epub_metadata, epub_urls from bindery.epub import epub_metadata, epub_urls
from bindery.ingest import MAX_NAME_LENGTH, IngestFolder from bindery.ingest import MAX_NAME_LENGTH, IngestError, IngestFolder
STORY_URL = "https://www.royalroad.com/fiction/1234" STORY_URL = "https://www.royalroad.com/fiction/1234"
@@ -63,6 +65,35 @@ def test_wrong_password_and_missing_address(fake):
CwaClient("").search("x") CwaClient("").search("x")
def test_cwa_that_needs_no_login_works_without_an_account(fake):
fake.anonymous = True
open_client = CwaClient(fake.url)
assert [b.id for b in open_client.search("Another")] == [3]
assert open_client.get_book("uuid-2")["id"] == 2
fake.anonymous = False
with pytest.raises(CwaError, match="CWA asks for a login. Set CWA_USERNAME and CWA_PASSWORD."):
open_client.search("Test Story")
def test_a_login_check_does_not_use_the_session_of_the_account(fake, monkeypatch):
c = client(fake)
monkeypatch.setattr(c.session, "get", Mock(side_effect=AssertionError("The login check used the session")))
assert c.login_ok("bindery", "secret")
assert not c.login_ok("bindery", "wrong")
def test_the_test_of_the_logins_of_cwa_is_kept_for_a_time(fake, monkeypatch):
c = client(fake)
assert not c.accepts_any_login()
fake.anonymous = True
assert not c.accepts_any_login() # the last test still counts
assert len([r for r in fake.requests if r == "/opds"]) == 1
clock = time.monotonic()
monkeypatch.setattr(time, "monotonic", lambda: clock + LOGIN_TEST_SECONDS + 1)
assert c.accepts_any_login()
assert CwaClient(fake.url).accepts_any_login()
def test_get_book(fake): def test_get_book(fake):
c = client(fake) c = client(fake)
assert c.get_book("uuid-2") == { assert c.get_book("uuid-2") == {
@@ -75,6 +106,17 @@ def test_get_book(fake):
assert c.get_book("nope") is None assert c.get_book("nope") is None
def test_get_book_can_wait_for_a_shorter_time(fake, monkeypatch):
c = client(fake)
get = c.session.get
timeouts = []
monkeypatch.setattr(c.session, "get", lambda url, **kw: timeouts.append(kw["timeout"]) or get(url, **kw))
c.get_book("uuid-2", timeout=5)
c.get_book("uuid-2")
c.search("Story")
assert timeouts == [5, 60, 60]
@pytest.mark.parametrize("book_uuid", ["../opds", "uuid-2?x=1", "uuid-2#x", "uuid%2F2", ""]) @pytest.mark.parametrize("book_uuid", ["../opds", "uuid-2?x=1", "uuid-2#x", "uuid%2F2", ""])
def test_get_book_refuses_a_uuid_that_changes_the_path(book_uuid): def test_get_book_refuses_a_uuid_that_changes_the_path(book_uuid):
# Nothing listens on this port, so a request would raise CwaError. # Nothing listens on this port, so a request would raise CwaError.
@@ -176,6 +218,7 @@ def test_epub_reader(story_epub, tmp_path):
def test_ingest_uses_part_names_and_unique_names(tmp_path): def test_ingest_uses_part_names_and_unique_names(tmp_path):
folder = IngestFolder(tmp_path / "ingest") folder = IngestFolder(tmp_path / "ingest")
folder.path.mkdir()
epub = tmp_path / "x.epub" epub = tmp_path / "x.epub"
epub.write_bytes(b"PK") epub.write_bytes(b"PK")
first = folder.ingest(epub, 'A: "Story" / Name') first = folder.ingest(epub, 'A: "Story" / Name')
@@ -187,6 +230,7 @@ def test_ingest_uses_part_names_and_unique_names(tmp_path):
def test_send_update_writes_sidecar(tmp_path): def test_send_update_writes_sidecar(tmp_path):
folder = IngestFolder(tmp_path / "ingest") folder = IngestFolder(tmp_path / "ingest")
folder.path.mkdir()
epub = tmp_path / "x.epub" epub = tmp_path / "x.epub"
epub.write_bytes(b"PK") epub.write_bytes(b"PK")
path = folder.send_update(epub, 7, "T" * 300) path = folder.send_update(epub, 7, "T" * 300)
@@ -195,3 +239,13 @@ def test_send_update_writes_sidecar(tmp_path):
manifest = json.loads((folder.path / (path.name + ".cwa.json")).read_text()) manifest = json.loads((folder.path / (path.name + ".cwa.json")).read_text())
assert manifest == {"action": "add_format", "book_id": 7} assert manifest == {"action": "add_format", "book_id": 7}
assert path.read_bytes() == b"PK" assert path.read_bytes() == b"PK"
def test_a_missing_ingest_folder_is_an_error_and_is_not_made(tmp_path, story_epub):
source = tmp_path / "book.epub"
source.write_bytes(story_epub)
folder = IngestFolder(tmp_path / "missing" / "ingest")
for send in (lambda: folder.ingest(source, "Title"), lambda: folder.send_update(source, 5, "Title")):
with pytest.raises(IngestError, match="does not exist. Check INGEST_DIR and the volume mount."):
send()
assert not (tmp_path / "missing").exists()
+23 -14
View File
@@ -13,7 +13,6 @@ from bindery import config, fff, orchestrator
from bindery.diffing import text_hash from bindery.diffing import text_hash
from bindery.epub import EpubError from bindery.epub import EpubError
from bindery.fff import SiteSnapshot, StoryError from bindery.fff import SiteSnapshot, StoryError
from bindery.ingest import IngestFolder
from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus
from bindery.store import Store from bindery.store import Store
@@ -35,7 +34,8 @@ def fake(story_epub, monkeypatch, tmp_path):
monkeypatch.setattr(config, "CWA_USERNAME", "bindery") monkeypatch.setattr(config, "CWA_USERNAME", "bindery")
monkeypatch.setattr(config, "CWA_PASSWORD", "secret") monkeypatch.setattr(config, "CWA_PASSWORD", "secret")
monkeypatch.setattr(orchestrator, "store", Store(tmp_path / "bindery.db")) monkeypatch.setattr(orchestrator, "store", Store(tmp_path / "bindery.db"))
monkeypatch.setattr(orchestrator, "ingest_folder", IngestFolder(tmp_path / "ingest")) monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
config.INGEST_DIR.mkdir()
monkeypatch.setattr(orchestrator, "finder", orchestrator.BookFinder()) monkeypatch.setattr(orchestrator, "finder", orchestrator.BookFinder())
yield server yield server
server.close() server.close()
@@ -51,17 +51,18 @@ def add_story(**changes):
def ingest_files(): def ingest_files():
folder = orchestrator.ingest_folder.path folder = config.INGEST_DIR
return sorted(p.name for p in folder.iterdir()) if folder.exists() else [] return sorted(p.name for p in folder.iterdir()) if folder.exists() else []
def opf(name): def opf(name):
with zipfile.ZipFile(orchestrator.ingest_folder.path / name) as z: with zipfile.ZipFile(config.INGEST_DIR / name) as z:
return z.read("content.opf").decode() return z.read("content.opf").decode()
def rebuild(story_id): def rebuild(story_id):
return orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) """A rebuild that the user asks for. It builds and sends the book also when the book did not change."""
return orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id, options={"force": True}))
def test_first_version_goes_in_as_a_new_book(fake): def test_first_version_goes_in_as_a_new_book(fake):
@@ -80,7 +81,7 @@ def test_ingested_story_is_found_and_updated_with_a_sidecar(fake):
assert (story["calibre_id"], story["calibre_uuid"]) == (1, "uuid-1") assert (story["calibre_id"], story["calibre_uuid"]) == (1, "uuid-1")
epub, manifest = ingest_files() epub, manifest = ingest_files()
assert manifest == epub + ".cwa.json" assert manifest == epub + ".cwa.json"
assert json.loads((orchestrator.ingest_folder.path / manifest).read_text()) == { assert json.loads((config.INGEST_DIR / manifest).read_text()) == {
"action": "add_format", "action": "add_format",
"book_id": 1, "book_id": 1,
} }
@@ -94,12 +95,13 @@ def test_update_fails_while_cwa_has_no_such_book(fake):
assert ingest_files() == [] # a second plain epub would make a duplicate book assert ingest_files() == [] # a second plain epub would make a duplicate book
def test_update_fails_without_cwa_url(fake, monkeypatch): def test_update_exports_without_a_library_connection(fake, monkeypatch):
monkeypatch.setattr(config, "CWA_URL", "") monkeypatch.setattr(config, "CWA_URL", "")
story_id = add_story(ingested_at="2026-01-01T00:00:00") story_id = add_story(ingested_at="2026-01-01T00:00:00")
with pytest.raises(orchestrator.StoryError, match="CWA_URL is not set"): assert rebuild(story_id) == "Sent to ingest folder"
rebuild(story_id) assert orchestrator.store.get_story(story_id)["status"] == "ok"
assert ingest_files() == [] assert orchestrator.store.get_setting(story_id, "upload_held") is None
assert len(ingest_files()) == 1
def test_linked_story_still_updates_when_cwa_is_down(fake): def test_linked_story_still_updates_when_cwa_is_down(fake):
@@ -140,7 +142,14 @@ def test_import_from_the_library(fake):
def test_library_candidates_and_search(fake): def test_library_candidates_and_search(fake):
candidates = orchestrator.library_candidates() candidates = orchestrator.library_candidates()
assert candidates == [ assert candidates == [
{"calibre_id": 1, "uuid": "uuid-1", "title": "Test Story", "authors": "Writer", "site": "www.royalroad.com"} {
"id": 1,
"uuid": "uuid-1",
"title": "Test Story",
"authors": ["Writer"],
"publisher": "www.royalroad.com",
"formats": ["epub"],
}
] ]
story_id = add_story() story_id = add_story()
orchestrator.link_story(story_id, "uuid-1") orchestrator.link_story(story_id, "uuid-1")
@@ -306,7 +315,7 @@ def test_the_site_cover_goes_in_the_book(fake, jobs, monkeypatch, tmp_path):
assert update(story_id).startswith("1 new chapter") assert update(story_id).startswith("1 new chapter")
assert orchestrator.store.cover(story_id)["data"] == PNG assert orchestrator.store.cover(story_id)["data"] == PNG
(epub,) = [n for n in ingest_files() if n.endswith(".epub")] (epub,) = [n for n in ingest_files() if n.endswith(".epub")]
with zipfile.ZipFile(orchestrator.ingest_folder.path / epub) as z: with zipfile.ZipFile(config.INGEST_DIR / epub) as z:
assert "OEBPS/cover.xhtml" in z.namelist() assert "OEBPS/cover.xhtml" in z.namelist()
@@ -598,8 +607,8 @@ def test_paused_uploads_go_to_cwa_when_the_pause_ends(fake, jobs):
orchestrator.save_settings(pause_uploads=True) orchestrator.save_settings(pause_uploads=True)
assert "pause" in rebuild(story_id) assert "pause" in rebuild(story_id)
assert ingest_files() == [] assert ingest_files() == []
assert orchestrator.approve_upload(story_id, "uuid-1")["mode"] == DownloadMode.REBUILD.value assert orchestrator.approve_upload(story_id, "uuid-1") is None
assert "pause" in orchestrator._run_rebuild(jobs.get_next()) assert jobs.get_next() is None
orchestrator.save_settings(pause_uploads=True) orchestrator.save_settings(pause_uploads=True)
assert jobs.get_next() is None assert jobs.get_next() is None
orchestrator.save_settings(pause_uploads=False) orchestrator.save_settings(pause_uploads=False)
+228
View File
@@ -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
+92
View File
@@ -0,0 +1,92 @@
"""Changes that the user makes to the chapters of a story while a job runs for it."""
import pytest
from test_deliver import CHAPTER, linked_story, site, titles, update
from test_deliver import fake as fake
from test_deliver import jobs as jobs
from test_deliver import story_epub as story_epub
from bindery import fff, orchestrator
from bindery.jobs import DownloadMode
from bindery.store import Store
THREE = [(1, "One", "a"), (2, "Two", "b"), (3, "Three", "c")]
def during_download(monkeypatch, number, action):
"""Run `action` when the job downloads the chapter with this number."""
fetch = fff.fetch_chapter
def fetch_and_act(adapter, url, index):
if url == CHAPTER.format(number):
action()
return fetch(adapter, url, index)
monkeypatch.setattr(fff, "fetch_chapter", fetch_and_act)
def chapter_id(story_id, title):
return next(c["id"] for c in orchestrator.store.list_chapters(story_id) if c["title"] == title)
def test_a_move_during_a_download_stays(fake, jobs, monkeypatch):
story_id = linked_story(THREE)
site(monkeypatch, [*THREE, (4, "Four", "d"), (5, "Five", "e")])
during_download(monkeypatch, 4, lambda: orchestrator.move_chapters(story_id, [chapter_id(story_id, "Three")], None))
assert "2 new chapters" in update(story_id)
assert titles(story_id) == ["Three", "One", "Two", "Four", "Five"]
def test_a_new_chapter_in_the_middle_goes_after_its_moved_predecessor(fake, jobs, monkeypatch):
story_id = linked_story(THREE)
site(monkeypatch, [(1, "One", "a"), (9, "Nine", "x"), (2, "Two", "b"), (3, "Three", "c")])
during_download(
monkeypatch,
9,
lambda: orchestrator.move_chapters(story_id, [chapter_id(story_id, "One")], chapter_id(story_id, "Three")),
)
update(story_id)
assert titles(story_id) == ["Two", "Three", "One", "Nine"]
def test_a_delete_during_a_download_stays(fake, jobs, monkeypatch):
story_id = linked_story(THREE)
site(monkeypatch, [*THREE, (4, "Four", "d")])
during_download(monkeypatch, 4, lambda: orchestrator.delete_chapters(story_id, [chapter_id(story_id, "Two")]))
assert "1 new chapter" in update(story_id)
assert titles(story_id) == ["One", "Three", "Four"]
def test_a_delete_during_a_recheck_does_not_stop_the_job(fake, jobs, monkeypatch):
story_id = linked_story(THREE)
site(monkeypatch, [(1, "One", "a2"), (2, "Two", "b2"), (3, "Three", "c2")])
during_download(monkeypatch, 2, lambda: orchestrator.delete_chapters(story_id, [chapter_id(story_id, "Two")]))
assert "2 of 3 rechecked chapters changed" in update(story_id, DownloadMode.RECHECK_ALL)
assert titles(story_id) == ["One", "Three"]
def test_the_order_function_of_add_chapters_sees_the_stored_order(tmp_path):
store = Store(tmp_path / "db.sqlite")
sid = store.create_story("u", "s", "T", "A", [])
for n, url in enumerate(["u2", "u1"]):
store.add_chapter(sid, url, url, "<p/>", "h", "new", n)
new = [{"url": "u3", "title": "u3", "html": "<p/>", "text_hash": "h"}]
seen = []
store.add_chapters(sid, new, "new", lambda stored: seen.append(stored) or [*stored, "u3"])
assert seen == [["u2", "u1"]]
assert [c["url"] for c in store.list_chapters(sid)] == ["u2", "u1", "u3"]
def test_a_refused_reorder_changes_nothing(tmp_path):
store = Store(tmp_path / "db.sqlite")
sid = store.create_story("u", "s", "T", "A", [])
ids = [store.add_chapter(sid, f"u{n}", str(n), "<p/>", "h", "new", n) for n in range(3)]
def refuse(order):
raise ValueError("no")
with pytest.raises(ValueError, match="no"):
store.reorder_chapters(sid, refuse)
assert [c["id"] for c in store.list_chapters(sid)] == ids
store.reorder_chapters(sid, lambda order: order[::-1])
assert [c["id"] for c in store.list_chapters(sid)] == ids[::-1]
+14
View File
@@ -1,5 +1,6 @@
"""The job queue: shared jobs, order, cancelling and the job history.""" """The job queue: shared jobs, order, cancelling and the job history."""
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from bindery import config from bindery import config
@@ -161,3 +162,16 @@ def test_finished_jobs_leave_the_database(tmp_path, monkeypatch):
old.updated = datetime.now() - timedelta(seconds=config.JOB_HISTORY + 1) old.updated = datetime.now() - timedelta(seconds=config.JOB_HISTORY + 1)
queue.get_status() queue.get_status()
assert JobQueue(tmp_path / "db.sqlite").get_status() == [] assert JobQueue(tmp_path / "db.sqlite").get_status() == []
def test_the_api_form_of_a_job_hides_the_path_of_an_upload():
job = Job(mode=DownloadMode.IMPORT_FILE, options={"path": "/config/uploads/x.epub", "name": "x.epub"})
assert job.to_dict()["options"] == {"name": "x.epub"}
assert Job.from_record(job.to_record()).options == job.options
def test_a_record_of_a_newer_bindery_loads_without_its_unknown_fields():
job = Job(mode=DownloadMode.NEW, story_id=3)
record = json.dumps({**json.loads(job.to_record()), "priority": 5})
loaded = Job.from_record(record)
assert (loaded.id, loaded.story_id, loaded.mode) == (job.id, 3, DownloadMode.NEW)
+1
View File
@@ -37,6 +37,7 @@ def test_local_login_logout_and_password_hashes(client, local, monkeypatch, meth
"authenticated": False, "authenticated": False,
"mode": "local", "mode": "local",
"user": None, "user": None,
"admin": False,
"logout_url": None, "logout_url": None,
} }
assert client.get("/api/stories").status_code == 401 assert client.get("/api/stories").status_code == 401
+482
View File
@@ -0,0 +1,482 @@
"""Downloads and builds work with uploads or library matching disabled."""
import json
import os
import subprocess
import sys
from unittest.mock import Mock
import pytest
from test_api import H
from test_api import client as client
from test_deliver import STORY_URL, add_story, rebuild, site, update
from test_deliver import jobs as jobs
from bindery import config, orchestrator
from bindery.cwa import Book
from bindery.ingest import IngestError
from bindery.jobs import DownloadMode, Job
from bindery.store import Store
@pytest.fixture(autouse=True)
def isolated(client, jobs, monkeypatch, tmp_path):
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
config.INGEST_DIR.mkdir()
monkeypatch.setattr(config, "TMP_DIR", tmp_path / "tmp")
config.TMP_DIR.mkdir()
monkeypatch.setattr(config, "CWA_URL", "")
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
monkeypatch.setattr(orchestrator, "cwa", Mock(side_effect=AssertionError("Unexpected CWA request")))
monkeypatch.setattr(orchestrator, "_find_book", Mock(side_effect=AssertionError("Unexpected book search")))
@pytest.fixture
def library(isolated, monkeypatch):
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
client = Mock()
client.get_book.return_value = Book(7, "uuid-7", "Test Story").to_dict()
monkeypatch.setattr(orchestrator, "cwa", Mock(return_value=client))
monkeypatch.setattr(orchestrator, "_find_book", Mock(return_value=None))
@pytest.mark.parametrize("enabled", [False, True])
def test_app_starts_without_any_cwa_configuration(tmp_path, enabled):
env = {
**os.environ,
"CONFIG_DIR": str(tmp_path / "standalone"),
"TMP_DIR": str(tmp_path / "builds"),
"AUTH_METHOD": "none",
"BINDERY_START_THREADS": "false",
"URL_BASE": "",
"CWA_ENABLED": str(enabled),
}
for name in ("INGEST_DIR", "CWA_URL", "CWA_DB_PATH", "CALIBRE_DB_PATH"):
env.pop(name, None)
result = subprocess.run(
[
sys.executable,
"-c",
"from bindery.web import app; "
"client = app.test_client(); "
"assert client.get('/api/health').status_code == 200; "
"settings = client.get('/api/settings').json; "
"assert settings['pause_uploads'] is True; "
"assert settings['ingest_available'] is False; "
"assert settings['link_books'] is False; "
"assert settings['linking_available'] is False",
],
env=env,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert "ERROR" not in result.stderr
# Bindery names what is missing, until the user says that there is no CWA.
for text in ("WARNING", "INGEST_DIR is not set", "CWA_URL and CALIBRE_DB_PATH are not set", "CWA_ENABLED=false"):
assert (text in result.stderr) is enabled
if not enabled:
assert "CWA" not in result.stderr and "INGEST" not in result.stderr
@pytest.fixture
def cwa_off(monkeypatch):
monkeypatch.setattr(config, "CWA_ENABLED", False)
def test_without_cwa_and_an_ingest_folder_a_job_only_builds_a_changed_book(monkeypatch, cwa_off):
monkeypatch.setattr(config, "INGEST_DIR", None)
site(monkeypatch, [(1, "One", "First chapter")])
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
assert orchestrator._run_site_job(job).endswith("Built the book")
story_id = job.story_id
story = orchestrator.store.get_story(story_id)
assert (story["status"], story["message"], story["ingested_at"]) == ("ok", "Built the book", None)
assert orchestrator.store.get_setting(story_id, "upload_held") is None
assert len(list(config.TMP_DIR.glob(f"story-{story_id}-*.epub"))) == 1
assert update(story_id).startswith("No changes")
assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes"
assert rebuild(story_id) == "Built the book"
site(monkeypatch, [(1, "One", "First chapter"), (2, "Two", "Second chapter")])
assert update(story_id).endswith("Built the book")
orchestrator.cwa.assert_not_called()
@pytest.mark.parametrize("saved_id", [None, 7])
def test_without_cwa_the_ingest_folder_gets_each_changed_book_as_a_plain_epub(cwa_off, saved_id):
# A pause, a hold and a saved book ID are from a time with CWA. They do not apply.
orchestrator.save_settings(pause_uploads=True, hold_new_links=True)
story_id = add_story(calibre_id=saved_id, calibre_uuid="saved-uuid" if saved_id else None)
orchestrator._link_changed(story_id)
orchestrator.store.set_setting(story_id, "upload_held", True)
assert rebuild(story_id) == "Sent to ingest folder"
assert [p.suffix for p in config.INGEST_DIR.iterdir()] == [".epub"]
assert orchestrator.store.get_story(story_id)["ingested_at"] is None
assert orchestrator.store.get_setting(story_id, "upload_held") is None
assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes"
assert len(list(config.INGEST_DIR.iterdir())) == 1
orchestrator.cwa.assert_not_called()
def test_without_cwa_a_missing_ingest_folder_is_still_an_error(monkeypatch, tmp_path, cwa_off):
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "missing")
story_id = add_story()
with pytest.raises(IngestError, match="does not exist"):
rebuild(story_id)
# The book counts as sent only when it is in the folder.
assert orchestrator.store.get_setting(story_id, "sent_key") is None
def test_cwa_off_shows_in_the_config_and_refuses_cwa_logins(client, monkeypatch, cwa_off):
assert client.get("/api/config").json["cwa_enabled"] is False
monkeypatch.setattr(config, "AUTH_METHOD", "cwa")
monkeypatch.setattr(config, "CWA_DB_PATH", "/cwa/app.db")
response = client.get("/api/stories")
assert response.status_code == 503
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)
assert response.status_code == 200
assert response.json["link_books"] is False
monkeypatch.setattr(orchestrator, "store", Store(tmp_path / "bindery.db"))
assert client.get("/api/settings").json["link_books"] is False
response = client.put("/api/settings", json={"hold_new_links": True}, headers=H)
assert response.json["link_books"] is False
assert client.put("/api/settings", json={"link_books": True}, headers=H).json["link_books"] is True
@pytest.mark.parametrize("url", ["", "https://cwa.invalid"])
@pytest.mark.parametrize("database", [False, True])
def test_linking_requires_either_library_source(client, monkeypatch, tmp_path, url, database):
monkeypatch.setattr(config, "CWA_URL", url)
monkeypatch.setattr(config, "CALIBRE_DB_PATH", tmp_path / "metadata.db" if database else None)
available = bool(url or database)
settings = client.get("/api/settings").json
assert settings["linking_available"] is available
assert settings["link_books"] is available
settings = client.put("/api/settings", json={"link_books": True}, headers=H).json
assert settings["linking_available"] is available
assert settings["link_books"] is available
@pytest.mark.parametrize("saved", [None, False, True])
def test_missing_sources_force_linking_off_without_changing_preferences(client, monkeypatch, tmp_path, jobs, saved):
monkeypatch.setattr(config, "CWA_DB_PATH", tmp_path / "app.db")
orchestrator.store.set_setting(None, "link_books", saved)
orchestrator.store.set_setting(None, "hold_new_links", True)
story_id = add_story()
orchestrator.store.set_setting(story_id, "upload_held", True)
for requested in (True, False):
response = client.put("/api/settings", json={"link_books": requested}, headers=H)
assert response.status_code == 200
assert response.json["link_books"] is False
assert response.json["linking_available"] is False
assert response.json["hold_new_links"] is True
assert orchestrator.store.get_setting(None, "link_books") is saved
assert jobs.get_next() is None
monkeypatch.setattr(config, "CALIBRE_DB_PATH", tmp_path / "metadata.db")
assert client.get("/api/settings").json["linking_available"] is True
assert client.get("/api/settings").json["link_books"] is (saved is not False)
@pytest.mark.parametrize("value", ["false", 0, [], {}])
def test_link_setting_requires_a_boolean(client, value):
response = client.put("/api/settings", json={"link_books": value}, headers=H)
assert response.status_code == 400
assert client.get("/api/settings").json["link_books"] is False
@pytest.mark.parametrize("paused", [False, True])
def test_missing_ingest_forces_pause_without_changing_saved_preference(client, monkeypatch, tmp_path, paused):
orchestrator.save_settings(pause_uploads=paused)
monkeypatch.setattr(config, "INGEST_DIR", None)
settings = client.get("/api/settings").json
assert settings["pause_uploads"] is True
assert settings["ingest_available"] is False
for requested in (False, True):
response = client.put("/api/settings", json={"pause_uploads": requested}, headers=H)
assert response.status_code == 200
assert response.json["pause_uploads"] is True
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "enabled")
assert client.get("/api/settings").json["pause_uploads"] is paused
@pytest.mark.parametrize("existing", [False, True])
@pytest.mark.parametrize("link_books", [False, True])
def test_build_without_ingest_succeeds_and_keeps_the_book_local(monkeypatch, tmp_path, existing, link_books, library):
orchestrator.save_settings(link_books=link_books)
monkeypatch.setattr(config, "INGEST_DIR", None)
story_id = add_story(ingested_at="2026-01-01" if existing else None)
assert rebuild(story_id) == "Not sent. INGEST_DIR is not set."
story = orchestrator.store.get_story(story_id)
assert story["status"] == "ok"
assert story["ingested_at"] == ("2026-01-01" if existing else None)
assert list(config.TMP_DIR.glob(f"story-{story_id}-*.epub"))
assert not list((tmp_path / "ingest").iterdir())
details = orchestrator.story_details(story_id)
assert details["pause_uploads"] is True
assert details["story"]["upload_held"] is True
assert details["library_error"] is None
@pytest.mark.parametrize("configured", [False, True])
@pytest.mark.parametrize("saved_id", [None, 7])
@pytest.mark.parametrize("paused", [False, True])
def test_linking_off_exports_books_and_respects_pause(monkeypatch, tmp_path, configured, saved_id, paused):
if configured:
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
monkeypatch.setattr(config, "CALIBRE_DB_PATH", tmp_path / "unreadable.db")
orchestrator.save_settings(link_books=False, pause_uploads=paused)
story_id = add_story(calibre_id=saved_id, calibre_uuid="saved-uuid", ingested_at="2026-01-01")
message = rebuild(story_id)
assert orchestrator.store.get_story(story_id)["status"] == "ok"
assert orchestrator.story_details(story_id)["library_error"] is None
orchestrator.cwa.assert_not_called()
orchestrator._find_book.assert_not_called()
if not paused:
if saved_id is not None:
assert message == "Updated library book 7"
manifest = next(config.INGEST_DIR.glob("*.cwa.json"))
assert json.loads(manifest.read_text()) == {"action": "add_format", "book_id": 7}
else:
assert message == "Sent to ingest folder"
assert not list(config.INGEST_DIR.glob("*.cwa.json"))
assert len(list(config.INGEST_DIR.glob("*.epub"))) == 1
assert orchestrator.store.get_setting(story_id, "upload_held") is None
else:
assert message.startswith("Not sent.")
assert not list(config.INGEST_DIR.iterdir())
assert orchestrator.store.get_setting(story_id, "upload_held") is True
def test_disabling_linking_releases_approval_holds_for_saved_ids(jobs, library):
orchestrator.save_settings(hold_new_links=True)
story_id = add_story(calibre_id=7, calibre_uuid="uuid-7", ingested_at="2026-01-01")
orchestrator._link_changed(story_id)
assert "Approve link" in rebuild(story_id)
orchestrator.save_settings(link_books=False)
assert orchestrator.get_settings()["hold_new_links"] is True
assert orchestrator.story_details(story_id)["story"]["needs_approval"] is False
job = jobs.get_next()
assert job.story_id == story_id
assert orchestrator._run_rebuild(job) == "Updated library book 7"
@pytest.mark.parametrize("link_books", [True, False])
def test_story_response_reports_linking_and_approval_settings(client, link_books, library):
orchestrator.save_settings(link_books=link_books, hold_new_links=True)
story_id = add_story()
orchestrator._link_changed(story_id)
response = client.get(f"/api/stories/{story_id}")
assert response.status_code == 200
assert response.json["link_books"] is link_books
assert response.json["story"]["needs_approval"] is link_books
def test_enabling_linking_restores_the_saved_approval_preference(library):
orchestrator.save_settings(link_books=False, hold_new_links=True)
story_id = add_story(calibre_id=7, calibre_uuid="uuid-7", ingested_at="2026-01-01")
orchestrator._link_changed(story_id)
assert orchestrator.story_details(story_id)["story"]["needs_approval"] is False
orchestrator.save_settings(link_books=True)
assert orchestrator.get_settings()["hold_new_links"] is True
assert orchestrator.story_details(story_id)["story"]["needs_approval"] is True
assert "Approve link" in rebuild(story_id)
assert not list(config.INGEST_DIR.iterdir())
def test_linking_off_exports_every_rebuild_without_an_id_or_approval():
orchestrator.save_settings(link_books=False, hold_new_links=True)
story_id = add_story()
orchestrator._link_changed(story_id)
for _ in range(2):
assert rebuild(story_id) == "Sent to ingest folder"
assert orchestrator.store.get_story(story_id)["ingested_at"] is not None
assert len(list(config.INGEST_DIR.glob("*.epub"))) == 2
assert not list(config.INGEST_DIR.glob("*.cwa.json"))
assert orchestrator.store.get_setting(story_id, "upload_held") is None
def test_disabling_linking_releases_an_unlinked_book_without_approval(jobs, library):
orchestrator.save_settings(hold_new_links=True)
story_id = add_story()
orchestrator._link_changed(story_id)
assert "Approve as new book" in rebuild(story_id)
orchestrator.save_settings(link_books=False)
assert orchestrator.list_stories()[0]["approval_held"] is False
assert orchestrator._run_rebuild(jobs.get_next()) == "Sent to ingest folder"
assert orchestrator.store.get_setting(story_id, "upload_held") is None
@pytest.mark.parametrize("standalone", [False, True])
def test_new_story_download_skips_library_matching(monkeypatch, standalone):
if standalone:
monkeypatch.setattr(config, "INGEST_DIR", None)
else:
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
orchestrator.save_settings(link_books=False)
site(monkeypatch, [(1, "One", "First chapter")])
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
message = orchestrator._run_site_job(job)
assert ("Not sent." if standalone else "Sent to ingest folder") in message
assert len(orchestrator.store.list_chapters(job.story_id)) == 1
assert orchestrator.store.get_story(job.story_id)["status"] == "ok"
assert list(config.TMP_DIR.glob(f"story-{job.story_id}-*.epub"))
orchestrator.cwa.assert_not_called()
orchestrator._find_book.assert_not_called()
def test_site_updates_export_without_a_library_or_saved_id(monkeypatch):
assert orchestrator.get_settings()["link_books"] is False
site(monkeypatch, [(1, "One", "First chapter")])
job = Job(mode=DownloadMode.NEW, url=STORY_URL)
assert "Sent to ingest folder" in orchestrator._run_site_job(job)
site(monkeypatch, [(1, "One", "First chapter"), (2, "Two", "Second chapter")])
assert "Sent to ingest folder" in orchestrator._run_site_job(Job(mode=DownloadMode.NEW, story_id=job.story_id))
assert len(orchestrator.store.list_chapters(job.story_id)) == 2
assert len(list(config.INGEST_DIR.glob("*.epub"))) == 2
assert not list(config.INGEST_DIR.glob("*.cwa.json"))
orchestrator.cwa.assert_not_called()
orchestrator._find_book.assert_not_called()
def test_saved_id_without_uuid_works_without_library_configuration():
story_id = add_story(calibre_id=7, ingested_at="2026-01-01")
assert rebuild(story_id) == "Updated library book 7"
orchestrator.cwa.assert_not_called()
orchestrator._find_book.assert_not_called()
def test_paused_unlinked_update_does_not_raise_not_found(monkeypatch):
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
orchestrator._find_book.return_value = None
orchestrator._find_book.side_effect = None
orchestrator.save_settings(pause_uploads=True)
story_id = add_story(ingested_at="2026-01-01")
assert rebuild(story_id) == "Not sent. Turn off pause to send."
assert orchestrator.store.get_story(story_id)["status"] == "ok"
assert not list(config.INGEST_DIR.iterdir())
def test_enabling_linking_rebuilds_held_books_and_matches_again(monkeypatch, jobs, tmp_path):
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
orchestrator.save_settings(link_books=False, pause_uploads=True)
story_id = add_story()
assert "Turn off pause" in rebuild(story_id)
downloaded = tmp_path / "library.epub"
downloaded.touch()
orchestrator._find_book.side_effect = None
orchestrator._find_book.return_value = (Book(7, "uuid-7", "Test Story"), downloaded)
orchestrator.save_settings(link_books=True, pause_uploads=False)
job = jobs.get_next()
assert job.mode == DownloadMode.REBUILD
assert job.story_id == story_id
assert orchestrator._run_rebuild(job) == "Updated library book 7"
assert orchestrator.store.get_story(story_id)["calibre_id"] == 7
assert orchestrator.store.get_setting(story_id, "upload_held") is None
@pytest.mark.parametrize("link_books", [False, True])
@pytest.mark.parametrize("hold_new_links", [False, True])
@pytest.mark.parametrize("paused", [False, True])
@pytest.mark.parametrize("saved_id", [None, 7])
def test_upload_settings_matrix(client, link_books, hold_new_links, paused, saved_id, library):
orchestrator.save_settings(link_books=link_books, hold_new_links=hold_new_links, pause_uploads=paused)
story_id = add_story(
calibre_id=saved_id,
calibre_uuid="uuid-7" if saved_id else None,
ingested_at="2026-01-01" if saved_id else None,
)
orchestrator._link_changed(story_id)
assert client.get("/api/stories").json[0]["approval_held"] is False
message = rebuild(story_id)
pending = paused or (link_books and hold_new_links)
assert message.startswith("Not sent.") is pending
listed = client.get("/api/stories").json[0]
assert listed["upload_held"] is pending
assert listed["approval_held"] is (link_books and hold_new_links and not paused)
assert bool(orchestrator.store.get_setting(story_id, "upload_held")) is pending
assert bool(list(config.INGEST_DIR.iterdir())) is (not pending)
@pytest.mark.parametrize("missing_ingest", [False, True])
def test_approval_while_paused_preserves_pending_upload_until_resume(
client, monkeypatch, jobs, missing_ingest, library
):
ingest = config.INGEST_DIR
orchestrator.save_settings(hold_new_links=True, pause_uploads=True)
if missing_ingest:
monkeypatch.setattr(config, "INGEST_DIR", None)
story_id = add_story()
orchestrator._link_changed(story_id)
rebuild(story_id)
response = client.post(f"/api/stories/{story_id}/approve", json={"uuid": None}, headers=H)
assert response.status_code == 200
assert response.json["job"] is None
assert response.json["story"]["needs_approval"] is False
assert response.json["story"]["upload_held"] is True
assert jobs.get_next() is None
assert client.get("/api/stories").json[0]["approval_held"] is False
assert orchestrator.store.get_setting(story_id, "upload_held") is True
monkeypatch.setattr(config, "INGEST_DIR", ingest)
orchestrator.save_settings(pause_uploads=False)
job = jobs.get_next()
assert job.story_id == story_id
assert orchestrator._run_rebuild(job) == "Sent to CWA"
assert orchestrator.store.get_setting(story_id, "upload_held") is None
assert client.get("/api/stories").json[0]["approval_held"] is False
def test_resuming_uploads_does_not_bypass_approval(client, jobs, library):
orchestrator.save_settings(hold_new_links=True, pause_uploads=True)
story_id = add_story(calibre_id=7, calibre_uuid="uuid-7", ingested_at="2026-01-01")
orchestrator._link_changed(story_id)
rebuild(story_id)
assert client.get("/api/stories").json[0]["approval_held"] is False
orchestrator.save_settings(pause_uploads=False)
assert client.get("/api/stories").json[0]["approval_held"] is True
assert "Approve link" in orchestrator._run_rebuild(jobs.get_next())
assert orchestrator.store.get_setting(story_id, "upload_held") is True
assert not list(config.INGEST_DIR.iterdir())
def test_missing_library_connection_is_not_an_approval_hold(client, monkeypatch):
orchestrator.save_settings(hold_new_links=True)
story_id = add_story(ingested_at="2026-01-01")
orchestrator._link_changed(story_id)
assert rebuild(story_id) == "Sent to ingest folder"
assert client.get("/api/stories").json[0]["approval_held"] is False
details = client.get(f"/api/stories/{story_id}").json
assert details["story"]["upload_held"] is False
assert details["story"]["needs_approval"] is False
assert details["link_books"] is False
assert details["ingest_available"] is True
monkeypatch.setattr(config, "INGEST_DIR", None)
assert client.get(f"/api/stories/{story_id}").json["ingest_available"] is False
monkeypatch.setattr(config, "CWA_URL", "https://cwa.invalid")
assert client.get(f"/api/stories/{story_id}").json["link_books"] is True
@pytest.mark.parametrize("missing_ingest", [False, True])
def test_releasing_other_holds_does_not_queue_uploads_while_paused(monkeypatch, jobs, missing_ingest, library):
orchestrator.save_settings(link_books=False, hold_new_links=True, pause_uploads=True)
story_id = add_story()
rebuild(story_id)
if missing_ingest:
monkeypatch.setattr(config, "INGEST_DIR", None)
orchestrator.save_settings(link_books=True, hold_new_links=False)
assert jobs.get_next() is None
assert orchestrator.get_settings()["pause_uploads"] is True
+7 -3
View File
@@ -6,13 +6,15 @@ def test_chapters_revisions_and_rules(tmp_path):
sid = store.create_story("https://example/s/1", "example", "T", "A", ["title"]) sid = store.create_story("https://example/s/1", "example", "T", "A", ["title"])
c1 = store.add_chapter(sid, "u1", "One", "<p>1</p>", "h1", "new", 0) c1 = store.add_chapter(sid, "u1", "One", "<p>1</p>", "h1", "new", 0)
c2 = store.add_chapter(sid, "u2", "Two", "<p>2</p>", "h2", "new", 1) c2 = store.add_chapter(sid, "u2", "Two", "<p>2</p>", "h2", "new", 1)
store.set_positions(sid, [c2, c1]) store.reorder_chapters(sid, lambda order: [c2, c1])
assert [c["url"] for c in store.chapters_for_build(sid)] == ["u2", "u1"] assert [c["url"] for c in store.chapters_for_build(sid)] == ["u2", "u1"]
assert store.add_revision(c1, "<p>1b</p>", "h1b", "recheck") == 2 assert store.add_revision(c1, "<p>1b</p>", "h1b", "recheck") == 2
assert store.current_revisions(sid)[c1] == {"rev": 2, "text_hash": "h1b", "source": "recheck"} newest = store.list_revisions(c1)[-1]
assert (newest["rev"], newest["text_hash"], newest["source"]) == (2, "h1b", "recheck")
assert store.get_chapter(c1)["current_rev"] == 2
store.replace_revision(c1, 2, "<p>1c</p>", "h1c") store.replace_revision(c1, 2, "<p>1c</p>", "h1c")
assert store.current_revisions(sid)[c1]["text_hash"] == "h1c" assert store.list_revisions(c1)[-1]["text_hash"] == "h1c"
assert store.revision_html(c1, 1) == "<p>1</p>" assert store.revision_html(c1, 1) == "<p>1</p>"
store.update_chapter(c1, current_rev=1, included=0) store.update_chapter(c1, current_rev=1, included=0)
assert [c["url"] for c in store.chapters_for_build(sid)] == ["u2"] assert [c["url"] for c in store.chapters_for_build(sid)] == ["u2"]
@@ -53,6 +55,8 @@ def test_list_stories_sorts_titles_without_leading_articles(tmp_path):
store.create_story(f"https://example/s/{n}", "example", title, "A", []) store.create_story(f"https://example/s/{n}", "example", title, "A", [])
titles = [s["title"] for s in store.list_stories()] titles = [s["title"] for s in store.list_stories()]
assert titles == ["Anthem", "A Bee", "Cat", "an Owl", "Theory", "The Zebra"] assert titles == ["Anthem", "A Bee", "Cat", "an Owl", "Theory", "The Zebra"]
# The web interface sorts by the same key.
assert [s["sort_title"] for s in store.list_stories()] == ["anthem", "bee", "cat", "owl", "theory", "zebra"]
def test_cover_replaces_previous_cover(tmp_path): def test_cover_replaces_previous_cover(tmp_path):
+41 -2
View File
@@ -6,7 +6,7 @@ from unittest.mock import Mock
import pytest import pytest
from bindery import config, fff from bindery import config, fff, web
from bindery import orchestrator as o from bindery import orchestrator as o
from bindery.diffing import text_hash from bindery.diffing import text_hash
from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus
@@ -25,6 +25,8 @@ def state(monkeypatch, tmp_path):
monkeypatch.setattr(o, "job_queue", queue) monkeypatch.setattr(o, "job_queue", queue)
monkeypatch.setattr(config, "CWA_URL", "") monkeypatch.setattr(config, "CWA_URL", "")
monkeypatch.setattr(config, "SITE_DELAY", 7) monkeypatch.setattr(config, "SITE_DELAY", 7)
monkeypatch.setattr(config, "TMP_DIR", tmp_path / "tmp")
config.TMP_DIR.mkdir()
return store, queue return store, queue
@@ -85,6 +87,42 @@ def test_worker_persists_outcomes_and_continues_after_failure(state, monkeypatch
assert [c.args[0] for c in sleeps.call_args_list] == [2, 7] assert [c.args[0] for c in sleeps.call_args_list] == [2, 7]
def test_worker_goes_on_after_an_error_of_the_queue(state, monkeypatch, caplog):
store, queue = state
job = queue.add(Job(mode=DownloadMode.REBUILD))
monkeypatch.setattr(queue, "get_next", Mock(side_effect=[OSError("disk full"), job, StopLoop()]))
monkeypatch.setattr(queue, "update", Mock(side_effect=[OSError("disk full"), None, None]))
sleeps = Mock()
monkeypatch.setattr(o.time, "sleep", sleeps)
with pytest.raises(StopLoop):
o.download_loop()
assert caplog.text.count("Job loop error: disk full") == 2
assert [c.args[0] for c in sleeps.call_args_list] == [2, 2]
def test_health_reports_a_stopped_job_thread(state, monkeypatch):
client = web.app.test_client()
assert o.stopped_threads() == []
alive, dead = Mock(), Mock()
alive.is_alive.return_value, dead.is_alive.return_value, dead.name = True, False, "download_loop"
monkeypatch.setattr(o, "_threads", [alive, dead])
response = client.get("/api/health")
assert response.status_code == 503
assert response.json == {"status": "error", "error": "Stopped: download_loop. Restart Bindery."}
monkeypatch.setattr(o, "_threads", [alive])
assert client.get("/api/health").json == {"status": "ok"}
def test_start_threads_keeps_the_threads_for_the_health_check(state, monkeypatch):
monkeypatch.setattr(o, "_threads", [])
monkeypatch.setattr(o, "download_loop", lambda: None)
monkeypatch.setattr(o, "scheduler_loop", lambda: None)
o.start_threads()
for thread in o._threads:
thread.join()
assert o.stopped_threads() == ["<lambda>", "<lambda>"]
def test_scheduler_skips_disabled_busy_and_recent_stories(state, monkeypatch): def test_scheduler_skips_disabled_busy_and_recent_stories(state, monkeypatch):
store, queue = state store, queue = state
now = datetime(2026, 6, 1, 12) now = datetime(2026, 6, 1, 12)
@@ -151,7 +189,8 @@ def site(state, monkeypatch):
calibre={"title": "Title"}, calibre={"title": "Title"},
) )
fetch = Mock(side_effect=lambda a, url, i: f"<p>fresh {url}</p>") fetch = Mock(side_effect=lambda a, url, i: f"<p>fresh {url}</p>")
build, deliver = Mock(), Mock(return_value="Sent") build = Mock(side_effect=lambda story, overrides, path: path.write_bytes(b"epub"))
deliver = Mock(return_value="Sent")
monkeypatch.setattr(fff, "get_adapter", lambda url: adapter) monkeypatch.setattr(fff, "get_adapter", lambda url: adapter)
monkeypatch.setattr(fff, "fetch_story", lambda a: snap) monkeypatch.setattr(fff, "fetch_story", lambda a: snap)
monkeypatch.setattr(fff, "fetch_chapter", fetch) monkeypatch.setattr(fff, "fetch_chapter", fetch)