Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua
T
Jadehawk 1f4420b454 feat(koreader): support customizable dashboard sections (#820)
* feat(koreader): add dashboard row configuration model

* feat(koreader): reuse catalog for dashboard source selection

* feat(koreader): configure dashboard rows on 1.4.0

* chore(koreader): preserve dashboard source line endings

* fix(koreader): keep BookOrbit tools menu loadable

* fix(koreader): keep dashboard requests out of rendering

* revert(koreader): restore stock 1.4.0 dashboard renderer

* revert(koreader): use stock single dashboard row

* fix(koreader): apply selected dashboard row source

* feat(koreader): add second dashboard row setting

* feat(koreader): render second dashboard row

* fix(koreader): fetch configured dashboard rows directly

* fix(koreader): honor configured dashboard row sources

* feat(koreader): support four configurable dashboard sections

* fix(koreader): use real catalog dashboard sources

* fix(koreader): select dashboard shelves from catalog

* fix(koreader): reuse catalog for dashboard source selection

* fix(koreader): close dashboard menu before catalog selection

* test(koreader): split dashboard configuration coverage

* chore(koreader): bump version to 1.5.0

* fix(koreader): address dashboard review feedback

* fix(koreader): address dashboard section review feedback

* test(koreader): cover dashboard behavior at runtime

* test(koreader): use Nest setup for dashboard DTO

* chore(ci): trigger pipeline

* fix(koreader): migrate untagged dashboard section settings

Plugin 1.4.x stored the configurable dashboard rows as a bare ordered
list with no schema marker, so gating the migration on schemaVersion == 1
never matched anything a device had actually written. Every upgrading
user who had ever picked a row was read positionally instead: the Stats
strip disappeared and Discover ended up in two slots, with a stored
SmartScope dropped entirely.

Treat an untagged stored list as the legacy shape and migrate it into the
slots those rows used to occupy. Cover it with the settings shapes 1.4.x
really wrote, rather than a marker nothing ever produced.

Also drop store() and primary(), which no caller uses now that slots are
written through storeAt(), along with the dead grow loop inside storeAt()
that normalize() already makes unreachable.

* fix(koreader): keep dashboard shelves in aspect, inside the page, and deduped

The four-slot renderer handed each shelf a height derived from the
leftover vertical budget while its cover cards kept the width computed
from the natural row, so any page with more than one shelf drew covers
out of aspect. It also had no answer for a page too small for the
configured slots: shelves clamped to a 48px floor and the layout simply
overflowed the available height.

Size shelves from their cards instead. A shelf that does not fit gets
more, narrower cards per row, so its height is always the height those
cards need. When even the narrowest row does not fit, drop slots the way
the pre-four-slot renderer did: the stats strip first, then shelves from
the bottom up.

Fetch each distinct shelf source once per dashboard load. Two slots
showing Discover previously rendered the same list twice, and two slots
on the same catalog filter each paid for their own request; a source that
fails is now also not retried by the next slot that wants it.

Drop what the four-slot rewrite left behind: the unused capabilities
require, the tall-layout helper and its constants, and the section
accessors and request helper that no longer have callers. Name the
per-shelf page size and the minimum cover width instead of repeating the
literals. Add setDashboardSections() so a whole configuration can be
applied in one refresh.

Add a layout spec that drives updateDashboardItems and asserts covers
stay in aspect, the drop order holds, and nothing is laid out past the
available height.

* fix(koreader): close the dashboard slot menu through its own API

Reset to Default wrote section_stale as a boolean where the renderer
reads a per-slot table, so the reset never actually marked anything
pending and stale books stayed on screen under the new headers until the
refresh landed. It also dereferenced current_context without a guard and
wrote the setting behind the catalog's back. Route it through the new
setDashboardSections() so the slots and the cache signature move
together in one refresh.

Close the invoking touch menu with closeMenu() rather than handing the
widget to UIManager:close(), which skips the close_callback that owns its
container.

Drop the section parameter from catalogDashboard() and the unused
catalogDashboardSection() helper: the plugin builds its shelves from the
ordinary catalog endpoints now and sends neither. The server keeps both
routes for devices still on 1.4.x, so its DTO test stays; rename it to
match the file it covers and drop the empty Nest module compile.

* revert(koreader): keep the plugin at 1.4.0

1.4.0 has not shipped yet: 1.3.0 is the last released plugin version, so
the dashboard slot work can land inside 1.4.0 rather than claiming a
version of its own. Devices in the field are on 1.3.0 and see a single
1.3.0 -> 1.4.0 step either way.

* fix(koreader): stop rotating upright read-status badges

buildReadStatusBadge turned every status icon a quarter turn, but only
two of the seven are corner folds. The dogear assets are drawn with their
right angle at the top left and the badge sits in the cover's top-right
corner, so those do need the turn. Bookmark, check and reload are upright
glyphs and were being drawn on their side: a want-to-read cover showed
its bookmark ribbon lying sideways like a chevron.

Rotate only the dogear icons.

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-07-30 13:37:26 -06:00

614 lines
22 KiB
Lua

--[[--
HTTP client for the BookOrbit server.
Speaks the kosync-compatible progress endpoints plus the BookOrbit plugin
endpoints. Sync clients run blocking requests in a subprocess when called from
a Trapper coroutine, keeping KOReader's UI loop responsive.
]]
local http = require("socket.http")
local ltn12 = require("ltn12")
local rapidjson = require("rapidjson")
local socket = require("socket")
local socketutil = require("socketutil")
local logger = require("logger")
local util = require("util")
local TransferPolicy = require("bookorbit_transfer_policy")
local TransferProgress = require("bookorbit_transfer_progress")
local MAX_BODY_BYTES = 900 * 1024 -- stays under the server's 1 MiB body limit
local MAX_DOWNLOAD_REDIRECTS = 5
-- Plain empty Lua tables would encode as {} and fail the server's array
-- validation; every empty table in our payloads is semantically an array.
local ENCODE_OPTIONS = { empty_table_as_array = true }
-- JSON null decodes to the rapidjson.null lightuserdata, which is truthy in
-- Lua and would leak into sidecars and truthiness checks; treat it as absent.
local function scrubNulls(value)
if value == rapidjson.null then return nil end
if type(value) == "table" then
for key, item in pairs(value) do
value[key] = scrubNulls(item)
end
end
return value
end
local function decodeResponse(parts)
local raw = table.concat(parts or {})
if raw == "" then
return {}
end
local ok, decoded, decode_err = pcall(rapidjson.decode, raw)
if not ok or decoded == nil then
logger.dbg("BookOrbit: invalid JSON response:", ok and decode_err or decoded)
return nil, "invalid_json"
end
return scrubNulls(decoded) or {}
end
local function encodeSubprocessResult(body, err, errbody)
local ok, encoded = pcall(rapidjson.encode, {
body = body,
err = err,
errbody = errbody,
})
if ok and type(encoded) == "string" and encoded ~= "" then
return encoded
end
return '{"err":"subprocess_result_encode_failed"}'
end
local function decodeSubprocessResult(encoded)
if type(encoded) ~= "string" or encoded == "" then
return nil, "subprocess_no_result"
end
local decoded = decodeResponse({ encoded })
if type(decoded) ~= "table" then
return nil, "subprocess_invalid_result"
end
return decoded
end
local function parseHttpUrl(value)
local scheme, authority = tostring(value or ""):match("^(https?)://([^/%?#]+)")
if not scheme or not authority or authority:find("@", 1, true) then return nil end
local host, port
if authority:sub(1, 1) == "[" then
host, port = authority:match("^(%b[]):?(%d*)$")
else
host, port = authority:match("^([^:]+):?(%d*)$")
end
if not host or host == "" then return nil end
port = port ~= "" and tonumber(port) or (scheme == "https" and 443 or 80)
if not port then return nil end
return {
scheme = scheme,
authority = authority,
host = host:lower(),
port = port,
}
end
local function absoluteUrl(base, location)
if location:match("^https?://") then return location end
local parsed = parseHttpUrl(base)
if not parsed then return nil end
if location:sub(1, 2) == "//" then
return parsed.scheme .. ":" .. location
end
local origin = parsed.scheme .. "://" .. parsed.authority
if location:sub(1, 1) == "/" then return origin .. location end
local directory = base:match("^(.*)/") or origin
return directory .. "/" .. location
end
local function forkSafeServerUrl(value)
local scheme, authority, suffix = tostring(value or ""):match("^(https?)://([^/%?#]+)(.*)$")
if scheme ~= "http" or not authority then return value end
local port = authority:lower():match("^localhost:(%d+)$")
if authority:lower() == "localhost" then
return "http://127.0.0.1" .. suffix
elseif port then
return "http://127.0.0.1:" .. port .. suffix
end
return value
end
local BookOrbitApi = {}
BookOrbitApi.__index = BookOrbitApi
BookOrbitApi.decodeResponse = decodeResponse
-- Normalizes a user-entered server address to the API base, e.g.
-- "https://books.example.com/" -> "https://books.example.com/api/v1".
function BookOrbitApi.normalizeServerUrl(input)
if not input then return nil end
local url = util.trim(input)
if url == "" then return nil end
url = url:gsub("/+$", "")
url = url:gsub("/api/v1/koreader$", "/api/v1")
if not url:match("/api/v1$") then
url = url .. "/api/v1"
end
return url
end
function BookOrbitApi.new(opts)
return setmetatable({
server_url = forkSafeServerUrl(opts.server_url),
username = opts.username,
userkey = opts.userkey,
device_id = opts.device_id,
device_model = opts.device_model,
plugin_version = opts.plugin_version,
background_requests = opts.background_requests == true,
}, BookOrbitApi)
end
function BookOrbitApi:isConfigured()
return self.server_url ~= nil and self.username ~= nil and self.userkey ~= nil
end
-- Returns decoded_body on success, or nil, err_code, decoded_error_body.
-- err_code is a number for HTTP errors and a string for transport errors.
function BookOrbitApi:requestBlocking(method, path, body)
local sink = {}
local request = {
url = self.server_url .. path,
method = method,
sink = ltn12.sink.table(sink),
headers = {
["accept"] = "application/json",
["x-auth-user"] = self.username,
["x-auth-key"] = self.userkey,
},
}
if body then
local body_json, encode_err = rapidjson.encode(body, ENCODE_OPTIONS)
if not body_json then
return nil, "encode_error: " .. tostring(encode_err)
end
if #body_json > MAX_BODY_BYTES then
return nil, "body_too_large"
end
request.source = ltn12.source.string(body_json)
request.headers["Content-Type"] = "application/json"
request.headers["Content-Length"] = #body_json
end
socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
local code, _, status = socket.skip(1, http.request(request))
socketutil:reset_timeout()
if type(code) ~= "number" then
logger.dbg("BookOrbit: network error:", status or code)
return nil, tostring(status or code or "network_error")
end
local decoded, decode_err = decodeResponse(sink)
if code < 200 or code >= 300 then
return nil, code, decoded
end
if decode_err then
return nil, decode_err
end
return decoded or {}
end
-- True when this call may fork its own background worker: the client is in
-- background mode, a Trapper coroutine is driving us, and no enclosing
-- runInSubprocess already owns a child for this work.
function BookOrbitApi:canForkSubprocess()
if not self.background_requests then return false end
if (self.subprocess_depth or 0) > 0 then return false end
local loaded, Trapper = pcall(require, "ui/trapper")
if not loaded or not Trapper:isWrapped() then return false end
return true
end
-- Owns exactly one background subprocess for the duration of fn. Calls that
-- fn makes back into this client see a non-zero depth in the forked child and
-- run inline, so a request is never wrapped by two nested workers.
--
-- fn must return (body, err, errbody); they are marshalled back through the
-- subprocess. Returns completed, { body, err, errbody }; completed is false
-- when the user dismissed the request.
function BookOrbitApi:runInSubprocess(fn, trap_widget)
if not self:canForkSubprocess() then
local ok, body, err, errbody = pcall(fn)
if not ok then return true, { err = tostring(body) } end
return true, { body = body, err = err, errbody = errbody }
end
local Trapper = require("ui/trapper")
-- An unmounted widget lets Trapper poll without intercepting reader input.
local widget = trap_widget or {}
local completed, encoded = Trapper:dismissableRunInSubprocess(function()
self.subprocess_depth = (self.subprocess_depth or 0) + 1
local ok, body, err, errbody = pcall(fn)
self.subprocess_depth = self.subprocess_depth - 1
if not ok then
return encodeSubprocessResult(nil, tostring(body))
end
return encodeSubprocessResult(body, err, errbody)
end, widget, true)
if type(widget) == "table" then widget.dismiss_callback = nil end
if not completed then return false end
local result, decode_err = decodeSubprocessResult(encoded)
if not result then
return true, { err = decode_err }
end
return true, result
end
function BookOrbitApi:request(method, path, body)
if not self:canForkSubprocess() then
return self:requestBlocking(method, path, body)
end
local completed, result = self:runInSubprocess(function()
return self:requestBlocking(method, path, body)
end)
if not completed then
return nil, "background_request_interrupted"
end
return result.body, result.err, result.errbody
end
function BookOrbitApi:query(path, params)
if not params then return path end
local keys = {}
for key, value in pairs(params) do
if value ~= nil and value ~= "" then
table.insert(keys, key)
end
end
table.sort(keys)
if #keys == 0 then return path end
local parts = {}
for _, key in ipairs(keys) do
table.insert(parts, util.urlEncode(key) .. "=" .. util.urlEncode(tostring(params[key])))
end
return path .. "?" .. table.concat(parts, "&")
end
-- Downloads to a temporary file and publishes it with an atomic rename, so a
-- timeout, an oversized body or a cancelled generation can never leave a
-- half-written file where a complete one is expected.
--
-- opts: accept, progress_cb, progress_path, progress_generation, temp_path,
-- max_bytes, expect_content_type, block_timeout, total_timeout, expected_bytes,
-- publish. With publish = "parent" the complete temporary file is handed back
-- instead of renamed, so a cancelled generation cannot be published by a child
-- that was already finishing.
function BookOrbitApi:downloadBlocking(path, local_path, opts)
opts = opts or {}
local temp_path = opts.temp_path or (local_path .. ".part")
local max_bytes = opts.max_bytes or TransferPolicy.maxBytes(opts.expected_bytes)
local progress_cb = opts.progress_cb
local progress_writer = opts.progress_path and TransferProgress.writer(opts.progress_path, {
generation = opts.progress_generation,
total = tonumber(opts.expected_bytes) or 0,
})
local current_url = self.server_url .. path
local origin = parseHttpUrl(current_url)
local total_received = 0
local function fail(out, err)
if out then pcall(function() out:close() end) end
util.removeFile(temp_path)
return nil, err
end
local function sameOriginRedirect(location)
if type(location) ~= "string" or location == "" then return nil end
local resolved = absoluteUrl(current_url, location:gsub("%s", ""))
local parsed = parseHttpUrl(resolved)
if not origin or not parsed
or parsed.scheme ~= origin.scheme
or parsed.host ~= origin.host
or parsed.port ~= origin.port then
return nil
end
return resolved
end
for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do
local out, open_err = io.open(temp_path, "w")
if not out then
return nil, tostring(open_err or "open_error")
end
local received = 0
local too_large = false
local file_sink = ltn12.sink.file(out)
local sink = function(chunk, err)
if chunk and chunk ~= "" then
received = received + #chunk
total_received = total_received + #chunk
if received > max_bytes or total_received > max_bytes then
too_large = true
return nil, "response_too_large"
end
if progress_cb then progress_cb(received) end
if progress_writer then progress_writer(received, false) end
end
return file_sink(chunk, err)
end
local request = {
url = current_url,
method = "GET",
sink = sink,
redirect = false,
headers = {
["accept"] = opts.accept or "application/octet-stream",
["Accept-Encoding"] = "identity",
["x-auth-user"] = self.username,
["x-auth-key"] = self.userkey,
},
}
socketutil:set_timeout(
opts.block_timeout or socketutil.FILE_BLOCK_TIMEOUT,
opts.total_timeout or socketutil.FILE_TOTAL_TIMEOUT)
local code, headers, status = socket.skip(1, http.request(request))
socketutil:reset_timeout()
if too_large then return fail(out, "response_too_large") end
if type(code) ~= "number" then
return fail(out, tostring(status or code or "network_error"))
end
if code == 301 or code == 302 or code == 303 or code == 307 or code == 308 then
pcall(function() out:close() end)
util.removeFile(temp_path)
if redirect_count >= MAX_DOWNLOAD_REDIRECTS then
return nil, "too_many_redirects"
end
local location = headers and (headers.location or headers.Location)
local redirected = sameOriginRedirect(location)
if not redirected then return nil, "unsafe_redirect" end
current_url = redirected
else
if code ~= 200 then return fail(out, code) end
if received == 0 then return fail(out, "empty_response") end
local expected_type = opts.expect_content_type
if expected_type then
local content_type = headers and (headers["content-type"] or headers["Content-Type"])
local media_type = content_type and tostring(content_type):lower():match("^%s*([^;]+)")
if not media_type or media_type:sub(1, #expected_type) ~= expected_type then
return fail(out, "unexpected_content_type")
end
end
if progress_writer then progress_writer(received, true) end
-- The parent owns publishing for user-cancellable transfers: a
-- dismissed subprocess cannot be stopped once it starts renaming,
-- which is harmless for a cover but wrong for a cancelled book.
if opts.publish == "parent" then
local result = { temp_path = temp_path, bytes = received }
-- Hashing here keeps the digest off the UI thread and lets the
-- caller compare it to the manifest without a match request.
if opts.hash == "partial_md5" then
local hashed, digest = pcall(util.partialMD5, temp_path)
result.hash = hashed and digest or nil
end
return result
end
if temp_path == local_path then return true end
local renamed, rename_err = os.rename(temp_path, local_path)
if not renamed then
return fail(out, tostring(rename_err or "publish_failed"))
end
return true
end
end
return nil, "too_many_redirects"
end
-- Runs the transfer in a background subprocess when this client owns no worker
-- yet. A forked child cannot reach the parent's UI, so live byte progress
-- travels through the snapshot file named by opts.progress_path; only a caller
-- that has nothing but an in-process progress_cb stays blocking.
function BookOrbitApi:download(path, local_path, opts)
opts = opts or {}
local blocking = opts.background == false
or (opts.progress_cb ~= nil and opts.progress_path == nil)
or not self:canForkSubprocess()
if blocking then
return self:downloadBlocking(path, local_path, opts)
end
-- A child's progress_cb would touch parent-only UI state from the forked
-- process; the snapshot file is the only channel back.
local child_opts = {}
for key, value in pairs(opts) do child_opts[key] = value end
child_opts.progress_cb = nil
local completed, result = self:runInSubprocess(function()
return self:downloadBlocking(path, local_path, child_opts)
end, opts.trap_widget)
if not completed then
return nil, "cancelled"
end
return result.body, result.err
end
function BookOrbitApi:withDevice(body)
body.deviceId = self.device_id
body.deviceModel = self.device_model
body.pluginVersion = self.plugin_version
-- KOReader datetimes are local wall clock with no timezone; the server
-- needs our clock to mint device datetimes that are not in our future.
body.deviceTime = os.date("%Y-%m-%d %H:%M:%S")
return body
end
-- kosync-compatible endpoints (existing BookOrbit API, snake_case wire format)
function BookOrbitApi:auth()
return self:request("GET", "/koreader/users/auth")
end
function BookOrbitApi:getProgress(digest)
return self:request("GET", "/koreader/syncs/progress/" .. digest)
end
function BookOrbitApi:updateProgress(digest, percentage, progress, timestamp)
return self:request("PUT", "/koreader/syncs/progress", {
document = digest,
percentage = percentage,
progress = progress,
device = self.device_model,
device_id = self.device_id,
timestamp = timestamp,
})
end
-- BookOrbit plugin endpoints (camelCase wire format)
function BookOrbitApi:matchCheck(hashes, candidates)
local payload = { hashes = hashes }
if candidates then
payload.books = {}
for _, hash in ipairs(hashes) do
local cand = candidates[hash] or {}
table.insert(payload.books, {
hash = hash,
title = cand.title,
authors = cand.authors,
lastOpen = cand.last_open,
source = cand.source,
metadataAmbiguous = cand.metadata_ambiguous,
})
end
end
return self:request("POST", "/koreader/plugin/match-check", self:withDevice(payload))
end
function BookOrbitApi:uploadPageStats(books)
return self:request("POST", "/koreader/plugin/page-stats", self:withDevice({ books = books }))
end
-- Deprecated one-way upload, kept as fallback for pre-0.4 servers.
function BookOrbitApi:uploadAnnotations(books)
return self:request("POST", "/koreader/plugin/annotations", self:withDevice({ books = books }))
end
function BookOrbitApi:exchangeAnnotations(books)
return self:request("POST", "/koreader/plugin/annotations/exchange", self:withDevice({ books = books }))
end
function BookOrbitApi:exchangeAck(books)
return self:request("POST", "/koreader/plugin/annotations/exchange-ack", self:withDevice({ books = books }))
end
function BookOrbitApi:exchangeBookmarks(books)
return self:request("POST", "/koreader/plugin/bookmarks/exchange", self:withDevice({ books = books }))
end
function BookOrbitApi:exchangeBookmarksAck(books)
return self:request("POST", "/koreader/plugin/bookmarks/exchange-ack", self:withDevice({ books = books }))
end
function BookOrbitApi:uploadBookStates(books)
return self:request("POST", "/koreader/plugin/book-states", self:withDevice({ books = books }))
end
function BookOrbitApi:bulkProgress(items)
return self:request("POST", "/koreader/plugin/progress", self:withDevice({ items = items }))
end
function BookOrbitApi:sweepComplete(counts)
return self:request("POST", "/koreader/plugin/sweeps", self:withDevice({
booksMatched = counts.books_matched or 0,
pageStatsUploaded = counts.page_stats or 0,
annotationsUpserted = counts.annotations or 0,
}))
end
-- BookOrbit catalog endpoints
function BookOrbitApi:catalogRoot()
return self:request("GET", "/koreader/plugin/catalog/root")
end
-- The dashboard body carries the stats, Continue reading and Discover the
-- shelves are built from. Each configured shelf is then fetched through the
-- ordinary catalog endpoints, so nothing here is section-aware.
function BookOrbitApi:catalogDashboard()
return self:request("GET", "/koreader/plugin/catalog/dashboard")
end
function BookOrbitApi:catalogDiscover()
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
end
function BookOrbitApi:catalogSection(section, params)
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
end
function BookOrbitApi:catalogSetReadStatus(book_id, status)
return self:request("PUT", "/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/read-status", { status = status })
end
function BookOrbitApi:catalogSetRating(book_id, rating)
if rating == nil then rating = rapidjson.null end
return self:request("PUT", "/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/rating", { rating = rating })
end
function BookOrbitApi:catalogBooks(params)
return self:request("GET", self:query("/koreader/plugin/catalog/books", params))
end
-- Cursor-paginated bulk download manifest. One page carries everything
-- selection and transfer need, so a bulk run issues no per-book detail request.
function BookOrbitApi:catalogManifest(params)
params = params or {}
params.deviceId = params.deviceId or self.device_id
return self:request("GET", self:query("/koreader/plugin/catalog/manifest", params))
end
function BookOrbitApi:catalogBook(book_id)
return self:request("GET", self:query("/koreader/plugin/catalog/books/" .. tostring(book_id), { deviceId = self.device_id }))
end
function BookOrbitApi:downloadCatalogFile(file_id, local_path, opts)
return self:download("/koreader/plugin/catalog/files/" .. tostring(file_id) .. "/download", local_path, opts)
end
function BookOrbitApi:downloadCatalogThumbnail(book_id, local_path, opts)
opts = opts or {}
opts.accept = "image/jpeg,image/*"
opts.expect_content_type = "image/"
return self:download("/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/thumbnail", local_path, opts)
end
-- Plugin self-update endpoints
function BookOrbitApi:getPluginVersion()
return self:request("GET", "/koreader/plugin/version")
end
function BookOrbitApi:downloadPluginUpdate(local_path, opts)
opts = opts or {}
opts.accept = "application/zip"
return self:download("/koreader/plugin/package", local_path, opts)
end
return BookOrbitApi