Make CWA linking and uploads optional
This commit is contained in:
@@ -4,6 +4,7 @@ Bindery downloads web fiction into a
|
|||||||
[Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated) (CWA) library and
|
[Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated) (CWA) library and
|
||||||
keeps the books up to date. It uses [FanFicFare](https://github.com/JimmXinu/FanFicFare), so it
|
keeps the books up to date. It uses [FanFicFare](https://github.com/JimmXinu/FanFicFare), so it
|
||||||
supports the sites that FanFicFare supports. The tested site is RoyalRoad.
|
supports the sites that FanFicFare supports. The tested site is RoyalRoad.
|
||||||
|
CWA is optional. You can download ePubs from Bindery without it.
|
||||||
|
|
||||||
## How it differs from FanFicFare
|
## How it differs from FanFicFare
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ builds each ePub from that database.
|
|||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
**You need CWA v4.0.1 or later.** Older versions can import an update as a new book. See
|
For uploads to CWA, use CWA v4.0.1 or later. Older versions can import an update as a new book. See
|
||||||
"Limitations".
|
"Limitations".
|
||||||
|
|
||||||
1. Get the code, and make the folder for Bindery's data.
|
1. Get the code, and make the folder for Bindery's data.
|
||||||
@@ -68,13 +69,31 @@ Environment variables set the configuration. **Restart the container after a cha
|
|||||||
| `SESSION_COOKIE_SECURE` | `true` if users reach Bindery only over HTTPS | `false` |
|
| `SESSION_COOKIE_SECURE` | `true` if users reach Bindery only over HTTPS | `false` |
|
||||||
| `TRUST_PROXY` | `true` if a reverse proxy sends the client address. See "Lockouts and sessions". | `false` |
|
| `TRUST_PROXY` | `true` if a reverse proxy sends the client address. See "Lockouts and sessions". | `false` |
|
||||||
| `URL_BASE` | A path prefix, such as `/bindery`. See "Reverse proxy". | None |
|
| `URL_BASE` | A path prefix, such as `/bindery`. See "Reverse proxy". | None |
|
||||||
| `INGEST_DIR` | The CWA ingest folder | `/cwa-book-ingest` |
|
| `INGEST_DIR` | The CWA ingest folder. If unset, all uploads stay paused. | None |
|
||||||
| `CONFIG_DIR` | The story database and `personal.ini` | `/config` |
|
| `CONFIG_DIR` | The story database and `personal.ini` | `/config` |
|
||||||
| `TMP_DIR` | The folder for ePubs while Bindery builds them | `/tmp/bindery` |
|
| `TMP_DIR` | The folder for ePubs while Bindery builds them | `/tmp/bindery` |
|
||||||
| `LOG_LEVEL` | The log level | `INFO` |
|
| `LOG_LEVEL` | The log level | `INFO` |
|
||||||
|
|
||||||
Separate the values in a list with commas.
|
Separate the values in a list with commas.
|
||||||
|
|
||||||
|
### Without CWA
|
||||||
|
|
||||||
|
Leave `INGEST_DIR`, `CWA_URL` and `CALIBRE_DB_PATH` unset to run Bindery without CWA.
|
||||||
|
Bindery still downloads chapters and builds ePubs. Download the books from each story page.
|
||||||
|
|
||||||
|
If you use the example Compose file, remove its CWA connection values, `INGEST_DIR` and the ingest volume mount.
|
||||||
|
Use `AUTH_METHOD=local` or `proxy` for logins. On a private network, you can use `AUTH_METHOD=none`.
|
||||||
|
|
||||||
|
Without `INGEST_DIR`, Settings shows "Pause all uploads" checked and a highlighted explanation.
|
||||||
|
You cannot clear that checkbox until you set `INGEST_DIR` and restart Bindery.
|
||||||
|
If neither `CWA_URL` nor `CALIBRE_DB_PATH` is set, library linking is off and its checkbox is disabled.
|
||||||
|
Set either value and restart Bindery to enable the checkbox. Bindery keeps your saved linking preference.
|
||||||
|
Saved CWA book IDs still work for uploads if you configure an ingest folder.
|
||||||
|
|
||||||
|
To export ePubs for other software, set `INGEST_DIR` and turn off "Link books to CWA library" in Settings.
|
||||||
|
Each build without a saved CWA book ID then puts a plain ePub in that folder, including updates.
|
||||||
|
"Pause all uploads" still stops these exports. If CWA imports the files, updates will create duplicate books.
|
||||||
|
|
||||||
### Logins
|
### Logins
|
||||||
|
|
||||||
`AUTH_METHOD` sets how users log in:
|
`AUTH_METHOD` sets how users log in:
|
||||||
@@ -274,7 +293,8 @@ from the library, Bindery links that book and imports its chapters from the ePub
|
|||||||
|
|
||||||
### If Bindery cannot find the book
|
### If Bindery cannot find the book
|
||||||
|
|
||||||
The update fails, and the story page shows the error "Bindery cannot find this story in CWA". The
|
If library matching is on and configured, an update fails when Bindery cannot find the book.
|
||||||
|
The story page shows the error "Cannot find this story in CWA. Link to library book, or send as new book." The
|
||||||
chapters stay stored, and the next successful update includes them. This happens if:
|
chapters stay stored, and the next successful update includes them. This happens if:
|
||||||
|
|
||||||
- CWA did not import the ePub.
|
- CWA did not import the ePub.
|
||||||
@@ -288,18 +308,26 @@ To fix it, go to "Library link" on the story page, and do one of these:
|
|||||||
- Click "Send to CWA as a new book". Use this if CWA did not import the first version, or if you
|
- Click "Send to CWA as a new book". Use this if CWA did not import the first version, or if you
|
||||||
deleted the book.
|
deleted the book.
|
||||||
|
|
||||||
### Hold uploads
|
### Upload settings
|
||||||
|
|
||||||
Two settings on the Settings page stop books from going to CWA. Bindery still downloads the chapters
|
The "Uploads to CWA" section controls library matching and uploads. Bindery still downloads chapters
|
||||||
and builds each book. The story list marks a book that waits with "held".
|
and builds books when uploads stop. The story list marks a built book with "held" only when it waits
|
||||||
|
for approval, library linking is on, and uploads are not paused. Bindery keeps track of all unsent books.
|
||||||
|
|
||||||
- **Hold new links for approval.** Use this to catch a story that is linked to the wrong CWA book.
|
- Link books to CWA library. This defaults to on when `CWA_URL` or `CALIBRE_DB_PATH` is set.
|
||||||
|
Without either value, it stays off and disabled. Turn it off to skip matching with a configured library connection.
|
||||||
|
Uploads use saved CWA book IDs when available. Without an ID, every build goes to `INGEST_DIR` as a plain ePub.
|
||||||
|
If CWA imports these files, updates will create duplicate books. Settings and the story page show a highlighted warning.
|
||||||
|
Changing this setting rebuilds unsent books unless uploads are paused.
|
||||||
|
- **Hold new links for approval.** This setting applies only when library linking is on.
|
||||||
|
Use this to catch a story that is linked to the wrong CWA book.
|
||||||
The first upload of a new story waits for your approval. The first upload after a change of the
|
The first upload of a new story waits for your approval. The first upload after a change of the
|
||||||
library link also waits. This includes a link that you make, and a book that Bindery finds after
|
library link also waits. This includes a link that you make, and a book that Bindery finds after
|
||||||
the linked book is gone. The story page names the book that gets the update, or says that the
|
the linked book is gone. The story page names the book that gets the update, or says that the
|
||||||
story goes to CWA as a new book.
|
story goes to CWA as a new book.
|
||||||
- **Pause all uploads.** No book goes to CWA. When you turn the pause off, Bindery rebuilds each
|
- **Pause all uploads.** No book goes to CWA. This is always checked when `INGEST_DIR` is unset.
|
||||||
book that waits and sends it.
|
When you turn the pause off, Bindery rebuilds unsent books. The other upload settings still apply.
|
||||||
|
While paused, use "Approve" or "Approve as a new book instead" to save a decision without sending the book.
|
||||||
|
|
||||||
To approve a link, open the story page and do one of these:
|
To approve a link, open the story page and do one of these:
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -35,7 +35,7 @@ def _base_path(value: str) -> str:
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
CONFIG_DIR = _path("CONFIG_DIR", "/config")
|
CONFIG_DIR = _path("CONFIG_DIR", "/config")
|
||||||
INGEST_DIR = _path("INGEST_DIR", "/cwa-book-ingest")
|
INGEST_DIR = _optional_path("INGEST_DIR")
|
||||||
TMP_DIR = _path("TMP_DIR", "/tmp/bindery")
|
TMP_DIR = _path("TMP_DIR", "/tmp/bindery")
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").strip().upper()
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").strip().upper()
|
||||||
# A path prefix, such as /bindery, for a reverse proxy that forwards the full path.
|
# A path prefix, such as /bindery, for a reverse proxy that forwards the full path.
|
||||||
@@ -84,9 +84,8 @@ BACKUP_COUNT = 7 # daily backups to keep
|
|||||||
def prepare() -> None:
|
def prepare() -> None:
|
||||||
"""Make the folders, and give the user a personal.ini to edit on first start."""
|
"""Make the folders, and give the user a personal.ini to edit on first start."""
|
||||||
for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR, INGEST_DIR):
|
for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR, INGEST_DIR):
|
||||||
|
if directory is not None:
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
if not PERSONAL_INI.exists():
|
if not PERSONAL_INI.exists():
|
||||||
shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI)
|
shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI)
|
||||||
logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}")
|
logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}")
|
||||||
if not CWA_URL:
|
|
||||||
logger.warning("CWA_URL is not set. OPDS imports and HTTP fallbacks are unavailable.")
|
|
||||||
|
|||||||
+52
-15
@@ -25,7 +25,6 @@ from bindery.store import Store, now
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
store = Store(config.DB_PATH)
|
store = Store(config.DB_PATH)
|
||||||
ingest_folder = IngestFolder(config.INGEST_DIR)
|
|
||||||
finder = BookFinder()
|
finder = BookFinder()
|
||||||
|
|
||||||
SITE_MODES = (
|
SITE_MODES = (
|
||||||
@@ -54,6 +53,18 @@ def library_available() -> bool:
|
|||||||
return bool(config.CWA_URL)
|
return bool(config.CWA_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def _linking_available() -> bool:
|
||||||
|
return bool(config.CWA_URL or config.CALIBRE_DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _link_books() -> bool:
|
||||||
|
return _linking_available() and store.get_setting(None, "link_books") is not False
|
||||||
|
|
||||||
|
|
||||||
|
def _can_match_books() -> bool:
|
||||||
|
return _link_books()
|
||||||
|
|
||||||
|
|
||||||
def default_locked_fields() -> list[str]:
|
def default_locked_fields() -> list[str]:
|
||||||
return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS]
|
return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS]
|
||||||
|
|
||||||
@@ -170,9 +181,17 @@ def _require_story(story_id: int) -> dict[str, Any]:
|
|||||||
def list_stories() -> list[dict[str, Any]]:
|
def list_stories() -> list[dict[str, Any]]:
|
||||||
stories = store.list_stories()
|
stories = store.list_stories()
|
||||||
held = store.story_settings("upload_held")
|
held = store.story_settings("upload_held")
|
||||||
|
unapproved = store.story_settings("unapproved_link")
|
||||||
|
show_holds = _link_books() and store.get_setting(None, "hold_new_links") and not _uploads_paused()
|
||||||
for s in stories:
|
for s in stories:
|
||||||
s["busy"] = job_queue.has_active(s["id"])
|
s["busy"] = job_queue.has_active(s["id"])
|
||||||
s["upload_held"] = bool(held.get(s["id"]))
|
s["upload_held"] = bool(held.get(s["id"]))
|
||||||
|
s["approval_held"] = bool(
|
||||||
|
show_holds
|
||||||
|
and s["upload_held"]
|
||||||
|
and unapproved.get(s["id"])
|
||||||
|
and (s["calibre_id"] is not None or not s["ingested_at"])
|
||||||
|
)
|
||||||
return stories
|
return stories
|
||||||
|
|
||||||
|
|
||||||
@@ -194,7 +213,7 @@ def story_details(story_id: int) -> dict[str, Any]:
|
|||||||
# The version changes with the image, so a browser can keep a cover until the site changes it.
|
# The version changes with the image, so a browser can keep a cover until the site changes it.
|
||||||
story["cover"] = hashlib.sha256(cover["data"]).hexdigest()[:16] if cover else None
|
story["cover"] = hashlib.sha256(cover["data"]).hexdigest()[:16] if cover else None
|
||||||
book, library_error = None, None
|
book, library_error = None, None
|
||||||
if story["calibre_uuid"] and (library_available() or config.CALIBRE_DB_PATH):
|
if story["calibre_uuid"] and _can_match_books():
|
||||||
try:
|
try:
|
||||||
book = cwa().get_book(story["calibre_uuid"])
|
book = cwa().get_book(story["calibre_uuid"])
|
||||||
except CwaError as e:
|
except CwaError as e:
|
||||||
@@ -206,6 +225,9 @@ def story_details(story_id: int) -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"recent_chapters": _setting_recent_chapters(),
|
"recent_chapters": _setting_recent_chapters(),
|
||||||
"uploads_paused": _uploads_paused(),
|
"uploads_paused": _uploads_paused(),
|
||||||
|
"ingest_available": config.INGEST_DIR is not None,
|
||||||
|
"link_books": _link_books(),
|
||||||
|
"library_matching_available": _can_match_books(),
|
||||||
"story": story,
|
"story": story,
|
||||||
"chapters": store.list_chapters(story_id),
|
"chapters": store.list_chapters(story_id),
|
||||||
"rules": store.rules(story_id),
|
"rules": store.rules(story_id),
|
||||||
@@ -315,8 +337,11 @@ def get_settings() -> dict[str, Any]:
|
|||||||
ini = config.PERSONAL_INI
|
ini = config.PERSONAL_INI
|
||||||
return {
|
return {
|
||||||
"recent_chapters": _setting_recent_chapters(),
|
"recent_chapters": _setting_recent_chapters(),
|
||||||
|
"link_books": _link_books(),
|
||||||
|
"linking_available": _linking_available(),
|
||||||
"hold_new_links": bool(store.get_setting(None, "hold_new_links")),
|
"hold_new_links": bool(store.get_setting(None, "hold_new_links")),
|
||||||
"pause_uploads": _uploads_paused(),
|
"pause_uploads": _uploads_paused(),
|
||||||
|
"ingest_available": config.INGEST_DIR is not None,
|
||||||
"personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "",
|
"personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "",
|
||||||
**job_list_settings(),
|
**job_list_settings(),
|
||||||
}
|
}
|
||||||
@@ -343,16 +368,23 @@ def save_settings(
|
|||||||
story_jobs_count: Any = None,
|
story_jobs_count: Any = None,
|
||||||
main_jobs_hours: Any = None,
|
main_jobs_hours: Any = None,
|
||||||
main_jobs_count: Any = None,
|
main_jobs_count: Any = None,
|
||||||
|
link_books: bool | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini."""
|
"""Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini."""
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
released = False
|
released = False
|
||||||
|
if link_books is not None:
|
||||||
|
if not isinstance(link_books, bool):
|
||||||
|
raise ValueError("Link books to CWA library must be true or false")
|
||||||
|
if _linking_available():
|
||||||
|
released = _link_books() != link_books
|
||||||
|
store.set_setting(None, "link_books", link_books)
|
||||||
for name, value in (("hold_new_links", hold_new_links), ("pause_uploads", pause_uploads)):
|
for name, value in (("hold_new_links", hold_new_links), ("pause_uploads", pause_uploads)):
|
||||||
if value is None:
|
if value is None or (name == "pause_uploads" and config.INGEST_DIR is None):
|
||||||
continue
|
continue
|
||||||
released = released or (bool(store.get_setting(None, name)) and not value)
|
released = released or (bool(store.get_setting(None, name)) and not value)
|
||||||
store.set_setting(None, name, True if value else None)
|
store.set_setting(None, name, True if value else None)
|
||||||
if released:
|
if released and not _uploads_paused():
|
||||||
_release_held_uploads()
|
_release_held_uploads()
|
||||||
if recent_chapters is not None:
|
if recent_chapters is not None:
|
||||||
try:
|
try:
|
||||||
@@ -539,7 +571,7 @@ def approve_upload(story_id: int, book_uuid: str | None) -> dict[str, Any] | Non
|
|||||||
if (book_uuid or None) != story["calibre_uuid"]:
|
if (book_uuid or None) != story["calibre_uuid"]:
|
||||||
raise ValueError("The library link of this story changed. Reload the page, and check the link again.")
|
raise ValueError("The library link of this story changed. Reload the page, and check the link again.")
|
||||||
store.set_setting(story_id, "unapproved_link", None)
|
store.set_setting(story_id, "unapproved_link", None)
|
||||||
if store.get_setting(story_id, "upload_held") and story["metahtml"]:
|
if not _uploads_paused() and store.get_setting(story_id, "upload_held") and story["metahtml"]:
|
||||||
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])).to_dict()
|
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])).to_dict()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -659,7 +691,9 @@ def _link(story: dict[str, Any], book_id: int, book_uuid: str) -> None:
|
|||||||
|
|
||||||
def _linked_book(story: dict[str, Any]) -> dict[str, Any] | None:
|
def _linked_book(story: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
"""The library book for a story. Links the story if CWA has a book with its URL."""
|
"""The library book for a story. Links the story if CWA has a book with its URL."""
|
||||||
if not library_available() and not config.CALIBRE_DB_PATH:
|
if not _can_match_books():
|
||||||
|
if story["calibre_id"] is not None:
|
||||||
|
return {"id": story["calibre_id"], "uuid": story["calibre_uuid"], "title": story["title"]}
|
||||||
return None
|
return None
|
||||||
if story["calibre_uuid"]:
|
if story["calibre_uuid"]:
|
||||||
try:
|
try:
|
||||||
@@ -705,7 +739,7 @@ NOT_FOUND = "Cannot find this story in CWA. Link to library book, or send as new
|
|||||||
|
|
||||||
|
|
||||||
def _uploads_paused() -> bool:
|
def _uploads_paused() -> bool:
|
||||||
return bool(store.get_setting(None, "pause_uploads"))
|
return config.INGEST_DIR is None or bool(store.get_setting(None, "pause_uploads"))
|
||||||
|
|
||||||
|
|
||||||
def _link_changed(story_id: int) -> None:
|
def _link_changed(story_id: int) -> None:
|
||||||
@@ -713,7 +747,9 @@ def _link_changed(story_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _needs_approval(story_id: int) -> bool:
|
def _needs_approval(story_id: int) -> bool:
|
||||||
return bool(store.get_setting(None, "hold_new_links") and store.get_setting(story_id, "unapproved_link"))
|
return bool(
|
||||||
|
_link_books() and store.get_setting(None, "hold_new_links") and store.get_setting(story_id, "unapproved_link")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _release_held_uploads() -> None:
|
def _release_held_uploads() -> None:
|
||||||
@@ -726,6 +762,8 @@ def _release_held_uploads() -> None:
|
|||||||
|
|
||||||
def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None:
|
def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None:
|
||||||
if _uploads_paused():
|
if _uploads_paused():
|
||||||
|
if config.INGEST_DIR is None:
|
||||||
|
return "Not sent. INGEST_DIR is not set."
|
||||||
return "Not sent. Turn off pause to send."
|
return "Not sent. Turn off pause to send."
|
||||||
if not _needs_approval(story["id"]):
|
if not _needs_approval(story["id"]):
|
||||||
return None
|
return None
|
||||||
@@ -735,15 +773,14 @@ def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | No
|
|||||||
|
|
||||||
|
|
||||||
def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, title: str, author: str) -> str:
|
def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, title: str, author: str) -> str:
|
||||||
# A second plain epub would make a duplicate book, so an update without a library book fails.
|
|
||||||
if not book and story["ingested_at"] and not library_available() and not config.CALIBRE_DB_PATH:
|
|
||||||
raise StoryError("Bindery cannot find the library book to update, because CWA_URL is not set.")
|
|
||||||
if not book and story["ingested_at"]:
|
|
||||||
raise StoryError(NOT_FOUND)
|
|
||||||
held = _hold_reason(story, book)
|
held = _hold_reason(story, book)
|
||||||
if held:
|
if held:
|
||||||
store.set_setting(story["id"], "upload_held", True)
|
store.set_setting(story["id"], "upload_held", True)
|
||||||
return held
|
return held
|
||||||
|
# With linking on, do not create a duplicate when the library book cannot be found.
|
||||||
|
if _link_books() and not book and story["ingested_at"]:
|
||||||
|
raise StoryError(NOT_FOUND)
|
||||||
|
ingest_folder = IngestFolder(config.INGEST_DIR)
|
||||||
if book:
|
if book:
|
||||||
ingest_folder.send_update(epub, book["id"], title)
|
ingest_folder.send_update(epub, book["id"], title)
|
||||||
message = f"Updated library book {book['id']}"
|
message = f"Updated library book {book['id']}"
|
||||||
@@ -751,7 +788,7 @@ def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, tit
|
|||||||
ingest_folder.ingest(epub, f"{title} - {author}" if author else title)
|
ingest_folder.ingest(epub, f"{title} - {author}" if author else title)
|
||||||
store.update_story(story["id"], ingested_at=now())
|
store.update_story(story["id"], ingested_at=now())
|
||||||
story["ingested_at"] = now()
|
story["ingested_at"] = now()
|
||||||
message = "Sent to CWA"
|
message = "Sent to CWA" if _link_books() else "Sent to ingest folder"
|
||||||
store.set_setting(story["id"], "upload_held", None)
|
store.set_setting(story["id"], "upload_held", None)
|
||||||
store.set_setting(story["id"], "unapproved_link", None)
|
store.set_setting(story["id"], "unapproved_link", None)
|
||||||
return message
|
return message
|
||||||
@@ -929,7 +966,7 @@ def _run_site_job(job: Job) -> str:
|
|||||||
if story is None:
|
if story is None:
|
||||||
# A new story. If the library already has it (for example from the
|
# A new story. If the library already has it (for example from the
|
||||||
# FanFicFare calibre plugin), link it and keep its chapters.
|
# FanFicFare calibre plugin), link it and keep its chapters.
|
||||||
found = _find_book(snap.url, [snap.title]) if library_available() else None
|
found = _find_book(snap.url, [snap.title]) if _link_books() and library_available() else None
|
||||||
book_id, book_uuid = (found[0].id, found[0].uuid) if found else (None, None)
|
book_id, book_uuid = (found[0].id, found[0].uuid) if found else (None, None)
|
||||||
contents = None
|
contents = None
|
||||||
if found:
|
if found:
|
||||||
|
|||||||
@@ -10,7 +10,31 @@ export function settingsPage(main, _params, config) {
|
|||||||
document.title = 'Settings · Bindery';
|
document.title = 'Settings · Bindery';
|
||||||
const recent = h('input', { id: 'recent-chapters', type: 'number', min: 0, step: 1 });
|
const recent = h('input', { id: 'recent-chapters', type: 'number', min: 0, step: 1 });
|
||||||
const holdNewLinks = checkbox(false, () => {});
|
const holdNewLinks = checkbox(false, () => {});
|
||||||
|
const libraryNote = h('p', { class: 'notice', id: 'library-note', hidden: true },
|
||||||
|
'CWA_URL and CALIBRE_DB_PATH are not set. Set one or both and restart Bindery to enable library linking.');
|
||||||
|
const linkNote = h('p', { class: 'notice', id: 'link-note', hidden: true },
|
||||||
|
'Updates without a saved CWA book ID create duplicate books if CWA imports them.');
|
||||||
|
const showLinkState = (enabled) => {
|
||||||
|
holdNewLinks.disabled = !enabled;
|
||||||
|
linkNote.hidden = enabled;
|
||||||
|
linkBooks.setAttribute('aria-describedby',
|
||||||
|
[linkBooks.disabled && 'library-note', !enabled && 'link-note'].filter(Boolean).join(' '));
|
||||||
|
};
|
||||||
|
const linkBooks = checkbox(true, showLinkState);
|
||||||
const pauseUploads = checkbox(false, () => {});
|
const pauseUploads = checkbox(false, () => {});
|
||||||
|
const ingestNote = h('p', { class: 'notice', id: 'ingest-note', hidden: true },
|
||||||
|
'INGEST_DIR is not set. Uploads stay paused until you set it and restart Bindery.');
|
||||||
|
const showUploadSettings = (settings) => {
|
||||||
|
linkBooks.disabled = settings.linking_available === false;
|
||||||
|
linkBooks.checked = !linkBooks.disabled && (settings.link_books ?? true);
|
||||||
|
libraryNote.hidden = !linkBooks.disabled;
|
||||||
|
holdNewLinks.checked = settings.hold_new_links;
|
||||||
|
showLinkState(linkBooks.checked);
|
||||||
|
pauseUploads.disabled = settings.ingest_available === false;
|
||||||
|
pauseUploads.checked = pauseUploads.disabled || settings.pause_uploads;
|
||||||
|
pauseUploads.setAttribute('aria-describedby', pauseUploads.disabled ? 'ingest-note' : '');
|
||||||
|
ingestNote.hidden = !pauseUploads.disabled;
|
||||||
|
};
|
||||||
const hoursField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_hours, step: 1 });
|
const hoursField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_hours, step: 1 });
|
||||||
const countField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_count, step: 1 });
|
const countField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_count, step: 1 });
|
||||||
const storyJobsHours = hoursField('story-jobs-hours');
|
const storyJobsHours = hoursField('story-jobs-hours');
|
||||||
@@ -40,8 +64,7 @@ export function settingsPage(main, _params, config) {
|
|||||||
api('settings')
|
api('settings')
|
||||||
.then((settings) => {
|
.then((settings) => {
|
||||||
recent.value = settings.recent_chapters;
|
recent.value = settings.recent_chapters;
|
||||||
holdNewLinks.checked = settings.hold_new_links;
|
showUploadSettings(settings);
|
||||||
pauseUploads.checked = settings.pause_uploads;
|
|
||||||
ini.value = settings.personal_ini;
|
ini.value = settings.personal_ini;
|
||||||
storyJobsHours.value = settings.story_jobs_hours;
|
storyJobsHours.value = settings.story_jobs_hours;
|
||||||
storyJobsCount.value = settings.story_jobs_count;
|
storyJobsCount.value = settings.story_jobs_count;
|
||||||
@@ -83,6 +106,15 @@ export function settingsPage(main, _params, config) {
|
|||||||
'section',
|
'section',
|
||||||
{ class: 'card' },
|
{ class: 'card' },
|
||||||
h('h2', {}, 'Uploads to CWA'),
|
h('h2', {}, 'Uploads to CWA'),
|
||||||
|
h('label', {}, linkBooks, ' Link books to CWA library'),
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{ class: 'hint' },
|
||||||
|
'Find library books through CWA_URL or CALIBRE_DB_PATH. When this is off, uploads use saved CWA book IDs ' +
|
||||||
|
'or send a plain ePub to INGEST_DIR.',
|
||||||
|
),
|
||||||
|
libraryNote,
|
||||||
|
linkNote,
|
||||||
h('label', {}, holdNewLinks, ' Hold new links for approval'),
|
h('label', {}, holdNewLinks, ' Hold new links for approval'),
|
||||||
h(
|
h(
|
||||||
'p',
|
'p',
|
||||||
@@ -92,6 +124,7 @@ export function settingsPage(main, _params, config) {
|
|||||||
'Later updates of an approved book go to CWA as usual.',
|
'Later updates of an approved book go to CWA as usual.',
|
||||||
),
|
),
|
||||||
h('label', {}, pauseUploads, ' Pause all uploads'),
|
h('label', {}, pauseUploads, ' Pause all uploads'),
|
||||||
|
ingestNote,
|
||||||
h(
|
h(
|
||||||
'p',
|
'p',
|
||||||
{ class: 'hint' },
|
{ class: 'hint' },
|
||||||
@@ -102,7 +135,10 @@ export function settingsPage(main, _params, config) {
|
|||||||
'div',
|
'div',
|
||||||
{ class: 'controls' },
|
{ class: 'controls' },
|
||||||
button('Save', async () => {
|
button('Save', async () => {
|
||||||
await api('settings', 'PUT', { hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked });
|
const saved = await api('settings', 'PUT', {
|
||||||
|
link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked,
|
||||||
|
});
|
||||||
|
showUploadSettings(saved);
|
||||||
toast('Saved');
|
toast('Saved');
|
||||||
}, { class: 'primary' }),
|
}, { class: 'primary' }),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ function storyRow(s) {
|
|||||||
'td',
|
'td',
|
||||||
{ 'data-label': 'Status' },
|
{ 'data-label': 'Status' },
|
||||||
status,
|
status,
|
||||||
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
|
s.approval_held && [' ', badge('warn', 'held', 'The book waits for your approval. See the story page.')],
|
||||||
),
|
),
|
||||||
storyDate('Last change', s.last_changed),
|
storyDate('Last change', s.last_changed),
|
||||||
storyDate('Last check', s.last_checked),
|
storyDate('Last check', s.last_checked),
|
||||||
|
|||||||
+32
-19
@@ -133,7 +133,7 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
let library = 'Not in the library yet';
|
let library = 'Not in the library yet';
|
||||||
if (book) library = `Library book ${book.id}`;
|
if (book) library = `Library book ${book.id}`;
|
||||||
else if (story.calibre_id) library = `Library book ${story.calibre_id}`;
|
else if (story.calibre_id) library = `Library book ${story.calibre_id}`;
|
||||||
else if (story.ingested_at) library = 'Sent to CWA, unlinked';
|
else if (story.ingested_at) library = details.link_books === false ? 'Sent to ingest folder' : 'Sent to CWA, unlinked';
|
||||||
fill(
|
fill(
|
||||||
summary,
|
summary,
|
||||||
// The version in the URL changes with the image, so the browser keeps the cover until the site changes it.
|
// The version in the URL changes with the image, so the browser keeps the cover until the site changes it.
|
||||||
@@ -165,15 +165,18 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
|
|
||||||
const approve = async (uuid) => {
|
const approve = async (uuid) => {
|
||||||
const result = await api(`stories/${id}/approve`, 'POST', { uuid });
|
const result = await api(`stories/${id}/approve`, 'POST', { uuid });
|
||||||
toast(result.job ? 'Approved. Bindery rebuilds the book and sends it.' : 'Approved');
|
toast(result.job ? 'Approved. Rebuild queued.' : 'Approved');
|
||||||
if (result.job) refreshJobs();
|
if (result.job) refreshJobs();
|
||||||
await load();
|
await load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const approveAsNew = async () => {
|
const approveAsNew = async () => {
|
||||||
const message =
|
const action = details.uploads_paused
|
||||||
'Unlink the library book, and send this story to CWA as a new book? If CWA already has the book, you get a ' +
|
? 'Unlink the library book, and approve this story as a new book? Uploads remain paused. '
|
||||||
'duplicate. In that case, search for the book under "Library link" and link it instead.';
|
: 'Unlink the library book, and send this story to CWA as a new book? ';
|
||||||
|
const message = action +
|
||||||
|
'If CWA already has the book, you get a duplicate. ' +
|
||||||
|
'In that case, search for the book under "Library link" and link it instead.';
|
||||||
if (!window.confirm(message)) return;
|
if (!window.confirm(message)) return;
|
||||||
await api(`stories/${id}/link`, 'POST', { uuid: null });
|
await api(`stories/${id}/link`, 'POST', { uuid: null });
|
||||||
await approve(null);
|
await approve(null);
|
||||||
@@ -182,15 +185,17 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
// Why the book did not go to CWA, and the approval of a new library link.
|
// Why the book did not go to CWA, and the approval of a new library link.
|
||||||
function holdNotice() {
|
function holdNotice() {
|
||||||
const { story, book } = details;
|
const { story, book } = details;
|
||||||
const paused = story.upload_held && details.uploads_paused;
|
const linking = details.link_books !== false;
|
||||||
// A story that went to CWA without a link gets its book at the next update, and the approval comes then.
|
const unlinked = !linking && story.calibre_id == null;
|
||||||
const approval = story.needs_approval && (story.calibre_uuid || !story.ingested_at);
|
// A previously uploaded book needs a known target before approval.
|
||||||
if (!paused && !approval) return null;
|
const approval = linking && story.needs_approval && (story.calibre_id != null || !story.ingested_at);
|
||||||
let target = 'The next upload goes to CWA as a new book.';
|
const paused = details.uploads_paused && (story.upload_held || approval);
|
||||||
|
if (!paused && !approval && !unlinked) return null;
|
||||||
|
let target = 'Approve this story as a new CWA book.';
|
||||||
if (book) {
|
if (book) {
|
||||||
target = `The next upload replaces the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
|
target = `Approve replacing the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
|
||||||
} else if (story.calibre_id) {
|
} else if (story.calibre_id != null) {
|
||||||
target = `The next upload replaces the ePub of library book ${story.calibre_id}.`;
|
target = `Approve replacing the ePub of library book ${story.calibre_id}.`;
|
||||||
}
|
}
|
||||||
return h(
|
return h(
|
||||||
'div',
|
'div',
|
||||||
@@ -199,19 +204,27 @@ export function storyPage(main, { storyId }, config) {
|
|||||||
h(
|
h(
|
||||||
'p',
|
'p',
|
||||||
{},
|
{},
|
||||||
'Uploads to CWA are paused. Bindery sends this book when you turn the pause off in ',
|
details.ingest_available === false
|
||||||
link('/settings', 'Settings'),
|
? 'Set INGEST_DIR and restart Bindery to enable uploads.'
|
||||||
'.',
|
: ['Uploads to CWA are paused. Resume them in ', link('/settings', 'Settings'), '.'],
|
||||||
|
),
|
||||||
|
unlinked &&
|
||||||
|
h(
|
||||||
|
'p',
|
||||||
|
{},
|
||||||
|
'Uploads of this book have no CWA book ID. If CWA imports them, updates will create duplicate books.',
|
||||||
),
|
),
|
||||||
approval && [
|
approval && [
|
||||||
h('p', {}, `${story.upload_held ? 'The book waits for your approval. ' : ''}${target} Make sure that this is right.`),
|
h('p', {}, target),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'controls' },
|
{ class: 'controls' },
|
||||||
button(story.upload_held ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
|
button(story.upload_held && !details.uploads_paused ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
|
||||||
class: 'primary',
|
class: 'primary',
|
||||||
}),
|
}),
|
||||||
story.calibre_uuid && button('Send as a new book instead', approveAsNew),
|
story.calibre_uuid && button(
|
||||||
|
details.uploads_paused ? 'Approve as a new book instead' : 'Send as a new book instead', approveAsNew,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -427,6 +427,10 @@ label:has(> input[type='radio']) {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
label:has(> input[type='checkbox']:disabled) {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.library-results {
|
.library-results {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ def settings() -> Any:
|
|||||||
data.get("story_jobs_count"),
|
data.get("story_jobs_count"),
|
||||||
data.get("main_jobs_hours"),
|
data.get("main_jobs_hours"),
|
||||||
data.get("main_jobs_count"),
|
data.get("main_jobs_count"),
|
||||||
|
link_books=data.get("link_books"),
|
||||||
)
|
)
|
||||||
return orchestrator.get_settings()
|
return orchestrator.get_settings()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
# The user and group that own ./config and can write CWA's ingest folder. Usually the same as CWA's.
|
# The user and group that own ./config and can write CWA's ingest folder. Usually the same as CWA's.
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
environment:
|
environment:
|
||||||
|
# Omit this and its volume mount to keep uploads paused.
|
||||||
|
INGEST_DIR: /cwa-book-ingest
|
||||||
# The address of CWA, as the Bindery container reaches it, and the CWA account that Bindery uses.
|
# The address of CWA, as the Bindery container reaches it, and the CWA account that Bindery uses.
|
||||||
CWA_URL: http://calibre-web-automated:8083
|
CWA_URL: http://calibre-web-automated:8083
|
||||||
CWA_USERNAME: bindery
|
CWA_USERNAME: bindery
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ test('sortable headings fill the column with one clickable highlight without shi
|
|||||||
assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/);
|
assert.doesNotMatch(css, /\.stories-table th button:hover\s*\{[^}]*underline/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('disabled checkbox labels use muted text', () => {
|
||||||
|
assert.equal(declarations("label:has(> input[type='checkbox']:disabled)").color, 'var(--muted)');
|
||||||
|
});
|
||||||
|
|
||||||
function luminance(hex) {
|
function luminance(hex) {
|
||||||
const channels = hex.slice(1).match(/../g).map((channel) => {
|
const channels = hex.slice(1).match(/../g).map((channel) => {
|
||||||
const value = parseInt(channel, 16) / 255;
|
const value = parseInt(channel, 16) / 255;
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ test('date columns keep each timestamp on one line, with last change before last
|
|||||||
|
|
||||||
test('status messages move into accessible pill descriptions without hiding held or chapter counts', async (t) => {
|
test('status messages move into accessible pill descriptions without hiding held or chapter counts', async (t) => {
|
||||||
await mount(t, [
|
await mount(t, [
|
||||||
story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true }),
|
story(1, 'Alpha', { offsite_count: 3, revised_count: 2, message: 'Updated 2 chapters', upload_held: true, approval_held: true }),
|
||||||
story(2, 'Beta', { status: 'error', message: '<script>Connection failed</script>' }),
|
story(2, 'Beta', { status: 'error', message: '<script>Connection failed</script>' }),
|
||||||
story(3, 'Gamma'),
|
story(3, 'Gamma'),
|
||||||
story(4, 'New', { status: '', message: '' }),
|
story(4, 'New', { status: '', message: '' }),
|
||||||
|
|||||||
+239
-12
@@ -31,24 +31,131 @@ async function renderStory(t, data) {
|
|||||||
t.after(() => cleanup?.());
|
t.after(() => cleanup?.());
|
||||||
await settle();
|
await settle();
|
||||||
const summary = nodes(main).find((el) => el.className === 'card story-summary');
|
const summary = nodes(main).find((el) => el.className === 'card story-summary');
|
||||||
return { backend, notice: nodes(summary).find((el) => el.className === 'notice') };
|
return { backend, summary, notice: nodes(summary).find((el) => el.className === 'notice') };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('the upload switches load and save on the settings page', async (t) => {
|
test('the upload switches load and save on the settings page', async (t) => {
|
||||||
const backend = server(t);
|
const backend = server(t);
|
||||||
backend.on('GET', 'settings', { recent_chapters: 0, hold_new_links: true, pause_uploads: false, personal_ini: '' });
|
backend.on('GET', 'settings', { recent_chapters: 0, link_books: true, hold_new_links: true, pause_uploads: false,
|
||||||
backend.on('PUT', 'settings', {});
|
ingest_available: true, personal_ini: '' });
|
||||||
|
backend.on('PUT', 'settings', { link_books: false, hold_new_links: true, pause_uploads: true, ingest_available: true });
|
||||||
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||||
backend.on('GET', 'stories', []);
|
backend.on('GET', 'stories', []);
|
||||||
const main = new Element('main');
|
const main = new Element('main');
|
||||||
settingsPage(main, {}, config);
|
settingsPage(main, {}, config);
|
||||||
await settle();
|
await settle();
|
||||||
const card = section(main, 'Uploads to CWA');
|
const card = section(main, 'Uploads to CWA');
|
||||||
const [hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||||
assert.deepEqual([hold.checked, pause.checked], [true, false]);
|
assert.deepEqual([link.checked, hold.checked, pause.checked], [true, true, false]);
|
||||||
|
assert.equal(find(card, 'label', ' Link books to CWA library').children[0], link);
|
||||||
|
assert.match(card.textContent, /send a plain ePub to INGEST_DIR/);
|
||||||
|
const warning = nodes(card).find((el) => el.attributes.id === 'link-note');
|
||||||
|
assert.equal(warning.className, 'notice');
|
||||||
|
assert.equal(warning.hidden, true);
|
||||||
|
assert.equal(link.attributes['aria-describedby'], '');
|
||||||
|
assert.match(warning.textContent, /Updates without a saved CWA book ID create duplicate books if CWA imports them/);
|
||||||
|
assert.equal(nodes(card).find((el) => el.attributes.id === 'ingest-note').hidden, true);
|
||||||
|
assert.equal(pause.disabled, false);
|
||||||
|
assert.equal(hold.disabled, false);
|
||||||
|
assert.equal(link.disabled, false);
|
||||||
|
assert.equal(nodes(card).find((el) => el.attributes.id === 'library-note').hidden, true);
|
||||||
|
change(link, false);
|
||||||
|
assert.equal(warning.hidden, false);
|
||||||
|
assert.equal(link.attributes['aria-describedby'], warning.attributes.id);
|
||||||
|
assert.equal(hold.disabled, true);
|
||||||
|
assert.equal(hold.checked, true);
|
||||||
|
change(link, true);
|
||||||
|
assert.equal(warning.hidden, true);
|
||||||
|
assert.equal(hold.disabled, false);
|
||||||
|
assert.equal(hold.checked, true);
|
||||||
|
change(link, false);
|
||||||
change(pause, true);
|
change(pause, true);
|
||||||
await click(card, 'Save');
|
await click(card, 'Save');
|
||||||
assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { hold_new_links: true, pause_uploads: true });
|
assert.deepEqual(backend.sent('PUT', 'settings')[0].body, { link_books: false, hold_new_links: true, pause_uploads: true });
|
||||||
|
assert.deepEqual([link.checked, hold.checked, pause.checked], [false, true, true]);
|
||||||
|
assert.equal(hold.disabled, true);
|
||||||
|
assert.equal(warning.hidden, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ingestAvailable of [true, false]) {
|
||||||
|
test(`missing library sources disable linking and approval with ingest available ${ingestAvailable}`, async (t) => {
|
||||||
|
const backend = server(t);
|
||||||
|
const saved = { link_books: true, linking_available: false, hold_new_links: true,
|
||||||
|
pause_uploads: false, ingest_available: ingestAvailable };
|
||||||
|
backend.on('GET', 'settings', saved);
|
||||||
|
backend.on('PUT', 'settings', saved);
|
||||||
|
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||||
|
backend.on('GET', 'stories', []);
|
||||||
|
const main = new Element('main');
|
||||||
|
settingsPage(main, {}, config);
|
||||||
|
await settle();
|
||||||
|
const card = section(main, 'Uploads to CWA');
|
||||||
|
const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||||
|
const note = nodes(card).find((el) => el.attributes.id === 'library-note');
|
||||||
|
assert.equal(link.checked, false);
|
||||||
|
assert.equal(link.disabled, true);
|
||||||
|
assert.equal(hold.checked, true);
|
||||||
|
assert.equal(hold.disabled, true);
|
||||||
|
assert.equal(pause.checked, !ingestAvailable);
|
||||||
|
assert.equal(pause.disabled, !ingestAvailable);
|
||||||
|
assert.equal(note.hidden, false);
|
||||||
|
assert.equal(note.className, 'notice');
|
||||||
|
assert.match(note.textContent, /CWA_URL and CALIBRE_DB_PATH are not set/);
|
||||||
|
assert.match(note.textContent, /Set one or both and restart Bindery/);
|
||||||
|
assert.equal(link.attributes['aria-describedby'], 'library-note link-note');
|
||||||
|
assert.equal(nodes(card).find((el) => el.attributes.id === 'link-note').hidden, false);
|
||||||
|
await click(card, 'Save');
|
||||||
|
assert.equal(backend.sent('PUT', 'settings')[0].body.link_books, false);
|
||||||
|
assert.equal(link.checked, false);
|
||||||
|
assert.equal(link.disabled, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const linkBooks of [true, false]) {
|
||||||
|
test(`missing INGEST_DIR forces pause and shows a highlighted note with linking ${linkBooks}`, async (t) => {
|
||||||
|
const backend = server(t);
|
||||||
|
const saved = { link_books: linkBooks, hold_new_links: false, pause_uploads: true, ingest_available: false };
|
||||||
|
backend.on('GET', 'settings', saved);
|
||||||
|
backend.on('PUT', 'settings', saved);
|
||||||
|
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||||
|
backend.on('GET', 'stories', []);
|
||||||
|
const main = new Element('main');
|
||||||
|
settingsPage(main, {}, config);
|
||||||
|
await settle();
|
||||||
|
const card = section(main, 'Uploads to CWA');
|
||||||
|
const [link, hold, pause] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||||
|
assert.equal(link.checked, linkBooks);
|
||||||
|
assert.equal(hold.disabled, !linkBooks);
|
||||||
|
assert.equal(nodes(card).find((el) => el.attributes.id === 'link-note').hidden, linkBooks);
|
||||||
|
assert.equal(pause.checked, true);
|
||||||
|
assert.equal(pause.disabled, true);
|
||||||
|
const note = nodes(card).find((el) => el.attributes.id === 'ingest-note');
|
||||||
|
assert.equal(note.hidden, false);
|
||||||
|
assert.equal(note.className, 'notice');
|
||||||
|
assert.equal(pause.attributes['aria-describedby'], note.attributes.id);
|
||||||
|
assert.match(note.textContent, /INGEST_DIR is not set/);
|
||||||
|
await click(card, 'Save');
|
||||||
|
assert.equal(pause.checked, true);
|
||||||
|
assert.equal(pause.disabled, true);
|
||||||
|
assert.equal(backend.sent('PUT', 'settings')[0].body.pause_uploads, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a saved approval preference stays checked but disabled while linking is off', async (t) => {
|
||||||
|
const backend = server(t);
|
||||||
|
backend.on('GET', 'settings', { link_books: false, hold_new_links: true, pause_uploads: false, ingest_available: true });
|
||||||
|
backend.on('GET', 'rules', { rules: [], paragraphs: false });
|
||||||
|
backend.on('GET', 'stories', []);
|
||||||
|
const main = new Element('main');
|
||||||
|
settingsPage(main, {}, config);
|
||||||
|
await settle();
|
||||||
|
const card = section(main, 'Uploads to CWA');
|
||||||
|
const [link, hold] = nodes(card).filter((el) => el.attributes.type === 'checkbox');
|
||||||
|
assert.equal(hold.disabled, true);
|
||||||
|
assert.equal(hold.checked, true);
|
||||||
|
change(link, true);
|
||||||
|
assert.equal(hold.disabled, false);
|
||||||
|
assert.equal(hold.checked, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a held book with a new link names the book and sends it on approval', async (t) => {
|
test('a held book with a new link names the book and sends it on approval', async (t) => {
|
||||||
@@ -57,12 +164,12 @@ test('a held book with a new link names the book and sends it on approval', asyn
|
|||||||
{ book },
|
{ book },
|
||||||
);
|
);
|
||||||
const { backend, notice } = await renderStory(t, data);
|
const { backend, notice } = await renderStory(t, data);
|
||||||
assert.match(notice.textContent, /waits for your approval\. The next upload replaces the ePub of library book 7: Library Title · Writer\./);
|
assert.match(notice.textContent, /^Approve replacing the ePub of library book 7: Library Title · Writer\./);
|
||||||
backend.on('POST', 'stories/1/approve', { story: data.story, job: { id: 'j1' } });
|
backend.on('POST', 'stories/1/approve', { story: data.story, job: { id: 'j1' } });
|
||||||
await click(notice, 'Approve and send');
|
await click(notice, 'Approve and send');
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: 'book-7' });
|
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: 'book-7' });
|
||||||
assert.match(ui.toasts.textContent, /Approved\. Bindery rebuilds the book and sends it\./);
|
assert.match(ui.toasts.textContent, /Approved\. Rebuild queued\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a wrong link can go to CWA as a new book instead', async (t) => {
|
test('a wrong link can go to CWA as a new book instead', async (t) => {
|
||||||
@@ -81,9 +188,50 @@ test('a wrong link can go to CWA as a new book instead', async (t) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const uuid of [null, 'book-7']) {
|
||||||
|
test(`approval while paused saves the decision without promising an upload: ${uuid}`, async (t) => {
|
||||||
|
const data = details(
|
||||||
|
{ calibre_id: uuid ? 7 : null, calibre_uuid: uuid, upload_held: true, needs_approval: true },
|
||||||
|
{ link_books: true, uploads_paused: true },
|
||||||
|
);
|
||||||
|
const { backend, notice } = await renderStory(t, data);
|
||||||
|
assert.ok(find(notice, 'button', 'Approve'));
|
||||||
|
assert.equal(find(notice, 'button', 'Approve and send'), undefined);
|
||||||
|
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
|
||||||
|
backend.on('POST', 'stories/1/approve', { story: data.story, job: null });
|
||||||
|
await click(notice, 'Approve');
|
||||||
|
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid });
|
||||||
|
assert.equal(ui.toasts.children.at(-1).textContent, 'Approved');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('approval as a new book while paused keeps the pause in the confirmation', async (t) => {
|
||||||
|
const data = details(
|
||||||
|
{ calibre_id: 7, calibre_uuid: 'book-7', upload_held: true, needs_approval: true },
|
||||||
|
{ link_books: true, uploads_paused: true },
|
||||||
|
);
|
||||||
|
const { backend, notice } = await renderStory(t, data);
|
||||||
|
const confirm = t.mock.method(window, 'confirm', () => true);
|
||||||
|
backend.on('POST', 'stories/1/link', data.story);
|
||||||
|
backend.on('POST', 'stories/1/approve', { story: data.story, job: null });
|
||||||
|
await click(notice, 'Approve as a new book instead');
|
||||||
|
assert.match(confirm.mock.calls[0].arguments[0], /approve this story as a new book\? Uploads remain paused/);
|
||||||
|
assert.match(confirm.mock.calls[0].arguments[0], /you get a duplicate/);
|
||||||
|
assert.deepEqual(backend.sent('POST', 'stories/1/approve')[0].body, { uuid: null });
|
||||||
|
assert.equal(ui.toasts.children.at(-1).textContent, 'Approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a saved ID without a UUID still offers approval', async (t) => {
|
||||||
|
const { notice } = await renderStory(t, details(
|
||||||
|
{ calibre_id: 7, ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
|
||||||
|
));
|
||||||
|
assert.ok(find(notice, 'button', 'Approve and send'));
|
||||||
|
assert.match(notice.textContent, /library book 7/);
|
||||||
|
});
|
||||||
|
|
||||||
test('a new story asks for approval to go to CWA as a new book', async (t) => {
|
test('a new story asks for approval to go to CWA as a new book', async (t) => {
|
||||||
const { notice } = await renderStory(t, details({ needs_approval: true }));
|
const { notice } = await renderStory(t, details({ needs_approval: true }));
|
||||||
assert.match(notice.textContent, /The next upload goes to CWA as a new book\./);
|
assert.match(notice.textContent, /^Approve this story as a new CWA book\./);
|
||||||
assert.ok(find(notice, 'button', 'Approve'));
|
assert.ok(find(notice, 'button', 'Approve'));
|
||||||
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
|
assert.equal(find(notice, 'button', 'Send as a new book instead'), undefined);
|
||||||
});
|
});
|
||||||
@@ -98,18 +246,96 @@ test('a paused upload points to the settings', async (t) => {
|
|||||||
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const savedId of [null, 7]) {
|
||||||
|
for (const paused of [false, true]) {
|
||||||
|
for (const needsApproval of [false, true]) {
|
||||||
|
test(`linking off hides approval with saved ID ${savedId}, pause ${paused}, and approval ${needsApproval}`, async (t) => {
|
||||||
|
const { notice } = await renderStory(t, details(
|
||||||
|
{ calibre_id: savedId, calibre_uuid: savedId ? 'book-7' : null,
|
||||||
|
upload_held: true, needs_approval: needsApproval },
|
||||||
|
{ link_books: false, uploads_paused: paused },
|
||||||
|
));
|
||||||
|
if (savedId !== null && !paused) {
|
||||||
|
assert.equal(notice, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||||
|
assert.doesNotMatch(notice.textContent, /waits for your approval|as a new book|Bindery sends this book/);
|
||||||
|
if (savedId === null) {
|
||||||
|
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
|
||||||
|
assert.equal(notice.className, 'notice');
|
||||||
|
} else {
|
||||||
|
assert.doesNotMatch(notice.textContent, /duplicate books/);
|
||||||
|
}
|
||||||
|
if (paused) {
|
||||||
|
assert.match(notice.textContent, /Uploads to CWA are paused/);
|
||||||
|
assert.equal(find(notice, 'a', 'Settings').attributes.href, '#/settings');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('linking off warns that repeated exports without a saved ID create duplicates in CWA', async (t) => {
|
||||||
|
const { notice, summary } = await renderStory(t, details(
|
||||||
|
{ ingested_at: '2026-01-01', needs_approval: true, upload_held: true },
|
||||||
|
{ link_books: false },
|
||||||
|
));
|
||||||
|
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
|
||||||
|
assert.match(summary.textContent, /Sent to ingest folder/);
|
||||||
|
assert.doesNotMatch(summary.textContent, /Sent to CWA, unlinked/);
|
||||||
|
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('a story that went to CWA without a link shows no approval until Bindery finds its book', async (t) => {
|
test('a story that went to CWA without a link shows no approval until Bindery finds its book', async (t) => {
|
||||||
const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true }));
|
const { notice } = await renderStory(t, details({ ingested_at: '2026-01-01', needs_approval: true }));
|
||||||
assert.equal(notice, undefined);
|
assert.equal(notice, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the story list marks a held book', async (t) => {
|
test('missing INGEST_DIR points to the environment rather than the disabled pause switch', async (t) => {
|
||||||
|
const { notice } = await renderStory(t, details(
|
||||||
|
{ upload_held: true }, { uploads_paused: true, ingest_available: false },
|
||||||
|
));
|
||||||
|
assert.equal(notice.textContent, 'Set INGEST_DIR and restart Bindery to enable uploads.');
|
||||||
|
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing library sources leave uploads enabled with the duplicate warning', async (t) => {
|
||||||
|
const { notice } = await renderStory(t, details(
|
||||||
|
{ ingested_at: '2026-01-01', upload_held: true, needs_approval: true },
|
||||||
|
{ link_books: false, library_matching_available: false },
|
||||||
|
));
|
||||||
|
assert.match(notice.textContent, /If CWA imports them, updates will create duplicate books/);
|
||||||
|
assert.equal(nodes(notice).filter((el) => el.tagName === 'button').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new book asks for approval when library linking is enabled', async (t) => {
|
||||||
|
const { notice } = await renderStory(t, details(
|
||||||
|
{ upload_held: true, needs_approval: true },
|
||||||
|
{ link_books: true, library_matching_available: true },
|
||||||
|
));
|
||||||
|
assert.match(notice.textContent, /^Approve this story as a new CWA book\./);
|
||||||
|
assert.ok(find(notice, 'button', 'Approve and send'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('approval before a build still shows the pause and never offers to send', async (t) => {
|
||||||
|
const { notice } = await renderStory(t, details(
|
||||||
|
{ needs_approval: true }, { uploads_paused: true },
|
||||||
|
));
|
||||||
|
assert.match(notice.textContent, /Uploads to CWA are paused\. Resume them in Settings\./);
|
||||||
|
assert.ok(find(notice, 'button', 'Approve'));
|
||||||
|
assert.equal(find(notice, 'button', 'Approve and send'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the story list marks only an approval hold, not every unsent book', async (t) => {
|
||||||
const backend = server(t);
|
const backend = server(t);
|
||||||
backend.on('GET', 'stories', [
|
backend.on('GET', 'stories', [
|
||||||
{ id: 1, url: 'u1', title: 'Held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
|
{ id: 1, url: 'u1', title: 'Held', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
|
||||||
message: '', upload_held: true },
|
message: '', upload_held: true, approval_held: true },
|
||||||
{ id: 2, url: 'u2', title: 'Sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
|
{ id: 2, url: 'u2', title: 'Sent', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
|
||||||
message: '', upload_held: false },
|
message: '', upload_held: false, approval_held: false },
|
||||||
|
{ id: 3, url: 'u3', title: 'Paused', author: 'A', site: 's', chapter_count: 1, offsite_count: 0, status: 'ok',
|
||||||
|
message: '', upload_held: true, approval_held: false },
|
||||||
]);
|
]);
|
||||||
backend.on('GET', 'jobs', []);
|
backend.on('GET', 'jobs', []);
|
||||||
const main = new Element('main');
|
const main = new Element('main');
|
||||||
@@ -119,4 +345,5 @@ test('the story list marks a held book', async (t) => {
|
|||||||
const held = nodes(main).filter((el) => el.tagName === 'tr').filter((row) => find(row, 'span', 'held'));
|
const held = nodes(main).filter((el) => el.tagName === 'tr').filter((row) => find(row, 'span', 'held'));
|
||||||
assert.equal(held.length, 1);
|
assert.equal(held.length, 1);
|
||||||
assert.match(held[0].textContent, /^Held/);
|
assert.match(held[0].textContent, /^Held/);
|
||||||
|
assert.match(find(held[0], 'span', 'held').attributes.title, /waits for your approval/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -284,10 +284,15 @@ def test_settings(client, monkeypatch, tmp_path):
|
|||||||
ini = tmp_path / "personal.ini"
|
ini = tmp_path / "personal.ini"
|
||||||
ini.write_text("[defaults]\nis_adult:true\n")
|
ini.write_text("[defaults]\nis_adult:true\n")
|
||||||
monkeypatch.setattr(config, "PERSONAL_INI", ini)
|
monkeypatch.setattr(config, "PERSONAL_INI", ini)
|
||||||
|
monkeypatch.setattr(config, "CWA_URL", "")
|
||||||
|
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
|
||||||
assert client.get("/api/settings").json == {
|
assert client.get("/api/settings").json == {
|
||||||
"recent_chapters": 0,
|
"recent_chapters": 0,
|
||||||
|
"link_books": False,
|
||||||
|
"linking_available": False,
|
||||||
"hold_new_links": False,
|
"hold_new_links": False,
|
||||||
"pause_uploads": False,
|
"pause_uploads": False,
|
||||||
|
"ingest_available": True,
|
||||||
"personal_ini": "[defaults]\nis_adult:true\n",
|
"personal_ini": "[defaults]\nis_adult:true\n",
|
||||||
"story_jobs_hours": 36,
|
"story_jobs_hours": 36,
|
||||||
"story_jobs_count": 10,
|
"story_jobs_count": 10,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from werkzeug.security import generate_password_hash
|
|||||||
|
|
||||||
from bindery import auth, calibre_db, config, orchestrator
|
from bindery import auth, calibre_db, config, orchestrator
|
||||||
from bindery.cwa import CwaClient
|
from bindery.cwa import CwaClient
|
||||||
from bindery.ingest import IngestFolder
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -250,7 +249,7 @@ def test_library_database_failure_keeps_the_http_lookup(library_db, monkeypatch,
|
|||||||
@pytest.mark.parametrize("saved_id,saved_uuid", [(None, None), (1, "uuid-42"), (42, None)])
|
@pytest.mark.parametrize("saved_id,saved_uuid", [(None, None), (1, "uuid-42"), (42, None)])
|
||||||
def test_database_book_id_reaches_the_epub_sidecar(client, library_db, monkeypatch, tmp_path, saved_id, saved_uuid):
|
def test_database_book_id_reaches_the_epub_sidecar(client, library_db, monkeypatch, tmp_path, saved_id, saved_uuid):
|
||||||
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
|
monkeypatch.setattr(config, "CALIBRE_DB_PATH", library_db)
|
||||||
monkeypatch.setattr(orchestrator, "ingest_folder", IngestFolder(tmp_path / "ingest"))
|
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
|
||||||
remote = CwaClient("")
|
remote = CwaClient("")
|
||||||
remote.session.get = Mock(side_effect=AssertionError("No HTTP lookup needed"))
|
remote.session.get = Mock(side_effect=AssertionError("No HTTP lookup needed"))
|
||||||
monkeypatch.setattr(orchestrator, "cwa", lambda: remote)
|
monkeypatch.setattr(orchestrator, "cwa", lambda: remote)
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ def test_defaults(monkeypatch, reload_config):
|
|||||||
"TRUST_PROXY",
|
"TRUST_PROXY",
|
||||||
"CWA_DB_PATH",
|
"CWA_DB_PATH",
|
||||||
"CALIBRE_DB_PATH",
|
"CALIBRE_DB_PATH",
|
||||||
|
"INGEST_DIR",
|
||||||
"BINDERY_USERNAME",
|
"BINDERY_USERNAME",
|
||||||
"BINDERY_PASSWORD_HASH",
|
"BINDERY_PASSWORD_HASH",
|
||||||
):
|
):
|
||||||
@@ -116,5 +117,26 @@ def test_defaults(monkeypatch, reload_config):
|
|||||||
assert config.TRUST_PROXY is False
|
assert config.TRUST_PROXY is False
|
||||||
assert config.CWA_DB_PATH is None
|
assert config.CWA_DB_PATH is None
|
||||||
assert config.CALIBRE_DB_PATH is None
|
assert config.CALIBRE_DB_PATH is None
|
||||||
|
assert config.INGEST_DIR is None
|
||||||
assert config.LOCAL_USERNAME == ""
|
assert config.LOCAL_USERNAME == ""
|
||||||
assert config.LOCAL_PASSWORD_HASH == ""
|
assert config.LOCAL_PASSWORD_HASH == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["", " "])
|
||||||
|
def test_empty_ingest_directory_disables_uploads(monkeypatch, reload_config, value):
|
||||||
|
monkeypatch.setenv("INGEST_DIR", value)
|
||||||
|
reload_config()
|
||||||
|
assert config.INGEST_DIR is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_without_cwa_creates_only_bindery_folders(monkeypatch, tmp_path, caplog):
|
||||||
|
monkeypatch.setattr(config, "INGEST_DIR", None)
|
||||||
|
monkeypatch.setattr(config, "CWA_URL", "")
|
||||||
|
monkeypatch.setattr(config, "CALIBRE_DB_PATH", None)
|
||||||
|
for name, folder in [("CONFIG_DIR", "config"), ("UPLOAD_DIR", "uploads"), ("TMP_DIR", "tmp")]:
|
||||||
|
monkeypatch.setattr(config, name, tmp_path / folder)
|
||||||
|
monkeypatch.setattr(config, "PERSONAL_INI", tmp_path / "config" / "personal.ini")
|
||||||
|
config.prepare()
|
||||||
|
assert config.PERSONAL_INI.exists()
|
||||||
|
assert {p.name for p in tmp_path.iterdir()} == {"config", "uploads", "tmp"}
|
||||||
|
assert caplog.records == []
|
||||||
|
|||||||
+12
-12
@@ -13,7 +13,6 @@ from bindery import config, fff, orchestrator
|
|||||||
from bindery.diffing import text_hash
|
from bindery.diffing import text_hash
|
||||||
from bindery.epub import EpubError
|
from bindery.epub import EpubError
|
||||||
from bindery.fff import SiteSnapshot, StoryError
|
from bindery.fff import SiteSnapshot, StoryError
|
||||||
from bindery.ingest import IngestFolder
|
|
||||||
from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus
|
from bindery.jobs import DownloadMode, Job, JobQueue, QueueStatus
|
||||||
from bindery.store import Store
|
from bindery.store import Store
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ def fake(story_epub, monkeypatch, tmp_path):
|
|||||||
monkeypatch.setattr(config, "CWA_USERNAME", "bindery")
|
monkeypatch.setattr(config, "CWA_USERNAME", "bindery")
|
||||||
monkeypatch.setattr(config, "CWA_PASSWORD", "secret")
|
monkeypatch.setattr(config, "CWA_PASSWORD", "secret")
|
||||||
monkeypatch.setattr(orchestrator, "store", Store(tmp_path / "bindery.db"))
|
monkeypatch.setattr(orchestrator, "store", Store(tmp_path / "bindery.db"))
|
||||||
monkeypatch.setattr(orchestrator, "ingest_folder", IngestFolder(tmp_path / "ingest"))
|
monkeypatch.setattr(config, "INGEST_DIR", tmp_path / "ingest")
|
||||||
monkeypatch.setattr(orchestrator, "finder", orchestrator.BookFinder())
|
monkeypatch.setattr(orchestrator, "finder", orchestrator.BookFinder())
|
||||||
yield server
|
yield server
|
||||||
server.close()
|
server.close()
|
||||||
@@ -51,12 +50,12 @@ def add_story(**changes):
|
|||||||
|
|
||||||
|
|
||||||
def ingest_files():
|
def ingest_files():
|
||||||
folder = orchestrator.ingest_folder.path
|
folder = config.INGEST_DIR
|
||||||
return sorted(p.name for p in folder.iterdir()) if folder.exists() else []
|
return sorted(p.name for p in folder.iterdir()) if folder.exists() else []
|
||||||
|
|
||||||
|
|
||||||
def opf(name):
|
def opf(name):
|
||||||
with zipfile.ZipFile(orchestrator.ingest_folder.path / name) as z:
|
with zipfile.ZipFile(config.INGEST_DIR / name) as z:
|
||||||
return z.read("content.opf").decode()
|
return z.read("content.opf").decode()
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ def test_ingested_story_is_found_and_updated_with_a_sidecar(fake):
|
|||||||
assert (story["calibre_id"], story["calibre_uuid"]) == (1, "uuid-1")
|
assert (story["calibre_id"], story["calibre_uuid"]) == (1, "uuid-1")
|
||||||
epub, manifest = ingest_files()
|
epub, manifest = ingest_files()
|
||||||
assert manifest == epub + ".cwa.json"
|
assert manifest == epub + ".cwa.json"
|
||||||
assert json.loads((orchestrator.ingest_folder.path / manifest).read_text()) == {
|
assert json.loads((config.INGEST_DIR / manifest).read_text()) == {
|
||||||
"action": "add_format",
|
"action": "add_format",
|
||||||
"book_id": 1,
|
"book_id": 1,
|
||||||
}
|
}
|
||||||
@@ -94,12 +93,13 @@ def test_update_fails_while_cwa_has_no_such_book(fake):
|
|||||||
assert ingest_files() == [] # a second plain epub would make a duplicate book
|
assert ingest_files() == [] # a second plain epub would make a duplicate book
|
||||||
|
|
||||||
|
|
||||||
def test_update_fails_without_cwa_url(fake, monkeypatch):
|
def test_update_exports_without_a_library_connection(fake, monkeypatch):
|
||||||
monkeypatch.setattr(config, "CWA_URL", "")
|
monkeypatch.setattr(config, "CWA_URL", "")
|
||||||
story_id = add_story(ingested_at="2026-01-01T00:00:00")
|
story_id = add_story(ingested_at="2026-01-01T00:00:00")
|
||||||
with pytest.raises(orchestrator.StoryError, match="CWA_URL is not set"):
|
assert rebuild(story_id) == "Sent to ingest folder"
|
||||||
rebuild(story_id)
|
assert orchestrator.store.get_story(story_id)["status"] == "ok"
|
||||||
assert ingest_files() == []
|
assert orchestrator.store.get_setting(story_id, "upload_held") is None
|
||||||
|
assert len(ingest_files()) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_linked_story_still_updates_when_cwa_is_down(fake):
|
def test_linked_story_still_updates_when_cwa_is_down(fake):
|
||||||
@@ -306,7 +306,7 @@ def test_the_site_cover_goes_in_the_book(fake, jobs, monkeypatch, tmp_path):
|
|||||||
assert update(story_id).startswith("1 new chapter")
|
assert update(story_id).startswith("1 new chapter")
|
||||||
assert orchestrator.store.cover(story_id)["data"] == PNG
|
assert orchestrator.store.cover(story_id)["data"] == PNG
|
||||||
(epub,) = [n for n in ingest_files() if n.endswith(".epub")]
|
(epub,) = [n for n in ingest_files() if n.endswith(".epub")]
|
||||||
with zipfile.ZipFile(orchestrator.ingest_folder.path / epub) as z:
|
with zipfile.ZipFile(config.INGEST_DIR / epub) as z:
|
||||||
assert "OEBPS/cover.xhtml" in z.namelist()
|
assert "OEBPS/cover.xhtml" in z.namelist()
|
||||||
|
|
||||||
|
|
||||||
@@ -598,8 +598,8 @@ def test_paused_uploads_go_to_cwa_when_the_pause_ends(fake, jobs):
|
|||||||
orchestrator.save_settings(pause_uploads=True)
|
orchestrator.save_settings(pause_uploads=True)
|
||||||
assert "pause" in rebuild(story_id)
|
assert "pause" in rebuild(story_id)
|
||||||
assert ingest_files() == []
|
assert ingest_files() == []
|
||||||
assert orchestrator.approve_upload(story_id, "uuid-1")["mode"] == DownloadMode.REBUILD.value
|
assert orchestrator.approve_upload(story_id, "uuid-1") is None
|
||||||
assert "pause" in orchestrator._run_rebuild(jobs.get_next())
|
assert jobs.get_next() is None
|
||||||
orchestrator.save_settings(pause_uploads=True)
|
orchestrator.save_settings(pause_uploads=True)
|
||||||
assert jobs.get_next() is None
|
assert jobs.get_next() is None
|
||||||
orchestrator.save_settings(pause_uploads=False)
|
orchestrator.save_settings(pause_uploads=False)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user