Files
bookhoard.koplugin/BookhoardAPI.lua
T
john-okeefe 9d7b0f689f feat: implement bidirectional annotation sync and delete propagation
Bring the plugin to feature parity with the server's annotation sync
system. Previously annotations were push-only (and even that was broken
because the server ignored inline annotations in progress pushes).

ANNOTATION PULL (server → device):
  - Add applyServerAnnotations() method called from _doGetProgress
    after progress handling
  - Parses annotations.highlights, annotations.notes, and
    annotations.bookmarks from GetMetadata response
  - Matches incoming annotations against local bookmarks by pos0
    (start position) to detect existing entries
  - New entries are inserted with correct KOReader bookmark format:
    page, pos0, pos1, datetime, text, notes, color, chapter
  - Existing entries with changed content are updated (LWW: server
    version is authoritative since the server already resolved conflicts)
  - Respects sync_highlights, sync_notes, sync_bookmarks toggles for
    inbound sync, not just outbound
  - Triggers onSortBookmarks + saveSettings on change for persistence

DELETE PROPAGATION:
  - Parses annotations.deleted_highlights and annotations.deleted_bookmarks
    from GetMetadata response
  - Each entry contains device_sync_data (pos0, datetime, page) from the
    original device push
  - Matches local bookmarks by pos0 and removes them
  - Deleted highlights only remove entries that have text (to avoid
    removing plain bookmarks at the same position)

CLEANUP:
  - Fix BookhoardAPI.lua: rename pcall return _ to err for clarity
    (was functional but misleading variable name)
  - Remove dead code: unused getLibrary() and syncBookmarks() API methods
  - Remove sync_endpoints field (stored from registration response but
    never read by any code)
2026-07-29 14:49:41 -04:00

136 lines
3.7 KiB
Lua

local json = require("json")
local json_util = require("json.util")
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
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
function BookhoardAPI:getMetadata(uuid)
return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS)
end
return BookhoardAPI