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