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:
1 parent
b466af09b0
commit
001628f2a2
2 files changed
+327
No files matched your search
@@ -155,6 +155,64 @@ function BookhoardAPI:getMetadata(uuid)
|
|||||||
return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS)
|
return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS)
|
||||||
end
|
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
|
return BookhoardAPI
|
||||||
@@ -67,6 +67,9 @@ Bookhoard.default_settings = {
|
|||||||
sync_bookmarks = true,
|
sync_bookmarks = true,
|
||||||
sync_highlights = true,
|
sync_highlights = true,
|
||||||
sync_notes = true,
|
sync_notes = true,
|
||||||
|
sync_ratings = true,
|
||||||
|
sync_reviews = true,
|
||||||
|
sync_stats = true,
|
||||||
sync_mode = SYNC_MODE.IMMEDIATE,
|
sync_mode = SYNC_MODE.IMMEDIATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +131,11 @@ function Bookhoard:onReaderReady()
|
|||||||
-- to the server on books already mid-read elsewhere, creating a
|
-- to the server on books already mid-read elsewhere, creating a
|
||||||
-- sync conflict before the first pull could ever happen.
|
-- sync conflict before the first pull could ever happen.
|
||||||
self:getProgress(true, false)
|
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)
|
||||||
end
|
end
|
||||||
self:registerEvents()
|
self:registerEvents()
|
||||||
@@ -197,6 +205,12 @@ function Bookhoard:buildMainMenu()
|
|||||||
callback = function()
|
callback = function()
|
||||||
self:getProgress(true, true)
|
self:getProgress(true, true)
|
||||||
end,
|
end,
|
||||||
|
})
|
||||||
|
table.insert(items, {
|
||||||
|
text = _("Sync rating, review & time now"),
|
||||||
|
callback = function()
|
||||||
|
self:syncBookMeta(true)
|
||||||
|
end,
|
||||||
separator = true,
|
separator = true,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -442,6 +456,30 @@ function Bookhoard:buildMainMenu()
|
|||||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||||
end,
|
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,
|
separator = true,
|
||||||
})
|
})
|
||||||
@@ -1875,6 +1913,237 @@ function Bookhoard:_onCloseDocument()
|
|||||||
self.onSuspend = nil
|
self.onSuspend = nil
|
||||||
NetworkMgr:goOnlineToRun(function()
|
NetworkMgr:goOnlineToRun(function()
|
||||||
self:updateProgress(false, false)
|
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)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user