Files
readest/apps/readest-calibre-plugin/tests/test_hashes.py
T
Huang Xin 6b403d019e 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 <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>
2026-07-04 16:40:16 +02:00

117 lines
3.6 KiB
Python

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()