Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua
T
Jadehawk ec84703cef feat(koreader): enhance settings and plugin behavior (#669)
* feat(koreader): add device file naming and improve plugin downloads (BO-658)

* fix(koreader): secure download paths and test collisions

* fix(koreader): preserve bulk collision identities

* fix(koreader): improve device file naming validation and path generation

* fix(koreader): remove duplicate settings lookup and audit file naming changes

* fix(koreader): resolve remaining CodeRabbit review issues

* fix(koreader): validate book detail query and finalize file naming release

validate book detail deviceId via DTO and ValidationPipe

localize new KOReader file naming settings (de, nl, sl)

bump KOReader plugin version to 1.3.0

* chore: rerun CI

* chore: normalized Closes #658 to not have embedded URL to issue. This is failing PR Conventions test

* test(koreader): align source and device removal assertions

* test(koreader): cover file naming settings flows

* test(koreader): cover file naming sync requests

* test(koreader): close remaining patch coverage gaps

* test(koreader): cover file naming server paths

* docs: add fixed2 modifier to file naming reference

* feat(koreader): support custom file naming patterns for KOReader sync

Add support for configurable KOReader file naming patterns, allowing users to define dynamic path templates for sync targets. Include UI draft settings, preview updates, and server path resolution.

Closes #658

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
Co-authored-by: jadehawk <jadehawk@hotmail.com>
2026-07-14 11:26:27 -06:00

361 lines
12 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 MAX_BODY_BYTES = 900 * 1024 -- stays under the server's 1 MiB body limit
-- 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 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 = 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
function BookOrbitApi:request(method, path, body)
if not self.background_requests then
return self:requestBlocking(method, path, body)
end
local loaded, Trapper = pcall(require, "ui/trapper")
if not loaded or not Trapper:isWrapped() then
return self:requestBlocking(method, path, body)
end
-- An unmounted widget lets Trapper poll without intercepting reader input.
local trap_widget = {}
local completed, result = Trapper:dismissableRunInSubprocess(function()
local response, err, errbody = self:requestBlocking(method, path, body)
return { response = response, err = err, errbody = errbody }
end, trap_widget)
trap_widget.dismiss_callback = nil
if not completed then
return nil, "background_request_interrupted"
end
result = result or {}
return result.response, 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
function BookOrbitApi:download(path, local_path, accept, progress_cb)
local out, open_err = io.open(local_path, "w")
if not out then
return nil, tostring(open_err or "open_error")
end
local file_sink = ltn12.sink.file(out)
local sink = file_sink
if progress_cb then
local received = 0
sink = function(chunk, err)
if chunk and chunk ~= "" then
received = received + #chunk
progress_cb(received)
end
return file_sink(chunk, err)
end
end
local request = {
url = self.server_url .. path,
method = "GET",
sink = sink,
headers = {
["accept"] = accept or "application/octet-stream",
["Accept-Encoding"] = "identity",
["x-auth-user"] = self.username,
["x-auth-key"] = self.userkey,
},
}
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code, _, status = socket.skip(1, http.request(request))
socketutil:reset_timeout()
if code == 200 then
return true
end
util.removeFile(local_path)
if type(code) ~= "number" then
return nil, tostring(status or code or "network_error")
end
return nil, code
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: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
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
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, progress_cb)
return self:download("/koreader/plugin/catalog/files/" .. tostring(file_id) .. "/download", local_path, nil, progress_cb)
end
function BookOrbitApi:downloadCatalogThumbnail(book_id, local_path)
return self:download("/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/thumbnail", local_path, "image/jpeg,image/*")
end
-- Plugin self-update endpoints
function BookOrbitApi:getPluginVersion()
return self:request("GET", "/koreader/plugin/version")
end
function BookOrbitApi:downloadPluginUpdate(local_path, progress_cb)
return self:download("/koreader/plugin/package", local_path, "application/zip", progress_cb)
end
return BookOrbitApi