Initial KOReader plugin: progress sync, annotations, device registration
Full feature set: - In-plugin device registration with approval polling - Reading progress sync (push on page update/close/suspend, pull on open/resume) - Bookmark, highlight, and note sync via batch annotation collection - Configurable sync behavior (silent/prompt/disable for forward/backward) - OPDS catalog setup helper - SHA-256 book hashing with doc_settings cache - Debounced API calls with periodic push scheduling
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
local json = require("json")
|
||||
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, code
|
||||
if url:match("^https://") then
|
||||
if ssl_ok then
|
||||
ok, code = pcall(ssl_https.request, request)
|
||||
else
|
||||
socketutil:reset_timeout()
|
||||
return false, { error = "HTTPS not supported on this device" }
|
||||
end
|
||||
else
|
||||
ok, code = pcall(http.request, request)
|
||||
end
|
||||
|
||||
socketutil:reset_timeout()
|
||||
|
||||
if not ok then
|
||||
logger.warn("BookhoardAPI: request failed:", code)
|
||||
return false, { error = tostring(code) }
|
||||
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)
|
||||
return self:_request("POST", "/api/sync/koreader/progress", {
|
||||
books = { book_data },
|
||||
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
|
||||
|
||||
function BookhoardAPI:getLibrary()
|
||||
return self:_request("GET", "/api/sync/koreader/library", nil, PROGRESS_TIMEOUTS)
|
||||
end
|
||||
|
||||
function BookhoardAPI:syncBookmarks(data)
|
||||
return self:_request("POST", "/api/sync/koreader/bookmarks", data, PROGRESS_TIMEOUTS)
|
||||
end
|
||||
|
||||
return BookhoardAPI
|
||||
@@ -0,0 +1,5 @@
|
||||
local _ = require("gettext")
|
||||
return {
|
||||
fullname = _("Bookhoard sync"),
|
||||
description = _([[Synchronizes reading progress, bookmarks, highlights, and notes with your Bookhoard server.]]),
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
local ConfirmBox = require("ui/widget/confirmbox")
|
||||
local Device = require("device")
|
||||
local Event = require("ui/event")
|
||||
local InfoMessage = require("ui/widget/infomessage")
|
||||
local InputDialog = require("ui/widget/inputdialog")
|
||||
local Math = require("optmath")
|
||||
local NetworkMgr = require("ui/network/manager")
|
||||
local SpinWidget = require("ui/widget/spinwidget")
|
||||
local UIManager = require("ui/uimanager")
|
||||
local WidgetContainer = require("ui/widget/container/widgetcontainer")
|
||||
local json = require("json")
|
||||
local logger = require("logger")
|
||||
local sha2 = require("ffi/sha2")
|
||||
local time = require("ui/time")
|
||||
local util = require("util")
|
||||
local T = require("ffi/util").template
|
||||
local _ = require("gettext")
|
||||
|
||||
local BookhoardAPI = require("BookhoardAPI")
|
||||
|
||||
local SYNC_STRATEGY = {
|
||||
PROMPT = 1,
|
||||
SILENT = 2,
|
||||
DISABLE = 3,
|
||||
}
|
||||
|
||||
local SYNC_MODE = {
|
||||
IMMEDIATE = "immediate",
|
||||
CHECKPOINT = "checkpoint",
|
||||
}
|
||||
|
||||
local API_CALL_DEBOUNCE_DELAY = time.s(25)
|
||||
local PERIODIC_PUSH_DELAY = 10
|
||||
|
||||
local sha256hex = sha2.sha256hex or sha2.sha256
|
||||
|
||||
local Bookhoard = WidgetContainer:extend({
|
||||
name = "bookhoard",
|
||||
is_doc_only = true,
|
||||
title = _("Bookhoard Server"),
|
||||
settings_key = "bookhoard",
|
||||
|
||||
push_timestamp = nil,
|
||||
pull_timestamp = nil,
|
||||
page_update_counter = nil,
|
||||
last_page = nil,
|
||||
periodic_push_task = nil,
|
||||
periodic_push_scheduled = nil,
|
||||
registration_poll_scheduled = nil,
|
||||
|
||||
settings = nil,
|
||||
})
|
||||
|
||||
Bookhoard.default_settings = {
|
||||
server_url = nil,
|
||||
auth_token = nil,
|
||||
device_id = nil,
|
||||
auto_sync = false,
|
||||
pages_before_update = 50,
|
||||
sync_forward = SYNC_STRATEGY.PROMPT,
|
||||
sync_backward = SYNC_STRATEGY.DISABLE,
|
||||
sync_progress = true,
|
||||
sync_bookmarks = true,
|
||||
sync_highlights = true,
|
||||
sync_notes = true,
|
||||
sync_mode = SYNC_MODE.IMMEDIATE,
|
||||
sync_endpoints = nil,
|
||||
}
|
||||
|
||||
function Bookhoard:init()
|
||||
self.push_timestamp = 0
|
||||
self.pull_timestamp = 0
|
||||
self.page_update_counter = 0
|
||||
self.last_page = -1
|
||||
self.periodic_push_scheduled = false
|
||||
self.registration_poll_scheduled = false
|
||||
|
||||
self.periodic_push_task = function()
|
||||
self.periodic_push_scheduled = false
|
||||
self.page_update_counter = 0
|
||||
self:updateProgress(false, false)
|
||||
end
|
||||
|
||||
self.settings = G_reader_settings:readSetting(self.settings_key, self.default_settings)
|
||||
|
||||
if self.settings.auto_sync
|
||||
and Device:hasSeamlessWifiToggle()
|
||||
and G_reader_settings:readSetting("wifi_enable_action") ~= "turn_on" then
|
||||
self.settings.auto_sync = false
|
||||
logger.warn("Bookhoard: auto-sync disabled because wifi_enable_action is not turn_on")
|
||||
end
|
||||
|
||||
self.ui.menu:registerToMainMenu(self)
|
||||
end
|
||||
|
||||
function Bookhoard:onReaderReady()
|
||||
if self.settings.auto_sync then
|
||||
UIManager:nextTick(function()
|
||||
self:getProgress(true, false)
|
||||
end)
|
||||
end
|
||||
self:registerEvents()
|
||||
self.last_page = self.ui:getCurrentPage()
|
||||
end
|
||||
|
||||
function Bookhoard:registerEvents()
|
||||
if self.settings.auto_sync then
|
||||
self.onCloseDocument = self._onCloseDocument
|
||||
self.onPageUpdate = self._onPageUpdate
|
||||
self.onResume = self._onResume
|
||||
self.onSuspend = self._onSuspend
|
||||
self.onNetworkConnected = self._onNetworkConnected
|
||||
self.onNetworkDisconnecting = self._onNetworkDisconnecting
|
||||
else
|
||||
self.onCloseDocument = nil
|
||||
self.onPageUpdate = nil
|
||||
self.onResume = nil
|
||||
self.onSuspend = nil
|
||||
self.onNetworkConnected = nil
|
||||
self.onNetworkDisconnecting = nil
|
||||
end
|
||||
end
|
||||
|
||||
function Bookhoard:getAPI()
|
||||
return BookhoardAPI:new({
|
||||
server_url = self.settings.server_url,
|
||||
auth_token = self.settings.auth_token,
|
||||
})
|
||||
end
|
||||
|
||||
function Bookhoard:isConfigured()
|
||||
return self.settings.server_url and self.settings.auth_token
|
||||
end
|
||||
|
||||
function Bookhoard:getSyncPeriod()
|
||||
if not self.settings.auto_sync then
|
||||
return _("Not available")
|
||||
end
|
||||
local period = self.settings.pages_before_update
|
||||
if period and period > 0 then
|
||||
return period
|
||||
end
|
||||
return _("Never")
|
||||
end
|
||||
|
||||
function Bookhoard:addToMainMenu(menu_items)
|
||||
menu_items.bookhoard_sync = {
|
||||
text = _("Bookhoard sync"),
|
||||
sub_item_table = self:buildMainMenu(),
|
||||
}
|
||||
end
|
||||
|
||||
function Bookhoard:buildMainMenu()
|
||||
local items = {}
|
||||
|
||||
table.insert(items, {
|
||||
text = _("Server URL"),
|
||||
keep_menu_open = true,
|
||||
tap_input_func = function()
|
||||
return {
|
||||
title = _("Bookhoard server URL"),
|
||||
input = self.settings.server_url or "http://",
|
||||
callback = function(input)
|
||||
self.settings.server_url = input ~= "" and input or nil
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
}
|
||||
end,
|
||||
})
|
||||
|
||||
if self:isConfigured() then
|
||||
table.insert(items, {
|
||||
text = _("Device info"),
|
||||
keep_menu_open = true,
|
||||
callback = function()
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = T(_("Device ID: %1\nServer: %2"),
|
||||
self.settings.device_id or _("unknown"),
|
||||
self.settings.server_url),
|
||||
})
|
||||
end,
|
||||
})
|
||||
table.insert(items, {
|
||||
text = _("Disconnect"),
|
||||
keep_menu_open = true,
|
||||
callback = function()
|
||||
UIManager:show(ConfirmBox:new{
|
||||
text = _("Disconnect from Bookhoard server?"),
|
||||
ok_text = _("Disconnect"),
|
||||
ok_callback = function()
|
||||
self.settings.auth_token = nil
|
||||
self.settings.device_id = nil
|
||||
self.settings.sync_endpoints = nil
|
||||
self.settings.auto_sync = false
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
self:registerEvents()
|
||||
UIManager:askForRestart()
|
||||
end,
|
||||
})
|
||||
end,
|
||||
separator = true,
|
||||
})
|
||||
else
|
||||
table.insert(items, {
|
||||
text = _("Register device"),
|
||||
keep_menu_open = true,
|
||||
callback = function()
|
||||
self:startRegistration()
|
||||
end,
|
||||
separator = true,
|
||||
})
|
||||
end
|
||||
|
||||
table.insert(items, {
|
||||
text = _("Automatically keep documents in sync"),
|
||||
checked_func = function() return self.settings.auto_sync end,
|
||||
help_text = _([[This may lead to prompts about toggling WiFi on document close and suspend/resume, depending on your device's connectivity.]]),
|
||||
callback = function()
|
||||
self:toggleAutoSync()
|
||||
end,
|
||||
})
|
||||
|
||||
table.insert(items, {
|
||||
text_func = function()
|
||||
return T(_("Periodically sync every # pages (%1)"), self:getSyncPeriod())
|
||||
end,
|
||||
enabled_func = function() return self.settings.auto_sync end,
|
||||
keep_menu_open = true,
|
||||
callback = function(touchmenu_instance)
|
||||
local spin = SpinWidget:new{
|
||||
text = _([[Number of page turns between progress updates. Set to 0 to disable.]]),
|
||||
value = self.settings.pages_before_update or 0,
|
||||
value_min = 0,
|
||||
value_max = 999,
|
||||
value_step = 1,
|
||||
value_hold_step = 10,
|
||||
ok_text = _("Set"),
|
||||
title_text = _("Pages before update"),
|
||||
default_value = 50,
|
||||
callback = function(spin)
|
||||
self.settings.pages_before_update = spin.value > 0 and spin.value or nil
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
if touchmenu_instance then touchmenu_instance:updateItems() end
|
||||
end,
|
||||
}
|
||||
UIManager:show(spin)
|
||||
end,
|
||||
})
|
||||
|
||||
table.insert(items, {
|
||||
text_func = function()
|
||||
return T(_("Sync mode (%1)"),
|
||||
self.settings.sync_mode == SYNC_MODE.IMMEDIATE and _("immediate") or _("checkpoint"))
|
||||
end,
|
||||
sub_item_table = {
|
||||
{
|
||||
text = _("Immediate"),
|
||||
checked_func = function()
|
||||
return self.settings.sync_mode == SYNC_MODE.IMMEDIATE
|
||||
end,
|
||||
callback = function()
|
||||
self.settings.sync_mode = SYNC_MODE.IMMEDIATE
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Checkpoint"),
|
||||
checked_func = function()
|
||||
return self.settings.sync_mode == SYNC_MODE.CHECKPOINT
|
||||
end,
|
||||
callback = function()
|
||||
self.settings.sync_mode = SYNC_MODE.CHECKPOINT
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
},
|
||||
separator = true,
|
||||
})
|
||||
|
||||
table.insert(items, {
|
||||
text = _("Sync behavior"),
|
||||
sub_item_table = {
|
||||
{
|
||||
text_func = function()
|
||||
return T(_("Sync to a newer state (%1)"),
|
||||
self:getStrategyName(self.settings.sync_forward))
|
||||
end,
|
||||
sub_item_table = {
|
||||
{
|
||||
text = _("Silently"),
|
||||
checked_func = function()
|
||||
return self.settings.sync_forward == SYNC_STRATEGY.SILENT
|
||||
end,
|
||||
callback = function()
|
||||
self.settings.sync_forward = SYNC_STRATEGY.SILENT
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Prompt"),
|
||||
checked_func = function()
|
||||
return self.settings.sync_forward == SYNC_STRATEGY.PROMPT
|
||||
end,
|
||||
callback = function()
|
||||
self.settings.sync_forward = SYNC_STRATEGY.PROMPT
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Never"),
|
||||
checked_func = function()
|
||||
return self.settings.sync_forward == SYNC_STRATEGY.DISABLE
|
||||
end,
|
||||
callback = function()
|
||||
self.settings.sync_forward = SYNC_STRATEGY.DISABLE
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text_func = function()
|
||||
return T(_("Sync to an older state (%1)"),
|
||||
self:getStrategyName(self.settings.sync_backward))
|
||||
end,
|
||||
sub_item_table = {
|
||||
{
|
||||
text = _("Silently"),
|
||||
checked_func = function()
|
||||
return self.settings.sync_backward == SYNC_STRATEGY.SILENT
|
||||
end,
|
||||
callback = function()
|
||||
self.settings.sync_backward = SYNC_STRATEGY.SILENT
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Prompt"),
|
||||
checked_func = function()
|
||||
return self.settings.sync_backward == SYNC_STRATEGY.PROMPT
|
||||
end,
|
||||
callback = function()
|
||||
self.settings.sync_backward = SYNC_STRATEGY.PROMPT
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Never"),
|
||||
checked_func = function()
|
||||
return self.settings.sync_backward == SYNC_STRATEGY.DISABLE
|
||||
end,
|
||||
callback = function()
|
||||
self.settings.sync_backward = SYNC_STRATEGY.DISABLE
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
separator = true,
|
||||
})
|
||||
|
||||
table.insert(items, {
|
||||
text = _("What to sync"),
|
||||
sub_item_table = {
|
||||
{
|
||||
text = _("Reading progress"),
|
||||
checked_func = function() return self.settings.sync_progress end,
|
||||
callback = function()
|
||||
self.settings.sync_progress = not self.settings.sync_progress
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Bookmarks"),
|
||||
checked_func = function() return self.settings.sync_bookmarks end,
|
||||
callback = function()
|
||||
self.settings.sync_bookmarks = not self.settings.sync_bookmarks
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Highlights"),
|
||||
checked_func = function() return self.settings.sync_highlights end,
|
||||
callback = function()
|
||||
self.settings.sync_highlights = not self.settings.sync_highlights
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Notes"),
|
||||
checked_func = function() return self.settings.sync_notes end,
|
||||
callback = function()
|
||||
self.settings.sync_notes = not self.settings.sync_notes
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
end,
|
||||
},
|
||||
},
|
||||
separator = true,
|
||||
})
|
||||
|
||||
table.insert(items, {
|
||||
text = _("Sync now"),
|
||||
enabled_func = function() return self:isConfigured() end,
|
||||
callback = function()
|
||||
self:updateProgress(true, true)
|
||||
self:getProgress(true, true)
|
||||
end,
|
||||
})
|
||||
|
||||
table.insert(items, {
|
||||
text = _("Push progress from this device"),
|
||||
enabled_func = function() return self:isConfigured() end,
|
||||
callback = function()
|
||||
self:updateProgress(true, true)
|
||||
end,
|
||||
})
|
||||
|
||||
table.insert(items, {
|
||||
text = _("Pull progress from server"),
|
||||
enabled_func = function() return self:isConfigured() end,
|
||||
callback = function()
|
||||
self:getProgress(true, true)
|
||||
end,
|
||||
separator = true,
|
||||
})
|
||||
|
||||
table.insert(items, {
|
||||
text = _("Setup OPDS catalog"),
|
||||
keep_menu_open = true,
|
||||
callback = function()
|
||||
if not self.settings.server_url or not self.settings.device_id then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Please configure and register your device first."),
|
||||
timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
local opds_url = self.settings.server_url
|
||||
.. "/opds/devices/" .. self.settings.device_id .. "/catalog"
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = T(_("Add this URL as an OPDS catalog in KOReader:\n\n%1\n\nGo to Home → + → OPDS Catalog to add it."), opds_url),
|
||||
})
|
||||
end,
|
||||
})
|
||||
|
||||
return items
|
||||
end
|
||||
|
||||
function Bookhoard:getStrategyName(strategy)
|
||||
if strategy == SYNC_STRATEGY.PROMPT then
|
||||
return _("Prompt")
|
||||
elseif strategy == SYNC_STRATEGY.SILENT then
|
||||
return _("Auto")
|
||||
else
|
||||
return _("Disable")
|
||||
end
|
||||
end
|
||||
|
||||
function Bookhoard:toggleAutoSync()
|
||||
if not self.settings.auto_sync
|
||||
and Device:hasSeamlessWifiToggle()
|
||||
and G_reader_settings:readSetting("wifi_enable_action") ~= "turn_on" then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Set 'Action when Wi-Fi is off' to 'turn on' in Network settings to enable auto sync."),
|
||||
})
|
||||
return
|
||||
end
|
||||
self.settings.auto_sync = not self.settings.auto_sync
|
||||
self:registerEvents()
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
|
||||
if self.settings.auto_sync then
|
||||
self:getProgress(true, true)
|
||||
end
|
||||
end
|
||||
|
||||
function Bookhoard:startRegistration()
|
||||
if not self.settings.server_url or self.settings.server_url == "" then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Please set your server URL first."),
|
||||
timeout = 3,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if NetworkMgr:willRerunWhenOnline(function() self:startRegistration() end) then
|
||||
return
|
||||
end
|
||||
|
||||
local device_name = Device.model or "KOReader Device"
|
||||
local device_identifier = Device:info() or device_name
|
||||
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Registering device…"),
|
||||
timeout = 1,
|
||||
})
|
||||
|
||||
UIManager:scheduleIn(0.5, function()
|
||||
local api = BookhoardAPI:new({ server_url = self.settings.server_url })
|
||||
local ok, result = api:registerDevice(device_name, device_identifier)
|
||||
|
||||
if not ok then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = T(_("Registration failed: %1"),
|
||||
result and result.error or _("unknown error")),
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
self.registration_id = result.registration_id
|
||||
local auth_url = result.auth_url
|
||||
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = T(_("Open this URL on your phone or computer to approve this device:\n\n%1\n\nWaiting for approval…"), auth_url),
|
||||
timeout = 30,
|
||||
})
|
||||
|
||||
self:startRegistrationPoll()
|
||||
end)
|
||||
end
|
||||
|
||||
function Bookhoard:startRegistrationPoll()
|
||||
if self.registration_poll_scheduled then return end
|
||||
self.registration_poll_scheduled = true
|
||||
|
||||
local function poll()
|
||||
self.registration_poll_scheduled = false
|
||||
|
||||
if not self.registration_id then return end
|
||||
|
||||
local api = BookhoardAPI:new({ server_url = self.settings.server_url })
|
||||
local ok, result = api:checkRegistrationStatus(self.registration_id)
|
||||
|
||||
if not ok then
|
||||
if result and result.status == 410 then
|
||||
self.registration_id = nil
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Registration expired. Please try again."),
|
||||
})
|
||||
return
|
||||
end
|
||||
self.registration_poll_scheduled = true
|
||||
UIManager:scheduleIn(3, poll)
|
||||
return
|
||||
end
|
||||
|
||||
if result.status == "approved" then
|
||||
self.registration_id = nil
|
||||
self.settings.auth_token = result.auth_token
|
||||
self.settings.device_id = result.device_id and tostring(result.device_id) or nil
|
||||
self.settings.sync_endpoints = result.sync_endpoints
|
||||
G_reader_settings:saveSetting(self.settings_key, self.settings)
|
||||
self:registerEvents()
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Device registered successfully!"),
|
||||
timeout = 3,
|
||||
})
|
||||
elseif result.status == "pending" then
|
||||
self.registration_poll_scheduled = true
|
||||
UIManager:scheduleIn(3, poll)
|
||||
else
|
||||
self.registration_id = nil
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = T(_("Registration %1"), result.status or _("failed")),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
UIManager:scheduleIn(3, poll)
|
||||
end
|
||||
|
||||
function Bookhoard:getLastPercent()
|
||||
if self.ui.document.info.has_pages then
|
||||
return Math.roundPercent(self.ui.paging:getLastPercent())
|
||||
else
|
||||
return Math.roundPercent(self.ui.rolling:getLastPercent())
|
||||
end
|
||||
end
|
||||
|
||||
function Bookhoard:getLastProgress()
|
||||
if self.ui.document.info.has_pages then
|
||||
return self.ui.paging:getLastProgress()
|
||||
else
|
||||
return self.ui.rolling:getLastProgress()
|
||||
end
|
||||
end
|
||||
|
||||
function Bookhoard:getFileSHA256()
|
||||
local cached = self.ui.doc_settings:readSetting("bookhoard_sha256")
|
||||
if cached then return cached end
|
||||
|
||||
local file = io.open(self.ui.document.file, "rb")
|
||||
if not file then return nil end
|
||||
local data = file:read("*a")
|
||||
file:close()
|
||||
|
||||
local hash = sha256hex(data)
|
||||
if hash then
|
||||
self.ui.doc_settings:saveSetting("bookhoard_sha256", hash)
|
||||
end
|
||||
return hash
|
||||
end
|
||||
|
||||
function Bookhoard:getBookhoardUUID()
|
||||
return self.ui.doc_settings:readSetting("bookhoard_uuid")
|
||||
end
|
||||
|
||||
function Bookhoard:collectBookData()
|
||||
local props = self.ui.doc_props
|
||||
local file_path = self.ui.document.file
|
||||
|
||||
local title = props.display_title or ""
|
||||
local authors = ""
|
||||
if props.authors then
|
||||
authors = props.authors
|
||||
end
|
||||
|
||||
local file_sha256 = self:getFileSHA256()
|
||||
local book_uuid = self:getBookhoardUUID()
|
||||
|
||||
local percentage = self:getLastPercent()
|
||||
local progress = self:getLastProgress()
|
||||
local page = self.ui:getCurrentPage()
|
||||
local total_pages = self.ui.document:getPageCount()
|
||||
|
||||
local chapter = ""
|
||||
if self.ui.toc and self.ui.toc.getTocTitleOfCurrentPage then
|
||||
chapter = self.ui.toc:getTocTitleOfCurrentPage() or ""
|
||||
end
|
||||
|
||||
local book_data = {
|
||||
uuid = book_uuid,
|
||||
sha256 = file_sha256,
|
||||
title = title,
|
||||
authors = authors,
|
||||
percentage = percentage,
|
||||
chapter = chapter,
|
||||
epubcfi = progress,
|
||||
page = page,
|
||||
total_pages = total_pages,
|
||||
file_path = file_path,
|
||||
device_info = {
|
||||
koreader_version = require("version"):getCurrentRevision(),
|
||||
device_model = Device.model,
|
||||
},
|
||||
}
|
||||
|
||||
return book_data
|
||||
end
|
||||
|
||||
function Bookhoard:collectAnnotations()
|
||||
local bookmarks = {}
|
||||
local highlights = {}
|
||||
local notes = {}
|
||||
|
||||
if not self.ui.bookmark then
|
||||
return bookmarks, highlights, notes
|
||||
end
|
||||
|
||||
local all_bookmarks = self.ui.bookmark.bookmarks
|
||||
if not all_bookmarks then
|
||||
return bookmarks, highlights, notes
|
||||
end
|
||||
|
||||
local file_sha256 = self:getFileSHA256()
|
||||
local total_pages = self.ui.document:getPageCount()
|
||||
|
||||
for _, bm in ipairs(all_bookmarks) do
|
||||
local page_num = bm.page
|
||||
if type(page_num) == "string" and self.ui.document.info.has_pages == false then
|
||||
page_num = self.ui.document:getPageFromXPointer(page_num)
|
||||
end
|
||||
page_num = tonumber(page_num) or 0
|
||||
|
||||
local percentage = total_pages > 0 and (page_num / total_pages) or 0
|
||||
local chapter = bm.chapter or ""
|
||||
|
||||
local entry = {
|
||||
chapter = chapter,
|
||||
datetime = bm.datetime or "",
|
||||
notes = bm.notes or "",
|
||||
pos0 = bm.pos0 or "",
|
||||
pos1 = bm.pos1 or "",
|
||||
page = tostring(bm.page or ""),
|
||||
text = bm.text or "",
|
||||
percentage = Math.roundPercent(percentage),
|
||||
book_sha256 = file_sha256,
|
||||
}
|
||||
|
||||
local has_text = bm.text and bm.text ~= ""
|
||||
local has_notes = bm.notes and bm.notes ~= ""
|
||||
|
||||
if has_text and has_notes then
|
||||
entry.type = "note"
|
||||
table.insert(notes, entry)
|
||||
elseif has_text then
|
||||
entry.type = "highlight"
|
||||
if bm.color then
|
||||
entry.color = bm.color
|
||||
end
|
||||
table.insert(highlights, entry)
|
||||
else
|
||||
entry.type = "bookmark"
|
||||
table.insert(bookmarks, entry)
|
||||
end
|
||||
end
|
||||
|
||||
return bookmarks, highlights, notes
|
||||
end
|
||||
|
||||
function Bookhoard:syncToProgress(progress, percentage)
|
||||
logger.dbg("Bookhoard: sync to progress", progress, percentage)
|
||||
if self.ui.document.info.has_pages then
|
||||
self.ui:handleEvent(Event:new("GotoPage", tonumber(progress)))
|
||||
else
|
||||
self.ui:handleEvent(Event:new("GotoXPointer", progress))
|
||||
end
|
||||
end
|
||||
|
||||
function Bookhoard:updateProgress(ensure_networking, interactive)
|
||||
if not self:isConfigured() then
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Please configure and register your device first."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if not self.settings.sync_progress then return end
|
||||
|
||||
local now = UIManager:getElapsedTimeSinceBoot()
|
||||
if not interactive and now - self.push_timestamp <= API_CALL_DEBOUNCE_DELAY then
|
||||
return
|
||||
end
|
||||
|
||||
if ensure_networking
|
||||
and NetworkMgr:willRerunWhenOnline(function() self:updateProgress(ensure_networking, interactive) end) then
|
||||
return
|
||||
end
|
||||
|
||||
UIManager:scheduleIn(0.5, function()
|
||||
self:_doUpdateProgress(interactive)
|
||||
end)
|
||||
|
||||
self.push_timestamp = now
|
||||
end
|
||||
|
||||
function Bookhoard:_doUpdateProgress(interactive)
|
||||
local book_data = self:collectBookData()
|
||||
if not book_data then return end
|
||||
|
||||
if self.settings.sync_bookmarks or self.settings.sync_highlights or self.settings.sync_notes then
|
||||
local bm, hl, nt = self:collectAnnotations()
|
||||
book_data.bookmarks = self.settings.sync_bookmarks and bm or {}
|
||||
book_data.highlights = self.settings.sync_highlights and hl or {}
|
||||
book_data.notes = self.settings.sync_notes and nt or {}
|
||||
else
|
||||
book_data.bookmarks = {}
|
||||
book_data.highlights = {}
|
||||
book_data.notes = {}
|
||||
end
|
||||
|
||||
local api = self:getAPI()
|
||||
local ok, result = api:syncProgress(book_data, self.settings.sync_mode)
|
||||
|
||||
UIManager:nextTick(function()
|
||||
if ok then
|
||||
logger.dbg("Bookhoard: progress pushed successfully")
|
||||
if result and result.device_updated and result.device_updated.uuid then
|
||||
self.ui.doc_settings:saveSetting("bookhoard_uuid", result.device_updated.uuid)
|
||||
self.ui.doc_settings:flush()
|
||||
end
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Progress has been pushed."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
else
|
||||
logger.warn("Bookhoard: failed to push progress")
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Failed to push progress. Check your network connection."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function Bookhoard:getProgress(ensure_networking, interactive)
|
||||
if not self:isConfigured() then
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Please configure and register your device first."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if not self.settings.sync_progress then return end
|
||||
|
||||
local now = UIManager:getElapsedTimeSinceBoot()
|
||||
if not interactive and now - self.pull_timestamp <= API_CALL_DEBOUNCE_DELAY then
|
||||
return
|
||||
end
|
||||
|
||||
if ensure_networking
|
||||
and NetworkMgr:willRerunWhenOnline(function() self:getProgress(ensure_networking, interactive) end) then
|
||||
return
|
||||
end
|
||||
|
||||
local book_uuid = self:getBookhoardUUID()
|
||||
if not book_uuid then
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("No Bookhoard UUID for this document. Push progress first."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
UIManager:scheduleIn(0.5, function()
|
||||
self:_doGetProgress(interactive)
|
||||
end)
|
||||
|
||||
self.pull_timestamp = now
|
||||
end
|
||||
|
||||
function Bookhoard:_doGetProgress(interactive)
|
||||
local book_uuid = self:getBookhoardUUID()
|
||||
if not book_uuid then return end
|
||||
|
||||
local api = self:getAPI()
|
||||
local ok, result = api:getMetadata(book_uuid)
|
||||
|
||||
UIManager:nextTick(function()
|
||||
if not ok or not result then
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Failed to pull progress."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if not result.progress then
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("No progress found for this document."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local progress = result.progress
|
||||
local percentage = self:getLastPercent()
|
||||
local server_percentage = progress.percentage or 0
|
||||
|
||||
if percentage == server_percentage then
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Progress is already synchronized."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local self_older = server_percentage > percentage
|
||||
|
||||
if self_older then
|
||||
if self.settings.sync_forward == SYNC_STRATEGY.SILENT then
|
||||
self:syncToProgress(progress.epubcfi or progress.page, server_percentage)
|
||||
self:_showSyncedMessage()
|
||||
elseif self.settings.sync_forward == SYNC_STRATEGY.PROMPT then
|
||||
UIManager:show(ConfirmBox:new{
|
||||
text = T(_("Sync to newer location %1%% from server?"),
|
||||
Math.round(server_percentage * 100)),
|
||||
ok_callback = function()
|
||||
self:syncToProgress(progress.epubcfi or progress.page, server_percentage)
|
||||
end,
|
||||
})
|
||||
end
|
||||
else
|
||||
if self.settings.sync_backward == SYNC_STRATEGY.SILENT then
|
||||
self:syncToProgress(progress.epubcfi or progress.page, server_percentage)
|
||||
self:_showSyncedMessage()
|
||||
elseif self.settings.sync_backward == SYNC_STRATEGY.PROMPT then
|
||||
UIManager:show(ConfirmBox:new{
|
||||
text = T(_("Sync to previous location %1%% from server?"),
|
||||
Math.round(server_percentage * 100)),
|
||||
ok_callback = function()
|
||||
self:syncToProgress(progress.epubcfi or progress.page, server_percentage)
|
||||
end,
|
||||
})
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function Bookhoard:_showSyncedMessage()
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Progress has been synchronized."),
|
||||
timeout = 3,
|
||||
})
|
||||
end
|
||||
|
||||
function Bookhoard:_onCloseDocument()
|
||||
self.onResume = nil
|
||||
self.onSuspend = nil
|
||||
NetworkMgr:goOnlineToRun(function()
|
||||
self:updateProgress(false, false)
|
||||
end)
|
||||
end
|
||||
|
||||
function Bookhoard:schedulePeriodicPush()
|
||||
UIManager:unschedule(self.periodic_push_task)
|
||||
UIManager:scheduleIn(PERIODIC_PUSH_DELAY, self.periodic_push_task)
|
||||
self.periodic_push_scheduled = true
|
||||
end
|
||||
|
||||
function Bookhoard:_onPageUpdate(page)
|
||||
if page == nil then return end
|
||||
|
||||
if self.last_page ~= page then
|
||||
self.last_page = page
|
||||
self.page_update_counter = self.page_update_counter + 1
|
||||
if self.periodic_push_scheduled
|
||||
or (self.settings.pages_before_update
|
||||
and self.page_update_counter >= self.settings.pages_before_update) then
|
||||
self:schedulePeriodicPush()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Bookhoard:_onResume()
|
||||
if Device:hasWifiRestore() and NetworkMgr.wifi_was_on
|
||||
and G_reader_settings:isTrue("auto_restore_wifi") then
|
||||
return
|
||||
end
|
||||
UIManager:scheduleIn(1, function()
|
||||
self:getProgress(true, false)
|
||||
end)
|
||||
end
|
||||
|
||||
function Bookhoard:_onSuspend()
|
||||
self:updateProgress(true, false)
|
||||
end
|
||||
|
||||
function Bookhoard:_onNetworkConnected()
|
||||
UIManager:scheduleIn(0.5, function()
|
||||
self:getProgress(false, false)
|
||||
end)
|
||||
end
|
||||
|
||||
function Bookhoard:_onNetworkDisconnecting()
|
||||
self:updateProgress(false, false)
|
||||
end
|
||||
|
||||
function Bookhoard:onCloseWidget()
|
||||
UIManager:unschedule(self.periodic_push_task)
|
||||
self.periodic_push_task = nil
|
||||
end
|
||||
|
||||
return Bookhoard
|
||||
Reference in New Issue
Block a user