From c1887958525f3ceea3a1df234485b2ceb004f86a Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Fri, 2 Oct 2026 20:25:45 +0200 Subject: [PATCH] Make CWA linking and uploads optional --- README.md | 46 +++- bindery/config.py | 7 +- bindery/orchestrator.py | 67 ++++-- bindery/static/js/settings.js | 42 +++- bindery/static/js/stories.js | 2 +- bindery/static/js/story.js | 51 +++-- bindery/static/style.css | 4 + bindery/web.py | 1 + docker-compose.yml | 2 + tests/js/layout.test.mjs | 4 + tests/js/story-list.test.mjs | 2 +- tests/js/upload-hold.test.mjs | 251 ++++++++++++++++++++- tests/test_api.py | 5 + tests/test_calibre_db.py | 3 +- tests/test_config.py | 22 ++ tests/test_deliver.py | 24 +- tests/test_optional_cwa.py | 409 ++++++++++++++++++++++++++++++++++ 17 files changed, 864 insertions(+), 78 deletions(-) create mode 100644 tests/test_optional_cwa.py diff --git a/README.md b/README.md index 427da88..b069450 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Bindery downloads web fiction into a [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 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 @@ -21,7 +22,7 @@ builds each ePub from that database. ## 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". 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` | | `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 | -| `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` | | `TMP_DIR` | The folder for ePubs while Bindery builds them | `/tmp/bindery` | | `LOG_LEVEL` | The log level | `INFO` | 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 `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 -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: - 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 deleted the book. -### Hold uploads +### Upload settings -Two settings on the Settings page stop books from going to CWA. Bindery still downloads the chapters -and builds each book. The story list marks a book that waits with "held". +The "Uploads to CWA" section controls library matching and uploads. Bindery still downloads chapters +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 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 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 - book that waits and sends it. +- **Pause all uploads.** No book goes to CWA. This is always checked when `INGEST_DIR` is unset. + 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: diff --git a/bindery/config.py b/bindery/config.py index 846571f..1d3521f 100644 --- a/bindery/config.py +++ b/bindery/config.py @@ -35,7 +35,7 @@ def _base_path(value: str) -> str: PROJECT_ROOT = Path(__file__).resolve().parent.parent 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") LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").strip().upper() # 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: """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): - directory.mkdir(parents=True, exist_ok=True) + if directory is not None: + directory.mkdir(parents=True, exist_ok=True) if not PERSONAL_INI.exists(): shutil.copy(DEFAULT_PERSONAL_INI, 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.") diff --git a/bindery/orchestrator.py b/bindery/orchestrator.py index f771ea2..04f9208 100644 --- a/bindery/orchestrator.py +++ b/bindery/orchestrator.py @@ -25,7 +25,6 @@ from bindery.store import Store, now logger = logging.getLogger(__name__) store = Store(config.DB_PATH) -ingest_folder = IngestFolder(config.INGEST_DIR) finder = BookFinder() SITE_MODES = ( @@ -54,6 +53,18 @@ def library_available() -> bool: 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]: 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]]: stories = store.list_stories() 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: s["busy"] = job_queue.has_active(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 @@ -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. story["cover"] = hashlib.sha256(cover["data"]).hexdigest()[:16] if cover else 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: book = cwa().get_book(story["calibre_uuid"]) except CwaError as e: @@ -206,6 +225,9 @@ def story_details(story_id: int) -> dict[str, Any]: return { "recent_chapters": _setting_recent_chapters(), "uploads_paused": _uploads_paused(), + "ingest_available": config.INGEST_DIR is not None, + "link_books": _link_books(), + "library_matching_available": _can_match_books(), "story": story, "chapters": store.list_chapters(story_id), "rules": store.rules(story_id), @@ -315,8 +337,11 @@ def get_settings() -> dict[str, Any]: ini = config.PERSONAL_INI return { "recent_chapters": _setting_recent_chapters(), + "link_books": _link_books(), + "linking_available": _linking_available(), "hold_new_links": bool(store.get_setting(None, "hold_new_links")), "pause_uploads": _uploads_paused(), + "ingest_available": config.INGEST_DIR is not None, "personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "", **job_list_settings(), } @@ -343,16 +368,23 @@ def save_settings( story_jobs_count: Any = None, main_jobs_hours: Any = None, main_jobs_count: Any = None, + link_books: bool | None = None, ) -> dict[str, Any]: """Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini.""" warnings: list[str] = [] 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)): - if value is None: + if value is None or (name == "pause_uploads" and config.INGEST_DIR is None): continue released = released or (bool(store.get_setting(None, name)) and not value) store.set_setting(None, name, True if value else None) - if released: + if released and not _uploads_paused(): _release_held_uploads() if recent_chapters is not None: 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"]: 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) - 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 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: """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 if story["calibre_uuid"]: 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: - 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: @@ -713,7 +747,9 @@ def _link_changed(story_id: int) -> None: 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: @@ -726,6 +762,8 @@ def _release_held_uploads() -> None: def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None: 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." if not _needs_approval(story["id"]): 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: - # 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) if held: store.set_setting(story["id"], "upload_held", True) 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: ingest_folder.send_update(epub, book["id"], title) 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) store.update_story(story["id"], 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"], "unapproved_link", None) return message @@ -929,7 +966,7 @@ def _run_site_job(job: Job) -> str: if story is None: # A new story. If the library already has it (for example from the # 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) contents = None if found: diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js index b17a0bd..5d9cc1f 100644 --- a/bindery/static/js/settings.js +++ b/bindery/static/js/settings.js @@ -10,7 +10,31 @@ export function settingsPage(main, _params, config) { document.title = 'Settings · Bindery'; const recent = h('input', { id: 'recent-chapters', type: 'number', min: 0, step: 1 }); 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 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 countField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_count, step: 1 }); const storyJobsHours = hoursField('story-jobs-hours'); @@ -40,8 +64,7 @@ export function settingsPage(main, _params, config) { api('settings') .then((settings) => { recent.value = settings.recent_chapters; - holdNewLinks.checked = settings.hold_new_links; - pauseUploads.checked = settings.pause_uploads; + showUploadSettings(settings); ini.value = settings.personal_ini; storyJobsHours.value = settings.story_jobs_hours; storyJobsCount.value = settings.story_jobs_count; @@ -83,6 +106,15 @@ export function settingsPage(main, _params, config) { 'section', { class: 'card' }, 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( 'p', @@ -92,6 +124,7 @@ export function settingsPage(main, _params, config) { 'Later updates of an approved book go to CWA as usual.', ), h('label', {}, pauseUploads, ' Pause all uploads'), + ingestNote, h( 'p', { class: 'hint' }, @@ -102,7 +135,10 @@ export function settingsPage(main, _params, config) { 'div', { class: 'controls' }, 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'); }, { class: 'primary' }), ), diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js index 0a7a02c..9d569ba 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -203,7 +203,7 @@ function storyRow(s) { 'td', { 'data-label': '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 check', s.last_checked), diff --git a/bindery/static/js/story.js b/bindery/static/js/story.js index 1472677..1d6ec48 100644 --- a/bindery/static/js/story.js +++ b/bindery/static/js/story.js @@ -133,7 +133,7 @@ export function storyPage(main, { storyId }, config) { let library = 'Not in the library yet'; if (book) library = `Library book ${book.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( summary, // 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 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(); await load(); }; const approveAsNew = async () => { - const message = - 'Unlink the library book, and send this story to CWA as a new book? If CWA already has the book, you get a ' + - 'duplicate. In that case, search for the book under "Library link" and link it instead.'; + const action = details.uploads_paused + ? 'Unlink the library book, and approve this story as a new book? Uploads remain paused. ' + : '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; await api(`stories/${id}/link`, 'POST', { uuid: 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. function holdNotice() { const { story, book } = details; - const paused = story.upload_held && details.uploads_paused; - // A story that went to CWA without a link gets its book at the next update, and the approval comes then. - const approval = story.needs_approval && (story.calibre_uuid || !story.ingested_at); - if (!paused && !approval) return null; - let target = 'The next upload goes to CWA as a new book.'; + const linking = details.link_books !== false; + const unlinked = !linking && story.calibre_id == null; + // A previously uploaded book needs a known target before approval. + const approval = linking && story.needs_approval && (story.calibre_id != null || !story.ingested_at); + 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) { - target = `The next upload replaces the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`; - } else if (story.calibre_id) { - target = `The next upload replaces the ePub of library book ${story.calibre_id}.`; + target = `Approve replacing the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`; + } else if (story.calibre_id != null) { + target = `Approve replacing the ePub of library book ${story.calibre_id}.`; } return h( 'div', @@ -199,19 +204,27 @@ export function storyPage(main, { storyId }, config) { h( 'p', {}, - 'Uploads to CWA are paused. Bindery sends this book when you turn the pause off in ', - link('/settings', 'Settings'), - '.', + details.ingest_available === false + ? '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 && [ - h('p', {}, `${story.upload_held ? 'The book waits for your approval. ' : ''}${target} Make sure that this is right.`), + h('p', {}, target), h( 'div', { 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', }), - 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, + ), ), ], ); diff --git a/bindery/static/style.css b/bindery/static/style.css index 292dd50..846a4cc 100644 --- a/bindery/static/style.css +++ b/bindery/static/style.css @@ -427,6 +427,10 @@ label:has(> input[type='radio']) { display: block; } +label:has(> input[type='checkbox']:disabled) { + color: var(--muted); +} + .library-results { list-style: none; margin: 8px 0 0; diff --git a/bindery/web.py b/bindery/web.py index 0c25526..9fa139b 100644 --- a/bindery/web.py +++ b/bindery/web.py @@ -230,6 +230,7 @@ def settings() -> Any: data.get("story_jobs_count"), data.get("main_jobs_hours"), data.get("main_jobs_count"), + link_books=data.get("link_books"), ) return orchestrator.get_settings() diff --git a/docker-compose.yml b/docker-compose.yml index 6e34f39..87b8447 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. user: "1000:1000" 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. CWA_URL: http://calibre-web-automated:8083 CWA_USERNAME: bindery diff --git a/tests/js/layout.test.mjs b/tests/js/layout.test.mjs index 6545185..901e365 100644 --- a/tests/js/layout.test.mjs +++ b/tests/js/layout.test.mjs @@ -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/); }); +test('disabled checkbox labels use muted text', () => { + assert.equal(declarations("label:has(> input[type='checkbox']:disabled)").color, 'var(--muted)'); +}); + function luminance(hex) { const channels = hex.slice(1).match(/../g).map((channel) => { const value = parseInt(channel, 16) / 255; diff --git a/tests/js/story-list.test.mjs b/tests/js/story-list.test.mjs index 931138b..5572384 100644 --- a/tests/js/story-list.test.mjs +++ b/tests/js/story-list.test.mjs @@ -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) => { 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: '' }), story(3, 'Gamma'), story(4, 'New', { status: '', message: '' }), diff --git a/tests/js/upload-hold.test.mjs b/tests/js/upload-hold.test.mjs index 7bb07ca..9952a8a 100644 --- a/tests/js/upload-hold.test.mjs +++ b/tests/js/upload-hold.test.mjs @@ -31,24 +31,131 @@ async function renderStory(t, data) { t.after(() => cleanup?.()); await settle(); 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) => { const backend = server(t); - backend.on('GET', 'settings', { recent_chapters: 0, hold_new_links: true, pause_uploads: false, personal_ini: '' }); - backend.on('PUT', 'settings', {}); + backend.on('GET', 'settings', { recent_chapters: 0, link_books: true, hold_new_links: true, pause_uploads: false, + 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', 'stories', []); const main = new Element('main'); settingsPage(main, {}, config); await settle(); const card = section(main, 'Uploads to CWA'); - const [hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox'); - assert.deepEqual([hold.checked, pause.checked], [true, false]); + const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox'); + 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); 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) => { @@ -57,12 +164,12 @@ test('a held book with a new link names the book and sends it on approval', asyn { book }, ); 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' } }); await click(notice, 'Approve and send'); await settle(); 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) => { @@ -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) => { 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.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); }); +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) => { const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true })); 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); backend.on('GET', 'stories', [ { 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', - 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', []); 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')); assert.equal(held.length, 1); assert.match(held[0].textContent, /^Held/); + assert.match(find(held[0], 'span', 'held').attributes.title, /waits for your approval/); }); diff --git a/tests/test_api.py b/tests/test_api.py index 8bdc629..81a2340 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -284,10 +284,15 @@ def test_settings(client, monkeypatch, tmp_path): ini = tmp_path / "personal.ini" ini.write_text("[defaults]\nis_adult:true\n") monkeypatch.setattr(config, "PERSONAL_INI", ini) + monkeypatch.setattr(config, "CWA_URL", "") + monkeypatch.setattr(config, "CALIBRE_DB_PATH", None) assert client.get("/api/settings").json == { "recent_chapters": 0, + "link_books": False, + "linking_available": False, "hold_new_links": False, "pause_uploads": False, + "ingest_available": True, "personal_ini": "[defaults]\nis_adult:true\n", "story_jobs_hours": 36, "story_jobs_count": 10, diff --git a/tests/test_calibre_db.py b/tests/test_calibre_db.py index 9f6fa76..2abee32 100644 --- a/tests/test_calibre_db.py +++ b/tests/test_calibre_db.py @@ -14,7 +14,6 @@ from werkzeug.security import generate_password_hash from bindery import auth, calibre_db, config, orchestrator from bindery.cwa import CwaClient -from bindery.ingest import IngestFolder @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)]) 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(orchestrator, "ingest_folder", IngestFolder(tmp_path / "ingest")) + monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest") remote = CwaClient("") remote.session.get = Mock(side_effect=AssertionError("No HTTP lookup needed")) monkeypatch.setattr(orchestrator, "cwa", lambda: remote) diff --git a/tests/test_config.py b/tests/test_config.py index 6f1d76c..1f72bbe 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -101,6 +101,7 @@ def test_defaults(monkeypatch, reload_config): "TRUST_PROXY", "CWA_DB_PATH", "CALIBRE_DB_PATH", + "INGEST_DIR", "BINDERY_USERNAME", "BINDERY_PASSWORD_HASH", ): @@ -116,5 +117,26 @@ def test_defaults(monkeypatch, reload_config): assert config.TRUST_PROXY is False assert config.CWA_DB_PATH is None assert config.CALIBRE_DB_PATH is None + assert config.INGEST_DIR is None assert config.LOCAL_USERNAME == "" 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 == [] diff --git a/tests/test_deliver.py b/tests/test_deliver.py index 0cbf0b5..a154f35 100644 --- a/tests/test_deliver.py +++ b/tests/test_deliver.py @@ -13,7 +13,6 @@ from bindery import config, fff, orchestrator from bindery.diffing import text_hash from bindery.epub import EpubError from bindery.fff import SiteSnapshot, StoryError -from bindery.ingest import IngestFolder from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus 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_PASSWORD", "secret") 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()) yield server server.close() @@ -51,12 +50,12 @@ def add_story(**changes): 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 [] 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() @@ -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") epub, manifest = ingest_files() 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", "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 -def test_update_fails_without_cwa_url(fake, monkeypatch): +def test_update_exports_without_a_library_connection(fake, monkeypatch): monkeypatch.setattr(config, "CWA_URL", "") story_id = add_story(ingested_at="2026-01-01T00:00:00") - with pytest.raises(orchestrator.StoryError, match="CWA_URL is not set"): - rebuild(story_id) - assert ingest_files() == [] + assert rebuild(story_id) == "Sent to ingest folder" + assert orchestrator.store.get_story(story_id)["status"] == "ok" + 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): @@ -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 orchestrator.store.cover(story_id)["data"] == PNG (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() @@ -598,8 +598,8 @@ def test_paused_uploads_go_to_cwa_when_the_pause_ends(fake, jobs): orchestrator.save_settings(pause_uploads=True) assert "pause" in rebuild(story_id) assert ingest_files() == [] - assert orchestrator.approve_upload(story_id, "uuid-1")["mode"] == DownloadMode.REBUILD.value - assert "pause" in orchestrator._run_rebuild(jobs.get_next()) + assert orchestrator.approve_upload(story_id, "uuid-1") is None + assert jobs.get_next() is None orchestrator.save_settings(pause_uploads=True) assert jobs.get_next() is None orchestrator.save_settings(pause_uploads=False) diff --git a/tests/test_optional_cwa.py b/tests/test_optional_cwa.py new file mode 100644 index 0000000..f8bea0b --- /dev/null +++ b/tests/test_optional_cwa.py @@ -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