From 6b403d019e60057367916d41c25c01b619bb74af Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 4 Jul 2026 23:40:16 +0900 Subject: [PATCH] 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 * 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-.calibre-plugin.zip to the GitHub release. The version committed in git stays a development placeholder. Co-Authored-By: Claude Fable 5 * chore(agent): add calibre plugin project memory Co-Authored-By: Claude Fable 5 * 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 * chore(calibre): set copyright holder to Bilingify LLC Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 27 ++ apps/readest-app/.claude/memory/MEMORY.md | 1 + .../memory/calibre-plugin-push-4863.md | 23 ++ apps/readest-calibre-plugin/.gitignore | 2 + apps/readest-calibre-plugin/Makefile | 28 ++ apps/readest-calibre-plugin/README.md | 98 +++++ apps/readest-calibre-plugin/__init__.py | 31 ++ apps/readest-calibre-plugin/api.py | 332 +++++++++++++++++ apps/readest-calibre-plugin/config.py | 50 +++ apps/readest-calibre-plugin/dialogs.py | 225 ++++++++++++ apps/readest-calibre-plugin/images/icon.png | Bin 0 -> 21578 bytes apps/readest-calibre-plugin/oauth.py | 106 ++++++ .../plugin-import-name-readest.txt | 0 .../tests/test_client.py | 234 ++++++++++++ .../tests/test_hashes.py | 116 ++++++ .../tests/test_oauth.py | 91 +++++ .../readest-calibre-plugin/tests/test_wire.py | 346 ++++++++++++++++++ apps/readest-calibre-plugin/ui.py | 115 ++++++ apps/readest-calibre-plugin/wire.py | 292 +++++++++++++++ apps/readest-calibre-plugin/worker.py | 267 ++++++++++++++ 20 files changed, 2384 insertions(+) create mode 100644 apps/readest-app/.claude/memory/calibre-plugin-push-4863.md create mode 100644 apps/readest-calibre-plugin/.gitignore create mode 100644 apps/readest-calibre-plugin/Makefile create mode 100644 apps/readest-calibre-plugin/README.md create mode 100644 apps/readest-calibre-plugin/__init__.py create mode 100644 apps/readest-calibre-plugin/api.py create mode 100644 apps/readest-calibre-plugin/config.py create mode 100644 apps/readest-calibre-plugin/dialogs.py create mode 100644 apps/readest-calibre-plugin/images/icon.png create mode 100644 apps/readest-calibre-plugin/oauth.py create mode 100644 apps/readest-calibre-plugin/plugin-import-name-readest.txt create mode 100644 apps/readest-calibre-plugin/tests/test_client.py create mode 100644 apps/readest-calibre-plugin/tests/test_hashes.py create mode 100644 apps/readest-calibre-plugin/tests/test_oauth.py create mode 100644 apps/readest-calibre-plugin/tests/test_wire.py create mode 100644 apps/readest-calibre-plugin/ui.py create mode 100644 apps/readest-calibre-plugin/wire.py create mode 100644 apps/readest-calibre-plugin/worker.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bb5bade..e035e0b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,33 @@ jobs: echo "Uploading ${plugin_zip} to GitHub release" gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber + build-calibre-plugin: + needs: get-release + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: create calibre plugin zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=${{ needs.get-release.outputs.release_version }} + + # Stamp PLUGIN_VERSION in __init__.py with the release version + # (from apps/readest-app/package.json, mirroring the koplugin's + # _meta.lua stamp above); the committed value is a dev placeholder. + version_tuple=$(echo "${version}" | awk -F. '{printf "(%d, %d, %d)", $1, $2, $3}') + perl -i -pe "s/^PLUGIN_VERSION = \(\d+, \d+, \d+\)/PLUGIN_VERSION = ${version_tuple}/" \ + apps/readest-calibre-plugin/__init__.py + + make -C apps/readest-calibre-plugin zip + plugin_zip="apps/readest-calibre-plugin/dist/Readest-${version}.calibre-plugin.zip" + + echo "Uploading ${plugin_zip} to GitHub release" + gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber + build-tauri: needs: get-release permissions: diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 5842581d..2a14bd03 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -114,6 +114,7 @@ - OPDS: [Firefox strict-XML #4479](opds-firefox-strict-xml-4479.md) junk after `` slice to last close; [JSON search #4502](opds2-json-search-4502.md) expand `{?query}` BEFORE resolveURL; [HTML desc #4503](opds-html-description-4503.md) decode-once+sanitize; [self-link meta #4749](opds-self-link-metadata-4749.md) `rel:self` deref; [popular dedup #4782](opds-popular-catalog-dedup-4782.md) - [D-pad Navigation](dpad-navigation.md) — Android TV remote / arrow-key nav - [koplugin cover upload (#4374)](koplugin-cover-upload.md) — `extractLocalCover` via `getCoverImage` +- [Calibre plugin push #4863](calibre-plugin-push-4863.md) apps/readest-calibre-plugin; OAuth localhost relay + /sync carry-over ## Library Fixes - [Book action platform surfaces](book-actions-platform-surfaces.md) — context menu Tauri-desktop-only; cross-platform - [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — single `await Menu.new({ items })` diff --git a/apps/readest-app/.claude/memory/calibre-plugin-push-4863.md b/apps/readest-app/.claude/memory/calibre-plugin-push-4863.md new file mode 100644 index 00000000..beac2b7f --- /dev/null +++ b/apps/readest-app/.claude/memory/calibre-plugin-push-4863.md @@ -0,0 +1,23 @@ +--- +name: calibre-plugin-push-4863 +description: "readest-calibre-plugin (#4863) pushes calibre books+metadata to Readest cloud; key protocol facts (OAuth localhost relay, /sync explicit-null carry-over)" +metadata: + node_type: memory + type: project + originSessionId: 5d4d83a0-0aee-4200-852f-555df5243bed +--- + +`apps/readest-calibre-plugin/` (added 2026-07-04, commit 29a42853d on dev) implements #4863: calibre GUI plugin pushing selected books + metadata into the Readest cloud, modeled on BookFusion's plugin. + +Design decisions and hard-won protocol facts: +- **Identity**: `Book.hash` = partial MD5 (KOReader algorithm; JS `1024 << -2` wraps to 0, so offsets are 0, 1024, 4096, ... 1024<<20). metaHash = `md5(NFC("title|authors,|ids,"))`, preferred id scheme uuid > calibre > isbn; Python impl verified byte-identical to `js-md5` output. +- **OPF embedding + uuid dedup** (v2, per maintainer request): metadata IS embedded into a temp copy at upload (`calibre.ebooks.metadata.meta.set_metadata` — deterministic for EPUB, writes custom columns as `calibre:user_metadata`). Dedup keys: calibre uuid in row `metadata.identifier` (survives byte changes) + `metadata.calibreSourceHash` = raw library-file partialMD5 (change detection, no local state; v1 rows fall back to `book_hash` which equals the raw hash). File changed → replace flow: upload new blob, push new row (carry-over) + tombstone old in one /sync POST, best-effort delete old cloud files. Metadata-only edit → row update, no re-upload (embedded OPF goes stale until next file upload). +- **POST /sync explicit-nulls absent fields** (transformBookToDB) — updates must carry over `uploadedAt`, `groupId/Name`, `progress`, `readingStatus*`, `coverHash` from the pulled server row (`wire.py::merge_for_push`); same lesson as koplugin syncbooks.lua. +- **Upload key** `Readest/Books/{hash}/{hash}.{ext}`; app's `{title}.{ext}` downloads resolve via download.ts hash+extension fallback. cover.png stores *original* bytes (app never converts formats, bookService.ts:568), so calibre's cover.jpg bytes upload as-is; coverHash = partialMD5 of those bytes. +- **OAuth from a non-app client works**: `{supabase}/auth/v1/authorize?provider=X&redirect_to=http://localhost:PORT` is whitelisted (readest-app's Flatpak/custom-OAuth production path uses it). Tokens arrive in the URL *fragment*; serve a page whose JS relays `location.hash` to `/callback?...` (tauri-plugin-oauth trick). Implemented in `oauth.py`. +- Pure modules (`api.py`, `wire.py`, `oauth.py`) are calibre-free; `make test` runs 56 unittests; `make zip` builds; smoke-test inside calibre with `calibre-debug -c` after `from calibre.customize.ui import find_plugin` (initializes the `calibre_plugins` namespace). + +- **Release packaging** (PR #4918): `build-calibre-plugin` job in release.yml mirrors the koplugin job; perl-stamps `PLUGIN_VERSION` from readest-app package.json, `make zip` → `Readest-.calibre-plugin.zip` release asset. Committed version stays the (0, 1, 0) dev placeholder. +- **Pushing workflow files**: gh's OAuth token lacks `workflow` scope (HTTPS push of .github/workflows/* rejected); SSH push works (transient hangs — retry with ConnectTimeout/ServerAliveInterval). + +Related: [[koplugin-cover-upload]], [[grimmory-native-sync]], [[ci-pr-delivery-and-push]] diff --git a/apps/readest-calibre-plugin/.gitignore b/apps/readest-calibre-plugin/.gitignore new file mode 100644 index 00000000..2ead01d0 --- /dev/null +++ b/apps/readest-calibre-plugin/.gitignore @@ -0,0 +1,2 @@ +dist/ +__pycache__/ diff --git a/apps/readest-calibre-plugin/Makefile b/apps/readest-calibre-plugin/Makefile new file mode 100644 index 00000000..5eedd42d --- /dev/null +++ b/apps/readest-calibre-plugin/Makefile @@ -0,0 +1,28 @@ +NAME = Readest +VERSION = $(shell python3 -c "import re; \ + m = re.search(r'PLUGIN_VERSION = \((\d+), (\d+), (\d+)\)', open('__init__.py').read()); \ + print('.'.join(m.groups()))") +ZIP = dist/$(NAME)-$(VERSION).calibre-plugin.zip + +FILES = __init__.py api.py config.py dialogs.py oauth.py ui.py wire.py worker.py \ + plugin-import-name-readest.txt images/icon.png + +.PHONY: zip test install clean + +zip: $(ZIP) + +$(ZIP): $(FILES) + mkdir -p dist + rm -f $(ZIP) + zip -q $(ZIP) $(FILES) + @echo Built $(ZIP) + +test: + python3 -m unittest discover -s tests + +# Load the plugin into a locally installed calibre for development. +install: zip + calibre-customize -a $(ZIP) + +clean: + rm -rf dist diff --git a/apps/readest-calibre-plugin/README.md b/apps/readest-calibre-plugin/README.md new file mode 100644 index 00000000..cd39315b --- /dev/null +++ b/apps/readest-calibre-plugin/README.md @@ -0,0 +1,98 @@ +# Readest calibre plugin + +A [calibre](https://calibre-ebook.com) GUI plugin that pushes selected books — +with their metadata — into your [Readest](https://readest.com) cloud library. +Re-pushing a book updates its existing entry instead of creating a duplicate, +so you can edit metadata in calibre and re-send it any time. + +Implements [readest/readest#4863](https://github.com/readest/readest/issues/4863). + +## Features + +- **Selective, manual push**: select books in calibre, click *Readest* in the + toolbar. Nothing syncs in the background. +- **Metadata included and embedded**: title, authors, series, tags, + description, publisher, language, identifiers — and optionally calibre + custom columns. Metadata is written both to the Readest library entry + (custom columns under `customColumns`) and into the uploaded file's OPF + (calibre's own embedding, so custom columns travel as + `calibre:user_metadata`). Your calibre library files are never modified — + embedding happens on a temporary copy. +- **Update on re-push**: books already in your Readest library are recognized + by their calibre uuid and only changed entries are rewritten; unchanged + books are skipped, and a changed file replaces the old entry instead of + duplicating it. Reading progress, grouping and reading status in Readest + are preserved. +- **Per-book status report**: uploaded / updated / up to date / failed, with a + storage-quota check (the push stops cleanly when your quota is exhausted). +- **Login like the apps**: email + password, or browser sign-in with Google, + Apple, GitHub or Discord (OAuth via a temporary localhost callback, the same + flow the desktop app uses). + +## Install + +Download `Readest-.calibre-plugin.zip` from the +[latest release](https://github.com/readest/readest/releases/latest), or build +it yourself: + +```sh +make zip # builds dist/Readest-.calibre-plugin.zip +calibre-customize -a dist/Readest-*.calibre-plugin.zip # or: make install +``` + +Or in calibre: *Preferences → Plugins → Load plugin from file*, then restart +calibre and add the *Readest* button to the main toolbar if it is not visible. + +Release zips are versioned from `apps/readest-app/package.json` by the release +workflow, which stamps `PLUGIN_VERSION` in `__init__.py` before zipping; the +version committed in git is a development placeholder. + +## Usage + +1. Click the *Readest* toolbar button menu → *Log in to Readest…* +2. Select the books to push (any number). +3. Click the *Readest* button (or menu → *Push selected books to Readest*). + +For each book the best Readest-supported format is pushed, preferring +`EPUB > PDF > AZW3 > MOBI > AZW > FB2 > FBZ > CBZ > TXT > MD`. + +## How updates and duplicates work + +At upload time the plugin embeds your current calibre metadata (including +custom columns) into a temporary copy of the book file, so the copy in your +Readest library is self-describing. Pushed books are then tracked by two keys: + +- the **calibre book uuid**, carried in the entry's metadata + (`urn:uuid:...`) — this recognizes "this calibre book is already in + Readest" across pushes, even when the file bytes change; +- a **fingerprint of the raw calibre file** (`calibreSourceHash`), which + detects whether the file itself changed since the last push — no local + state, so it works from any machine. + +Re-push behavior: + +- **Nothing changed** → skipped. +- **Metadata edited** → the Readest library entry is updated in place; the + file is not re-uploaded (its embedded OPF keeps the metadata from its + upload time). +- **File changed** (e.g. re-converted) → the new file is uploaded with fresh + embedded metadata and *replaces* the old entry: reading status, grouping, + progress and the library date carry over, the old entry is removed and its + cloud files are deleted. Notes and reading positions re-attach when + title/authors are unchanged (Readest matches book versions by metadata + identity). +- Books pushed by older plugin versions (or dragged into Readest manually) + are recognized too: their entry hash doubles as the raw-file fingerprint. + +## Development + +Pure-logic modules (`api.py`, `wire.py`, `oauth.py`) have no calibre or Qt +dependencies and are covered by unit tests: + +```sh +make test # python3 -m unittest discover -s tests +``` + +The wire protocol mirrors what the Readest apps and `readest.koplugin` use: +Supabase auth (`/auth/v1`), `GET/POST /api/sync` for library rows, and +`POST /api/storage/upload` + presigned PUT for file blobs. diff --git a/apps/readest-calibre-plugin/__init__.py b/apps/readest-calibre-plugin/__init__.py new file mode 100644 index 00000000..7fb5bfcd --- /dev/null +++ b/apps/readest-calibre-plugin/__init__.py @@ -0,0 +1,31 @@ +__license__ = 'AGPL v3' +__copyright__ = '2026, Bilingify LLC' + +from calibre.customize import InterfaceActionBase + +PLUGIN_VERSION = (0, 1, 0) + + +class ReadestPlugin(InterfaceActionBase): + name = 'Readest Sync' + description = ( + 'Push selected books and their metadata into your Readest cloud library. ' + 'Re-pushing a book updates its existing entry instead of creating a duplicate.' + ) + supported_platforms = ['windows', 'osx', 'linux'] + author = 'Bilingify LLC' + version = PLUGIN_VERSION + minimum_calibre_version = (6, 0, 0) + + actual_plugin = 'calibre_plugins.readest.ui:ReadestInterfacePlugin' + + def is_customizable(self): + return True + + def config_widget(self): + from calibre_plugins.readest.config import ConfigWidget + + return ConfigWidget() + + def save_settings(self, config_widget): + config_widget.save_settings() diff --git a/apps/readest-calibre-plugin/api.py b/apps/readest-calibre-plugin/api.py new file mode 100644 index 00000000..a25314c8 --- /dev/null +++ b/apps/readest-calibre-plugin/api.py @@ -0,0 +1,332 @@ +__license__ = 'AGPL v3' +__copyright__ = '2026, Bilingify LLC' + +"""HTTP client + content hashes for the Readest cloud API. + +Standard-library only (no calibre / Qt imports) so it can be unit-tested +outside calibre. The endpoints and shapes mirror the ones already consumed by +readest.koplugin (readest-sync-api.json / supabase-auth-api.json) and served +by apps/readest-app/src/pages/api/. +""" + +import hashlib +import json +import time +import unicodedata +import urllib.error +import urllib.parse +import urllib.request + +DEFAULT_API_BASE = 'https://web.readest.com/api' +DEFAULT_SUPABASE_URL = 'https://readest.supabase.co' +# Public anon key, same one readest.koplugin ships (base64-encoded in main.lua). +DEFAULT_ANON_KEY = ( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + 'eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZic3l4ZnVzampxZHhranFseXNjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3' + 'MzQxMjM2NzEsImV4cCI6MjA0OTY5OTY3MX0.' + '3U5Uqaou_1SgrVe1eo9rApc0uKjqhpQdUXhvwUHmUfg' +) + +TIMEOUT = 30 +UPLOAD_TIMEOUT = 600 + + +class ReadestAPIError(Exception): + def __init__(self, message, status=None): + super().__init__(message) + self.status = status + + +class AuthRequiredError(ReadestAPIError): + """No valid session — the user must log in.""" + + +class QuotaExceededError(ReadestAPIError): + """The server rejected an upload for insufficient storage quota.""" + + +# --------------------------------------------------------------------------- +# Content hashes +# --------------------------------------------------------------------------- + + +def _partial_md5_ranges(size): + """Chunk ranges of apps/readest-app/src/utils/md5.ts::partialMD5. + + 1024-byte chunks at offsets 0, 1024, 4096, ..., 1024 << 20 (the JS loop + runs i in -1..10; `1024 << -2` wraps to 0 under JS 32-bit shift). + """ + ranges = [] + for i in range(-1, 11): + offset = 0 if i == -1 else 1024 << (2 * i) + start = min(size, offset) + if start >= size: + break + ranges.append((start, min(start + 1024, size))) + return ranges + + +def partial_md5(file_or_path, size=None): + """Readest's Book.hash: partial MD5 of a file (KOReader-compatible).""" + if isinstance(file_or_path, str): + with open(file_or_path, 'rb') as f: + f.seek(0, 2) + return partial_md5(f, f.tell()) + f = file_or_path + hasher = hashlib.md5() + for start, end in _partial_md5_ranges(size): + f.seek(start) + hasher.update(f.read(end - start)) + return hasher.hexdigest() + + +def partial_md5_bytes(data): + hasher = hashlib.md5() + for start, end in _partial_md5_ranges(len(data)): + hasher.update(data[start:end]) + return hasher.hexdigest() + + +def _normalize_identifier(identifier): + # Mirrors utils/book.ts::normalizeIdentifier. + if 'urn:' in identifier: + return identifier.rsplit(':', 1)[-1] + if ':' in identifier: + return identifier.split(':', 1)[1] + return identifier + + +def _identifiers_list(identifiers): + # Mirrors utils/book.ts::getIdentifiersList / getPreferredIdentifier. + if not identifiers: + return [] + for scheme in ('uuid', 'calibre', 'isbn'): + for identifier in identifiers: + if scheme in identifier.lower(): + return [_normalize_identifier(identifier)] + return [_normalize_identifier(i) for i in identifiers if i] + + +def meta_hash(title, authors, identifiers): + """Readest's Book.metaHash: md5 over "title|authors|identifiers" (NFC). + + `identifiers` are raw identifier strings, scheme-prefixed where known + (e.g. "urn:uuid:...", "isbn:..."). + """ + source = '%s|%s|%s' % ( + title or '', + ','.join(authors or []), + ','.join(_identifiers_list(identifiers)), + ) + return hashlib.md5(unicodedata.normalize('NFC', source).encode('utf-8')).hexdigest() + + +# --------------------------------------------------------------------------- +# HTTP client +# --------------------------------------------------------------------------- + + +def _default_transport(request, timeout): + try: + with urllib.request.urlopen(request, timeout=timeout) as res: + return res.status, res.read() + except urllib.error.HTTPError as err: + return err.code, err.read() + + +class ReadestClient: + """Supabase auth + Readest sync/storage API client. + + tokens: dict with access_token / refresh_token / expires_at (epoch s) / + expires_in (s), persisted through the on_tokens callback whenever they + change. `transport(request, timeout) -> (status, body_bytes)` is + injectable for tests. + """ + + def __init__( + self, + api_base=DEFAULT_API_BASE, + supabase_url=DEFAULT_SUPABASE_URL, + anon_key=DEFAULT_ANON_KEY, + tokens=None, + on_tokens=None, + transport=None, + ): + self.api_base = api_base.rstrip('/') + self.supabase_url = supabase_url.rstrip('/') + self.anon_key = anon_key + self.tokens = dict(tokens) if tokens else None + self.on_tokens = on_tokens + self.transport = transport or _default_transport + + # -- plumbing ----------------------------------------------------------- + + def _request(self, method, url, headers, body=None, timeout=TIMEOUT): + data = None + if body is not None: + data = json.dumps(body).encode('utf-8') + headers = dict(headers, **{'Content-Type': 'application/json'}) + request = urllib.request.Request(url, data=data, headers=headers, method=method) + status, payload = self.transport(request, timeout) + parsed = None + if payload: + try: + parsed = json.loads(payload.decode('utf-8')) + except (ValueError, UnicodeDecodeError): + parsed = None + return status, parsed, payload + + @staticmethod + def _error_message(parsed, payload, fallback): + if isinstance(parsed, dict): + for key in ('error_description', 'msg', 'message', 'error'): + value = parsed.get(key) + if isinstance(value, str) and value: + return value + if payload: + return payload.decode('utf-8', 'replace')[:200] + return fallback + + # -- Supabase auth ------------------------------------------------------ + + def _auth_headers(self): + return {'apikey': self.anon_key, 'Accept': 'application/json'} + + def _store_tokens(self, body): + self.tokens = { + 'access_token': body.get('access_token'), + 'refresh_token': body.get('refresh_token'), + 'expires_at': body.get('expires_at'), + 'expires_in': body.get('expires_in'), + } + if self.on_tokens: + self.on_tokens(dict(self.tokens)) + + def sign_in_password(self, email, password): + status, parsed, payload = self._request( + 'POST', + self.supabase_url + '/auth/v1/token?grant_type=password', + self._auth_headers(), + body={'email': email, 'password': password}, + ) + if status != 200 or not isinstance(parsed, dict): + raise ReadestAPIError(self._error_message(parsed, payload, 'Login failed'), status) + self._store_tokens(parsed) + return parsed.get('user') or {} + + def set_session(self, tokens): + """Adopt tokens obtained externally (browser OAuth callback).""" + self._store_tokens(tokens) + + def refresh(self): + refresh_token = self.tokens and self.tokens.get('refresh_token') + if not refresh_token: + raise AuthRequiredError('Not logged in') + status, parsed, payload = self._request( + 'POST', + self.supabase_url + '/auth/v1/token?grant_type=refresh_token', + self._auth_headers(), + body={'refresh_token': refresh_token}, + ) + if status != 200 or not isinstance(parsed, dict): + raise AuthRequiredError( + self._error_message(parsed, payload, 'Session expired, please log in again'), + status, + ) + self._store_tokens(parsed) + + def ensure_fresh_token(self): + # Mirrors readest_syncauth.lua: refresh once less than half the TTL + # remains; a token past its final minute is unusable without refresh. + if not self.tokens or not self.tokens.get('access_token'): + raise AuthRequiredError('Not logged in') + expires_at = self.tokens.get('expires_at') or 0 + expires_in = self.tokens.get('expires_in') or 3600 + if expires_at < time.time() + max(60, expires_in / 2): + self.refresh() + + def get_user(self): + self.ensure_fresh_token() + headers = dict(self._auth_headers()) + headers['Authorization'] = 'Bearer ' + self.tokens['access_token'] + status, parsed, payload = self._request( + 'GET', self.supabase_url + '/auth/v1/user', headers + ) + if status != 200 or not isinstance(parsed, dict): + raise AuthRequiredError(self._error_message(parsed, payload, 'Not logged in'), status) + return parsed + + def sign_out(self): + if not self.tokens or not self.tokens.get('access_token'): + return + headers = dict(self._auth_headers()) + headers['Authorization'] = 'Bearer ' + self.tokens['access_token'] + try: + self._request('POST', self.supabase_url + '/auth/v1/logout', headers, body={}) + except Exception: + pass # best-effort; local tokens are cleared regardless + self.tokens = None + if self.on_tokens: + self.on_tokens(None) + + # -- Readest API -------------------------------------------------------- + + def _api(self, method, path, body=None): + self.ensure_fresh_token() + headers = { + 'Authorization': 'Bearer ' + self.tokens['access_token'], + 'Accept': 'application/json', + } + status, parsed, payload = self._request(method, self.api_base + path, headers, body=body) + if status in (401, 403): + message = self._error_message(parsed, payload, 'Not authenticated') + if 'quota' in message.lower(): + raise QuotaExceededError(message, status) + raise AuthRequiredError(message, status) + if status != 200: + raise ReadestAPIError( + self._error_message(parsed, payload, 'Request failed (%s)' % status), status + ) + return parsed + + def pull_books(self, since=0): + result = self._api('GET', '/sync?type=books&since=%d' % since) + return (result or {}).get('books') or [] + + def push_books(self, records): + return self._api('POST', '/sync', body={'books': records, 'notes': [], 'configs': []}) + + def get_upload_url(self, file_name, file_size, book_hash): + return self._api( + 'POST', + '/storage/upload', + body={'fileName': file_name, 'fileSize': file_size, 'bookHash': book_hash}, + ) + + def list_files(self, book_hash): + result = self._api('GET', '/storage/list?bookHash=' + urllib.parse.quote(book_hash)) + return (result or {}).get('files') or [] + + def delete_file(self, file_key): + return self._api( + 'DELETE', '/storage/delete?fileKey=' + urllib.parse.quote(file_key, safe='') + ) + + def put_file(self, url, fileobj, size): + """PUT raw bytes to a presigned URL (no auth headers).""" + request = urllib.request.Request( + url, + data=fileobj, + headers={'Content-Length': str(size)}, + method='PUT', + ) + status, payload = self.transport(request, UPLOAD_TIMEOUT) + if status not in (200, 201, 204): + message = 'Upload failed (%s)' % status + if payload: + # S3/R2 error responses are XML; surface the tag. + text = payload.decode('utf-8', 'replace') + start, end = text.find(''), text.find('') + if 0 <= start < end: + message += ': ' + text[start + 6 : end] + raise ReadestAPIError(message, status) diff --git a/apps/readest-calibre-plugin/config.py b/apps/readest-calibre-plugin/config.py new file mode 100644 index 00000000..9cfde330 --- /dev/null +++ b/apps/readest-calibre-plugin/config.py @@ -0,0 +1,50 @@ +__license__ = 'AGPL v3' +__copyright__ = '2026, Bilingify LLC' + +from calibre.utils.config import JSONConfig +from qt.core import QCheckBox, QLabel, QLineEdit, QVBoxLayout, QWidget + +from calibre_plugins.readest.api import DEFAULT_API_BASE, DEFAULT_SUPABASE_URL + +prefs = JSONConfig('plugins/readest') + +prefs.defaults['api_base'] = DEFAULT_API_BASE +prefs.defaults['supabase_url'] = DEFAULT_SUPABASE_URL +prefs.defaults['tokens'] = None # {access_token, refresh_token, expires_at, expires_in} +prefs.defaults['user_email'] = None +prefs.defaults['include_custom_columns'] = True + + +def save_tokens(tokens): + prefs['tokens'] = tokens + if tokens is None: + prefs['user_email'] = None + + +class ConfigWidget(QWidget): + def __init__(self): + QWidget.__init__(self) + layout = QVBoxLayout() + self.setLayout(layout) + + account = prefs['user_email'] + status = ('Logged in as %s.' % account) if account else 'Not logged in.' + layout.addWidget(QLabel(status + ' Use the Readest toolbar menu to log in or out.')) + + self.custom_columns_checkbox = QCheckBox('Include custom columns in pushed metadata') + self.custom_columns_checkbox.setChecked(bool(prefs['include_custom_columns'])) + layout.addWidget(self.custom_columns_checkbox) + + layout.addWidget(QLabel('API server:')) + self.api_base_edit = QLineEdit(self) + self.api_base_edit.setText(prefs['api_base']) + self.api_base_edit.setToolTip( + 'Only change this if you run a self-hosted Readest server.' + ) + layout.addWidget(self.api_base_edit) + + layout.addStretch() + + def save_settings(self): + prefs['include_custom_columns'] = self.custom_columns_checkbox.isChecked() + prefs['api_base'] = self.api_base_edit.text().strip() or DEFAULT_API_BASE diff --git a/apps/readest-calibre-plugin/dialogs.py b/apps/readest-calibre-plugin/dialogs.py new file mode 100644 index 00000000..f5b854a9 --- /dev/null +++ b/apps/readest-calibre-plugin/dialogs.py @@ -0,0 +1,225 @@ +__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' diff --git a/apps/readest-calibre-plugin/images/icon.png b/apps/readest-calibre-plugin/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2d8aa82d4706098c86be676f706fda5fdffb49d8 GIT binary patch literal 21578 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

