From 207fb8fe17fc31c55574f9167b92c6393f942d0b Mon Sep 17 00:00:00 2001 From: bndct-devops Date: Mon, 6 Jul 2026 00:06:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(tomesync):=20library=20sweep=20=E2=80=94?= =?UTF-8?q?=20adopt=20closed=20books=20(build=2034)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last teardown item, unblocked by build 27's hash matching: a "Sync closed books" menu action that adopts state from books TomeSync has never synced (pre-Tome history, other launchers, sideloads). Plugin: - Walk home_dir for supported books not in book_map that have a sidecar; mtime-gated against an ack-gated ledger (tomesync_sweep in the state file), so interrupting loses nothing and re-runs only touch books whose sidecar changed. Chunked (5/tick) off the UI path via scheduleIn; a server failure mid-run stops cleanly with honest counts. - Each chunk: util.partialMD5 per file → POST /tome-sync/match-hashes (batch) → matches enter book_map (a sweep match is a full resolve — positions/annotations/ratings all start working for that book) → adopt the sidecar via DocSettings:open on the CLOSED book (summary.status → read/reading, summary.rating/note, percent_finished, last_xpointer). "abandoned" stays unmapped; bare layout-only sidecars ledger as done. Server: - POST /tome-sync/match-hashes: batch partial-MD5 → book id (500 cap), visibility-filtered — an invisible book is indistinguishable from an unmatched hash. - POST /tome-sync/sweep/{book_id}: FILL GAPS ONLY. Status applies only over unread/absent (never downgrades curation), rating/review only when none exists, position row only when none exists — a closed book's stale sidecar can never clobber live sync state. Returns what was actually taken. Sweep prune added to _pruneState. Verification: 918 tests green (13 new: batch matching + visibility, the fill-gap invariants incl. granular fills and idempotence, impl contracts). Emulator round-trip vs the showcase: an orphaned closed book with a crafted sidecar matched by hash and adopted end-to-end (status=read, rating=4, review, 42% position on the server); the visibility filter correctly rejected a hash pointing at a nonexistent book id first, and an immediate re-run adopted nothing (ledger no-op). luajit compile green. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++ backend/api/tome_sync.py | 331 ++++++++++++++++++++++++++++++++++- tests/test_tomesync_sweep.py | 194 ++++++++++++++++++++ 3 files changed, 534 insertions(+), 1 deletion(-) create mode 100644 tests/test_tomesync_sweep.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 45af953..52f1ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ All notable changes to Tome are documented here. Format loosely follows ## [Unreleased] ### Added +- **Sync closed books (KOReader plugin, build 34).** A new TomeSync menu + action walks the device for books the plugin has never synced — read before + Tome existed, sideloaded, or opened under another launcher — matches them + against your library by content hash, and adopts each one's status, rating + and reading position from its KOReader sidecar. Adoption only fills what + Tome doesn't already have: it can never overwrite live sync state or your + own curation. The sweep is resumable (interrupting it loses nothing) and + cheap to re-run — books are skipped until their sidecar actually changes. + Matched books also become fully synced from then on, positions, highlights + and all. - **Search your library from the device (KOReader plugin, build 33).** The series browser gains a "Search library…" entry: submit-based free-text search over title, author, and series, with your last searches one tap away. Results diff --git a/backend/api/tome_sync.py b/backend/api/tome_sync.py index 6c83af5..a199e82 100644 --- a/backend/api/tome_sync.py +++ b/backend/api/tome_sync.py @@ -56,7 +56,12 @@ # author browse axis, read-status write-back (hold a book row); the shared # _bookListMenu drill-down shows per-book status markers. Server side: series # list N+1 fixed, /tome-sync/{authors,author-books,search} + PUT status. -TOMESYNC_PLUGIN_BUILD = 33 +# BUILD 34: library sweep — "Sync closed books" walks the device for books +# TomeSync never synced, resolves them by partial-MD5 in batches +# (POST /tome-sync/match-hashes) and adopts each sidecar's status/rating/ +# progress via the fill-gaps-only POST /tome-sync/sweep/{book_id}. Ack-gated +# ledger keyed by sidecar mtime → resumable, re-run-safe. +TOMESYNC_PLUGIN_BUILD = 34 TOMESYNC_PLUGIN_SEMVER = "1.8.0" TOMESYNC_PLUGIN_VERSION = str(TOMESYNC_PLUGIN_BUILD) @@ -976,6 +981,137 @@ def put_reading_status( return {"ok": True, "book_id": book_id, "status": body.status} +# ── Library sweep — closed books (build 34) ────────────────────────────────── +# The plugin walks the device library for books it has NEVER synced (pre-Tome +# history, other launchers, sideloads), resolves them by partial-MD5 in +# batches, then adopts each sidecar's state. Adoption is FILL GAPS ONLY: a +# closed book's stale sidecar must never overwrite state the live sync (or the +# user, on the web) already owns. + +class MatchHashesRequest(PydanticBaseModel): + hashes: list[str] = [] + + # KOReader's Lua rapidjson encodes an empty table as a JSON object. + @field_validator("hashes", mode="before") + @classmethod + def _empty_obj_to_list(cls, v): + return [] if v in (None, {}) else v + + +@router.post("/tome-sync/match-hashes") +def match_hashes( + body: MatchHashesRequest, + db: Session = Depends(get_db), + user: User = Depends(_get_api_key_user), +): + """Batch partial-MD5 → book id resolution (max 500 per call). Only books + this user can see are returned — an unmatched hash is indistinguishable + from an invisible book, by design.""" + from backend.services.ko_hash import lookup_book_ids + + hashes = [h for h in body.hashes if isinstance(h, str) and h][:500] + found = lookup_book_ids(db, hashes) + if not found: + return {"matches": {}} + visible = { + r[0] + for r in db.query(Book.id) + .filter( + Book.id.in_(set(found.values())), + Book.status == "active", + book_visibility_filter(db, user), + ) + .all() + } + return {"matches": {m: bid for m, bid in found.items() if bid in visible}} + + +class SweepBody(PydanticBaseModel): + status: Optional[str] = None # reading | read (sidecar summary.status) + rating: Optional[float] = None # 0.5–5.0 half-star steps + review: Optional[str] = None + percentage: Optional[float] = None # 0..1 (sidecar percent_finished) + progress: Optional[str] = None # last xpointer + device: Optional[str] = None + + +@router.post("/tome-sync/sweep/{book_id}") +def sweep_book( + book_id: int, + body: SweepBody, + db: Session = Depends(get_db), + user: User = Depends(_get_api_key_user), +): + """Adopt a closed book's sidecar state, filling only what the server does + not already have: + + - status: applied only over unread/absent — never downgrades curation + - rating/review: applied only when no rating exists + - position/progress: created only when NO position row exists — an actively + synced book keeps its live position untouched + + Returns which fields were actually taken so the plugin can report honestly. + """ + book = db.get(Book, book_id) + if not book or book.status != "active" or not user_can_see_book(db, user, book): + raise HTTPException(status_code=404, detail="Book not found") + + applied: dict = {} + row = ( + db.query(UserBookStatus) + .filter(UserBookStatus.user_id == user.id, UserBookStatus.book_id == book_id) + .first() + ) + + def ensure_row() -> UserBookStatus: + nonlocal row + if row is None: + row = UserBookStatus(user_id=user.id, book_id=book_id, status="unread") + db.add(row) + return row + + if body.status in ("reading", "read"): + r = ensure_row() + if r.status in (None, "", "unread"): + r.status = body.status + applied["status"] = body.status + + if body.rating is not None: + validate_rating(body.rating) + r = ensure_row() + if r.rating is None: + r.rating = body.rating + r.rated_at = datetime.utcnow() + applied["rating"] = body.rating + if body.review and not r.review: + r.review = body.review + applied["review"] = True + + if body.percentage is not None: + pct = min(max(float(body.percentage), 0.0), 1.0) + r = ensure_row() + if r.progress_pct is None: + r.progress_pct = pct + applied["progress_pct"] = pct + existing_pos = ( + db.query(TomeSyncPosition) + .filter(TomeSyncPosition.user_id == user.id, TomeSyncPosition.book_id == book_id) + .first() + ) + if existing_pos is None: + db.add(TomeSyncPosition( + user_id=user.id, book_id=book_id, + progress=body.progress, percentage=pct, + device=body.device or "sweep", + )) + applied["position"] = True + + db.commit() + if "rating" in applied: + hardcover_nudge() + return {"ok": True, "book_id": book_id, "applied": applied} + + @router.get("/tome-sync/series/{book_id}") def get_series_books( book_id: int, @@ -1501,6 +1637,9 @@ def _main_impl_lua(server_url: str, api_key: str, username: str) -> str: local last_session_init = {{ book_id = nil, at = 0 }} -- Once-per-process guard for the state prune (init runs per reader instance). local state_pruned = false +-- Library-sweep latch: one sweep at a time per KOReader process. +local sweep_running = false +local SWEEP_EXTS = {{ epub = true, pdf = true, cbz = true, cbr = true, mobi = true, azw3 = true }} -- ── HTTP client ────────────────────────────────────────────────────────────── @@ -1988,6 +2127,17 @@ def _main_impl_lua(server_url: str, api_key: str, username: str) -> str: pruneById(self.annot_baseline) pruneById(self.rating_baseline) pruneById(self.repair_map) + -- Sweep ledger is keyed by file path directly. + local sweep = self.state:readSetting("tomesync_sweep") + if sweep and sweep.done then + for path in pairs(sweep.done) do + if lfs.attributes(path, "mode") ~= "file" then + sweep.done[path] = nil + changed = true + end + end + if changed then self.state:saveSetting("tomesync_sweep", sweep) end + end if changed then self.state:saveSetting("tomesync_book_map", self.book_map) self.state:saveSetting("tomesync_annot_baseline", self.annot_baseline) @@ -3741,6 +3891,177 @@ def _main_impl_lua(server_url: str, api_key: str, username: str) -> str: self:_downloadSeriesBooks(data.series_name, data.books, min_index, data.book_type) end +-- ── Library sweep: adopt closed books (build 34) ───────────────────────────── +-- Walks the device library for books TomeSync has NEVER synced (pre-Tome +-- history, other launchers, sideloads), resolves them by partial-MD5 in +-- batches, and adopts each sidecar's status/rating/progress. The server fills +-- gaps only, so re-sweeping is always safe. Resumable: every book is ack-gated +-- into a ledger keyed by its sidecar's mtime — re-runs skip everything already +-- taken unless the sidecar changed since. + +function TomeSync:_sweepLibrary() + if sweep_running then + UIManager:show(InfoMessage:new{{ text = "Library sweep already running.", timeout = 3 }}) + return + end + whenConnected(function() self:_sweepLibraryImpl() end) +end + +function TomeSync:_sweepLibraryImpl() + local base_dir = G_reader_settings:readSetting("home_dir") + or G_reader_settings:readSetting("download_dir") + or G_reader_settings:readSetting("lastdir") + if not base_dir then + UIManager:show(InfoMessage:new{{ text = "No home folder configured.", timeout = 4 }}) + return + end + local DocSettings = require("docsettings") + local sweep = self.state:readSetting("tomesync_sweep") or {{}} + sweep.done = sweep.done or {{}} + + -- Phase 1: walk — never-synced books with a sidecar whose mtime moved past + -- the ledger. Cheap: no file is opened or hashed here. + local candidates = {{}} + local function walk(dir, depth) + if depth > 10 then return end + local ok, iter, dirobj = pcall(lfs.dir, dir) + if not ok then return end + for entry in iter, dirobj do + if entry ~= "." and entry ~= ".." and entry:sub(1, 1) ~= "." then + local path = dir .. "/" .. entry + local mode = lfs.attributes(path, "mode") + if mode == "directory" then + if not entry:match("%.sdr$") then walk(path, depth + 1) end + elseif mode == "file" then + local ext = entry:match("%.([^.]+)$") + if ext and SWEEP_EXTS[ext:lower()] and not self.book_map[path] then + local sidecar = DocSettings:findSidecarFile(path) + if sidecar then + local smtime = lfs.attributes(sidecar, "modification") or 0 + if (sweep.done[path] or -1) < smtime then + table.insert(candidates, {{ path = path, smtime = smtime }}) + end + end + end + end + end + end + end + walk(base_dir, 0) + + if #candidates == 0 then + UIManager:show(InfoMessage:new{{ text = "Library sweep: nothing new to adopt.", timeout = 4 }}) + return + end + + sweep_running = true + UIManager:show(Notification:new{{ + text = string.format("TomeSync: sweeping %d closed book(s)…", #candidates), + timeout = 3, + }}) + + local stats = {{ matched = 0, adopted = 0, unmatched = 0, failed = 0 }} + local idx = 1 + local CHUNK = 5 + + local function finish(aborted) + sweep_running = false + self:_saveState("tomesync_sweep", sweep) + UIManager:show(InfoMessage:new{{ + text = string.format( + "Library sweep %s.\\n\\nMatched in Tome: %d\\nState adopted: %d\\nNot in Tome: %d\\nFailed: %d", + aborted and "stopped (server unreachable)" or "done", + stats.matched, stats.adopted, stats.unmatched, stats.failed), + timeout = 8, + }}) + end + + local function step() + if idx > #candidates then finish(false) return end + local batch = {{}} + while idx <= #candidates and #batch < CHUNK do + table.insert(batch, candidates[idx]) + idx = idx + 1 + end + -- Phase 2: hash + batch-resolve this chunk, then adopt each match. + local hashes, by_hash = {{}}, {{}} + for _, c in ipairs(batch) do + local md5 = util.partialMD5(c.path) + if md5 then + table.insert(hashes, md5) + by_hash[md5] = c + else + stats.failed = stats.failed + 1 + end + end + local ok, resp, code = pcall(apiRequest, "POST", "/tome-sync/match-hashes", + {{ hashes = hashes }}) + if not ok or type(resp) ~= "table" or type(resp.matches) ~= "table" + or (type(code) == "number" and code >= 300) then + -- Server gone mid-run: stop; the ledger keeps everything adopted so far. + finish(true) + return + end + for _, md5 in ipairs(hashes) do + local c = by_hash[md5] + local book_id = resp.matches[md5] + if type(book_id) == "number" then + stats.matched = stats.matched + 1 + -- A sweep match is a real resolve: everything else (positions, + -- annotations, ratings) now works for this book too. + self.book_map[c.path] = book_id + if self:_adoptSidecar(book_id, c.path) then + stats.adopted = stats.adopted + 1 + sweep.done[c.path] = c.smtime + else + stats.failed = stats.failed + 1 + end + else + stats.unmatched = stats.unmatched + 1 + -- Ledger it anyway — no point re-hashing every run; a changed + -- sidecar (mtime) will retry it naturally. + sweep.done[c.path] = c.smtime + end + end + self:_saveState("tomesync_book_map", self.book_map) + self:_saveState("tomesync_sweep", sweep) + UIManager:scheduleIn(0.5, step) + end + step() +end + +function TomeSync:_adoptSidecar(book_id, path) + -- Read the CLOSED book's sidecar (no document open) and push its state; + -- the server fills gaps only, so nothing here can clobber live sync state. + local DocSettings = require("docsettings") + local okd, ds = pcall(DocSettings.open, DocSettings, path) + if not okd or not ds then return false end + local summary = ds:readSetting("summary") or {{}} + local pct = tonumber(ds:readSetting("percent_finished")) + local xp = ds:readSetting("last_xpointer") + local status = nil + if summary.status == "complete" then + status = "read" + elseif summary.status == "reading" then + status = "reading" + end + -- "abandoned" has no Tome equivalent; the book's other state still adopts. + local body = {{ + status = status, + rating = tonumber(summary.rating), + review = (type(summary.note) == "string" and summary.note ~= "") and summary.note or nil, + percentage = pct, + progress = (type(xp) == "string") and xp or nil, + device = deviceName(), + }} + if body.status == nil and body.rating == nil and body.percentage == nil then + -- Bare sidecar (layout settings only): nothing to adopt, count as done. + return true + end + local ok, _resp, code = pcall(apiRequest, "POST", "/tome-sync/sweep/" .. book_id, body) + return ok and type(code) == "number" and code < 300 +end + -- ── Self-update ────────────────────────────────────────────────────────────── -- on_result(avail) where avail is a {{build, semver}} table if newer, false if @@ -4109,6 +4430,14 @@ def _main_impl_lua(server_url: str, api_key: str, username: str) -> str: whenConnected(function() self:_syncReadingStats(true) end) end, }}) + table.insert(sub_items, {{ + text = "Sync closed books", + help_text = "Adopt status, rating and progress from books on this " + .. "device that TomeSync has never synced (read before Tome, " + .. "sideloaded, or opened under another launcher). Only fills " + .. "what Tome doesn't already have.", + callback = function() self:_sweepLibrary() end, + }}) -- Inbox: only shown when the server has Send-to-KOReader enabled (set by the -- launch poll). Badge shows the pending count. if self.inbox_enabled then diff --git a/tests/test_tomesync_sweep.py b/tests/test_tomesync_sweep.py new file mode 100644 index 0000000..bfa658e --- /dev/null +++ b/tests/test_tomesync_sweep.py @@ -0,0 +1,194 @@ +"""Library sweep for closed books (plugin build 34). + +Server half: batch partial-MD5 matching + the fill-gaps-only adoption +endpoint. The invariant under test: a closed book's stale sidecar can NEVER +overwrite state the live sync or the user already owns. +""" +from backend.models.tome_sync import ApiKey, TomeSyncPosition +from backend.models.user import User +from backend.models.user_book_status import UserBookStatus +from backend.services.ko_hash import record_ko_hash +from backend.core.security import hash_password + + +def _api_key_for(db, user_id: int) -> str: + plaintext = ApiKey.generate() + db.add(ApiKey(user_id=user_id, key_hash=ApiKey.hash_key(plaintext), + key_prefix=plaintext[:11], label="test")) + db.flush() + return plaintext + + +def _hdr(db, user): + return {"Authorization": f"Bearer {_api_key_for(db, user.id)}"} + + +# ── match-hashes ───────────────────────────────────────────────────────────── + +def test_match_hashes_batch(db, client, admin_user, make_book): + user, _ = admin_user + b1 = make_book(title="Hashed One") + b2 = make_book(title="Hashed Two") + record_ko_hash(db, b1.id, "a" * 32) + record_ko_hash(db, b2.id, "b" * 32) + db.commit() + + r = client.post("/api/tome-sync/match-hashes", headers=_hdr(db, user), + json={"hashes": ["a" * 32, "b" * 32, "c" * 32]}) + assert r.status_code == 200, r.text + assert r.json()["matches"] == {"a" * 32: b1.id, "b" * 32: b2.id} + + +def test_match_hashes_empty_object_coerced(db, client, admin_user): + user, _ = admin_user + # Lua rapidjson sends {} for an empty list + r = client.post("/api/tome-sync/match-hashes", headers=_hdr(db, user), + json={"hashes": {}}) + assert r.status_code == 200 and r.json()["matches"] == {} + + +def test_match_hashes_respects_visibility(db, client, admin_user, make_book): + admin, _ = admin_user + owner = User(username="sweep-owner", email="so@example.com", + hashed_password=hash_password("pw"), is_active=True, role="member") + peeker = User(username="sweep-peeker", email="sp@example.com", + hashed_password=hash_password("pw"), is_active=True, role="member") + db.add_all([owner, peeker]) + db.flush() + private = make_book(title="Private Hashed") + private.added_by = owner.id + record_ko_hash(db, private.id, "d" * 32) + db.flush() + + r = client.post("/api/tome-sync/match-hashes", headers=_hdr(db, peeker), + json={"hashes": ["d" * 32]}) + # Invisible book == unmatched hash, indistinguishable by design. + assert r.json()["matches"] == {} + + +# ── sweep adoption: fill gaps only ─────────────────────────────────────────── + +def _sweep(client, hdr, book_id, **body): + return client.post(f"/api/tome-sync/sweep/{book_id}", headers=hdr, json=body) + + +def test_sweep_fills_everything_on_virgin_book(db, client, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Virgin") + hdr = _hdr(db, user) + + r = _sweep(client, hdr, book.id, status="read", rating=4.5, + review="great", percentage=1.0, progress="/body/x", device="kindle") + assert r.status_code == 200, r.text + applied = r.json()["applied"] + assert applied["status"] == "read" + assert applied["rating"] == 4.5 + assert applied["review"] is True + assert applied["progress_pct"] == 1.0 + assert applied["position"] is True + + row = db.query(UserBookStatus).filter_by(user_id=user.id, book_id=book.id).first() + assert (row.status, row.rating, row.review, row.progress_pct) == ("read", 4.5, "great", 1.0) + pos = db.query(TomeSyncPosition).filter_by(user_id=user.id, book_id=book.id).first() + assert pos.progress == "/body/x" and pos.percentage == 1.0 and pos.device == "kindle" + + +def test_sweep_never_overwrites_existing_state(db, client, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Owned") + hdr = _hdr(db, user) + db.add(UserBookStatus(user_id=user.id, book_id=book.id, status="read", + rating=5.0, review="mine", progress_pct=0.8)) + db.add(TomeSyncPosition(user_id=user.id, book_id=book.id, + progress="/live/pos", percentage=0.8, device="kindle")) + db.commit() + + r = _sweep(client, hdr, book.id, status="reading", rating=2.0, + review="stale", percentage=0.3, progress="/stale/pos") + assert r.status_code == 200 + assert r.json()["applied"] == {} + + row = db.query(UserBookStatus).filter_by(user_id=user.id, book_id=book.id).first() + assert (row.status, row.rating, row.review, row.progress_pct) == ("read", 5.0, "mine", 0.8) + pos = db.query(TomeSyncPosition).filter_by(user_id=user.id, book_id=book.id).first() + assert pos.progress == "/live/pos" and pos.percentage == 0.8 + + +def test_sweep_fills_gaps_granularly(db, client, admin_user, make_book): + """A book with a status but no rating takes the rating and nothing else.""" + user, _ = admin_user + book = make_book(title="Partial") + hdr = _hdr(db, user) + db.add(UserBookStatus(user_id=user.id, book_id=book.id, status="reading")) + db.commit() + + r = _sweep(client, hdr, book.id, status="read", rating=3.5, percentage=0.5) + applied = r.json()["applied"] + assert "status" not in applied # reading is curation — not downgraded to sweep data + assert applied["rating"] == 3.5 + assert applied["progress_pct"] == 0.5 + assert applied["position"] is True + + row = db.query(UserBookStatus).filter_by(user_id=user.id, book_id=book.id).first() + assert row.status == "reading" and row.rating == 3.5 + + +def test_sweep_is_idempotent(db, client, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Twice") + hdr = _hdr(db, user) + _sweep(client, hdr, book.id, status="read", rating=4.0, percentage=1.0) + r = _sweep(client, hdr, book.id, status="read", rating=4.0, percentage=1.0) + assert r.json()["applied"] == {} + + +def test_sweep_validates_and_404s(db, client, admin_user, make_book): + user, _ = admin_user + book = make_book(title="Vld") + hdr = _hdr(db, user) + assert _sweep(client, hdr, book.id, rating=4.3).status_code == 400 # not a half-star step + assert _sweep(client, hdr, 999999, status="read").status_code == 404 + + +# ── generated-impl contract ────────────────────────────────────────────────── + +def _impl() -> str: + from backend.api.tome_sync import _main_impl_lua + return _main_impl_lua("https://tome.example.org", "tk_testkey", "tester") + + +def _body(lua: str, func: str) -> str: + start = lua.find(f"\nfunction TomeSync:{func}") + assert start != -1, f"missing function {func}" + return lua[start:lua.find("\nfunction ", start + 1)] + + +def test_sweep_menu_entry_exists(): + assert "Sync closed books" in _impl() + + +def test_sweep_is_mtime_gated_and_ack_gated(): + body = _body(_impl(), "_sweepLibraryImpl") + assert "findSidecarFile" in body + assert '(sweep.done[path] or -1) < smtime' in body # mtime gate + assert "sweep.done[c.path] = c.smtime" in body # ack after adopt + + +def test_sweep_skips_books_already_synced(): + body = _body(_impl(), "_sweepLibraryImpl") + assert "not self.book_map[path]" in body + + +def test_sweep_matches_feed_the_book_map(): + # A sweep match is a full resolve — positions/annotations gain from it too. + body = _body(_impl(), "_sweepLibraryImpl") + assert "self.book_map[c.path] = book_id" in body + assert 'self:_saveState("tomesync_book_map"' in body + + +def test_adopt_reads_closed_sidecar_and_maps_status(): + body = _body(_impl(), "_adoptSidecar") + assert "DocSettings.open" in body + assert 'summary.status == "complete"' in body + assert '"read"' in body + assert "percent_finished" in body