Files
Huang Xin 6b403d019e feat(calibre): add Readest calibre plugin to push books and metadata (#4918)
* feat(calibre): add Readest calibre plugin to push books and metadata (#4863)

Add apps/readest-calibre-plugin, a calibre GUI plugin that uploads
selected books with their metadata into the user's Readest cloud
library, modeled on BookFusion's open-source plugin.

- Selective manual push from the calibre toolbar with per-book status
  (uploaded / updated / up to date / failed) and quota handling
- Books are content-addressed with the same partial MD5 as the apps,
  so re-pushing updates the existing entry instead of duplicating;
  metadata edits re-push without re-uploading the file
- Metadata mapping includes series, tags, identifiers and optional
  calibre custom columns; carries over server-side fields (progress,
  reading status, grouping, cover) that POST /sync would null out
- Auth mirrors readest.koplugin and the desktop app: email/password
  plus browser OAuth (Google/Apple/GitHub/Discord) through a localhost
  callback server with the fragment-to-query relay
- Pure-logic modules (api.py, wire.py, oauth.py) are calibre-free and
  covered by 56 unit tests (make test); make zip builds the plugin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): package readest-calibre-plugin in releases

Mirror the KOReader plugin packaging: a build-calibre-plugin job stamps
PLUGIN_VERSION in __init__.py with the release version from
apps/readest-app/package.json, builds the zip via make, and uploads
Readest-<version>.calibre-plugin.zip to the GitHub release. The version
committed in git stays a development placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(agent): add calibre plugin project memory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(calibre): embed metadata in OPF and dedupe by calibre uuid

Rework book identity so metadata can be embedded into the uploaded file
without creating duplicates, as requested in review:

- Embed calibre metadata (including custom columns) into a temporary
  copy of the book file at upload time via calibre's set_metadata; the
  library file is never modified
- Dedupe by the calibre book uuid carried in the entry's metadata
  identifier, which survives file-byte changes, with a live-row
  preference when both hash and uuid match rows
- Detect file content changes via calibreSourceHash, the raw library
  file fingerprint stored in the pushed metadata, so detection works
  from any machine; v1 rows fall back to book_hash which equals the
  raw hash for them
- A changed file now replaces the old entry in one sync push (new row
  with carried-over reading status, grouping, progress and created
  date, plus a tombstone for the old row) and deletes the old cloud
  files to reclaim quota
- Metadata-only edits still update the library entry without
  re-uploading the file

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(calibre): set copyright holder to Bilingify LLC

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:40:16 +02:00

226 lines
7.7 KiB
Python

__license__ = 'AGPL v3'
__copyright__ = '2026, Bilingify LLC'
from qt.core import (
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QProgressBar,
QPushButton,
QTableWidget,
QTableWidgetItem,
QThread,
QUrl,
QVBoxLayout,
pyqtSignal,
)
from calibre.gui2 import error_dialog, open_url
from calibre_plugins.readest.api import ReadestAPIError
from calibre_plugins.readest.oauth import PROVIDERS, OAuthCallbackServer, build_authorize_url
from calibre_plugins.readest.worker import STATUS_LABELS, PushWorker
OAUTH_WAIT_SECONDS = 300
class _OAuthWaiter(QThread):
got_tokens = pyqtSignal(dict)
timed_out = pyqtSignal()
def __init__(self, parent, server):
QThread.__init__(self, parent)
self.server = server
def run(self):
tokens = self.server.wait(OAUTH_WAIT_SECONDS)
if tokens:
self.got_tokens.emit(tokens)
else:
self.timed_out.emit()
class LoginDialog(QDialog):
"""Email/password login plus browser OAuth (Google, Apple, GitHub, Discord)."""
def __init__(self, parent, client):
QDialog.__init__(self, parent)
self.client = client
self.user = None
self.oauth_server = None
self.oauth_waiter = None
self.setWindowTitle('Log in to Readest')
layout = QVBoxLayout()
self.setLayout(layout)
form = QFormLayout()
self.email_edit = QLineEdit(self)
self.password_edit = QLineEdit(self)
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
form.addRow('Email:', self.email_edit)
form.addRow('Password:', self.password_edit)
layout.addLayout(form)
self.login_btn = QPushButton('Log in', self)
self.login_btn.clicked.connect(self.password_login)
layout.addWidget(self.login_btn)
layout.addWidget(QLabel('Or sign in with your browser:'))
providers_layout = QHBoxLayout()
for provider in PROVIDERS:
btn = QPushButton(provider.capitalize(), self)
btn.clicked.connect(lambda _=False, p=provider: self.oauth_login(p))
providers_layout.addWidget(btn)
layout.addLayout(providers_layout)
self.status_label = QLabel('')
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def password_login(self):
email = self.email_edit.text().strip()
password = self.password_edit.text()
if not email or not password:
self.status_label.setText('Please enter both email and password.')
return
self.status_label.setText('Logging in…')
self.login_btn.setEnabled(False)
try:
self.user = self.client.sign_in_password(email, password)
except ReadestAPIError as err:
self.status_label.setText('Login failed: %s' % err)
self.login_btn.setEnabled(True)
return
self.accept()
def oauth_login(self, provider):
self.stop_oauth()
self.oauth_server = OAuthCallbackServer()
port = self.oauth_server.start()
self.oauth_waiter = _OAuthWaiter(self, self.oauth_server)
self.oauth_waiter.got_tokens.connect(self.oauth_finished)
self.oauth_waiter.timed_out.connect(
lambda: self.status_label.setText('Browser login timed out. Try again.')
)
self.oauth_waiter.start()
self.status_label.setText('Waiting for the browser login to complete…')
open_url(QUrl(build_authorize_url(self.client.supabase_url, provider, port)))
def oauth_finished(self, tokens):
if tokens.get('error'):
self.status_label.setText(
'Login failed: %s' % (tokens.get('error_description') or tokens['error'])
)
return
if not tokens.get('access_token') or not tokens.get('refresh_token'):
self.status_label.setText('Login failed: the browser callback carried no session.')
return
self.client.set_session(tokens)
try:
self.user = self.client.get_user()
except ReadestAPIError as err:
self.status_label.setText('Login failed: %s' % err)
return
self.accept()
def stop_oauth(self):
# Disconnect before stopping: stop() wakes the waiter thread, and its
# signals must not fire into a dialog that is going away.
if self.oauth_waiter:
try:
self.oauth_waiter.got_tokens.disconnect()
self.oauth_waiter.timed_out.disconnect()
except TypeError:
pass
if self.oauth_server:
self.oauth_server.stop()
self.oauth_server = None
if self.oauth_waiter:
self.oauth_waiter.wait(2000)
self.oauth_waiter = None
def done(self, result):
self.stop_oauth()
QDialog.done(self, result)
class PushDialog(QDialog):
"""Per-book status table for a push run, modeled on BookFusion's sync log."""
def __init__(self, parent, db, book_ids, client, include_custom_columns):
QDialog.__init__(self, parent)
self.db = db
self.worker = PushWorker(self, db, book_ids, client, include_custom_columns)
self.worker.progress.connect(self.on_progress)
self.worker.book_status.connect(self.on_book_status)
self.worker.done.connect(self.on_done)
self.setWindowTitle('Push to Readest')
self.setMinimumSize(520, 380)
layout = QVBoxLayout()
self.setLayout(layout)
count = len(book_ids)
layout.addWidget(
QLabel('Pushing %d %s to your Readest library…' % (count, _plural(count)))
)
self.progress_bar = QProgressBar(self)
self.progress_bar.setRange(0, count)
layout.addWidget(self.progress_bar)
self.table = QTableWidget(0, 3, self)
self.table.setHorizontalHeaderLabels(['Book', 'Status', 'Details'])
self.table.horizontalHeader().setStretchLastSection(True)
self.table.setColumnWidth(0, 220)
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
layout.addWidget(self.table)
self.summary_label = QLabel('')
self.summary_label.setWordWrap(True)
layout.addWidget(self.summary_label)
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
self.buttons.rejected.connect(self.reject)
layout.addWidget(self.buttons)
self.worker.start()
def on_progress(self, done, total):
self.progress_bar.setValue(done)
def on_book_status(self, book_id, status, detail):
title = self.db.field_for('title', book_id) or 'Unknown'
row = self.table.rowCount()
self.table.insertRow(row)
self.table.setItem(row, 0, QTableWidgetItem(title))
self.table.setItem(row, 1, QTableWidgetItem(STATUS_LABELS.get(status, status)))
self.table.setItem(row, 2, QTableWidgetItem(detail))
self.table.scrollToBottom()
def on_done(self, ok, message):
self.progress_bar.setValue(self.progress_bar.maximum())
self.summary_label.setText(message)
self.buttons.setStandardButtons(QDialogButtonBox.StandardButton.Close)
if not ok and self.table.rowCount() == 0:
error_dialog(self, 'Push to Readest failed', message, show=True)
def reject(self):
if self.worker.isRunning():
self.worker.cancel()
self.summary_label.setText('Canceling…')
return
QDialog.reject(self)
def _plural(count):
return 'book' if count == 1 else 'books'