-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 N002ovPDHLkV1iHskQ)F1 literal 0 HcmV?d00001 diff --git a/apps/readest-calibre-plugin/oauth.py b/apps/readest-calibre-plugin/oauth.py new file mode 100644 index 00000000..4628df86 --- /dev/null +++ b/apps/readest-calibre-plugin/oauth.py @@ -0,0 +1,106 @@ +__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""" +Readest Login +

Completing login…

+ +""" + +DONE_PAGE = b""" +Readest Login +

Login complete. You can close this tab and return to calibre.

+""" + + +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() diff --git a/apps/readest-calibre-plugin/plugin-import-name-readest.txt b/apps/readest-calibre-plugin/plugin-import-name-readest.txt new file mode 100644 index 00000000..e69de29b diff --git a/apps/readest-calibre-plugin/tests/test_client.py b/apps/readest-calibre-plugin/tests/test_client.py new file mode 100644 index 00000000..f105ad33 --- /dev/null +++ b/apps/readest-calibre-plugin/tests/test_client.py @@ -0,0 +1,234 @@ +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'InternalError') + 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() diff --git a/apps/readest-calibre-plugin/tests/test_hashes.py b/apps/readest-calibre-plugin/tests/test_hashes.py new file mode 100644 index 00000000..0f0a1b7e --- /dev/null +++ b/apps/readest-calibre-plugin/tests/test_hashes.py @@ -0,0 +1,116 @@ +import hashlib +import io +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from api import meta_hash, partial_md5, partial_md5_bytes # noqa: E402 + + +def reference_ranges(size): + """Chunk ranges of utils/md5.ts::partialMD5, computed independently. + + The JS loop runs i in -1..10 with start = min(size, 1024 << (2*i)); the + JS << operator wraps 1024 << -2 to 0, so i == -1 reads offset 0. + """ + ranges = [] + for i in range(-1, 11): + offset = 0 if i == -1 else 1024 << (2 * i) + start = min(size, offset) + if start >= size: + break + ranges.append((start, min(start + 1024, size))) + return ranges + + +def reference_hash(data): + hasher = hashlib.md5() + for start, end in reference_ranges(len(data)): + hasher.update(data[start:end]) + return hasher.hexdigest() + + +class PartialMd5Test(unittest.TestCase): + def check(self, data): + self.assertEqual(partial_md5(io.BytesIO(data), len(data)), reference_hash(data)) + self.assertEqual(partial_md5_bytes(data), reference_hash(data)) + + def test_small_file_reads_everything(self): + data = b'hello world' + self.assertEqual(partial_md5_bytes(data), hashlib.md5(data).hexdigest()) + + def test_exactly_one_chunk(self): + self.check(bytes(range(256)) * 4) # 1024 bytes + + def test_two_chunks(self): + self.check(os.urandom(3000)) + + def test_skips_middle_of_larger_file(self): + data = os.urandom(20000) + self.check(data) + # Sanity: 20000 bytes covers offsets 0, 1024, 4096, 16384 then stops. + self.assertEqual( + reference_ranges(20000), + [(0, 1024), (1024, 2048), (4096, 5120), (16384, 17408)], + ) + + def test_from_file_path(self, tmp_name='partial_md5_fixture.bin'): + import tempfile + + data = os.urandom(5000) + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, tmp_name) + with open(path, 'wb') as f: + f.write(data) + self.assertEqual(partial_md5(path), reference_hash(data)) + + +class MetaHashTest(unittest.TestCase): + def md5(self, source): + import unicodedata + + return hashlib.md5(unicodedata.normalize('NFC', source).encode('utf-8')).hexdigest() + + def test_basic(self): + self.assertEqual( + meta_hash('Foo', ['Alice', 'Bob'], ['urn:uuid:1234']), + self.md5('Foo|Alice,Bob|1234'), + ) + + def test_prefers_uuid_over_isbn(self): + self.assertEqual( + meta_hash('Foo', ['Alice'], ['isbn:9781234567890', 'uuid:abcd']), + self.md5('Foo|Alice|abcd'), + ) + + def test_calibre_scheme_preferred_over_isbn(self): + self.assertEqual( + meta_hash('Foo', ['Alice'], ['isbn:9781234567890', 'calibre:42']), + self.md5('Foo|Alice|42'), + ) + + def test_urn_identifier_sliced_after_last_colon(self): + self.assertEqual( + meta_hash('T', ['A'], ['urn:isbn:978-0-00-000000-0']), + self.md5('T|A|978-0-00-000000-0'), + ) + + def test_no_identifiers(self): + self.assertEqual(meta_hash('T', ['A'], []), self.md5('T|A|')) + + def test_plain_identifier_without_scheme(self): + self.assertEqual(meta_hash('T', ['A'], ['abcdef']), self.md5('T|A|abcdef')) + + def test_nfc_normalization(self): + decomposed = 'Cafe\u0301' # e + combining acute accent + composed = 'Caf\u00e9' + self.assertEqual( + meta_hash(decomposed, [], []), + hashlib.md5(f'{composed}||'.encode('utf-8')).hexdigest(), + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/apps/readest-calibre-plugin/tests/test_oauth.py b/apps/readest-calibre-plugin/tests/test_oauth.py new file mode 100644 index 00000000..20e909c5 --- /dev/null +++ b/apps/readest-calibre-plugin/tests/test_oauth.py @@ -0,0 +1,91 @@ +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() diff --git a/apps/readest-calibre-plugin/tests/test_wire.py b/apps/readest-calibre-plugin/tests/test_wire.py new file mode 100644 index 00000000..5c5f9362 --- /dev/null +++ b/apps/readest-calibre-plugin/tests/test_wire.py @@ -0,0 +1,346 @@ +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from wire import ( # noqa: E402 + build_metadata, + build_wire_book, + index_rows_by_uuid, + iso_to_ms, + merge_for_push, + pick_format, + pick_server_row, + plan_push, + tombstone_record, +) + +NOW = 1_800_000_000_000 +SRC = 's' * 32 # partial MD5 of the raw calibre library file + +BOOK = { + 'title': 'The Test Book', + 'authors': ['Alice Author', 'Bob Writer'], + 'languages': ['eng'], + 'publisher': 'Test House', + 'pubdate': '2020-05-01T00:00:00+00:00', + 'comments': '

