forked from akai/readest
6b403d019e
* 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>
226 lines
7.7 KiB
Python
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'
|