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>
235 lines
8.1 KiB
Python
235 lines
8.1 KiB
Python
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
from api import ( # noqa: E402
|
|
AuthRequiredError,
|
|
QuotaExceededError,
|
|
ReadestAPIError,
|
|
ReadestClient,
|
|
)
|
|
|
|
API_BASE = 'https://web.example.com/api'
|
|
SUPABASE = 'https://sb.example.com'
|
|
ANON_KEY = 'anon-key'
|
|
|
|
|
|
class FakeTransport:
|
|
"""Records requests; replies from a queue of (status, json_body) tuples."""
|
|
|
|
def __init__(self):
|
|
self.requests = []
|
|
self.responses = []
|
|
|
|
def queue(self, status, body):
|
|
self.responses.append((status, body))
|
|
|
|
def __call__(self, request, timeout):
|
|
data = request.data
|
|
if hasattr(data, 'read'):
|
|
data = data.read()
|
|
self.requests.append(
|
|
{
|
|
'method': request.get_method(),
|
|
'url': request.full_url,
|
|
'headers': {k.lower(): v for k, v in request.header_items()},
|
|
'body': data,
|
|
}
|
|
)
|
|
status, body = self.responses.pop(0)
|
|
payload = body if isinstance(body, bytes) else json.dumps(body).encode('utf-8')
|
|
return status, payload
|
|
|
|
|
|
def make_client(transport, tokens=None, saved=None):
|
|
def on_tokens(t):
|
|
if saved is not None:
|
|
saved.append(t)
|
|
|
|
return ReadestClient(
|
|
api_base=API_BASE,
|
|
supabase_url=SUPABASE,
|
|
anon_key=ANON_KEY,
|
|
tokens=tokens,
|
|
on_tokens=on_tokens,
|
|
transport=transport,
|
|
)
|
|
|
|
|
|
def valid_tokens(expires_in=3600):
|
|
return {
|
|
'access_token': 'at',
|
|
'refresh_token': 'rt',
|
|
'expires_at': int(time.time()) + expires_in,
|
|
'expires_in': expires_in,
|
|
}
|
|
|
|
|
|
class SignInTest(unittest.TestCase):
|
|
def test_password_sign_in_stores_tokens(self):
|
|
transport = FakeTransport()
|
|
saved = []
|
|
transport.queue(
|
|
200,
|
|
{
|
|
'access_token': 'new-at',
|
|
'refresh_token': 'new-rt',
|
|
'expires_at': 1234,
|
|
'expires_in': 3600,
|
|
'user': {'id': 'u1', 'email': 'a@b.c'},
|
|
},
|
|
)
|
|
client = make_client(transport, saved=saved)
|
|
user = client.sign_in_password('a@b.c', 'pw')
|
|
|
|
req = transport.requests[0]
|
|
self.assertEqual(req['method'], 'POST')
|
|
self.assertEqual(req['url'], f'{SUPABASE}/auth/v1/token?grant_type=password')
|
|
self.assertEqual(req['headers']['apikey'], ANON_KEY)
|
|
self.assertEqual(json.loads(req['body']), {'email': 'a@b.c', 'password': 'pw'})
|
|
self.assertEqual(user['id'], 'u1')
|
|
self.assertEqual(saved[-1]['access_token'], 'new-at')
|
|
|
|
def test_failed_sign_in_raises_with_message(self):
|
|
transport = FakeTransport()
|
|
transport.queue(400, {'error_description': 'Invalid login credentials'})
|
|
client = make_client(transport)
|
|
with self.assertRaises(ReadestAPIError) as ctx:
|
|
client.sign_in_password('a@b.c', 'bad')
|
|
self.assertIn('Invalid login credentials', str(ctx.exception))
|
|
|
|
|
|
class TokenRefreshTest(unittest.TestCase):
|
|
def test_fresh_token_not_refreshed(self):
|
|
transport = FakeTransport()
|
|
transport.queue(200, {'books': []})
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
client.pull_books()
|
|
self.assertEqual(len(transport.requests), 1) # no refresh round-trip
|
|
|
|
def test_expiring_token_refreshed_before_call(self):
|
|
transport = FakeTransport()
|
|
saved = []
|
|
transport.queue(
|
|
200,
|
|
{'access_token': 'at2', 'refresh_token': 'rt2', 'expires_at': 99, 'expires_in': 3600},
|
|
)
|
|
transport.queue(200, {'books': []})
|
|
tokens = valid_tokens()
|
|
tokens['expires_at'] = int(time.time()) + 10 # nearly expired
|
|
client = make_client(transport, tokens=tokens, saved=saved)
|
|
client.pull_books()
|
|
|
|
self.assertEqual(
|
|
transport.requests[0]['url'],
|
|
f'{SUPABASE}/auth/v1/token?grant_type=refresh_token',
|
|
)
|
|
self.assertEqual(json.loads(transport.requests[0]['body']), {'refresh_token': 'rt'})
|
|
self.assertEqual(transport.requests[1]['headers']['authorization'], 'Bearer at2')
|
|
self.assertEqual(saved[-1]['access_token'], 'at2')
|
|
|
|
def test_no_tokens_raises_auth_required(self):
|
|
client = make_client(FakeTransport())
|
|
with self.assertRaises(AuthRequiredError):
|
|
client.pull_books()
|
|
|
|
|
|
class SyncTest(unittest.TestCase):
|
|
def test_pull_books(self):
|
|
transport = FakeTransport()
|
|
transport.queue(200, {'books': [{'book_hash': 'h1'}]})
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
books = client.pull_books()
|
|
|
|
req = transport.requests[0]
|
|
self.assertEqual(req['method'], 'GET')
|
|
self.assertEqual(req['url'], f'{API_BASE}/sync?type=books&since=0')
|
|
self.assertEqual(req['headers']['authorization'], 'Bearer at')
|
|
self.assertEqual(books, [{'book_hash': 'h1'}])
|
|
|
|
def test_push_books(self):
|
|
transport = FakeTransport()
|
|
transport.queue(200, {'books': [{'book_hash': 'h1'}]})
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
client.push_books([{'hash': 'h1', 'title': 'T'}])
|
|
|
|
req = transport.requests[0]
|
|
self.assertEqual(req['method'], 'POST')
|
|
self.assertEqual(req['url'], f'{API_BASE}/sync')
|
|
body = json.loads(req['body'])
|
|
self.assertEqual(body['books'][0]['hash'], 'h1')
|
|
self.assertEqual(body['notes'], [])
|
|
self.assertEqual(body['configs'], [])
|
|
|
|
|
|
class StorageTest(unittest.TestCase):
|
|
def test_get_upload_url(self):
|
|
transport = FakeTransport()
|
|
transport.queue(200, {'uploadUrl': 'https://s3/put', 'fileKey': 'k'})
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
res = client.get_upload_url('Readest/Books/h/h.epub', 5, 'h')
|
|
|
|
body = json.loads(transport.requests[0]['body'])
|
|
self.assertEqual(body['fileName'], 'Readest/Books/h/h.epub')
|
|
self.assertEqual(body['fileSize'], 5)
|
|
self.assertEqual(body['bookHash'], 'h')
|
|
self.assertEqual(res['uploadUrl'], 'https://s3/put')
|
|
|
|
def test_quota_exceeded(self):
|
|
transport = FakeTransport()
|
|
transport.queue(403, {'error': 'Insufficient storage quota', 'usage': 1})
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
with self.assertRaises(QuotaExceededError):
|
|
client.get_upload_url('f', 5, 'h')
|
|
|
|
def test_list_files(self):
|
|
transport = FakeTransport()
|
|
transport.queue(200, {'files': [{'file_key': 'u/Readest/Books/h/h.epub'}]})
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
files = client.list_files('h')
|
|
|
|
req = transport.requests[0]
|
|
self.assertEqual(req['method'], 'GET')
|
|
self.assertEqual(req['url'], f'{API_BASE}/storage/list?bookHash=h')
|
|
self.assertEqual(files, [{'file_key': 'u/Readest/Books/h/h.epub'}])
|
|
|
|
def test_delete_file(self):
|
|
transport = FakeTransport()
|
|
transport.queue(200, {'success': True})
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
client.delete_file('u/Readest/Books/h/h.epub')
|
|
|
|
req = transport.requests[0]
|
|
self.assertEqual(req['method'], 'DELETE')
|
|
self.assertEqual(
|
|
req['url'],
|
|
f'{API_BASE}/storage/delete?fileKey=u%2FReadest%2FBooks%2Fh%2Fh.epub',
|
|
)
|
|
|
|
def test_put_file_sends_content_length(self):
|
|
transport = FakeTransport()
|
|
transport.queue(200, b'')
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
client.put_file('https://s3/put', io.BytesIO(b'12345'), 5)
|
|
|
|
req = transport.requests[0]
|
|
self.assertEqual(req['method'], 'PUT')
|
|
self.assertEqual(req['headers']['content-length'], '5')
|
|
self.assertEqual(req['body'], b'12345')
|
|
|
|
def test_put_file_failure_raises(self):
|
|
transport = FakeTransport()
|
|
transport.queue(500, b'<Error><Code>InternalError</Code></Error>')
|
|
client = make_client(transport, tokens=valid_tokens())
|
|
with self.assertRaises(ReadestAPIError):
|
|
client.put_file('https://s3/put', io.BytesIO(b'x'), 1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|