From 001628f2a23f5c164250b02d045e1ad5559443cb Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 15 Oct 2026 11:27:08 -0400 Subject: [PATCH] feat: ratings/review/reading-time sync bridge (handoff item 4 Batch B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new What-to-sync toggles — Ratings, Book review, Reading time — plus a manual 'Sync rating, review & time now' menu action. Ratings mirror summary.rating (0-5) against the server's 1-10 (exact doubling over, nearest-star back, 0 clears). Reviews mirror summary.note as raw markdown (the review box shows it literally, the same accepted tradeoff as notes). Reading time pushes the statistics plugin's cumulative book total (getStatsBookStatus) — the server watermark-converts only the delta, so repeated pushes are harmless. KOReader's summary carries no timestamps, so sync runs on change-detection: the plugin remembers the last values it pushed per book (settings.meta_sync_state); a local diff means koreader changed it (push), a server diff with an unchanged local copy means the server moved (pull applies, writing straight into doc_settings + flush). Pull on book open (2s behind the progress pull), push on book close inside the existing goOnlineToRun window, and both in the manual action (push first so a koreader edit survives the same gesture). Server endpoints: /api/sync/koreader/{rating,review,stats}. --- BookhoardAPI.lua | 58 ++++++++++ main.lua | 269 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) diff --git a/BookhoardAPI.lua b/BookhoardAPI.lua index f818acf..171d752 100644 --- a/BookhoardAPI.lua +++ b/BookhoardAPI.lua @@ -155,6 +155,64 @@ function BookhoardAPI:getMetadata(uuid) return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS) end +-- ----- Ratings / review / reading-time bridge (handoff item 4 Batch B) ----- + +-- GET ref: only uuid/sha256 travel as query params (both URL-safe); the +-- fallback title/authors resolution only exists on the push side, where the +-- body can carry them. An unlinked book has no server rating to pull anyway. +local function book_ref_query(ref) + local q = {} + if ref.uuid and ref.uuid ~= "" then + table.insert(q, "uuid=" .. ref.uuid) + end + if ref.sha256 and ref.sha256 ~= "" then + table.insert(q, "sha256=" .. ref.sha256) + end + return table.concat(q, "&") +end + +local function book_ref_body(ref) + local authors = ref.authors or {} + json_util.InitArray(authors) + return { + uuid = ref.uuid, + sha256 = ref.sha256, + title = ref.title, + authors = authors, + } +end + +-- Server rating is 1-10; koreader's summary.rating is 0-5 (0 = unrated). +function BookhoardAPI:getRating(ref) + return self:_request("GET", "/api/sync/koreader/rating?" .. book_ref_query(ref), nil, PROGRESS_TIMEOUTS) +end + +function BookhoardAPI:setRating(ref, rating) + local body = book_ref_body(ref) + body.rating = rating + return self:_request("POST", "/api/sync/koreader/rating", body, PROGRESS_TIMEOUTS) +end + +-- Review bodies are RAW MARKDOWN both ways (koreader's review box shows it +-- literally — the same accepted tradeoff as notes). +function BookhoardAPI:getReview(ref) + return self:_request("GET", "/api/sync/koreader/review?" .. book_ref_query(ref), nil, PROGRESS_TIMEOUTS) +end + +function BookhoardAPI:setReview(ref, review) + local body = book_ref_body(ref) + body.review = review + return self:_request("POST", "/api/sync/koreader/review", body, PROGRESS_TIMEOUTS) +end + +-- Cumulative total reading seconds for the book; the server converts +-- watermark-deltas into reading_history sessions (device-attributed). +function BookhoardAPI:pushStats(ref, total_seconds) + local body = book_ref_body(ref) + body.total_seconds = math.floor(total_seconds) + return self:_request("POST", "/api/sync/koreader/stats", body, PROGRESS_TIMEOUTS) +end + return BookhoardAPI diff --git a/main.lua b/main.lua index 0d41289..85cfe0e 100644 --- a/main.lua +++ b/main.lua @@ -67,6 +67,9 @@ Bookhoard.default_settings = { sync_bookmarks = true, sync_highlights = true, sync_notes = true, + sync_ratings = true, + sync_reviews = true, + sync_stats = true, sync_mode = SYNC_MODE.IMMEDIATE, } @@ -128,6 +131,11 @@ function Bookhoard:onReaderReady() -- to the server on books already mid-read elsewhere, creating a -- sync conflict before the first pull could ever happen. self:getProgress(true, false) + -- Rating/review pull rides one tick behind the progress pull so + -- the two never race the network stack; silent (non-interactive). + UIManager:scheduleIn(2, function() + self:pullBookMeta(false) + end) end) end self:registerEvents() @@ -197,6 +205,12 @@ function Bookhoard:buildMainMenu() callback = function() self:getProgress(true, true) end, + }) + table.insert(items, { + text = _("Sync rating, review & time now"), + callback = function() + self:syncBookMeta(true) + end, separator = true, }) end @@ -442,6 +456,30 @@ function Bookhoard:buildMainMenu() G_reader_settings:saveSetting(self.settings_key, self.settings) end, }, + { + text = _("Ratings"), + checked_func = function() return self.settings.sync_ratings end, + callback = function() + self.settings.sync_ratings = not self.settings.sync_ratings + G_reader_settings:saveSetting(self.settings_key, self.settings) + end, + }, + { + text = _("Book review"), + checked_func = function() return self.settings.sync_reviews end, + callback = function() + self.settings.sync_reviews = not self.settings.sync_reviews + G_reader_settings:saveSetting(self.settings_key, self.settings) + end, + }, + { + text = _("Reading time"), + checked_func = function() return self.settings.sync_stats end, + callback = function() + self.settings.sync_stats = not self.settings.sync_stats + G_reader_settings:saveSetting(self.settings_key, self.settings) + end, + }, }, separator = true, }) @@ -1875,6 +1913,237 @@ function Bookhoard:_onCloseDocument() self.onSuspend = nil NetworkMgr:goOnlineToRun(function() self:updateProgress(false, false) + -- Ratings/review/time ride the same close-time online window; + -- silent (non-interactive) so a book close never nags. + self:pushBookMeta(false) + end) +end + +-- --------------------------------------------------------------------------- +-- Ratings / review / reading-time bridge (handoff item 4 Batch B) +-- +-- Ratings live in the book summary as summary.rating (0-5, 0/nil = unrated) +-- and reviews as summary.note — both in the .sdr doc settings, exactly the +-- fields the Book Status widget and Book Information edit. The server side +-- is 1-10 (rating) and raw-markdown (review). KOReader's summary carries no +-- timestamps, so true LWW is impossible: we run CHANGE-DETECTION instead — +-- the plugin remembers the last values it synced per book; a local diff +-- against that memory means koreader changed it (push wins), a server diff +-- with an unchanged local copy means the server changed it (pull applies). +-- The race window is the same one notes/highlights already live with. +-- --------------------------------------------------------------------------- + +-- Server 1-10 → koreader 0-5 (nearest star; nil/0 = unrated). +local function rating10to5(rating10) + if not rating10 or rating10 <= 0 then return 0 end + return math.floor((rating10 + 1) / 2) +end + +-- Koreader 0-5 → server 1-10 (exact doubling); 0/unset → nil (clear). +local function rating5to10(rating5) + if not rating5 or rating5 <= 0 then return nil end + return rating5 * 2 +end + +-- Per-book identity for the bridge: uuid when linked, else file hash. +function Bookhoard:getBookRef() + if not (self.ui and self.ui.document) then return nil end + local sha256 = self:getFileSHA256() + if not sha256 then return nil end + local props = self.ui.doc_props or {} + local authors = {} + if props.authors then authors = { props.authors } end + return { + uuid = self:getBookhoardUUID(), + sha256 = sha256, + title = props.display_title or "", + authors = authors, + } +end + +function Bookhoard:readSummary() + if not (self.ui and self.ui.doc_settings) then return {} end + return self.ui.doc_settings:readSetting("summary", {}) or {} +end + +function Bookhoard:writeSummary(summary) + if not (self.ui and self.ui.doc_settings) then return end + self.ui.doc_settings:saveSetting("summary", summary) + -- Persist the sidecar now: the pull may land long before the user + -- closes the book, and Book Information reads doc_settings live. + pcall(function() self.ui.doc_settings:flush() end) +end + +function Bookhoard:getMetaSyncState(book_key) + self.settings.meta_sync_state = self.settings.meta_sync_state or {} + local state = self.settings.meta_sync_state[book_key] + if type(state) ~= "table" then + state = {} + self.settings.meta_sync_state[book_key] = state + end + return state +end + +function Bookhoard:rememberMetaSync(book_key, rating10, review) + local state = self:getMetaSyncState(book_key) + state.rating = rating10 + state.review = review + G_reader_settings:saveSetting(self.settings_key, self.settings) +end + +-- Pull server rating/review and apply where the local copy is unchanged +-- since our last sync (server moved, koreader didn't). +function Bookhoard:pullBookMeta(interactive) + if not (self.settings.sync_ratings or self.settings.sync_reviews) then return end + if not self:isConfigured() then return end + local ref = self:getBookRef() + if not ref then return end + local book_key = ref.uuid or ref.sha256 + local summary = self:readSummary() + local remembered = self:getMetaSyncState(book_key) + local applied = {} + local api = self:getAPI() + + if self.settings.sync_ratings then + local ok, result = api:getRating(ref) + if ok and result and not result.error then + local server10 = result.rating + local server5 = rating10to5(server10) + local local5 = summary.rating or 0 + local local10 = rating5to10(local5) or 0 + local remembered10 = remembered.rating or 0 + if server10 ~= nil and server10 ~= local10 and remembered10 == local10 then + -- Server moved, koreader didn't → apply. + summary.rating = server5 + self:writeSummary(summary) + remembered.rating = server10 + table.insert(applied, T(_("rating %1/5"), server5)) + elseif server10 ~= nil and server10 == local10 and remembered10 ~= local10 then + -- Values agree but our memory is stale; just re-sync memory. + remembered.rating = server10 + end + end + end + + if self.settings.sync_reviews then + local ok, result = api:getReview(ref) + if ok and result and not result.error then + local server_review = result.review + local local_review = summary.note + local remembered_review = remembered.review + local same = function(a, b) return (a or "") == (b or "") end + if not same(server_review, local_review) and same(remembered_review, local_review) then + summary.note = server_review + self:writeSummary(summary) + remembered.review = server_review + table.insert(applied, _("review")) + elseif same(server_review, local_review) and not same(remembered_review, local_review) then + remembered.review = server_review + end + end + end + + G_reader_settings:saveSetting(self.settings_key, self.settings) + + if interactive and #applied > 0 then + UIManager:show(InfoMessage:new{ + text = T(_("Applied from server: %1"), table.concat(applied, ", ")), + timeout = 3, + }) + end +end + +-- Push koreader's summary values when they differ from what we last +-- synced, then push the statistics plugin's cumulative reading time. +function Bookhoard:pushBookMeta(interactive) + if not self:isConfigured() then return end + if not (self.settings.sync_ratings or self.settings.sync_reviews or self.settings.sync_stats) then return end + local ref = self:getBookRef() + if not ref then return end + local book_key = ref.uuid or ref.sha256 + local summary = self:readSummary() + local remembered = self:getMetaSyncState(book_key) + local pushed = {} + local errors = 0 + local api = self:getAPI() + + if self.settings.sync_ratings then + local local10 = rating5to10(summary.rating) or 0 + local remembered10 = remembered.rating or 0 + if local10 ~= remembered10 then + local ok, result = api:setRating(ref, local10) + if ok and result and not result.error then + remembered.rating = local10 + table.insert(pushed, _("rating")) + else + errors = errors + 1 + end + end + end + + if self.settings.sync_reviews then + local local_review = summary.note + local same = function(a, b) return (a or "") == (b or "") end + if not same(local_review, remembered.review) then + local ok, result = api:setReview(ref, local_review or "") + if ok and result and not result.error then + remembered.review = local_review + table.insert(pushed, _("review")) + else + errors = errors + 1 + end + end + end + + -- Reading time: push the book's cumulative total every time — the + -- server's watermark conversion makes repeated pushes harmless + -- (zero-delta pushes record nothing). + if self.settings.sync_stats and self.ui.statistics and self.ui.statistics.getStatsBookStatus then + local ok_stats, book_stats = pcall(function() + return self.ui.statistics:getStatsBookStatus() + end) + if ok_stats and book_stats and book_stats.time and book_stats.time > 0 then + local ok, result = api:pushStats(ref, book_stats.time) + if ok and result and not result.error then + if result.session_recorded then + table.insert(pushed, T(_("%1 min reading time"), math.max(1, math.floor((result.session_seconds or 0) / 60)))) + end + else + errors = errors + 1 + end + end + end + + G_reader_settings:saveSetting(self.settings_key, self.settings) + + if interactive then + if #pushed > 0 then + UIManager:show(InfoMessage:new{ + text = T(_("Synced to server: %1"), table.concat(pushed, ", ")), + timeout = 3, + }) + elseif errors == 0 then + UIManager:show(InfoMessage:new{ + text = _("Rating, review and reading time already in sync."), + timeout = 3, + }) + end + if errors > 0 then + UIManager:show(InfoMessage:new{ + text = _("Some data failed to sync. Check your network connection."), + timeout = 3, + }) + end + end +end + +-- Manual menu entry: push local changes, then pull server changes — +-- push first so a koreader edit isn't immediately overwritten by the +-- pull in the same gesture. +function Bookhoard:syncBookMeta(interactive) + NetworkMgr:goOnlineToRun(function() + self:pushBookMeta(interactive) + self:pullBookMeta(interactive) end) end