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>
107 lines
3.5 KiB
Python
107 lines
3.5 KiB
Python
__license__ = 'AGPL v3'
|
|
__copyright__ = '2026, Bilingify LLC'
|
|
|
|
"""Browser OAuth sign-in via a localhost callback server.
|
|
|
|
Supabase redirects OAuth logins to the whitelisted http://localhost:{port}
|
|
with the session tokens in the URL *fragment* — the same flow readest-app's
|
|
desktop custom-OAuth mode uses (tauri-plugin-oauth). Fragments never reach an
|
|
HTTP server, so the first response serves a page whose script forwards the
|
|
fragment as query parameters to /callback. Standard-library only.
|
|
"""
|
|
|
|
import threading
|
|
import urllib.parse
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
PROVIDERS = ('google', 'apple', 'github', 'discord')
|
|
|
|
LANDING_PAGE = b"""<!DOCTYPE html>
|
|
<html><head><title>Readest Login</title></head><body>
|
|
<p>Completing login…</p>
|
|
<script>
|
|
var hash = window.location.hash.replace(/^#/, '');
|
|
window.location.replace('/callback?' + hash);
|
|
</script>
|
|
</body></html>"""
|
|
|
|
DONE_PAGE = b"""<!DOCTYPE html>
|
|
<html><head><title>Readest Login</title></head><body>
|
|
<p>Login complete. You can close this tab and return to calibre.</p>
|
|
</body></html>"""
|
|
|
|
|
|
def build_authorize_url(supabase_url, provider, port):
|
|
return '%s/auth/v1/authorize?%s' % (
|
|
supabase_url.rstrip('/'),
|
|
urllib.parse.urlencode({'provider': provider, 'redirect_to': 'http://localhost:%d' % port}),
|
|
)
|
|
|
|
|
|
def parse_callback_query(query):
|
|
"""Extract session tokens (or an OAuth error) from the callback query."""
|
|
params = urllib.parse.parse_qs(query)
|
|
result = {}
|
|
for key in ('access_token', 'refresh_token', 'error', 'error_description'):
|
|
if key in params:
|
|
result[key] = params[key][0]
|
|
for key in ('expires_at', 'expires_in'):
|
|
if key in params:
|
|
try:
|
|
result[key] = int(params[key][0])
|
|
except ValueError:
|
|
pass
|
|
return result
|
|
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
if parsed.path == '/callback':
|
|
tokens = parse_callback_query(parsed.query)
|
|
self._respond(DONE_PAGE)
|
|
if tokens:
|
|
self.server.oauth_result = tokens
|
|
self.server.oauth_event.set()
|
|
else:
|
|
self._respond(LANDING_PAGE)
|
|
|
|
def _respond(self, page):
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
self.send_header('Content-Length', str(len(page)))
|
|
self.end_headers()
|
|
self.wfile.write(page)
|
|
|
|
def log_message(self, *args):
|
|
pass # keep calibre's stdout clean
|
|
|
|
|
|
class OAuthCallbackServer:
|
|
def __init__(self):
|
|
self._server = None
|
|
self._thread = None
|
|
|
|
def start(self):
|
|
"""Bind to an ephemeral port and serve in a daemon thread."""
|
|
self._server = HTTPServer(('127.0.0.1', 0), _Handler)
|
|
self._server.oauth_result = None
|
|
self._server.oauth_event = threading.Event()
|
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
self._thread.start()
|
|
return self._server.server_address[1]
|
|
|
|
def wait(self, timeout):
|
|
"""Tokens dict once the callback arrives, or None on timeout/stop."""
|
|
server = self._server
|
|
if server is None or not server.oauth_event.wait(timeout):
|
|
return None
|
|
return server.oauth_result
|
|
|
|
def stop(self):
|
|
server, self._server = self._server, None
|
|
if server:
|
|
server.oauth_event.set() # wake any wait()er (returns None)
|
|
server.shutdown()
|
|
server.server_close()
|