From 05ef1dbfad135206b70b20c37e9d5939953f90c9 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Fri, 2 Oct 2026 23:07:42 +0200 Subject: [PATCH] Add CWA_ENABLED to run Bindery without CWA With CWA_ENABLED=false, Bindery ignores the CWA settings, makes no request to CWA, and shows nothing about CWA in the log and the web interface. A set INGEST_DIR still gets each changed book as a plain epub. Without it, the log names each CWA function that a missing setting turns off. --- AGENTS.md | 2 +- README.md | 28 +++++++++++--- bindery/auth.py | 2 + bindery/config.py | 35 +++++++++++++++-- bindery/orchestrator.py | 34 ++++++++++++----- bindery/static/js/settings.js | 2 +- bindery/static/js/stories.js | 5 ++- bindery/static/js/story.js | 18 ++++++--- bindery/web.py | 1 + docker-compose.yml | 2 + tests/js/flows.test.mjs | 24 +++++++++++- tests/js/frontend.test.mjs | 2 +- tests/js/upload-hold.test.mjs | 4 +- tests/test_api_flows.py | 1 + tests/test_config.py | 72 +++++++++++++++++++++++++++++++++++ tests/test_optional_cwa.py | 70 ++++++++++++++++++++++++++++++++-- 16 files changed, 265 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 90357bd..e8823dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Start a development server with no CWA: ```bash mkdir -p dev/ingest -CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery +CWA_ENABLED=false CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery ``` The server has no reloader. If you change Python code, restart it. Flask reads `bindery/static` from disk on each request, so a server that is not restarted sends new JavaScript to old Python routes. diff --git a/README.md b/README.md index c13e732..5373744 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Environment variables set the configuration. **Restart the container after a cha | Variable | Description | Default value | | --- | --- | --- | +| `CWA_ENABLED` | `false` if you have no CWA. See "Without CWA". | `true` | | `CWA_URL` | The address of CWA, for example `http://calibre-web-automated:8083` | None | | `CWA_USERNAME`, `CWA_PASSWORD` | The CWA account of Bindery. Leave them unset if CWA asks for no login. | None | | `CWA_DB_PATH` | Calibre-Web's `app.db`, for logins. See "CWA logins". | None | @@ -72,7 +73,7 @@ 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. If unset, all uploads stay paused. Bindery does not make this folder. | None | +| `INGEST_DIR` | The CWA ingest folder. If unset, all uploads stay paused. Bindery does not make this folder. Without CWA, a folder for the built ePubs. | None | | `CONFIG_DIR` | The story database and `personal.ini` | `/config` | | `TMP_DIR` | The folder for the built ePubs. Bindery keeps the last build of each story. | `/tmp/bindery` | | `LOG_LEVEL` | The log level | `INFO` | @@ -81,11 +82,26 @@ 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. +Set `CWA_ENABLED=false` 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`. +With `CWA_ENABLED=false`: + +- Bindery makes no request to CWA. It ignores `CWA_URL`, `CWA_USERNAME`, `CWA_PASSWORD`, + `CWA_DB_PATH` and `CALIBRE_DB_PATH`. +- The web interface and the log show nothing about CWA. The pause, the library links and the + approvals do not apply. +- If `INGEST_DIR` is set, Bindery puts each changed book in that folder as a plain ePub. Use this to + import the books into other software. Each file gets a new name, so Bindery never replaces a file + in the folder. +- `AUTH_METHOD=cwa` does not work. Use `local` or `proxy`. On a private network, you can use `none`. + +If you use the example Compose file, remove its CWA connection values. Keep `INGEST_DIR` and its +volume mount only if you want the ePubs in a folder. + +Without `CWA_ENABLED=false`, Bindery expects CWA. At each start, the log then has a warning for each +function that a missing `INGEST_DIR`, `CWA_URL` or `CALIBRE_DB_PATH` turns off. Bindery still +downloads chapters and builds ePubs. Without `INGEST_DIR`, Settings shows "Pause all uploads" checked and a highlighted explanation. You cannot clear that checkbox until you set `INGEST_DIR` and restart Bindery. @@ -590,7 +606,7 @@ enough to work on downloads, chapters, rules and the web interface. ```bash mkdir -p dev/ingest -CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery +CWA_ENABLED=false CONFIG_DIR=dev/config INGEST_DIR=dev/ingest TMP_DIR=dev/tmp uv run python -m bindery ``` Open `http://127.0.0.1:8085`. The folders go in `dev/`, which git ignores. To look at an ePub, use diff --git a/bindery/auth.py b/bindery/auth.py index ba8d822..247baaa 100644 --- a/bindery/auth.py +++ b/bindery/auth.py @@ -118,6 +118,8 @@ def config_error() -> str | None: return "AUTH_METHOD=local needs BINDERY_USERNAME and BINDERY_PASSWORD_HASH" if not _valid_local_hash(config.LOCAL_PASSWORD_HASH): return "BINDERY_PASSWORD_HASH must be a Werkzeug scrypt or PBKDF2 hash of a nonempty password" + if method == AUTH_CWA and not config.CWA_ENABLED: + return "AUTH_METHOD=cwa needs CWA, but CWA_ENABLED is false" if method == AUTH_CWA and not (config.CWA_URL or config.CWA_DB_PATH): return "AUTH_METHOD=cwa needs CWA_URL or CWA_DB_PATH" if method == AUTH_PROXY: diff --git a/bindery/config.py b/bindery/config.py index 649e309..2e2e95e 100644 --- a/bindery/config.py +++ b/bindery/config.py @@ -64,6 +64,14 @@ CWA_PASSWORD = os.getenv("CWA_PASSWORD", "") # Independent, optional read-only databases. HTTP remains the fallback. CWA_DB_PATH = _optional_path("CWA_DB_PATH") # Calibre-Web app.db, for logins CALIBRE_DB_PATH = _optional_path("CALIBRE_DB_PATH") # calibre metadata.db, for book IDs +# false if the user has no CWA. Bindery then ignores the CWA settings above, and shows nothing about CWA. INGEST_DIR +# still gets each built book, as a plain epub. Only a clear "false" turns CWA off. +CWA_ENABLED = os.getenv("CWA_ENABLED", "").strip().lower() not in ("false", "no", "0", "n", "off") +CWA_SETTINGS = ("CWA_URL", "CWA_USERNAME", "CWA_PASSWORD", "CWA_DB_PATH", "CALIBRE_DB_PATH") +IGNORED_SETTINGS = [] if CWA_ENABLED else [name for name in CWA_SETTINGS if os.getenv(name, "").strip()] +if not CWA_ENABLED: + CWA_URL = CWA_USERNAME = CWA_PASSWORD = "" + CWA_DB_PATH = CALIBRE_DB_PATH = None UPDATE_INTERVAL_HOURS = _number("UPDATE_INTERVAL_HOURS", "24") DEFAULT_LOCKED_FIELDS = _list("DEFAULT_LOCKED_FIELDS", "title") @@ -121,10 +129,29 @@ def prepare() -> None: 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_ENABLED: + if IGNORED_SETTINGS: + logger.info(f"CWA_ENABLED is false. Bindery ignores {', '.join(IGNORED_SETTINGS)}.") + return if CWA_PASSWORD and not CWA_USERNAME: logger.warning("CWA_PASSWORD is set, but CWA_USERNAME is not. Bindery sends no login to CWA.") - if INGEST_DIR and not (CWA_URL or CALIBRE_DB_PATH): - logger.warning( - "INGEST_DIR is set, but CWA_URL and CALIBRE_DB_PATH are not. Bindery cannot find the library book " - "of a story, so each update adds a new book to CWA." + missing = _missing_cwa() + for warning in missing: + logger.warning(warning) + if missing: + logger.warning("If you do not use CWA, set CWA_ENABLED=false. Bindery then shows no messages about CWA.") + + +def _missing_cwa() -> list[str]: + """What Bindery cannot do with CWA, because a setting is not set.""" + missing = [] + if INGEST_DIR is None: + missing.append("INGEST_DIR is not set. Bindery sends no book to CWA.") + if not (CWA_URL or CALIBRE_DB_PATH): + effect = ", so each update adds a new book to CWA." if INGEST_DIR else "." + missing.append( + "CWA_URL and CALIBRE_DB_PATH are not set. Bindery cannot find the library book of a story" + effect ) + elif not CWA_URL: + missing.append("CWA_URL is not set. Bindery cannot search the CWA library, or import books from it.") + return missing diff --git a/bindery/orchestrator.py b/bindery/orchestrator.py index 90781cb..1a9257f 100644 --- a/bindery/orchestrator.py +++ b/bindery/orchestrator.py @@ -880,11 +880,13 @@ def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, tit message = f"Updated library book {book['id']}" else: 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" if _link_books() else "Sent to ingest folder" - store.set_setting(story["id"], "upload_held", None) - store.set_setting(story["id"], "unapproved_link", None) + # Without CWA, the ingest folder is a plain folder, so the story did not go to CWA. + if config.CWA_ENABLED: + if not book: + store.update_story(story["id"], ingested_at=now()) + story["ingested_at"] = now() + store.set_setting(story["id"], "unapproved_link", None) return message @@ -896,19 +898,31 @@ def _send( A held book that did not change is not built again. With `force`, Bindery builds the book in each case. """ story_id = story["id"] - book = _linked_book(story) - held = _hold_reason(story, book) changed = key != store.get_setting(story_id, "built_key") + # Without CWA, a story has no library book, and nothing holds a book. + book = _linked_book(story) if config.CWA_ENABLED else None + held = _hold_reason(story, book) if config.CWA_ENABLED else None if held: if changed or force: _built_epub(story, overrides, key, force) store.set_setting(story_id, "upload_held", True) return held, changed - message = _deliver(story, _built_epub(story, overrides, key, force), book, title, author) - store.set_setting(story_id, "sent_key", key) + epub = _built_epub(story, overrides, key, force) + if config.INGEST_DIR is None: + message = "Built the book" + else: + message = _deliver(story, epub, book, title, author) + store.set_setting(story_id, "sent_key", key) + store.set_setting(story_id, "upload_held", None) return message, changed +def _done_key(story_id: int) -> Any: + """The key of the book that is at its destination: CWA, the ingest folder or, without both, the built file.""" + plain = not config.CWA_ENABLED and config.INGEST_DIR is None + return store.get_setting(story_id, "built_key" if plain else "sent_key") + + def adopt_build_keys() -> None: """Give the stories of an older Bindery their keys, so the first check does not send each book again.""" built = store.story_settings("built_key") @@ -1057,7 +1071,7 @@ def _run_rebuild(job: Job) -> str: overrides = _epub_overrides(story, pinned) key = _build_key(story, overrides, story["site_values"]) force = bool(job.options.get("force")) - if not force and key == store.get_setting(story["id"], "sent_key"): + if not force and key == _done_key(story["id"]): return "No changes" message, changed = _send(story, overrides, key, story["title"], story["author"], force) changes: dict[str, Any] = {"last_changed": now()} if changed or force else {} @@ -1300,7 +1314,7 @@ def _run_site_job(job: Job) -> str: overrides = _epub_overrides(story, pinned) key = _build_key(story, overrides, snap.calibre) checked: dict[str, Any] = {"pinned": pinned, "site_values": snap.calibre, "title": title, "last_checked": now()} - if key == store.get_setting(story_id, "sent_key"): + if key == _done_key(story_id): store.update_story(story_id, **checked, status="ok", message="No changes") return "No changes. " + ", ".join(summary) diff --git a/bindery/static/js/settings.js b/bindery/static/js/settings.js index e4c187c..a24761f 100644 --- a/bindery/static/js/settings.js +++ b/bindery/static/js/settings.js @@ -102,7 +102,7 @@ export function settingsPage(main, _params, config) { }, { class: 'primary' }), ), ), - h( + config.cwa_enabled && h( 'section', { class: 'card' }, h('h2', {}, 'Uploads to CWA'), diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js index 9d569ba..b3c562b 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -341,7 +341,7 @@ export function storiesPage(main, _params, config) { 'div', { class: 'controls' }, add, - h( + config.cwa_enabled && h( 'span', { class: 'hint' }, 'If CWA already has this story, Bindery links that book and keeps its chapters, then downloads only the chapters that are missing.', @@ -371,7 +371,8 @@ export function storiesPage(main, _params, config) { ), h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Jobs')), jobs.el), epubImport(), - h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)), + config.cwa_enabled && + h('details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Import from the library')), libraryImport(config)), ); load(); diff --git a/bindery/static/js/story.js b/bindery/static/js/story.js index 1d6ec48..736b79c 100644 --- a/bindery/static/js/story.js +++ b/bindery/static/js/story.js @@ -69,6 +69,9 @@ const table = (headings, rows, extra = '') => export function storyPage(main, { storyId }, config) { const id = Number(storyId); const fields = config.metadata_fields; + // With CWA_ENABLED=false, the page shows nothing about CWA. + const cwa = config.cwa_enabled; + const libraryStays = cwa ? ' The library book stays.' : ''; let details = null; let selected = new Set(); let mode = 'new'; @@ -147,7 +150,7 @@ export function storyPage(main, { storyId }, config) { { class: 'byline' }, `${story.author} · `, externalLink(story.url, story.site), - ` · ${library}`, + cwa && ` · ${library}`, ), h( 'p', @@ -184,6 +187,7 @@ export function storyPage(main, { storyId }, config) { // Why the book did not go to CWA, and the approval of a new library link. function holdNotice() { + if (!cwa) return null; const { story, book } = details; const linking = details.link_books !== false; const unlinked = !linking && story.calibre_id == null; @@ -663,8 +667,10 @@ export function storyPage(main, { storyId }, config) { { class: 'hint' }, 'Bindery writes this metadata into the ePub. You can edit each field. A locked field keeps its value on ' + 'update, unless you take it from the site above. An unlocked field keeps an edited value until the site ' + - "changes that field. CWA reads the ePub's metadata only when it first imports a book, so later changes " + - 'show in the ePub but not in the CWA catalog.', + 'changes that field.', + cwa && + " CWA reads the ePub's metadata only when it first imports a book, so later changes show in the ePub " + + 'but not in the CWA catalog.', ), metadata, ), @@ -706,7 +712,7 @@ export function storyPage(main, { storyId }, config) { ), epubImport(id, 'Import chapters from an ePub'), h('details', { class: 'card', open: true }, h('summary', {}, h('h2', {}, 'Recent jobs')), jobs.el), - h( + cwa && h( 'details', { class: 'card' }, h('summary', {}, h('h2', {}, 'Library link')), @@ -738,11 +744,11 @@ export function storyPage(main, { storyId }, config) { 'details', { class: 'card card-danger' }, h('summary', {}, h('h2', {}, 'Stop tracking')), - h('p', { class: 'hint' }, 'Deletes the stored chapters, revisions and rules for this story. The library book stays.'), + h('p', { class: 'hint' }, `Deletes the stored chapters, revisions and rules for this story.${libraryStays}`), button( 'Stop tracking this story', async () => { - const message = 'Stop tracking this story? Stored chapters, revisions and rules are deleted. The library book stays.'; + const message = `Stop tracking this story? Stored chapters, revisions and rules are deleted.${libraryStays}`; if (!window.confirm(message)) return; await api(`stories/${id}`, 'DELETE'); window.location.hash = '#/'; diff --git a/bindery/web.py b/bindery/web.py index 50be79c..8db50ca 100644 --- a/bindery/web.py +++ b/bindery/web.py @@ -267,6 +267,7 @@ def app_config() -> Any: "metadata_fields": METADATA_FIELDS, "mode_labels": MODE_LABELS, "library_available": orchestrator.library_available(), + "cwa_enabled": config.CWA_ENABLED, "update_interval": config.UPDATE_INTERVAL_HOURS, "job_list_max_hours": config.JOB_LIST_MAX_HOURS, "job_list_max_count": config.JOB_LIST_MAX_COUNT, diff --git a/docker-compose.yml b/docker-compose.yml index ba1e9f1..b6a106b 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: + # Without CWA: set this, and remove the CWA values below. INGEST_DIR then gets the built ePubs. + # CWA_ENABLED: "false" # Omit this and its volume mount to keep uploads paused. INGEST_DIR: /cwa-book-ingest # The address of CWA, as the Bindery container reaches it, and the CWA account that Bindery uses. diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index 8ab1cf3..74913f2 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -14,7 +14,7 @@ const { setUnauthorizedHandler } = await import('../../bindery/static/js/lib.js' let api, main, details, cleanups; const config = { metadata_fields: { title: 'Title', authors: 'Authors', comments: 'Description', series: 'Series', pubdate: 'Published', tags: 'Tags', languages: 'Language' }, - mode_labels: { new: 'New chapters' }, library_available: true, update_interval: 2, + mode_labels: { new: 'New chapters' }, library_available: true, cwa_enabled: true, update_interval: 2, job_list_max_hours: 720, job_list_max_count: 500, }; const rule = { pattern: 'before', replacement: 'after', flags: 'i', note: '', enabled: true }; @@ -641,3 +641,25 @@ test('chapter titles show the entities that FanFicFare keeps as text', async () render([{ id: 'j', status: 'done', story_id: 1, result: { changed: [{ chapter_id: 2, title: 'A & B' }] } }]); assert.ok(find(el, 'a', 'A & B')); }); + +test('with CWA off, the pages show nothing about CWA', async () => { + await mountStory(); + assert.ok(section(main, 'Library link')); + const off = { ...config, cwa_enabled: false, library_available: false }; + // Values that show a notice with CWA on. + Object.assign(details, { uploads_paused: true, ingest_available: false, link_books: false }); + details.story.upload_held = true; + await mountStory(off); + assert.ok(section(main, 'Metadata')); + assert.equal(section(main, 'Library link'), undefined); + assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i); + await mountStories(off); + assert.ok(section(main, 'Add stories')); + assert.equal(section(main, 'Import from the library'), undefined); + assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i); + settingsPage(main, {}, off); + await settle(); + assert.ok(section(main, 'Jobs list')); + assert.equal(section(main, 'Uploads to CWA'), undefined); + assert.doesNotMatch(main.textContent, /CWA|INGEST_DIR|library/i); +}); diff --git a/tests/js/frontend.test.mjs b/tests/js/frontend.test.mjs index aac564a..c6376ab 100644 --- a/tests/js/frontend.test.mjs +++ b/tests/js/frontend.test.mjs @@ -46,7 +46,7 @@ const text = (node) => node instanceof Element ? node.children.map(text).join('' const find = (root, tag, label) => elements(root).find((el) => el.tagName === tag && text(el) === label); const settle = () => new Promise((resolve) => setImmediate(resolve)); const config = { - metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, update_interval: 0, + metadata_fields: { title: 'Title' }, mode_labels: {}, library_available: true, cwa_enabled: true, update_interval: 0, job_list_max_hours: 720, job_list_max_count: 500, }; const story = { diff --git a/tests/js/upload-hold.test.mjs b/tests/js/upload-hold.test.mjs index 9952a8a..a3d42af 100644 --- a/tests/js/upload-hold.test.mjs +++ b/tests/js/upload-hold.test.mjs @@ -7,7 +7,7 @@ const { settingsPage } = await import('../../bindery/static/js/settings.js'); const { storyPage } = await import('../../bindery/static/js/story.js'); const { storiesPage } = await import('../../bindery/static/js/stories.js'); -const config = { job_list_max_hours: 720, job_list_max_count: 500 }; +const config = { cwa_enabled: true, job_list_max_hours: 720, job_list_max_count: 500 }; const book = { id: 7, uuid: 'book-7', title: 'Library Title', authors: ['Writer'] }; const details = (story, extra = {}) => ({ story: { id: 1, title: 'Story', author: 'Writer', url: 'https://example.com/story', site: 'example.com', @@ -27,7 +27,7 @@ async function renderStory(t, data) { backend.on('GET', 'jobs?story_id=1', []); backend.on('GET', 'jobs', []); const main = new Element('main'); - const cleanup = storyPage(main, { storyId: '1' }, { metadata_fields: {}, mode_labels: {} }); + const cleanup = storyPage(main, { storyId: '1' }, { cwa_enabled: true, metadata_fields: {}, mode_labels: {} }); t.after(() => cleanup?.()); await settle(); const summary = nodes(main).find((el) => el.className === 'card story-summary'); diff --git a/tests/test_api_flows.py b/tests/test_api_flows.py index 6fd7f93..bf11832 100644 --- a/tests/test_api_flows.py +++ b/tests/test_api_flows.py @@ -46,6 +46,7 @@ def test_add_list_download_and_cancel_jobs(client, story): assert result.json["mode"] == "metadata" app_config = client.get("/api/config").json assert app_config["metadata_fields"]["title"] + assert app_config["cwa_enabled"] is True assert (app_config["story_jobs_hours"], app_config["story_jobs_count"]) == (36, 10) assert (app_config["main_jobs_hours"], app_config["main_jobs_count"]) == (6, 25) assert (app_config["job_list_max_hours"], app_config["job_list_max_count"]) == (720, 500) diff --git a/tests/test_config.py b/tests/test_config.py index 4ae1652..461c1ef 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -104,6 +104,7 @@ def test_defaults(monkeypatch, reload_config): "PROXY_AUTH_TRUSTED_IPS", "ALLOWED_HOSTS", "BINDERY_ADMIN_USERS", + "CWA_ENABLED", "SESSION_COOKIE_SECURE", "TRUST_PROXY", "CWA_DB_PATH", @@ -131,6 +132,8 @@ def test_defaults(monkeypatch, reload_config): ] assert config.ALLOWED_HOSTS == [] assert config.ADMIN_USERS == [] + assert config.CWA_ENABLED is True + assert config.IGNORED_SETTINGS == [] assert config.SESSION_COOKIE_SECURE is False assert config.TRUST_PROXY is False assert config.CWA_DB_PATH is None @@ -147,7 +150,76 @@ def test_empty_ingest_directory_disables_uploads(monkeypatch, reload_config, val assert config.INGEST_DIR is None +@pytest.mark.parametrize("value", ["false", " No ", "0", "off"]) +def test_cwa_off_ignores_the_cwa_settings_but_not_the_ingest_folder(monkeypatch, reload_config, value): + monkeypatch.setenv("CWA_ENABLED", value) + monkeypatch.setenv("CWA_URL", "https://cwa.invalid") + monkeypatch.setenv("CWA_USERNAME", "bindery") + monkeypatch.setenv("CWA_PASSWORD", "secret") + monkeypatch.setenv("CWA_DB_PATH", "/cwa/app.db") + monkeypatch.setenv("CALIBRE_DB_PATH", "/library/metadata.db") + monkeypatch.setenv("INGEST_DIR", "/books") + reload_config() + assert config.CWA_ENABLED is False + assert (config.CWA_URL, config.CWA_USERNAME, config.CWA_PASSWORD) == ("", "", "") + assert (config.CWA_DB_PATH, config.CALIBRE_DB_PATH) == (None, None) + assert config.IGNORED_SETTINGS == ["CWA_URL", "CWA_USERNAME", "CWA_PASSWORD", "CWA_DB_PATH", "CALIBRE_DB_PATH"] + assert str(config.INGEST_DIR) == "/books" + + +@pytest.mark.parametrize("value", ["", "true", "maybe"]) +def test_only_a_clear_false_turns_cwa_off(monkeypatch, reload_config, value): + monkeypatch.setenv("CWA_ENABLED", value) + monkeypatch.setenv("CWA_URL", "https://cwa.invalid") + reload_config() + assert config.CWA_ENABLED is True + assert config.CWA_URL == "https://cwa.invalid" + + +@pytest.mark.parametrize("ignored", [[], ["CWA_URL", "CALIBRE_DB_PATH"]]) +def test_prepare_with_cwa_off_logs_nothing_about_missing_cwa(monkeypatch, tmp_path, caplog, ignored): + monkeypatch.setattr(config, "CWA_ENABLED", False) + monkeypatch.setattr(config, "IGNORED_SETTINGS", ignored) + monkeypatch.setattr(config, "INGEST_DIR", None) + monkeypatch.setattr(config, "CWA_URL", "") + monkeypatch.setattr(config, "CALIBRE_DB_PATH", None) + 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") + with caplog.at_level("INFO"): + config.prepare() + lines = [r.getMessage() for r in caplog.records] + expected = ["CWA_ENABLED is false. Bindery ignores CWA_URL, CALIBRE_DB_PATH."] if ignored else [] + assert [line for line in lines if "CWA" in line or "INGEST" in line] == expected + + +@pytest.mark.parametrize( + ("ingest", "url", "database", "expected"), + [ + (False, "", None, ["INGEST_DIR is not set", "cannot find the library book of a story.", "CWA_ENABLED=false"]), + (True, "", None, ["so each update adds a new book to CWA.", "CWA_ENABLED=false"]), + (True, "", Path("/library/metadata.db"), ["CWA_URL is not set. Bindery cannot search", "CWA_ENABLED=false"]), + (False, "https://cwa.invalid", None, ["INGEST_DIR is not set", "CWA_ENABLED=false"]), + (True, "https://cwa.invalid", None, []), + ], +) +def test_prepare_warns_about_each_missing_cwa_function(monkeypatch, tmp_path, caplog, ingest, url, database, expected): + monkeypatch.setattr(config, "CWA_URL", url) + monkeypatch.setattr(config, "CALIBRE_DB_PATH", database) + monkeypatch.setattr(config, "INGEST_DIR", tmp_path if ingest else None) + 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") + with caplog.at_level("WARNING"): + config.prepare() + warnings = [r.getMessage() for r in caplog.records] + assert len(warnings) == len(expected) + for warning, text in zip(warnings, expected, strict=True): + assert text in warning + + def test_prepare_without_cwa_creates_only_bindery_folders(monkeypatch, tmp_path, caplog): + monkeypatch.setattr(config, "CWA_ENABLED", False) monkeypatch.setattr(config, "INGEST_DIR", None) monkeypatch.setattr(config, "CWA_URL", "") monkeypatch.setattr(config, "CALIBRE_DB_PATH", None) diff --git a/tests/test_optional_cwa.py b/tests/test_optional_cwa.py index 862f5ab..d5a8737 100644 --- a/tests/test_optional_cwa.py +++ b/tests/test_optional_cwa.py @@ -9,11 +9,12 @@ 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 STORY_URL, add_story, rebuild, site, update from test_deliver import jobs as jobs from bindery import config, orchestrator from bindery.cwa import Book +from bindery.ingest import IngestError from bindery.jobs import DownloadMode, Job from bindery.store import Store @@ -39,7 +40,8 @@ def library(isolated, monkeypatch): monkeypatch.setattr(orchestrator, "_find_book", Mock(return_value=None)) -def test_app_starts_without_any_cwa_configuration(tmp_path): +@pytest.mark.parametrize("enabled", [False, True]) +def test_app_starts_without_any_cwa_configuration(tmp_path, enabled): env = { **os.environ, "CONFIG_DIR": str(tmp_path / "standalone"), @@ -47,6 +49,7 @@ def test_app_starts_without_any_cwa_configuration(tmp_path): "AUTH_METHOD": "none", "BINDERY_START_THREADS": "false", "URL_BASE": "", + "CWA_ENABLED": str(enabled), } for name in ("INGEST_DIR", "CWA_URL", "CWA_DB_PATH", "CALIBRE_DB_PATH"): env.pop(name, None) @@ -69,8 +72,69 @@ def test_app_starts_without_any_cwa_configuration(tmp_path): timeout=30, ) assert result.returncode == 0, result.stderr - assert "WARNING" not in result.stderr assert "ERROR" not in result.stderr + # Bindery names what is missing, until the user says that there is no CWA. + for text in ("WARNING", "INGEST_DIR is not set", "CWA_URL and CALIBRE_DB_PATH are not set", "CWA_ENABLED=false"): + assert (text in result.stderr) is enabled + if not enabled: + assert "CWA" not in result.stderr and "INGEST" not in result.stderr + + +@pytest.fixture +def cwa_off(monkeypatch): + monkeypatch.setattr(config, "CWA_ENABLED", False) + + +def test_without_cwa_and_an_ingest_folder_a_job_only_builds_a_changed_book(monkeypatch, cwa_off): + monkeypatch.setattr(config, "INGEST_DIR", None) + site(monkeypatch, [(1, "One", "First chapter")]) + job = Job(mode=DownloadMode.NEW, url=STORY_URL) + assert orchestrator._run_site_job(job).endswith("Built the book") + story_id = job.story_id + story = orchestrator.store.get_story(story_id) + assert (story["status"], story["message"], story["ingested_at"]) == ("ok", "Built the book", None) + assert orchestrator.store.get_setting(story_id, "upload_held") is None + assert len(list(config.TMP_DIR.glob(f"story-{story_id}-*.epub"))) == 1 + assert update(story_id).startswith("No changes") + assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes" + assert rebuild(story_id) == "Built the book" + site(monkeypatch, [(1, "One", "First chapter"), (2, "Two", "Second chapter")]) + assert update(story_id).endswith("Built the book") + orchestrator.cwa.assert_not_called() + + +@pytest.mark.parametrize("saved_id", [None, 7]) +def test_without_cwa_the_ingest_folder_gets_each_changed_book_as_a_plain_epub(cwa_off, saved_id): + # A pause, a hold and a saved book ID are from a time with CWA. They do not apply. + orchestrator.save_settings(pause_uploads=True, hold_new_links=True) + story_id = add_story(calibre_id=saved_id, calibre_uuid="saved-uuid" if saved_id else None) + orchestrator._link_changed(story_id) + orchestrator.store.set_setting(story_id, "upload_held", True) + assert rebuild(story_id) == "Sent to ingest folder" + assert [p.suffix for p in config.INGEST_DIR.iterdir()] == [".epub"] + assert orchestrator.store.get_story(story_id)["ingested_at"] is None + assert orchestrator.store.get_setting(story_id, "upload_held") is None + assert orchestrator._run_rebuild(Job(mode=DownloadMode.REBUILD, story_id=story_id)) == "No changes" + assert len(list(config.INGEST_DIR.iterdir())) == 1 + orchestrator.cwa.assert_not_called() + + +def test_without_cwa_a_missing_ingest_folder_is_still_an_error(monkeypatch, tmp_path, cwa_off): + monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "missing") + story_id = add_story() + with pytest.raises(IngestError, match="does not exist"): + rebuild(story_id) + # The book counts as sent only when it is in the folder. + assert orchestrator.store.get_setting(story_id, "sent_key") is None + + +def test_cwa_off_shows_in_the_config_and_refuses_cwa_logins(client, monkeypatch, cwa_off): + assert client.get("/api/config").json["cwa_enabled"] is False + monkeypatch.setattr(config, "AUTH_METHOD", "cwa") + monkeypatch.setattr(config, "CWA_DB_PATH", "/cwa/app.db") + response = client.get("/api/stories") + assert response.status_code == 503 + assert response.json["error"].startswith("AUTH_METHOD=cwa needs CWA, but CWA_ENABLED is false.") def test_link_setting_defaults_on_and_persists(client, monkeypatch, tmp_path, library):