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>
92 lines
3.2 KiB
Python
92 lines
3.2 KiB
Python
import os
|
|
import sys
|
|
import unittest
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
from oauth import OAuthCallbackServer, build_authorize_url, parse_callback_query # noqa: E402
|
|
|
|
|
|
class AuthorizeUrlTest(unittest.TestCase):
|
|
def test_url_shape(self):
|
|
url = build_authorize_url('https://sb.example.com', 'google', 43210)
|
|
parsed = urllib.parse.urlparse(url)
|
|
query = urllib.parse.parse_qs(parsed.query)
|
|
self.assertEqual(parsed.scheme, 'https')
|
|
self.assertEqual(parsed.netloc, 'sb.example.com')
|
|
self.assertEqual(parsed.path, '/auth/v1/authorize')
|
|
self.assertEqual(query['provider'], ['google'])
|
|
self.assertEqual(query['redirect_to'], ['http://localhost:43210'])
|
|
|
|
|
|
class ParseCallbackQueryTest(unittest.TestCase):
|
|
def test_tokens(self):
|
|
tokens = parse_callback_query(
|
|
'access_token=at&refresh_token=rt&expires_at=123&expires_in=3600&token_type=bearer'
|
|
)
|
|
self.assertEqual(tokens['access_token'], 'at')
|
|
self.assertEqual(tokens['refresh_token'], 'rt')
|
|
self.assertEqual(tokens['expires_at'], 123)
|
|
self.assertEqual(tokens['expires_in'], 3600)
|
|
|
|
def test_error(self):
|
|
tokens = parse_callback_query('error=access_denied&error_description=Denied')
|
|
self.assertEqual(tokens['error'], 'access_denied')
|
|
self.assertEqual(tokens['error_description'], 'Denied')
|
|
|
|
def test_missing_tokens(self):
|
|
self.assertEqual(parse_callback_query(''), {})
|
|
|
|
|
|
class CallbackServerTest(unittest.TestCase):
|
|
def test_full_roundtrip(self):
|
|
server = OAuthCallbackServer()
|
|
port = server.start()
|
|
try:
|
|
# First request: Supabase redirects to "/" with tokens in the URL
|
|
# fragment, which never reaches the server — it must serve a page
|
|
# whose script forwards the fragment as query params.
|
|
with urllib.request.urlopen(f'http://127.0.0.1:{port}/', timeout=5) as res:
|
|
page = res.read().decode('utf-8')
|
|
self.assertIn('location.hash', page)
|
|
self.assertIn('/callback', page)
|
|
|
|
with urllib.request.urlopen(
|
|
f'http://127.0.0.1:{port}/callback?access_token=at&refresh_token=rt'
|
|
'&expires_at=123&expires_in=3600',
|
|
timeout=5,
|
|
) as res:
|
|
self.assertEqual(res.status, 200)
|
|
|
|
tokens = server.wait(timeout=5)
|
|
self.assertIsNotNone(tokens)
|
|
self.assertEqual(tokens['access_token'], 'at')
|
|
self.assertEqual(tokens['refresh_token'], 'rt')
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_wait_timeout(self):
|
|
server = OAuthCallbackServer()
|
|
server.start()
|
|
try:
|
|
self.assertIsNone(server.wait(timeout=0.1))
|
|
finally:
|
|
server.stop()
|
|
|
|
def test_stop_wakes_waiter(self):
|
|
import threading
|
|
import time
|
|
|
|
server = OAuthCallbackServer()
|
|
server.start()
|
|
threading.Timer(0.1, server.stop).start()
|
|
started = time.monotonic()
|
|
self.assertIsNone(server.wait(timeout=10))
|
|
self.assertLess(time.monotonic() - started, 5)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|