Make CWA linking and uploads optional
This commit is contained in:
+3
-4
@@ -35,7 +35,7 @@ def _base_path(value: str) -> str:
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
CONFIG_DIR = _path("CONFIG_DIR", "/config")
|
||||
INGEST_DIR = _path("INGEST_DIR", "/cwa-book-ingest")
|
||||
INGEST_DIR = _optional_path("INGEST_DIR")
|
||||
TMP_DIR = _path("TMP_DIR", "/tmp/bindery")
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").strip().upper()
|
||||
# A path prefix, such as /bindery, for a reverse proxy that forwards the full path.
|
||||
@@ -84,9 +84,8 @@ BACKUP_COUNT = 7 # daily backups to keep
|
||||
def prepare() -> None:
|
||||
"""Make the folders, and give the user a personal.ini to edit on first start."""
|
||||
for directory in (CONFIG_DIR, UPLOAD_DIR, TMP_DIR, INGEST_DIR):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
if directory is not None:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
if not PERSONAL_INI.exists():
|
||||
shutil.copy(DEFAULT_PERSONAL_INI, PERSONAL_INI)
|
||||
logger.info(f"Created the default FanFicFare configuration at {PERSONAL_INI}")
|
||||
if not CWA_URL:
|
||||
logger.warning("CWA_URL is not set. OPDS imports and HTTP fallbacks are unavailable.")
|
||||
|
||||
+52
-15
@@ -25,7 +25,6 @@ from bindery.store import Store, now
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
store = Store(config.DB_PATH)
|
||||
ingest_folder = IngestFolder(config.INGEST_DIR)
|
||||
finder = BookFinder()
|
||||
|
||||
SITE_MODES = (
|
||||
@@ -54,6 +53,18 @@ def library_available() -> bool:
|
||||
return bool(config.CWA_URL)
|
||||
|
||||
|
||||
def _linking_available() -> bool:
|
||||
return bool(config.CWA_URL or config.CALIBRE_DB_PATH)
|
||||
|
||||
|
||||
def _link_books() -> bool:
|
||||
return _linking_available() and store.get_setting(None, "link_books") is not False
|
||||
|
||||
|
||||
def _can_match_books() -> bool:
|
||||
return _link_books()
|
||||
|
||||
|
||||
def default_locked_fields() -> list[str]:
|
||||
return [f for f in config.DEFAULT_LOCKED_FIELDS if f in METADATA_FIELDS]
|
||||
|
||||
@@ -170,9 +181,17 @@ def _require_story(story_id: int) -> dict[str, Any]:
|
||||
def list_stories() -> list[dict[str, Any]]:
|
||||
stories = store.list_stories()
|
||||
held = store.story_settings("upload_held")
|
||||
unapproved = store.story_settings("unapproved_link")
|
||||
show_holds = _link_books() and store.get_setting(None, "hold_new_links") and not _uploads_paused()
|
||||
for s in stories:
|
||||
s["busy"] = job_queue.has_active(s["id"])
|
||||
s["upload_held"] = bool(held.get(s["id"]))
|
||||
s["approval_held"] = bool(
|
||||
show_holds
|
||||
and s["upload_held"]
|
||||
and unapproved.get(s["id"])
|
||||
and (s["calibre_id"] is not None or not s["ingested_at"])
|
||||
)
|
||||
return stories
|
||||
|
||||
|
||||
@@ -194,7 +213,7 @@ def story_details(story_id: int) -> dict[str, Any]:
|
||||
# The version changes with the image, so a browser can keep a cover until the site changes it.
|
||||
story["cover"] = hashlib.sha256(cover["data"]).hexdigest()[:16] if cover else None
|
||||
book, library_error = None, None
|
||||
if story["calibre_uuid"] and (library_available() or config.CALIBRE_DB_PATH):
|
||||
if story["calibre_uuid"] and _can_match_books():
|
||||
try:
|
||||
book = cwa().get_book(story["calibre_uuid"])
|
||||
except CwaError as e:
|
||||
@@ -206,6 +225,9 @@ def story_details(story_id: int) -> dict[str, Any]:
|
||||
return {
|
||||
"recent_chapters": _setting_recent_chapters(),
|
||||
"uploads_paused": _uploads_paused(),
|
||||
"ingest_available": config.INGEST_DIR is not None,
|
||||
"link_books": _link_books(),
|
||||
"library_matching_available": _can_match_books(),
|
||||
"story": story,
|
||||
"chapters": store.list_chapters(story_id),
|
||||
"rules": store.rules(story_id),
|
||||
@@ -315,8 +337,11 @@ def get_settings() -> dict[str, Any]:
|
||||
ini = config.PERSONAL_INI
|
||||
return {
|
||||
"recent_chapters": _setting_recent_chapters(),
|
||||
"link_books": _link_books(),
|
||||
"linking_available": _linking_available(),
|
||||
"hold_new_links": bool(store.get_setting(None, "hold_new_links")),
|
||||
"pause_uploads": _uploads_paused(),
|
||||
"ingest_available": config.INGEST_DIR is not None,
|
||||
"personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "",
|
||||
**job_list_settings(),
|
||||
}
|
||||
@@ -343,16 +368,23 @@ def save_settings(
|
||||
story_jobs_count: Any = None,
|
||||
main_jobs_hours: Any = None,
|
||||
main_jobs_count: Any = None,
|
||||
link_books: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Change the settings that are given. Return all settings, and FanFicFare's warnings about personal.ini."""
|
||||
warnings: list[str] = []
|
||||
released = False
|
||||
if link_books is not None:
|
||||
if not isinstance(link_books, bool):
|
||||
raise ValueError("Link books to CWA library must be true or false")
|
||||
if _linking_available():
|
||||
released = _link_books() != link_books
|
||||
store.set_setting(None, "link_books", link_books)
|
||||
for name, value in (("hold_new_links", hold_new_links), ("pause_uploads", pause_uploads)):
|
||||
if value is None:
|
||||
if value is None or (name == "pause_uploads" and config.INGEST_DIR is None):
|
||||
continue
|
||||
released = released or (bool(store.get_setting(None, name)) and not value)
|
||||
store.set_setting(None, name, True if value else None)
|
||||
if released:
|
||||
if released and not _uploads_paused():
|
||||
_release_held_uploads()
|
||||
if recent_chapters is not None:
|
||||
try:
|
||||
@@ -539,7 +571,7 @@ def approve_upload(story_id: int, book_uuid: str | None) -> dict[str, Any] | Non
|
||||
if (book_uuid or None) != story["calibre_uuid"]:
|
||||
raise ValueError("The library link of this story changed. Reload the page, and check the link again.")
|
||||
store.set_setting(story_id, "unapproved_link", None)
|
||||
if store.get_setting(story_id, "upload_held") and story["metahtml"]:
|
||||
if not _uploads_paused() and store.get_setting(story_id, "upload_held") and story["metahtml"]:
|
||||
return job_queue.add(Job(mode=DownloadMode.REBUILD, story_id=story_id, title=story["title"])).to_dict()
|
||||
return None
|
||||
|
||||
@@ -659,7 +691,9 @@ def _link(story: dict[str, Any], book_id: int, book_uuid: str) -> None:
|
||||
|
||||
def _linked_book(story: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""The library book for a story. Links the story if CWA has a book with its URL."""
|
||||
if not library_available() and not config.CALIBRE_DB_PATH:
|
||||
if not _can_match_books():
|
||||
if story["calibre_id"] is not None:
|
||||
return {"id": story["calibre_id"], "uuid": story["calibre_uuid"], "title": story["title"]}
|
||||
return None
|
||||
if story["calibre_uuid"]:
|
||||
try:
|
||||
@@ -705,7 +739,7 @@ NOT_FOUND = "Cannot find this story in CWA. Link to library book, or send as new
|
||||
|
||||
|
||||
def _uploads_paused() -> bool:
|
||||
return bool(store.get_setting(None, "pause_uploads"))
|
||||
return config.INGEST_DIR is None or bool(store.get_setting(None, "pause_uploads"))
|
||||
|
||||
|
||||
def _link_changed(story_id: int) -> None:
|
||||
@@ -713,7 +747,9 @@ def _link_changed(story_id: int) -> None:
|
||||
|
||||
|
||||
def _needs_approval(story_id: int) -> bool:
|
||||
return bool(store.get_setting(None, "hold_new_links") and store.get_setting(story_id, "unapproved_link"))
|
||||
return bool(
|
||||
_link_books() and store.get_setting(None, "hold_new_links") and store.get_setting(story_id, "unapproved_link")
|
||||
)
|
||||
|
||||
|
||||
def _release_held_uploads() -> None:
|
||||
@@ -726,6 +762,8 @@ def _release_held_uploads() -> None:
|
||||
|
||||
def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | None:
|
||||
if _uploads_paused():
|
||||
if config.INGEST_DIR is None:
|
||||
return "Not sent. INGEST_DIR is not set."
|
||||
return "Not sent. Turn off pause to send."
|
||||
if not _needs_approval(story["id"]):
|
||||
return None
|
||||
@@ -735,15 +773,14 @@ def _hold_reason(story: dict[str, Any], book: dict[str, Any] | None) -> str | No
|
||||
|
||||
|
||||
def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, title: str, author: str) -> str:
|
||||
# A second plain epub would make a duplicate book, so an update without a library book fails.
|
||||
if not book and story["ingested_at"] and not library_available() and not config.CALIBRE_DB_PATH:
|
||||
raise StoryError("Bindery cannot find the library book to update, because CWA_URL is not set.")
|
||||
if not book and story["ingested_at"]:
|
||||
raise StoryError(NOT_FOUND)
|
||||
held = _hold_reason(story, book)
|
||||
if held:
|
||||
store.set_setting(story["id"], "upload_held", True)
|
||||
return held
|
||||
# With linking on, do not create a duplicate when the library book cannot be found.
|
||||
if _link_books() and not book and story["ingested_at"]:
|
||||
raise StoryError(NOT_FOUND)
|
||||
ingest_folder = IngestFolder(config.INGEST_DIR)
|
||||
if book:
|
||||
ingest_folder.send_update(epub, book["id"], title)
|
||||
message = f"Updated library book {book['id']}"
|
||||
@@ -751,7 +788,7 @@ def _deliver(story: dict[str, Any], epub: Path, book: dict[str, Any] | None, tit
|
||||
ingest_folder.ingest(epub, f"{title} - {author}" if author else title)
|
||||
store.update_story(story["id"], ingested_at=now())
|
||||
story["ingested_at"] = now()
|
||||
message = "Sent to CWA"
|
||||
message = "Sent to CWA" if _link_books() else "Sent to ingest folder"
|
||||
store.set_setting(story["id"], "upload_held", None)
|
||||
store.set_setting(story["id"], "unapproved_link", None)
|
||||
return message
|
||||
@@ -929,7 +966,7 @@ def _run_site_job(job: Job) -> str:
|
||||
if story is None:
|
||||
# A new story. If the library already has it (for example from the
|
||||
# FanFicFare calibre plugin), link it and keep its chapters.
|
||||
found = _find_book(snap.url, [snap.title]) if library_available() else None
|
||||
found = _find_book(snap.url, [snap.title]) if _link_books() and library_available() else None
|
||||
book_id, book_uuid = (found[0].id, found[0].uuid) if found else (None, None)
|
||||
contents = None
|
||||
if found:
|
||||
|
||||
@@ -10,7 +10,31 @@ export function settingsPage(main, _params, config) {
|
||||
document.title = 'Settings · Bindery';
|
||||
const recent = h('input', { id: 'recent-chapters', type: 'number', min: 0, step: 1 });
|
||||
const holdNewLinks = checkbox(false, () => {});
|
||||
const libraryNote = h('p', { class: 'notice', id: 'library-note', hidden: true },
|
||||
'CWA_URL and CALIBRE_DB_PATH are not set. Set one or both and restart Bindery to enable library linking.');
|
||||
const linkNote = h('p', { class: 'notice', id: 'link-note', hidden: true },
|
||||
'Updates without a saved CWA book ID create duplicate books if CWA imports them.');
|
||||
const showLinkState = (enabled) => {
|
||||
holdNewLinks.disabled = !enabled;
|
||||
linkNote.hidden = enabled;
|
||||
linkBooks.setAttribute('aria-describedby',
|
||||
[linkBooks.disabled && 'library-note', !enabled && 'link-note'].filter(Boolean).join(' '));
|
||||
};
|
||||
const linkBooks = checkbox(true, showLinkState);
|
||||
const pauseUploads = checkbox(false, () => {});
|
||||
const ingestNote = h('p', { class: 'notice', id: 'ingest-note', hidden: true },
|
||||
'INGEST_DIR is not set. Uploads stay paused until you set it and restart Bindery.');
|
||||
const showUploadSettings = (settings) => {
|
||||
linkBooks.disabled = settings.linking_available === false;
|
||||
linkBooks.checked = !linkBooks.disabled && (settings.link_books ?? true);
|
||||
libraryNote.hidden = !linkBooks.disabled;
|
||||
holdNewLinks.checked = settings.hold_new_links;
|
||||
showLinkState(linkBooks.checked);
|
||||
pauseUploads.disabled = settings.ingest_available === false;
|
||||
pauseUploads.checked = pauseUploads.disabled || settings.pause_uploads;
|
||||
pauseUploads.setAttribute('aria-describedby', pauseUploads.disabled ? 'ingest-note' : '');
|
||||
ingestNote.hidden = !pauseUploads.disabled;
|
||||
};
|
||||
const hoursField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_hours, step: 1 });
|
||||
const countField = (id) => h('input', { id, type: 'number', min: 0, max: config.job_list_max_count, step: 1 });
|
||||
const storyJobsHours = hoursField('story-jobs-hours');
|
||||
@@ -40,8 +64,7 @@ export function settingsPage(main, _params, config) {
|
||||
api('settings')
|
||||
.then((settings) => {
|
||||
recent.value = settings.recent_chapters;
|
||||
holdNewLinks.checked = settings.hold_new_links;
|
||||
pauseUploads.checked = settings.pause_uploads;
|
||||
showUploadSettings(settings);
|
||||
ini.value = settings.personal_ini;
|
||||
storyJobsHours.value = settings.story_jobs_hours;
|
||||
storyJobsCount.value = settings.story_jobs_count;
|
||||
@@ -83,6 +106,15 @@ export function settingsPage(main, _params, config) {
|
||||
'section',
|
||||
{ class: 'card' },
|
||||
h('h2', {}, 'Uploads to CWA'),
|
||||
h('label', {}, linkBooks, ' Link books to CWA library'),
|
||||
h(
|
||||
'p',
|
||||
{ class: 'hint' },
|
||||
'Find library books through CWA_URL or CALIBRE_DB_PATH. When this is off, uploads use saved CWA book IDs ' +
|
||||
'or send a plain ePub to INGEST_DIR.',
|
||||
),
|
||||
libraryNote,
|
||||
linkNote,
|
||||
h('label', {}, holdNewLinks, ' Hold new links for approval'),
|
||||
h(
|
||||
'p',
|
||||
@@ -92,6 +124,7 @@ export function settingsPage(main, _params, config) {
|
||||
'Later updates of an approved book go to CWA as usual.',
|
||||
),
|
||||
h('label', {}, pauseUploads, ' Pause all uploads'),
|
||||
ingestNote,
|
||||
h(
|
||||
'p',
|
||||
{ class: 'hint' },
|
||||
@@ -102,7 +135,10 @@ export function settingsPage(main, _params, config) {
|
||||
'div',
|
||||
{ class: 'controls' },
|
||||
button('Save', async () => {
|
||||
await api('settings', 'PUT', { hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked });
|
||||
const saved = await api('settings', 'PUT', {
|
||||
link_books: linkBooks.checked, hold_new_links: holdNewLinks.checked, pause_uploads: pauseUploads.checked,
|
||||
});
|
||||
showUploadSettings(saved);
|
||||
toast('Saved');
|
||||
}, { class: 'primary' }),
|
||||
),
|
||||
|
||||
@@ -203,7 +203,7 @@ function storyRow(s) {
|
||||
'td',
|
||||
{ 'data-label': 'Status' },
|
||||
status,
|
||||
s.upload_held && [' ', badge('warn', 'held', 'The book did not go to CWA. See the story page.')],
|
||||
s.approval_held && [' ', badge('warn', 'held', 'The book waits for your approval. See the story page.')],
|
||||
),
|
||||
storyDate('Last change', s.last_changed),
|
||||
storyDate('Last check', s.last_checked),
|
||||
|
||||
+32
-19
@@ -133,7 +133,7 @@ export function storyPage(main, { storyId }, config) {
|
||||
let library = 'Not in the library yet';
|
||||
if (book) library = `Library book ${book.id}`;
|
||||
else if (story.calibre_id) library = `Library book ${story.calibre_id}`;
|
||||
else if (story.ingested_at) library = 'Sent to CWA, unlinked';
|
||||
else if (story.ingested_at) library = details.link_books === false ? 'Sent to ingest folder' : 'Sent to CWA, unlinked';
|
||||
fill(
|
||||
summary,
|
||||
// The version in the URL changes with the image, so the browser keeps the cover until the site changes it.
|
||||
@@ -165,15 +165,18 @@ export function storyPage(main, { storyId }, config) {
|
||||
|
||||
const approve = async (uuid) => {
|
||||
const result = await api(`stories/${id}/approve`, 'POST', { uuid });
|
||||
toast(result.job ? 'Approved. Bindery rebuilds the book and sends it.' : 'Approved');
|
||||
toast(result.job ? 'Approved. Rebuild queued.' : 'Approved');
|
||||
if (result.job) refreshJobs();
|
||||
await load();
|
||||
};
|
||||
|
||||
const approveAsNew = async () => {
|
||||
const message =
|
||||
'Unlink the library book, and send this story to CWA as a new book? If CWA already has the book, you get a ' +
|
||||
'duplicate. In that case, search for the book under "Library link" and link it instead.';
|
||||
const action = details.uploads_paused
|
||||
? 'Unlink the library book, and approve this story as a new book? Uploads remain paused. '
|
||||
: 'Unlink the library book, and send this story to CWA as a new book? ';
|
||||
const message = action +
|
||||
'If CWA already has the book, you get a duplicate. ' +
|
||||
'In that case, search for the book under "Library link" and link it instead.';
|
||||
if (!window.confirm(message)) return;
|
||||
await api(`stories/${id}/link`, 'POST', { uuid: null });
|
||||
await approve(null);
|
||||
@@ -182,15 +185,17 @@ export function storyPage(main, { storyId }, config) {
|
||||
// Why the book did not go to CWA, and the approval of a new library link.
|
||||
function holdNotice() {
|
||||
const { story, book } = details;
|
||||
const paused = story.upload_held && details.uploads_paused;
|
||||
// A story that went to CWA without a link gets its book at the next update, and the approval comes then.
|
||||
const approval = story.needs_approval && (story.calibre_uuid || !story.ingested_at);
|
||||
if (!paused && !approval) return null;
|
||||
let target = 'The next upload goes to CWA as a new book.';
|
||||
const linking = details.link_books !== false;
|
||||
const unlinked = !linking && story.calibre_id == null;
|
||||
// A previously uploaded book needs a known target before approval.
|
||||
const approval = linking && story.needs_approval && (story.calibre_id != null || !story.ingested_at);
|
||||
const paused = details.uploads_paused && (story.upload_held || approval);
|
||||
if (!paused && !approval && !unlinked) return null;
|
||||
let target = 'Approve this story as a new CWA book.';
|
||||
if (book) {
|
||||
target = `The next upload replaces the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
|
||||
} else if (story.calibre_id) {
|
||||
target = `The next upload replaces the ePub of library book ${story.calibre_id}.`;
|
||||
target = `Approve replacing the ePub of library book ${book.id}: ${book.title} · ${authorsText(book.authors)}.`;
|
||||
} else if (story.calibre_id != null) {
|
||||
target = `Approve replacing the ePub of library book ${story.calibre_id}.`;
|
||||
}
|
||||
return h(
|
||||
'div',
|
||||
@@ -199,19 +204,27 @@ export function storyPage(main, { storyId }, config) {
|
||||
h(
|
||||
'p',
|
||||
{},
|
||||
'Uploads to CWA are paused. Bindery sends this book when you turn the pause off in ',
|
||||
link('/settings', 'Settings'),
|
||||
'.',
|
||||
details.ingest_available === false
|
||||
? 'Set INGEST_DIR and restart Bindery to enable uploads.'
|
||||
: ['Uploads to CWA are paused. Resume them in ', link('/settings', 'Settings'), '.'],
|
||||
),
|
||||
unlinked &&
|
||||
h(
|
||||
'p',
|
||||
{},
|
||||
'Uploads of this book have no CWA book ID. If CWA imports them, updates will create duplicate books.',
|
||||
),
|
||||
approval && [
|
||||
h('p', {}, `${story.upload_held ? 'The book waits for your approval. ' : ''}${target} Make sure that this is right.`),
|
||||
h('p', {}, target),
|
||||
h(
|
||||
'div',
|
||||
{ class: 'controls' },
|
||||
button(story.upload_held ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
|
||||
button(story.upload_held && !details.uploads_paused ? 'Approve and send' : 'Approve', () => approve(story.calibre_uuid), {
|
||||
class: 'primary',
|
||||
}),
|
||||
story.calibre_uuid && button('Send as a new book instead', approveAsNew),
|
||||
story.calibre_uuid && button(
|
||||
details.uploads_paused ? 'Approve as a new book instead' : 'Send as a new book instead', approveAsNew,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -427,6 +427,10 @@ label:has(> input[type='radio']) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
label:has(> input[type='checkbox']:disabled) {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.library-results {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
|
||||
@@ -230,6 +230,7 @@ def settings() -> Any:
|
||||
data.get("story_jobs_count"),
|
||||
data.get("main_jobs_hours"),
|
||||
data.get("main_jobs_count"),
|
||||
link_books=data.get("link_books"),
|
||||
)
|
||||
return orchestrator.get_settings()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user