Files
bookhoard.koplugin/BookhoardAPI.lua
T
John O'Keefe 001628f2a2 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}.
2026-10-15 11:27:08 -04:00

219 lines
6.8 KiB
Lua

local json = require("json")
local json_util = require("json.util")
-- Recursively replace LuaJSON's truthy null-sentinel functions with nil
-- (JSON null), so server payloads behave like normal Lua tables.
local function strip_json_nulls(t)
for k, v in pairs(t) do
if type(v) == "function" then
t[k] = nil
elseif type(v) == "table" then
strip_json_nulls(v)
end
end
return t
end
local logger = require("logger")
local ltn12 = require("ltn12")
local socketutil = require("socketutil")
local http = require("socket.http")
local ssl_ok, ssl_https = pcall(require, "ssl.https")
local PROGRESS_TIMEOUTS = { 2, 5 }
local AUTH_TIMEOUTS = { 5, 10 }
local BookhoardAPI = {
server_url = nil,
auth_token = nil,
}
function BookhoardAPI:new(o)
o = o or {}
setmetatable(o, self)
self.__index = self
return o
end
function BookhoardAPI:_buildHeaders()
local headers = {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
}
if self.auth_token then
headers["Authorization"] = "Bearer " .. self.auth_token
end
return headers
end
function BookhoardAPI:_request(method, path, body, timeouts)
if not self.server_url then
return false, { error = "server URL not configured" }
end
local url = self.server_url .. path
local sink = {}
local headers = self:_buildHeaders()
local body_str = body and json.encode(body) or nil
if body_str then
headers["Content-Length"] = tostring(#body_str)
end
local request = {
url = url,
method = method,
headers = headers,
sink = ltn12.sink.table(sink),
}
if body_str then
request.source = ltn12.source.string(body_str)
end
socketutil:set_timeout(timeouts[1], timeouts[2])
local ok, err, code
if url:match("^https://") then
if ssl_ok then
ok, err, code = pcall(ssl_https.request, request)
else
socketutil:reset_timeout()
return false, { error = "HTTPS not supported on this device" }
end
else
ok, err, code = pcall(http.request, request)
end
socketutil:reset_timeout()
if not ok then
logger.warn("BookhoardAPI: request failed:", err)
return false, { error = tostring(err) }
end
local response_body = table.concat(sink)
local status = tonumber(code)
if status and status >= 200 and status < 300 then
if response_body and #response_body > 0 then
local decode_ok, data = pcall(json.decode, response_body)
if decode_ok and type(data) == "table" then
-- LuaJSON decodes JSON null into a truthy sentinel FUNCTION,
-- which defeats every `if not x.field` nil-check downstream
-- (crash: "attempt to index local 'progress' (a function
-- value)"). Normalize sentinels to nil at the boundary.
strip_json_nulls(data)
return true, data
end
return true, response_body
end
return true, nil
end
local error_msg = "HTTP " .. tostring(code)
if response_body and #response_body > 0 then
local decode_ok, data = pcall(json.decode, response_body)
if decode_ok and data and data.error then
error_msg = data.error
end
end
logger.warn("BookhoardAPI:", method, path, "→", status, error_msg)
return false, { status = status, error = error_msg }
end
function BookhoardAPI:registerDevice(device_name, device_identifier)
return self:_request("POST", "/api/devices/register", {
device_name = device_name,
device_type = "koreader",
device_identifier = device_identifier,
}, AUTH_TIMEOUTS)
end
function BookhoardAPI:checkRegistrationStatus(registration_id)
return self:_request("POST", "/api/devices/register/status", {
registration_id = registration_id,
}, AUTH_TIMEOUTS)
end
function BookhoardAPI:syncProgress(book_data, sync_mode)
local books = json_util.InitArray({ book_data })
return self:_request("POST", "/api/sync/koreader/progress", {
books = books,
sync_mode = sync_mode or "immediate",
}, PROGRESS_TIMEOUTS)
end
-- Read-only SHA-256 → book UUID lookup. Used to link a freshly downloaded
-- book WITHOUT pushing progress (a push would transmit the device's
-- first-page position and clobber/conflict with real server progress).
function BookhoardAPI:resolveBook(sha256)
return self:_request("GET", "/api/sync/koreader/resolve?sha256=" .. sha256, nil, PROGRESS_TIMEOUTS)
end
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