Make CWA linking and uploads optional

This commit is contained in:
tim
2026-10-02 20:25:45 +02:00
parent 34ff432aac
commit c188795852
17 changed files with 864 additions and 78 deletions
+37 -9
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.
@@ -68,13 +69,31 @@ Environment variables set the configuration. **Restart the container after a cha
| `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. | 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 ePubs while Bindery builds them | `/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
Leave `INGEST_DIR`, `CWA_URL` and `CALIBRE_DB_PATH` unset to run Bindery without CWA.
Bindery still downloads chapters and builds ePubs. Download the books from each story page.
If you use the example Compose file, remove its CWA connection values, `INGEST_DIR` and the ingest volume mount.
Use `AUTH_METHOD=local` or `proxy` for logins. On a private network, you can use `AUTH_METHOD=none`.
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:
@@ -274,7 +293,8 @@ 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 +308,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.
Changing this setting rebuilds 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 rebuilds 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:
+3 -4
View File
@@ -35,7 +35,7 @@ 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 = os.getenv("LOG_LEVEL", "INFO").strip().upper()
# 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.
@@ -84,9 +84,8 @@ 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, INGEST_DIR):
directory.mkdir(parents=True, exist_ok=True) if directory is not None:
directory.mkdir(parents=True, exist_ok=True)
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:
logger.warning("CWA_URL is not set. OPDS imports and HTTP fallbacks are unavailable.")
+52 -15
View File
@@ -25,7 +25,6 @@ from bindery.store import Store, now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
store = Store(config.DB_PATH) store = Store(config.DB_PATH)
ingest_folder = IngestFolder(config.INGEST_DIR)
finder = BookFinder() finder = BookFinder()
SITE_MODES = ( SITE_MODES = (
@@ -54,6 +53,18 @@ def library_available() -> bool:
return bool(config.CWA_URL) return bool(config.CWA_URL)
def _linking_available() -> bool:
return bool(config.CWA_URL or config.CALIBRE_DB_PATH)
def _link_books() -> bool:
return _linking_available() and store.get_setting(None, "link_books") is not False
def _can_match_books() -> bool:
return _link_books()
def default_locked_fields() -> list[str]: def default_locked_fields() -> list[str]:
return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS] return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS]
@@ -170,9 +181,17 @@ def _require_story(story_id: int) -> dict[str, Any]:
def list_stories() -> list[dict[str, Any]]: def list_stories() -> list[dict[str, Any]]:
stories = store.list_stories() stories = store.list_stories()
held = store.story_settings("upload_held") held = store.story_settings("upload_held")
unapproved = store.story_settings("unapproved_link")
show_holds = _link_books() and store.get_setting(None, "hold_new_links") and not _uploads_paused()
for s in stories: for s in stories:
s["busy"] = job_queue.has_active(s["id"]) s["busy"] = job_queue.has_active(s["id"])
s["upload_held"] = bool(held.get(s["id"])) s["upload_held"] = bool(held.get(s["id"]))
s["approval_held"] = bool(
show_holds
and s["upload_held"]
and unapproved.get(s["id"])
and (s["calibre_id"] is not None or not s["ingested_at"])
)
return stories return stories
@@ -194,7 +213,7 @@ def story_details(story_id: int) -> dict[str, Any]:
# The version changes with the image, so a browser can keep a cover until the site changes it. # The version changes with the image, so a browser can keep a cover until the site changes it.
story["cover"] = hashlib.sha256(cover["data"]).hexdigest()[:16] if cover else None story["cover"] = hashlib.sha256(cover["data"]).hexdigest()[:16] if cover else None
book, library_error = None, None book, library_error = None, None
if story["calibre_uuid"] and (library_available() or config.CALIBRE_DB_PATH): if story["calibre_uuid"] and _can_match_books():
try: try:
book = cwa().get_book(story["calibre_uuid"]) book = cwa().get_book(story["calibre_uuid"])
except CwaError as e: except CwaError as e:
@@ -206,6 +225,9 @@ def story_details(story_id: int) -> dict[str, Any]:
return { return {
"recent_chapters": _setting_recent_chapters(), "recent_chapters": _setting_recent_chapters(),
"uploads_paused": _uploads_paused(), "uploads_paused": _uploads_paused(),
"ingest_available": config.INGEST_DIR is not None,
"link_books": _link_books(),
"library_matching_available": _can_match_books(),
"story": story, "story": story,
"chapters": store.list_chapters(story_id), "chapters": store.list_chapters(story_id),
"rules": store.rules(story_id), "rules": store.rules(story_id),
@@ -315,8 +337,11 @@ def get_settings() -> dict[str, Any]:
ini = config.PERSONAL_INI ini = config.PERSONAL_INI
return { return {
"recent_chapters": _setting_recent_chapters(), "recent_chapters": _setting_recent_chapters(),
"link_books": _link_books(),
"linking_available": _linking_available(),
"hold_new_links": bool(store.get_setting(None, "hold_new_links")), "hold_new_links": bool(store.get_setting(None, "hold_new_links")),
"pause_uploads": _uploads_paused(), "pause_uploads": _uploads_paused(),
"ingest_available": config.INGEST_DIR is not None,
"personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "", "personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "",
**job_list_settings(), **job_list_settings(),
} }
@@ -343,16 +368,23 @@ def save_settings(
story_jobs_count: Any = None, story_jobs_count: Any = None,
main_jobs_hours: Any = None, main_jobs_hours: Any = None,
main_jobs_count: Any = None, main_jobs_count: Any = None,
link_books: bool | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini.""" """Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini."""
warnings: list[str] = [] warnings: list[str] = []
released = False released = False
if link_books is not None:
if not isinstance(link_books, bool):
raise ValueError("Link books to CWA library must be true or false")
if _linking_available():
released = _link_books() != link_books
store.set_setting(None, "link_books", link_books)
for name, value in (("hold_new_links", hold_new_links), ("pause_uploads", pause_uploads)): for name, value in (("hold_new_links", hold_new_links), ("pause_uploads", pause_uploads)):
if value is None: if value is None or (name == "pause_uploads" and config.INGEST_DIR is None):
continue continue
released = released or (bool(store.get_setting(None, name)) and not value) released = released or (bool(store.get_setting(None, name)) and not value)
store.set_setting(None, name, True if value else None) store.set_setting(None, name, True if value else None)
if released: if released and not _uploads_paused():
_release_held_uploads() _release_held_uploads()
if recent_chapters is not None: if recent_chapters is not None:
try: try:
@@ -539,7 +571,7 @@ def approve_upload(story_id: int, book_uuid: str | None) -> dict[str, Any] | Non
if (book_uuid or None) != story["calibre_uuid"]: if (book_uuid or None) != story["calibre_uuid"]:
raise ValueError("The library link of this story changed. Reload the page, and check the link again.") raise ValueError("The library link of this story changed. Reload the page, and check the link again.")
store.set_setting(story_id, "unapproved_link", None) store.set_setting(story_id, "unapproved_link", None)
if store.get_setting(story_id, "upload_held") and story["metahtml"]: if not _uploads_paused() and store.get_setting(story_id, "upload_held") and story["metahtml"]:
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])).to_dict() return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])).to_dict()
return None return None
@@ -659,7 +691,9 @@ def _link(story: dict[str, Any], book_id: int, book_uuid: str) -> None:
def _linked_book(story: dict[str, Any]) -> dict[str, Any] | None: def _linked_book(story: dict[str, Any]) -> dict[str, Any] | None:
"""The library book for a story. Links the story if CWA has a book with its URL.""" """The library book for a story. Links the story if CWA has a book with its URL."""
if not library_available() and not config.CALIBRE_DB_PATH: if not _can_match_books():
if story["calibre_id"] is not None:
return {"id": story["calibre_id"], "uuid": story["calibre_uuid"], "title": story["title"]}
return None return None
if story["calibre_uuid"]: if story["calibre_uuid"]:
try: try:
@@ -705,7 +739,7 @@ NOT_FOUND = "Cannot find this story in CWA. Link to library book, or send as new
def _uploads_paused() -> bool: def _uploads_paused() -> bool:
return bool(store.get_setting(None, "pause_uploads")) return config.INGEST_DIR is None or bool(store.get_setting(None, "pause_uploads"))
def _link_changed(story_id: int) -> None: def _link_changed(story_id: int) -> None:
@@ -713,7 +747,9 @@ def _link_changed(story_id: int) -> None:
def _needs_approval(story_id: int) -> bool: def _needs_approval(story_id: int) -> bool:
return bool(store.get_setting(None, "hold_new_links") and store.get_setting(story_id, "unapproved_link")) return bool(
_link_books() and store.get_setting(None, "hold_new_links") and store.get_setting(story_id, "unapproved_link")
)
def _release_held_uploads() -> None: def _release_held_uploads() -> None:
@@ -726,6 +762,8 @@ def _release_held_uploads() -> None:
def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None: def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None:
if _uploads_paused(): if _uploads_paused():
if config.INGEST_DIR is None:
return "Not sent. INGEST_DIR is not set."
return "Not sent. Turn off pause to send." return "Not sent. Turn off pause to send."
if not _needs_approval(story["id"]): if not _needs_approval(story["id"]):
return None return None
@@ -735,15 +773,14 @@ def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | No
def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, title: str, author: str) -> str: def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, title: str, author: str) -> str:
# A second plain epub would make a duplicate book, so an update without a library book fails.
if not book and story["ingested_at"] and not library_available() and not config.CALIBRE_DB_PATH:
raise StoryError("Bindery cannot find the library book to update, because CWA_URL is not set.")
if not book and story["ingested_at"]:
raise StoryError(NOT_FOUND)
held = _hold_reason(story, book) held = _hold_reason(story, book)
if held: if held:
store.set_setting(story["id"], "upload_held", True) store.set_setting(story["id"], "upload_held", True)
return held return held
# With linking on, do not create a duplicate when the library book cannot be found.
if _link_books() and not book and story["ingested_at"]:
raise StoryError(NOT_FOUND)
ingest_folder = IngestFolder(config.INGEST_DIR)
if book: if book:
ingest_folder.send_update(epub, book["id"], title) ingest_folder.send_update(epub, book["id"], title)
message = f"Updated library book {book['id']}" message = f"Updated library book {book['id']}"
@@ -751,7 +788,7 @@ def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, tit
ingest_folder.ingest(epub, f"{title} - {author}" if author else title) ingest_folder.ingest(epub, f"{title} - {author}" if author else title)
store.update_story(story["id"], ingested_at=now()) store.update_story(story["id"], ingested_at=now())
story["ingested_at"] = now() story["ingested_at"] = now()
message = "Sent to CWA" message = "Sent to CWA" if _link_books() else "Sent to ingest folder"
store.set_setting(story["id"], "upload_held", None) store.set_setting(story["id"], "upload_held", None)
store.set_setting(story["id"], "unapproved_link", None) store.set_setting(story["id"], "unapproved_link", None)
return message return message
@@ -929,7 +966,7 @@ def _run_site_job(job: Job) -> str:
if story is None: if story is None:
# A new story. If the library already has it (for example from the # A new story. If the library already has it (for example from the
# FanFicFare calibre plugin), link it and keep its chapters. # FanFicFare calibre plugin), link it and keep its chapters.
found = _find_book(snap.url, [snap.title]) if library_available() else None found = _find_book(snap.url, [snap.title]) if _link_books() and library_available() else None
book_id, book_uuid = (found[0].id, found[0].uuid) if found else (None, None) book_id, book_uuid = (found[0].id, found[0].uuid) if found else (None, None)
contents = None contents = None
if found: if found:
+39 -3
View File
@@ -10,7 +10,31 @@ 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 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');
@@ -40,8 +64,7 @@ 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;
storyJobsHours.value = settings.story_jobs_hours; storyJobsHours.value = settings.story_jobs_hours;
storyJobsCount.value = settings.story_jobs_count; storyJobsCount.value = settings.story_jobs_count;
@@ -83,6 +106,15 @@ export function settingsPage(main, _params, config) {
'section', 'section',
{ class: 'card' }, { class: 'card' },
h('h2', {}, 'Uploads to CWA'), h('h2', {}, 'Uploads to CWA'),
h('label', {}, linkBooks, ' Link books to CWA library'),
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,6 +124,7 @@ 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' },
@@ -102,7 +135,10 @@ export function settingsPage(main, _params, config) {
'div', 'div',
{ class: 'controls' }, { class: 'controls' },
button('Save', async () => { button('Save', async () => {
await api('settings', 'PUT', { hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked }); const saved = await api('settings', 'PUT', {
link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked,
});
showUploadSettings(saved);
toast('Saved'); toast('Saved');
}, { class: 'primary' }), }, { class: 'primary' }),
), ),
+1 -1
View File
@@ -203,7 +203,7 @@ 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),
+32 -19
View File
@@ -133,7 +133,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.
@@ -165,15 +165,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.uploads_paused
'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);
@@ -182,15 +185,17 @@ 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() {
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.uploads_paused && (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 +204,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.uploads_paused ? '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.uploads_paused ? 'Approve as a new book instead' : 'Send as a new book instead', approveAsNew,
),
), ),
], ],
); );
+4
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;
+1
View File
@@ -230,6 +230,7 @@ def settings() -> Any:
data.get("story_jobs_count"), data.get("story_jobs_count"),
data.get("main_jobs_hours"), data.get("main_jobs_hours"),
data.get("main_jobs_count"), data.get("main_jobs_count"),
link_books=data.get("link_books"),
) )
return orchestrator.get_settings() return orchestrator.get_settings()
+2
View File
@@ -5,6 +5,8 @@ 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:
# 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.
CWA_URL: http://calibre-web-automated:8083 CWA_URL: http://calibre-web-automated:8083
CWA_USERNAME: bindery CWA_USERNAME: bindery
+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
@@ -258,7 +258,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: '' }),
+239 -12
View File
@@ -31,24 +31,131 @@ async function renderStory(t, data) {
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, uploads_paused: 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, uploads_paused: 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);
}); });
@@ -98,18 +246,96 @@ test('a paused upload points to the settings', async (t) => {
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, uploads_paused: 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 }, { uploads_paused: 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, library_matching_available: 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, library_matching_available: 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 }, { uploads_paused: 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', 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', 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', 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/);
}); });
+5
View File
@@ -284,10 +284,15 @@ 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",
"story_jobs_hours": 36, "story_jobs_hours": 36,
"story_jobs_count": 10, "story_jobs_count": 10,
+1 -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
@@ -250,7 +249,7 @@ 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")
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)
+22
View File
@@ -101,6 +101,7 @@ def test_defaults(monkeypatch, reload_config):
"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",
): ):
@@ -116,5 +117,26 @@ def test_defaults(monkeypatch, reload_config):
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
def test_prepare_without_cwa_creates_only_bindery_folders(monkeypatch, tmp_path, caplog):
monkeypatch.setattr(config, "INGEST_DIR", None)
monkeypatch.setattr(config, "CWA_URL", "")
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
for name, folder in [("CONFIG_DIR", "config"), ("UPLOAD_DIR", "uploads"), ("TMP_DIR", "tmp")]:
monkeypatch.setattr(config, name, tmp_path / folder)
monkeypatch.setattr(config, "PERSONAL_INI", tmp_path / "config" / "personal.ini")
config.prepare()
assert config.PERSONAL_INI.exists()
assert {p.name for p in tmp_path.iterdir()} == {"config", "uploads", "tmp"}
assert caplog.records == []
+12 -12
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,7 @@ 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")
monkeypatch.setattr(orchestrator, "finder", orchestrator.BookFinder()) monkeypatch.setattr(orchestrator, "finder", orchestrator.BookFinder())
yield server yield server
server.close() server.close()
@@ -51,12 +50,12 @@ 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()
@@ -80,7 +79,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 +93,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):
@@ -306,7 +306,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 +598,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)
+409
View File
@@ -0,0 +1,409 @@
"""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
from test_deliver import jobs as jobs
from bindery import config, orchestrator
from bindery.cwa import Book
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")
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))
def test_app_starts_without_any_cwa_configuration(tmp_path):
env = {
**os.environ,
"CONFIG_DIR": str(tmp_path / "standalone"),
"TMP_DIR": str(tmp_path / "builds"),
"AUTH_METHOD": "none",
"BINDERY_START_THREADS": "false",
"URL_BASE": "",
}
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 "WARNING" not in result.stderr
assert "ERROR" not in result.stderr
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 (config.TMP_DIR / f"story-{story_id}.epub").exists()
assert not (tmp_path / "ingest").exists()
details = orchestrator.story_details(story_id)
assert details["uploads_paused"] 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 config.INGEST_DIR.exists()
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 config.INGEST_DIR.exists()
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 (config.TMP_DIR / f"story-{job.story_id}.epub").exists()
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 config.INGEST_DIR.exists()
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 config.INGEST_DIR.exists() 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 config.INGEST_DIR.exists()
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["library_matching_available"] 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["library_matching_available"] 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