feat: ratings/review/reading-time sync bridge (handoff item 4 Batch B)

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}.
This commit is contained in:
John O'Keefe committed 2026-10-15 11:27:08 -04:00
1 parent b466af09b0
commit 001628f2a2
2 files changed
+327

No files matched your search

+58
View File
@@ -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
+269
View File
@@ -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