From 790840e4bc1a92f28505a780dfc39c4c32e4cdbe Mon Sep 17 00:00:00 2001 From: bndct-devops Date: Sat, 18 Jul 2026 19:53:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(tomesync):=20sync=20on=20suspend=20?= =?UTF-8?q?=E2=80=94=20morning=20stats=20without=20waking=20the=20device?= =?UTF-8?q?=20(build=2035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #128. Two new opt-in settings: "Sync on suspend" catches up pending sessions/ratings and the reading-history backfill at lid close when WiFi is already connected; "Aggressive sync" turns WiFi on first via turnOnWifiAndWaitForConnection (bypassing wifi_enable_action so "prompt" never dialogs onto the sleep screen). Every step is queue-or-resume safe, so a device that sleeps mid-sync just finishes on the next connection. onNetworkConnected now also runs the history backfill (was launch-only) and no longer requires an open book to flush pending state — WiFi coming up in the file manager catches everything up. Verified in the emulator: suspend with WiFi up syncs; suspend with WiFi down + aggressive brings the radio up and syncs after connect; toggle off = no-op; aggressive toggle gates on the master toggle. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +++ backend/api/tome_sync.py | 155 ++++++++++++++++++++++-------- tests/test_tomesync_plugin_url.py | 8 +- 3 files changed, 134 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cef2fe1..5b34ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,17 @@ All notable changes to Tome are documented here. Format loosely follows dedicated attempt marker now records "tried, nothing there" once. ### Added +- **Sync on suspend (KOReader plugin, build 35).** Two new opt-in settings + make morning stats current without waking the device (#128). "Sync on + suspend" catches up anything still pending — sessions, ratings, and the + reading-history backfill — when the device goes to sleep, provided WiFi is + already connected. "Aggressive sync" additionally turns WiFi on first for + devices that sleep the radio (e.g. PocketBook), letting the system power it + back down as the device suspends. Everything on this path is + queue-or-resume safe: if the device sleeps before the sync finishes, it + completes on the next connection. Reconnecting WiFi now also catches up the + reading-history backfill (previously launch-only) and flushes pending + sessions even before a book is opened. - **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 diff --git a/backend/api/tome_sync.py b/backend/api/tome_sync.py index a199e82..608aba3 100644 --- a/backend/api/tome_sync.py +++ b/backend/api/tome_sync.py @@ -61,8 +61,14 @@ # (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" +# BUILD 35: sync on suspend (issue #128) — opt-in catch-up of pending +# sessions/ratings + reading-history backfill when the device sleeps, with an +# aggressive variant that turns WiFi on first (turnOnWifiAndWaitForConnection, +# bypassing wifi_enable_action so "prompt" never dialogs onto the sleep +# screen). onNetworkConnected also runs the history backfill now (was +# launch-only) and no longer requires an open book to flush pending state. +TOMESYNC_PLUGIN_BUILD = 35 +TOMESYNC_PLUGIN_SEMVER = "1.9.0" TOMESYNC_PLUGIN_VERSION = str(TOMESYNC_PLUGIN_BUILD) @@ -2274,47 +2280,81 @@ def _main_impl_lua(server_url: str, api_key: str, username: str) -> str: function TomeSync:onSuspend() UIManager:unschedule(self._heartbeat_task) self._heartbeat_armed = false - if not self.enabled or not self.book_id then return end - - -- Record the reading session (lid close = end of session) - local pct = self:_getCurrentPercentage() - local cfi = self:_getCurrentProgress() - local duration = self.session_start and (os.time() - self.session_start) or 0 - local dev = deviceName() + if not self.enabled then return end - pcall(apiRequest, "PUT", "/tome-sync/position/" .. self.book_id, {{ - progress = cfi, percentage = pct, device = dev, - }}) + if self.book_id then + -- Record the reading session (lid close = end of session) + local pct = self:_getCurrentPercentage() + local cfi = self:_getCurrentProgress() + local duration = self.session_start and (os.time() - self.session_start) or 0 + local dev = deviceName() - -- Sync highlights/notes alongside position (bidirectional merge with the server). - pcall(function() self:_syncAnnotations() end) - -- Push a rating set this session (lid close ends the session like a book close). - pcall(function() self:_pushRatingOnLeave() end) + pcall(apiRequest, "PUT", "/tome-sync/position/" .. self.book_id, {{ + progress = cfi, percentage = pct, device = dev, + }}) - if duration > 10 then - local session = {{ - book_id = self.book_id, - started_at = os.date("!%Y-%m-%dT%H:%M:%SZ", self.session_start), - ended_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time()), - duration_seconds = duration, - progress_start = self.progress_start, - progress_end = pct, - pages_turned = self.page_count, - device = dev, - session_uuid = string.format("%d-%d-%s", self.book_id, self.session_start or 0, dev), - }} - local sok, sresult, scode = pcall(apiRequest, "POST", "/tome-sync/session", session) - if not sok or not sresult or (type(scode) == "number" and scode >= 300) then - -- Failed to send — save for later - table.insert(self.pending_sessions, session) - -- Cap at 50 to prevent unbounded growth - while #self.pending_sessions > 50 do - table.remove(self.pending_sessions, 1) + -- Sync highlights/notes alongside position (bidirectional merge with the server). + pcall(function() self:_syncAnnotations() end) + -- Push a rating set this session (lid close ends the session like a book close). + pcall(function() self:_pushRatingOnLeave() end) + + if duration > 10 then + local session = {{ + book_id = self.book_id, + started_at = os.date("!%Y-%m-%dT%H:%M:%SZ", self.session_start), + ended_at = os.date("!%Y-%m-%dT%H:%M:%SZ", os.time()), + duration_seconds = duration, + progress_start = self.progress_start, + progress_end = pct, + pages_turned = self.page_count, + device = dev, + session_uuid = string.format("%d-%d-%s", self.book_id, self.session_start or 0, dev), + }} + local sok, sresult, scode = pcall(apiRequest, "POST", "/tome-sync/session", session) + if not sok or not sresult or (type(scode) == "number" and scode >= 300) then + -- Failed to send — save for later + table.insert(self.pending_sessions, session) + -- Cap at 50 to prevent unbounded growth + while #self.pending_sessions > 50 do + table.remove(self.pending_sessions, 1) + end + self:_saveState("tomesync_pending_sessions", self.pending_sessions) + logger.info("TomeSync: session queued for retry, pending:", #self.pending_sessions) end - self:_saveState("tomesync_pending_sessions", self.pending_sessions) - logger.info("TomeSync: session queued for retry, pending:", #self.pending_sessions) end end + + self:_suspendSync() +end + +-- Sync on suspend (issue #128): before the device sleeps, catch up everything +-- still queued — pending sessions/ratings and, when the launch backfill is +-- enabled, the KOReader reading-history import — so stats are current in the +-- morning without waking the device. Two opt-in levels: +-- tomesync_sync_on_suspend sync only if WiFi is already connected +-- tomesync_suspend_wifi also bring the radio up first (aggressive) +-- The device may finish suspending before WiFi associates or mid-transfer; +-- every step here is queue-or-resume safe (sessions/ratings re-flush, the +-- history import resumes from the server watermark), so a cut connection just +-- finishes on the next opportunity. +function TomeSync:_suspendSync() + if not G_reader_settings:isTrue("tomesync_sync_on_suspend") then return end + local function catchUp() + self:_flushPendingSessions() + self:_flushPendingRatings() + if G_reader_settings:isTrue("tomesync_auto_sync_stats") then + pcall(function() self:_syncReadingStats(false) end) + end + end + if NetworkMgr:isConnected() then + catchUp() + elseif G_reader_settings:isTrue("tomesync_suspend_wifi") then + -- Deliberately NOT beforeWifiAction/whenConnected: those honour the + -- user's wifi_enable_action, and "prompt" would pop a dialog onto the + -- sleep screen. The toggle's promise is "turn WiFi on", so do exactly + -- that. The system powers the radio back down as part of suspend. + pcall(function() NetworkMgr:turnOnWifiAndWaitForConnection(catchUp) end) + end end function TomeSync:onResume() @@ -2340,9 +2380,20 @@ def _main_impl_lua(server_url: str, api_key: str, username: str) -> str: function TomeSync:onNetworkConnected() consecutive_failures = 0 backoff_until = 0 - if not self.enabled or not self.book_id then return end + -- No book_id guard: pending sessions/ratings and the history import are + -- device-level, and WiFi often comes up in the file manager (morning + -- catch-up) before any book is opened. + if not self.enabled then return end self:_flushPendingSessions() self:_flushPendingRatings() + -- Catch the reading-history backfill up too — previously launch-only, so + -- turning WiFi on without restarting KOReader left stats stale (issue #128). + -- Deferred so it doesn't contend with whatever brought the network up. + if G_reader_settings:isTrue("tomesync_auto_sync_stats") then + UIManager:scheduleIn(4, function() + pcall(function() self:_syncReadingStats(false) end) + end) + end end -- ── KOReader statistics.sqlite3 import (reading-history backfill) ──────────── @@ -4524,6 +4575,34 @@ def _main_impl_lua(server_url: str, api_key: str, username: str) -> str: not G_reader_settings:isTrue("tomesync_auto_sync_stats")) end, }}) + table.insert(settings_items, {{ + text = "Sync on suspend", + help_text = "When the device goes to sleep, catch up anything still " + .. "pending - sessions, ratings and (if enabled above) " + .. "reading history - so your stats are current in the " + .. "morning without waking the device. Only syncs when " + .. "WiFi is already connected; enable aggressive sync " + .. "below to turn WiFi on first.", + checked_func = function() return G_reader_settings:isTrue("tomesync_sync_on_suspend") end, + callback = function() + G_reader_settings:saveSetting("tomesync_sync_on_suspend", + not G_reader_settings:isTrue("tomesync_sync_on_suspend")) + end, + }}) + table.insert(settings_items, {{ + text = "Aggressive sync (turn WiFi on at suspend)", + help_text = "If WiFi is off when the device goes to sleep, turn it on, " + .. "sync, and let the device power it back down as it " + .. "suspends. Uses more battery than plain sync on " + .. "suspend. If the device sleeps before the sync " + .. "finishes, it completes on the next connection.", + enabled_func = function() return G_reader_settings:isTrue("tomesync_sync_on_suspend") end, + checked_func = function() return G_reader_settings:isTrue("tomesync_suspend_wifi") end, + callback = function() + G_reader_settings:saveSetting("tomesync_suspend_wifi", + not G_reader_settings:isTrue("tomesync_suspend_wifi")) + end, + }}) -- In-book items if in_book then diff --git a/tests/test_tomesync_plugin_url.py b/tests/test_tomesync_plugin_url.py index 8a369b1..b895328 100644 --- a/tests/test_tomesync_plugin_url.py +++ b/tests/test_tomesync_plugin_url.py @@ -189,5 +189,9 @@ def test_build_bumped_for_rebake(): # 1.8.0 / build 31 introduces half-star ratings server-side: the plugin's # rating_baseline splits into {remote, device} so a Tome half-star rounded # onto the whole-star sidecar is never pushed back as a "local edit". - assert TOMESYNC_PLUGIN_BUILD >= 31 - assert TOMESYNC_PLUGIN_SEMVER == "1.8.0" + # 1.9.0 / build 35 adds sync on suspend (issue #128): opt-in catch-up of + # pending sessions/ratings + reading-history backfill at lid close, with an + # aggressive variant that turns WiFi on first; onNetworkConnected also runs + # the history backfill (was launch-only) and flushes without an open book. + assert TOMESYNC_PLUGIN_BUILD >= 35 + assert TOMESYNC_PLUGIN_SEMVER == "1.9.0"