A very good book.

', + 'tags': ['Fiction', 'Test'], + 'series': 'Test Series', + 'series_index': 2.0, + 'uuid': 'cafebabe-0000-0000-0000-000000000001', + 'isbn': '9781234567897', + 'custom_columns': {'read_status': 'done'}, + 'source_hash': SRC, +} + + +def server_row(**overrides): + row = { + 'book_hash': 'a' * 32, + 'meta_hash': 'b' * 32, + 'format': 'EPUB', + 'title': 'The Test Book', + 'author': 'Alice Author, Bob Writer', + 'tags': ['Fiction', 'Test'], + 'group_id': 'g1', + 'group_name': 'Group One', + 'progress': [3, 100], + 'reading_status': 'reading', + 'reading_status_updated_at': '2024-01-02T00:00:00.000Z', + 'cover_hash': 'c' * 32, + 'cover_updated_at': '2024-01-02T00:00:00.000Z', + 'metadata': None, + 'created_at': '2024-01-01T00:00:00.000Z', + 'updated_at': '2024-01-02T00:00:00.000Z', + 'deleted_at': None, + 'uploaded_at': '2024-01-01T12:00:00.000Z', + } + row.update(overrides) + return row + + +def wire_for(book=BOOK, file_hash='a' * 32, fmt='EPUB', now=NOW): + return build_wire_book(book, file_hash, fmt, now) + + +def synced_row(wire): + """A server row that matches `wire` (as if we pushed it earlier).""" + return server_row( + title=wire['title'], + author=wire['author'], + tags=wire.get('tags'), + metadata=json.dumps(wire['metadata']), + ) + + +class PickFormatTest(unittest.TestCase): + def test_prefers_epub(self): + self.assertEqual(pick_format(['MOBI', 'EPUB', 'PDF']), 'EPUB') + + def test_case_insensitive(self): + self.assertEqual(pick_format(['azw3', 'txt']), 'AZW3') + + def test_unsupported_only(self): + self.assertIsNone(pick_format(['DOCX', 'LRF'])) + + def test_empty(self): + self.assertIsNone(pick_format([])) + + +class BuildMetadataTest(unittest.TestCase): + def test_fields(self): + meta = build_metadata(BOOK) + self.assertEqual(meta['title'], 'The Test Book') + self.assertEqual(meta['author'], ['Alice Author', 'Bob Writer']) + self.assertEqual(meta['language'], 'eng') + self.assertEqual(meta['publisher'], 'Test House') + self.assertEqual(meta['published'], '2020-05-01T00:00:00+00:00') + self.assertEqual(meta['description'], '

A very good book.

') + self.assertEqual(meta['subject'], ['Fiction', 'Test']) + self.assertEqual(meta['series'], 'Test Series') + self.assertEqual(meta['seriesIndex'], 2.0) + self.assertEqual(meta['identifier'], 'urn:uuid:cafebabe-0000-0000-0000-000000000001') + self.assertEqual(meta['isbn'], '9781234567897') + self.assertEqual(meta['customColumns'], {'read_status': 'done'}) + self.assertEqual(meta['calibreSourceHash'], SRC) + + def test_single_author_is_string(self): + meta = build_metadata(dict(BOOK, authors=['Solo'])) + self.assertEqual(meta['author'], 'Solo') + + def test_omits_empty_fields(self): + meta = build_metadata({'title': 'T', 'authors': []}) + self.assertNotIn('publisher', meta) + self.assertNotIn('series', meta) + self.assertNotIn('customColumns', meta) + self.assertNotIn('isbn', meta) + self.assertNotIn('calibreSourceHash', meta) + + def test_strips_nul_characters(self): + meta = build_metadata({'title': 'T\x00itle', 'authors': ['A\x00nn']}) + self.assertEqual(meta['title'], 'Title') + self.assertEqual(meta['author'], 'Ann') + + +class BuildWireBookTest(unittest.TestCase): + def test_record_shape(self): + wire = wire_for() + self.assertEqual(wire['hash'], 'a' * 32) + self.assertEqual(wire['bookHash'], 'a' * 32) + self.assertEqual(wire['format'], 'EPUB') + self.assertEqual(wire['title'], 'The Test Book') + self.assertEqual(wire['sourceTitle'], 'The Test Book') + self.assertEqual(wire['author'], 'Alice Author, Bob Writer') + self.assertEqual(wire['tags'], ['Fiction', 'Test']) + self.assertEqual(wire['createdAt'], NOW) + self.assertEqual(wire['updatedAt'], NOW) + self.assertEqual(len(wire['metaHash']), 32) + self.assertEqual(wire['metadata']['title'], 'The Test Book') + + def test_meta_hash_uses_uuid_identifier(self): + import hashlib + import unicodedata + + wire = wire_for() + source = 'The Test Book|Alice Author,Bob Writer|cafebabe-0000-0000-0000-000000000001' + expected = hashlib.md5(unicodedata.normalize('NFC', source).encode('utf-8')).hexdigest() + self.assertEqual(wire['metaHash'], expected) + + +class IsoToMsTest(unittest.TestCase): + def test_iso_with_ms(self): + self.assertEqual(iso_to_ms('2024-01-01T00:00:00.000Z'), 1704067200000) + + def test_iso_without_ms(self): + self.assertEqual(iso_to_ms('2024-01-01T00:00:00Z'), 1704067200000) + + def test_none(self): + self.assertIsNone(iso_to_ms(None)) + + +class PlanPushTest(unittest.TestCase): + def test_new_book(self): + plan = plan_push(None, wire_for(), 'c' * 32, SRC) + self.assertEqual(plan['action'], 'new') + self.assertTrue(plan['upload_cover']) + + def test_new_book_without_cover(self): + plan = plan_push(None, wire_for(), None, SRC) + self.assertEqual(plan['action'], 'new') + self.assertFalse(plan['upload_cover']) + + def test_unchanged_book_is_skipped(self): + wire = wire_for() + plan = plan_push(synced_row(wire), wire, 'c' * 32, SRC) + self.assertEqual(plan['action'], 'skip') + + def test_changed_metadata_is_update(self): + wire = wire_for(dict(BOOK, title='Renamed Title')) + row = synced_row(wire_for()) + plan = plan_push(row, wire, 'c' * 32, SRC) + self.assertEqual(plan['action'], 'update') + self.assertFalse(plan['upload_cover']) + + def test_changed_cover_only_is_update_with_cover(self): + wire = wire_for() + plan = plan_push(synced_row(wire), wire, 'd' * 32, SRC) + self.assertEqual(plan['action'], 'update') + self.assertTrue(plan['upload_cover']) + + def test_tombstoned_row_is_resurrected(self): + wire = wire_for() + row = synced_row(wire) + row['deleted_at'] = '2024-06-01T00:00:00.000Z' + plan = plan_push(row, wire, 'c' * 32, SRC) + self.assertEqual(plan['action'], 'update') + + def test_tags_change_is_update(self): + wire = wire_for(dict(BOOK, tags=['Fiction'])) + row = synced_row(wire_for()) + self.assertEqual(plan_push(row, wire, 'c' * 32, SRC)['action'], 'update') + + def test_missing_local_cover_does_not_force_update(self): + wire = wire_for() + plan = plan_push(synced_row(wire), wire, None, SRC) + self.assertEqual(plan['action'], 'skip') + + def test_row_without_file_is_replaced(self): + wire = wire_for() + row = synced_row(wire) + row['uploaded_at'] = None + plan = plan_push(row, wire, 'c' * 32, SRC) + self.assertEqual(plan['action'], 'replace') + self.assertTrue(plan['upload_cover']) # new hash namespace needs its own cover + + def test_changed_source_file_is_replaced(self): + wire = wire_for(dict(BOOK, source_hash='n' * 32)) + row = synced_row(wire_for()) # cloud copy built from SRC + plan = plan_push(row, wire, 'c' * 32, 'n' * 32) + self.assertEqual(plan['action'], 'replace') + + def test_v1_row_with_matching_raw_hash_is_not_replaced(self): + # v1 rows have no calibreSourceHash but their book_hash IS the raw + # file hash (v1 uploaded the file unmodified). + book = dict(BOOK) + del book['source_hash'] + v1_wire = wire_for(book) + row = synced_row(v1_wire) + row['book_hash'] = SRC + plan = plan_push(row, wire_for(), 'c' * 32, SRC) + self.assertEqual(plan['action'], 'update') # gains calibreSourceHash + + def test_v1_row_with_changed_file_is_replaced(self): + book = dict(BOOK) + del book['source_hash'] + row = synced_row(wire_for(book)) + row['book_hash'] = 'o' * 32 # raw hash of the OLD file + plan = plan_push(row, wire_for(), 'c' * 32, SRC) + self.assertEqual(plan['action'], 'replace') + + +class ServerRowLookupTest(unittest.TestCase): + def test_index_rows_by_uuid(self): + wire = wire_for() + row = synced_row(wire) + index = index_rows_by_uuid([row, server_row(book_hash='x' * 32, metadata='not json')]) + self.assertEqual(index, {'cafebabe-0000-0000-0000-000000000001': row}) + + def test_index_prefers_live_row(self): + wire = wire_for() + dead = synced_row(wire) + dead['deleted_at'] = '2024-06-01T00:00:00.000Z' + live = synced_row(wire) + live['book_hash'] = 'x' * 32 + for rows in ([dead, live], [live, dead]): + index = index_rows_by_uuid(rows) + self.assertEqual(index['cafebabe-0000-0000-0000-000000000001'], live) + + def test_index_prefers_newer_row(self): + wire = wire_for() + old = synced_row(wire) + old['updated_at'] = '2024-01-01T00:00:00.000Z' + new = synced_row(wire) + new['book_hash'] = 'x' * 32 + new['updated_at'] = '2024-06-01T00:00:00.000Z' + index = index_rows_by_uuid([old, new]) + self.assertEqual(index['cafebabe-0000-0000-0000-000000000001'], new) + + def test_pick_prefers_live_hash_match(self): + hash_row = server_row() + uuid_row = server_row(book_hash='x' * 32) + self.assertIs(pick_server_row(hash_row, uuid_row), hash_row) + + def test_pick_falls_back_to_live_uuid_row(self): + dead = server_row(deleted_at='2024-06-01T00:00:00.000Z') + live = server_row(book_hash='x' * 32) + self.assertIs(pick_server_row(dead, live), live) + + def test_pick_returns_tombstone_when_nothing_live(self): + dead = server_row(deleted_at='2024-06-01T00:00:00.000Z') + self.assertIs(pick_server_row(None, dead), dead) + self.assertIsNone(pick_server_row(None, None)) + + +class TombstoneRecordTest(unittest.TestCase): + def test_shape(self): + rec = tombstone_record(server_row(), NOW) + self.assertEqual(rec['hash'], 'a' * 32) + self.assertEqual(rec['bookHash'], 'a' * 32) + self.assertEqual(rec['format'], 'EPUB') + self.assertEqual(rec['title'], 'The Test Book') + self.assertEqual(rec['author'], 'Alice Author, Bob Writer') + self.assertEqual(rec['createdAt'], iso_to_ms('2024-01-01T00:00:00.000Z')) + self.assertEqual(rec['updatedAt'], NOW) + self.assertEqual(rec['deletedAt'], NOW) + + +class MergeForPushTest(unittest.TestCase): + def test_new_book_merge(self): + wire = wire_for() + rec = merge_for_push(wire, None, NOW, uploaded_at_ms=NOW, cover_hash='e' * 32) + self.assertEqual(rec['createdAt'], NOW) + self.assertEqual(rec['updatedAt'], NOW) + self.assertEqual(rec['uploadedAt'], NOW) + self.assertEqual(rec['coverHash'], 'e' * 32) + self.assertEqual(rec['coverUpdatedAt'], NOW) + self.assertIsNone(rec['deletedAt']) + + def test_update_carries_server_fields(self): + wire = wire_for(dict(BOOK, title='Renamed')) + row = server_row(metadata=json.dumps(build_metadata(BOOK))) + rec = merge_for_push(wire, row, NOW) + # Fields the server would explicit-null if omitted must be carried over. + self.assertEqual(rec['groupId'], 'g1') + self.assertEqual(rec['groupName'], 'Group One') + self.assertEqual(rec['progress'], [3, 100]) + self.assertEqual(rec['readingStatus'], 'reading') + self.assertEqual(rec['readingStatusUpdatedAt'], iso_to_ms('2024-01-02T00:00:00.000Z')) + self.assertEqual(rec['uploadedAt'], iso_to_ms('2024-01-01T12:00:00.000Z')) + self.assertEqual(rec['coverHash'], 'c' * 32) + self.assertEqual(rec['coverUpdatedAt'], iso_to_ms('2024-01-02T00:00:00.000Z')) + self.assertEqual(rec['createdAt'], iso_to_ms('2024-01-01T00:00:00.000Z')) + # LWW: the push must win over the server row. + self.assertEqual(rec['updatedAt'], NOW) + # Our fresh metadata wins. + self.assertEqual(rec['title'], 'Renamed') + # Resurrects tombstones. + self.assertIsNone(rec['deletedAt']) + + def test_reupload_overrides_uploaded_at(self): + wire = wire_for() + row = server_row(uploaded_at=None) + rec = merge_for_push(wire, row, NOW, uploaded_at_ms=NOW) + self.assertEqual(rec['uploadedAt'], NOW) + + def test_new_cover_overrides_server_cover(self): + wire = wire_for() + rec = merge_for_push(wire, server_row(), NOW, cover_hash='f' * 32) + self.assertEqual(rec['coverHash'], 'f' * 32) + self.assertEqual(rec['coverUpdatedAt'], NOW) + + +if __name__ == '__main__': + unittest.main() diff --git a/apps/readest-calibre-plugin/ui.py b/apps/readest-calibre-plugin/ui.py new file mode 100644 index 00000000..81961b8e --- /dev/null +++ b/apps/readest-calibre-plugin/ui.py @@ -0,0 +1,115 @@ +__license__ = 'AGPL v3' +__copyright__ = '2026, Bilingify LLC' + +from qt.core import QMenu + +from calibre.gui2 import error_dialog, info_dialog +from calibre.gui2.actions import InterfaceAction + +from calibre_plugins.readest.api import ReadestClient +from calibre_plugins.readest.config import prefs, save_tokens +from calibre_plugins.readest.dialogs import LoginDialog, PushDialog + + +def make_client(): + return ReadestClient( + api_base=prefs['api_base'], + supabase_url=prefs['supabase_url'], + tokens=prefs['tokens'], + on_tokens=save_tokens, + ) + + +class ReadestInterfacePlugin(InterfaceAction): + name = 'Readest Sync' + action_spec = ( + 'Readest', + None, + 'Push selected books and metadata to your Readest library', + None, + ) + + def genesis(self): + self.push_action = self.create_action( + spec=('Push selected books to Readest', None, None, None), + attr='Push selected books to Readest', + ) + self.push_action.triggered.connect(self.push_selected) + + self.login_action = self.create_action( + spec=('Log in to Readest…', None, None, None), attr='Log in to Readest' + ) + self.login_action.triggered.connect(self.login) + + self.logout_action = self.create_action( + spec=('Log out', None, None, None), attr='Log out from Readest' + ) + self.logout_action.triggered.connect(self.logout) + + self.config_action = self.create_action( + spec=('Customize plugin…', None, None, None), attr='Customize Readest plugin' + ) + self.config_action.triggered.connect(self.show_config) + + self.menu = QMenu(self.gui) + self.menu.addAction(self.push_action) + self.menu.addSeparator() + self.menu.addAction(self.login_action) + self.menu.addAction(self.logout_action) + self.menu.addAction(self.config_action) + self.menu.aboutToShow.connect(self.update_menu) + + self.qaction.setMenu(self.menu) + self.qaction.setIcon(get_icons('images/icon.png', 'Readest Sync')) + self.qaction.triggered.connect(self.push_selected) + + def update_menu(self): + logged_in = bool(prefs['tokens']) + email = prefs['user_email'] + self.login_action.setVisible(not logged_in) + self.logout_action.setVisible(logged_in) + if logged_in and email: + self.logout_action.setText('Log out (%s)' % email) + self.push_action.setEnabled(len(self.selected_book_ids()) > 0) + + def selected_book_ids(self): + return self.gui.library_view.get_selected_ids() + + def push_selected(self): + book_ids = self.selected_book_ids() + if not book_ids: + return error_dialog( + self.gui, 'No books selected', 'Select the books to push to Readest.', show=True + ) + if not prefs['tokens'] and not self.login(): + return + PushDialog( + self.gui, + self.gui.current_db.new_api, + book_ids, + make_client(), + bool(prefs['include_custom_columns']), + ).exec() + + def login(self): + dialog = LoginDialog(self.gui, make_client()) + if dialog.exec() != dialog.DialogCode.Accepted: + return False + user = dialog.user or {} + prefs['user_email'] = user.get('email') + info_dialog( + self.gui, + 'Readest', + 'Logged in as %s.' % (user.get('email') or 'your Readest account'), + show=True, + ) + return True + + def logout(self): + try: + make_client().sign_out() + finally: + save_tokens(None) + + def show_config(self): + self.interface_action_base_plugin.do_user_config(self.gui) diff --git a/apps/readest-calibre-plugin/wire.py b/apps/readest-calibre-plugin/wire.py new file mode 100644 index 00000000..8ec84237 --- /dev/null +++ b/apps/readest-calibre-plugin/wire.py @@ -0,0 +1,292 @@ +__license__ = 'AGPL v3' +__copyright__ = '2026, Bilingify LLC' + +"""Calibre metadata → Readest wire records, and per-book push planning. + +Standard-library only (no calibre / Qt imports) so it can be unit-tested +outside calibre. The wire shape is the camelCase Book record consumed by +POST /api/sync (apps/readest-app/src/utils/transform.ts::transformBookToDB, +inverted — same as readest.koplugin's row_to_wire). +""" + +import json +from datetime import datetime, timezone + +try: # inside calibre the plugin loads as a package + from calibre_plugins.readest.api import meta_hash +except ImportError: # plain imports for the unit tests + from api import meta_hash + +# Readest-supported formats, in upload preference order. +FORMAT_PRIORITY = ('EPUB', 'PDF', 'AZW3', 'MOBI', 'AZW', 'FB2', 'FBZ', 'CBZ', 'TXT', 'MD') + +EXTS = { + 'EPUB': 'epub', + 'PDF': 'pdf', + 'MOBI': 'mobi', + 'AZW': 'azw', + 'AZW3': 'azw3', + 'CBZ': 'cbz', + 'FB2': 'fb2', + 'FBZ': 'fbz', + 'TXT': 'txt', + 'MD': 'md', +} + +CLOUD_BOOKS_SUBDIR = 'Readest/Books' + + +def pick_format(available): + """Best Readest-supported format among calibre's, or None.""" + upper = {f.upper() for f in available} + for fmt in FORMAT_PRIORITY: + if fmt in upper: + return fmt + return None + + +def book_file_name(file_hash, fmt): + # Matches getRemoteBookFilename for S3 ({hash}/{hash}.{ext}); R2 + # deployments resolve it through the download API's hash+extension + # fallback, same as the koplugin. + return '%s/%s/%s.%s' % (CLOUD_BOOKS_SUBDIR, file_hash, file_hash, EXTS[fmt]) + + +def cover_file_name(file_hash): + return '%s/%s/cover.png' % (CLOUD_BOOKS_SUBDIR, file_hash) + + +def _clean(value): + # The server strips NUL from strings (utils/sanitize.ts::sanitizeString); + # strip locally too so pushed and pulled values compare equal. + if isinstance(value, str): + return value.replace('\x00', '') + return value + + +def build_metadata(book): + """BookMetadata JSON for the books row (libs/document.ts::BookMetadata). + + `book` is a plain dict extracted from calibre's Metadata object: + title, authors, languages, publisher, pubdate, comments, tags, series, + series_index, uuid, isbn, custom_columns. + """ + authors = [_clean(a) for a in book.get('authors') or [] if a] + meta = { + 'title': _clean(book.get('title')) or '', + 'author': authors[0] if len(authors) == 1 else authors, + } + languages = book.get('languages') or [] + if isinstance(languages, str): + languages = [languages] + if languages: + meta['language'] = languages[0] if len(languages) == 1 else languages + optional = { + 'publisher': _clean(book.get('publisher')), + 'published': _clean(book.get('pubdate')), + 'description': _clean(book.get('comments')), + 'subject': [_clean(t) for t in book.get('tags') or []] or None, + 'series': _clean(book.get('series')), + 'isbn': _clean(book.get('isbn')), + # Partial MD5 of the RAW calibre library file. The uploaded blob has + # metadata embedded so its hash (book_hash) shifts with every embed; + # this stable fingerprint is how a later push detects "the file + # itself changed" without any machine-local state. + 'calibreSourceHash': book.get('source_hash'), + } + if book.get('series'): + optional['seriesIndex'] = book.get('series_index') + if book.get('uuid'): + optional['identifier'] = 'urn:uuid:%s' % book['uuid'] + if book.get('custom_columns'): + optional['customColumns'] = book['custom_columns'] + for key, value in optional.items(): + if value not in (None, '', []): + meta[key] = value + return meta + + +def _author_string(book): + return ', '.join(_clean(a) for a in book.get('authors') or [] if a) + + +def build_wire_book(book, file_hash, fmt, now_ms): + """Base wire record for a calibre book (before server-row merging).""" + identifiers = [] + if book.get('uuid'): + identifiers.append('urn:uuid:%s' % book['uuid']) + if book.get('isbn'): + identifiers.append('isbn:%s' % book['isbn']) + title = _clean(book.get('title')) or '' + authors = [_clean(a) for a in book.get('authors') or [] if a] + return { + 'hash': file_hash, + 'bookHash': file_hash, + 'metaHash': meta_hash(title, authors, identifiers), + 'format': fmt, + 'title': title, + 'sourceTitle': title, + 'author': _author_string(book), + 'tags': [_clean(t) for t in book.get('tags') or []], + 'metadata': build_metadata(book), + 'createdAt': now_ms, + 'updatedAt': now_ms, + } + + +def iso_to_ms(iso): + if not iso: + return None + text = iso.replace('Z', '+00:00') + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + + +def _parse_row_metadata(row): + raw = row.get('metadata') + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw: + try: + return json.loads(raw) + except ValueError: + return None + return None + + +def _row_matches_wire(row, wire): + if (row.get('title') or '') != wire['title']: + return False + if (row.get('author') or '') != wire['author']: + return False + if (row.get('tags') or []) != (wire.get('tags') or []): + return False + return _parse_row_metadata(row) == wire['metadata'] + + +def row_source_hash(row): + """The raw-file fingerprint a server row was built from. + + v2 rows carry it as metadata.calibreSourceHash; v1 rows uploaded the raw + file unmodified, so their book_hash IS the raw hash. + """ + meta = _parse_row_metadata(row) or {} + return meta.get('calibreSourceHash') or row.get('book_hash') + + +def metadata_uuid(meta): + """Calibre book uuid from a metadata dict's urn:uuid identifier.""" + identifier = (meta or {}).get('identifier') + if isinstance(identifier, str) and 'urn:uuid:' in identifier: + return identifier.rsplit(':', 1)[-1].lower() + return None + + +def _prefer_row(row, over): + row_live, over_live = not row.get('deleted_at'), not over.get('deleted_at') + if row_live != over_live: + return row_live + return (iso_to_ms(row.get('updated_at')) or 0) > (iso_to_ms(over.get('updated_at')) or 0) + + +def index_rows_by_uuid(rows): + """Map calibre uuid -> best server row (live over tombstoned, then newest). + + This is the identity that survives file-content changes: the uploaded blob + has metadata embedded, so book_hash shifts whenever the file or its + metadata does, but the calibre uuid in metadata.identifier stays put. + """ + best = {} + for row in rows: + uuid = metadata_uuid(_parse_row_metadata(row)) + if not uuid: + continue + current = best.get(uuid) + if current is None or _prefer_row(row, current): + best[uuid] = row + return best + + +def pick_server_row(hash_row, uuid_row): + """Choose the row a push should target: live hash match, then live uuid + match, then whatever tombstone is left (for resurrection).""" + for row in (hash_row, uuid_row): + if row is not None and not row.get('deleted_at'): + return row + return hash_row if hash_row is not None else uuid_row + + +def plan_push(server_row, wire, local_cover_hash, source_hash): + """Decide what to do for one book. + + Returns {'action': 'new' | 'replace' | 'update' | 'skip', + 'upload_cover': bool}. + - new: no server row — embed metadata, upload file, insert row + - replace: the raw file changed (or the row has no file blob) — upload a + fresh embedded blob under its new hash, tombstone the old row + - update: file unchanged, but metadata/cover/tombstone differ — row only + - skip: file + metadata + cover all unchanged + """ + if server_row is None: + return {'action': 'new', 'upload_cover': bool(local_cover_hash)} + if not server_row.get('uploaded_at') or row_source_hash(server_row) != source_hash: + # A replaced book gets a new hash namespace, so it needs its own cover. + return {'action': 'replace', 'upload_cover': bool(local_cover_hash)} + cover_changed = bool(local_cover_hash) and local_cover_hash != server_row.get('cover_hash') + changed = ( + not _row_matches_wire(server_row, wire) + or bool(server_row.get('deleted_at')) + or cover_changed + ) + return {'action': 'update' if changed else 'skip', 'upload_cover': cover_changed} + + +def tombstone_record(row, now_ms): + """Wire record that soft-deletes a replaced server row.""" + return { + 'hash': row['book_hash'], + 'bookHash': row['book_hash'], + 'metaHash': row.get('meta_hash'), + 'format': row.get('format'), + 'title': row.get('title') or '', + 'author': row.get('author') or '', + 'createdAt': iso_to_ms(row.get('created_at')) or now_ms, + 'updatedAt': now_ms, + 'deletedAt': now_ms, + } + + +def merge_for_push(wire, server_row, now_ms, uploaded_at_ms=None, cover_hash=None): + """Final record for POST /sync. + + The server explicit-nulls any field absent from the wire record + (transformBookToDB), so an update must carry over the server row's + groupId/groupName, progress, readingStatus, uploadedAt and cover fields — + the koplugin learned this the hard way (see syncbooks.lua:291-299). + """ + record = dict(wire) + record['updatedAt'] = now_ms + record['deletedAt'] = None # an explicit push (re)activates the book + row = server_row or {} + record['createdAt'] = iso_to_ms(row.get('created_at')) or wire['createdAt'] + if row.get('group_id'): + record['groupId'] = row['group_id'] + if row.get('group_name'): + record['groupName'] = row['group_name'] + if row.get('progress') is not None: + record['progress'] = row['progress'] + if row.get('reading_status'): + record['readingStatus'] = row['reading_status'] + record['readingStatusUpdatedAt'] = iso_to_ms(row.get('reading_status_updated_at')) + record['uploadedAt'] = ( + uploaded_at_ms if uploaded_at_ms is not None else iso_to_ms(row.get('uploaded_at')) + ) + if cover_hash: + record['coverHash'] = cover_hash + record['coverUpdatedAt'] = now_ms + elif row.get('cover_hash'): + record['coverHash'] = row['cover_hash'] + record['coverUpdatedAt'] = iso_to_ms(row.get('cover_updated_at')) + return record diff --git a/apps/readest-calibre-plugin/worker.py b/apps/readest-calibre-plugin/worker.py new file mode 100644 index 00000000..c65535ff --- /dev/null +++ b/apps/readest-calibre-plugin/worker.py @@ -0,0 +1,267 @@ +__license__ = 'AGPL v3' +__copyright__ = '2026, Bilingify LLC' + +"""Background push worker: one QThread, sequential per-book processing.""" + +import io +import os +import shutil +import tempfile +import time +import traceback + +from qt.core import QThread, pyqtSignal + +from calibre_plugins.readest.api import ( + AuthRequiredError, + QuotaExceededError, + partial_md5, + partial_md5_bytes, +) +from calibre_plugins.readest.wire import ( + EXTS, + book_file_name, + build_wire_book, + cover_file_name, + index_rows_by_uuid, + merge_for_push, + pick_format, + pick_server_row, + plan_push, + tombstone_record, +) + +STATUS_LABELS = { + 'uploaded': 'Uploaded', + 'replaced': 'Replaced', + 'updated': 'Updated', + 'skipped': 'Up to date', + 'failed': 'Failed', +} + + +def _jsonable(value): + if hasattr(value, 'isoformat'): + return value.isoformat() + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def _custom_columns(mi): + columns = {} + for key in mi.custom_field_keys(): + try: + value = mi.get(key) + except Exception: + continue + if value in (None, '', []) or value == (): + continue + columns[key.lstrip('#')] = _jsonable(value) + return columns + + +def _book_dict(mi, include_custom_columns, source_hash): + pubdate = getattr(mi, 'pubdate', None) + # calibre uses year-101 dates as "undefined". + if pubdate is not None and getattr(pubdate, 'year', 0) < 1000: + pubdate = None + return { + 'title': mi.title, + 'authors': [a for a in (mi.authors or []) if a], + 'languages': list(mi.languages or []), + 'publisher': mi.publisher, + 'pubdate': pubdate.isoformat() if pubdate else None, + 'comments': mi.comments, + 'tags': sorted(mi.tags or []), + 'series': mi.series, + 'series_index': mi.series_index if mi.series else None, + 'uuid': getattr(mi, 'uuid', None), + 'isbn': (mi.get_identifiers() or {}).get('isbn'), + 'custom_columns': _custom_columns(mi) if include_custom_columns else None, + 'source_hash': source_hash, + } + + +def _embed_metadata_copy(path, mi, fmt): + """Copy the library file to a temp path and embed `mi` into it. + + The library file itself is never touched. Formats without a metadata + writer (or a failing writer) fall back to the unmodified copy. + """ + fd, tmp = tempfile.mkstemp(suffix='.' + EXTS[fmt], prefix='readest-') + os.close(fd) + shutil.copyfile(path, tmp) + try: + from calibre.ebooks.metadata.meta import set_metadata + + with open(tmp, 'r+b') as stream: + set_metadata(stream, mi, EXTS[fmt]) + except Exception: + traceback.print_exc() + return tmp + + +class PushWorker(QThread): + progress = pyqtSignal(int, int) # done, total + book_status = pyqtSignal(int, str, str) # book_id, status key, detail + done = pyqtSignal(bool, str) # ok, message + + def __init__(self, parent, db, book_ids, client, include_custom_columns): + QThread.__init__(self, parent) + self.db = db # calibre new_api (thread-safe) + self.book_ids = list(book_ids) + self.client = client + self.include_custom_columns = include_custom_columns + self.canceled = False + + def cancel(self): + self.canceled = True + + def run(self): + try: + rows = [r for r in self.client.pull_books() if r.get('book_hash')] + self.by_hash = {r['book_hash']: r for r in rows} + self.by_uuid = index_rows_by_uuid(rows) + except AuthRequiredError as err: + self.done.emit(False, 'Please log in to Readest first. (%s)' % err) + return + except Exception as err: + self.done.emit(False, 'Could not reach Readest: %s' % err) + return + + counts = {} + for index, book_id in enumerate(self.book_ids): + if self.canceled: + self.done.emit(False, 'Canceled.') + return + try: + status, detail = self._push_one(book_id) + except QuotaExceededError as err: + self.book_status.emit(book_id, 'failed', str(err)) + self.done.emit(False, 'Readest storage quota exceeded — push stopped.') + return + except AuthRequiredError as err: + self.book_status.emit(book_id, 'failed', str(err)) + self.done.emit(False, 'Session expired — please log in again.') + return + except Exception as err: + traceback.print_exc() + status, detail = 'failed', str(err) + counts[status] = counts.get(status, 0) + 1 + self.book_status.emit(book_id, status, detail) + self.progress.emit(index + 1, len(self.book_ids)) + + summary = ', '.join( + '%d %s' % (counts[key], STATUS_LABELS[key].lower()) + for key in ('uploaded', 'replaced', 'updated', 'skipped', 'failed') + if key in counts + ) + self.done.emit('failed' not in counts, summary or 'Nothing to push.') + + def _upload(self, file_name, fileobj, size, book_hash): + upload = self.client.get_upload_url(file_name, size, book_hash) + self.client.put_file(upload['uploadUrl'], fileobj, size) + + def _upload_cover(self, book_hash, cover_bytes): + self._upload(cover_file_name(book_hash), io.BytesIO(cover_bytes), len(cover_bytes), book_hash) + + def _delete_cloud_files(self, book_hash): + # Best-effort quota reclaim for a replaced book, mirroring the + # koplugin's deleteCloudFiles: list to learn exact keys, then DELETE. + try: + for record in self.client.list_files(book_hash): + if record.get('file_key'): + self.client.delete_file(record['file_key']) + except Exception: + traceback.print_exc() + + def _remember(self, record, uuid): + # Keep the lookup maps current so a duplicate calibre entry later in + # this run resolves to what we just pushed. + row = { + 'book_hash': record['hash'], + 'title': record['title'], + 'author': record['author'], + 'tags': record.get('tags'), + 'metadata': record['metadata'], + 'uploaded_at': 'pushed', + 'cover_hash': record.get('coverHash'), + } + self.by_hash[record['hash']] = row + if uuid: + self.by_uuid[uuid] = row + + def _push_one(self, book_id): + mi = self.db.get_metadata(book_id) + fmt = pick_format(self.db.formats(book_id)) + if fmt is None: + return 'failed', 'No Readest-supported format (EPUB, PDF, ...)' + path = self.db.format_abspath(book_id, fmt) + if not path or not os.path.exists(path): + return 'failed', 'Book file is missing from the calibre library' + + source_hash = partial_md5(path) + cover_bytes = self.db.cover(book_id) + cover_hash = partial_md5_bytes(cover_bytes) if cover_bytes else None + + now_ms = int(time.time() * 1000) + book = _book_dict(mi, self.include_custom_columns, source_hash) + wire = build_wire_book(book, source_hash, fmt, now_ms) + uuid = (book.get('uuid') or '').lower() + server_row = pick_server_row( + self.by_hash.get(source_hash), self.by_uuid.get(uuid) if uuid else None + ) + plan = plan_push(server_row, wire, cover_hash, source_hash) + + if plan['action'] == 'skip': + return 'skipped', '' + + if plan['action'] in ('new', 'replace'): + blob_path = _embed_metadata_copy(path, mi, fmt) + try: + blob_hash = partial_md5(blob_path) + wire['hash'] = wire['bookHash'] = blob_hash + size = os.path.getsize(blob_path) + with open(blob_path, 'rb') as f: + self._upload(book_file_name(blob_hash, fmt), f, size, blob_hash) + finally: + try: + os.unlink(blob_path) + except OSError: + pass + if plan['upload_cover'] and cover_bytes: + self._upload_cover(blob_hash, cover_bytes) + records = [ + merge_for_push( + wire, + server_row, + now_ms, + uploaded_at_ms=now_ms, + cover_hash=cover_hash if plan['upload_cover'] and cover_bytes else None, + ) + ] + replaced_hash = None + if server_row is not None and server_row['book_hash'] != blob_hash: + replaced_hash = server_row['book_hash'] + records.append(tombstone_record(server_row, now_ms)) + self.client.push_books(records) + if replaced_hash: + self._delete_cloud_files(replaced_hash) + self._remember(records[0], uuid) + return ('replaced' if plan['action'] == 'replace' else 'uploaded'), '' + + # action == 'update': the cloud file is current, only the row (and + # possibly the cover) changed. Keep the row's hash namespace. + wire['hash'] = wire['bookHash'] = server_row['book_hash'] + pushed_cover = plan['upload_cover'] and cover_bytes + if pushed_cover: + self._upload_cover(server_row['book_hash'], cover_bytes) + record = merge_for_push( + wire, server_row, now_ms, cover_hash=cover_hash if pushed_cover else None + ) + self.client.push_books([record]) + self._remember(record, uuid) + return 'updated', ''