ef129fdc48
collectBookData put getLastProgress() directly into the epubcfi field
for every document. For paging documents (has_pages == true: PDF, CBZ,
CBR, CB7, CBT, DjVu) that value is the current page as a *number*, so
the payload carried "epubcfi": 5 (a JSON number). The server types
KOReaderBookProgress.Epubcfi as *string, and the strict JSON binder
rejected it with a 400 ("cannot unmarshal number into ... epububcfi of
type string"), which the plugin surfaced as the misleading
"Failed to push progress. Check your network connection." Reflowable
EPUBs were unaffected because CREngine returns an xpointer string.
Only set epubcfi for rolling (reflowable) documents. Paging documents
carry their position in the numeric page/total_pages fields, which the
server already treats as the canonical locator for fixed-layout/comic
formats. The pull path already falls back to progress.page, so restore
keeps working.
1161 lines
37 KiB
Lua
1161 lines
37 KiB
Lua
local ConfirmBox = require("ui/widget/confirmbox")
|
|
local DataStorage = require("datastorage")
|
|
local Device = require("device")
|
|
local Event = require("ui/event")
|
|
local InfoMessage = require("ui/widget/infomessage")
|
|
local InputDialog = require("ui/widget/inputdialog")
|
|
local LuaSettings = require("luasettings")
|
|
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 json_util = require("json.util")
|
|
local logger = require("logger")
|
|
local sha2 = require("ffi/sha2")
|
|
local time = require("ui/time")
|
|
local util = require("util")
|
|
local lfs = require("libs/libkoreader-lfs")
|
|
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 = false,
|
|
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)
|
|
self:setupMenuOrder()
|
|
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"),
|
|
sorting_hint = "tools",
|
|
sub_item_table = self:buildMainMenu(),
|
|
}
|
|
end
|
|
|
|
function Bookhoard:buildMainMenu()
|
|
local items = {}
|
|
|
|
if self:isConfigured() then
|
|
table.insert(items, {
|
|
text = _("Push progress from this device"),
|
|
callback = function()
|
|
self:updateProgress(true, true)
|
|
end,
|
|
})
|
|
table.insert(items, {
|
|
text = _("Pull progress from server"),
|
|
callback = function()
|
|
self:getProgress(true, true)
|
|
end,
|
|
separator = true,
|
|
})
|
|
end
|
|
|
|
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 push progress"),
|
|
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 = _("Setup OPDS catalog"),
|
|
keep_menu_open = true,
|
|
enabled_func = function() return self:isConfigured() end,
|
|
callback = function()
|
|
if self:setupOPDS() then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Bookhoard OPDS catalog added! Find it in Home → OPDS Catalog."),
|
|
timeout = 3,
|
|
})
|
|
else
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Please configure and register your device first."),
|
|
timeout = 3,
|
|
})
|
|
end
|
|
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 and self.ui.doc_settings then
|
|
self:getProgress(true, true)
|
|
end
|
|
end
|
|
|
|
function Bookhoard:setupMenuOrder()
|
|
local settings_dir = DataStorage:getSettingsDir()
|
|
self:patchMenuOrderFile(settings_dir .. "/reader_menu_order.lua")
|
|
self:patchMenuOrderFile(settings_dir .. "/filemanager_menu_order.lua")
|
|
end
|
|
|
|
function Bookhoard:patchMenuOrderFile(filepath)
|
|
local default_tools = {
|
|
"read_timer",
|
|
"calibre",
|
|
"bookhoard_sync",
|
|
"exporter",
|
|
"statistics",
|
|
"progress_sync",
|
|
"move_to_archive",
|
|
"wallabag",
|
|
"news_downloader",
|
|
"text_editor",
|
|
"profiles",
|
|
"qrclipboard",
|
|
"----------------------------",
|
|
"more_tools",
|
|
}
|
|
|
|
local attr = lfs.attributes(filepath)
|
|
if not attr then
|
|
local f = io.open(filepath, "w")
|
|
if not f then return end
|
|
f:write("return {\n tools = {\n")
|
|
for _, id in ipairs(default_tools) do
|
|
f:write(' "' .. id .. '",\n')
|
|
end
|
|
f:write(" },\n}\n")
|
|
f:close()
|
|
logger.dbg("Bookhoard: created menu order file", filepath)
|
|
return
|
|
end
|
|
|
|
local existing = dofile(filepath)
|
|
if not existing or type(existing) ~= "table" then return end
|
|
|
|
local tools = existing.tools
|
|
if not tools or type(tools) ~= "table" then return end
|
|
|
|
for _, id in ipairs(tools) do
|
|
if id == "bookhoard" then return end
|
|
end
|
|
|
|
local insert_pos = nil
|
|
for i, id in ipairs(tools) do
|
|
if id == "calibre" then
|
|
insert_pos = i + 1
|
|
break
|
|
end
|
|
end
|
|
if not insert_pos then
|
|
insert_pos = 2
|
|
end
|
|
|
|
table.insert(tools, insert_pos, "bookhoard")
|
|
existing.tools = tools
|
|
|
|
local f = io.open(filepath, "w")
|
|
if not f then return end
|
|
f:write("return {\n")
|
|
for key, val in pairs(existing) do
|
|
if type(val) == "table" then
|
|
f:write(" " .. key .. " = {\n")
|
|
for _, id in ipairs(val) do
|
|
f:write(' "' .. id .. '",\n')
|
|
end
|
|
f:write(" },\n")
|
|
end
|
|
end
|
|
f:write("}\n")
|
|
f:close()
|
|
logger.dbg("Bookhoard: patched menu order file", filepath)
|
|
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
|
|
|
|
self.waiting_dialog = InfoMessage:new{
|
|
text = T(_("Device registered on server.\n\nOpen your Bookhoard web UI and go to:\n%1/devices\n\nApprove this device in the \"Pending Device Registrations\" section.\n\nWaiting for approval…"), self.settings.server_url),
|
|
}
|
|
UIManager:show(self.waiting_dialog)
|
|
|
|
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()
|
|
self:setupOPDS()
|
|
if self.waiting_dialog then
|
|
UIManager:close(self.waiting_dialog)
|
|
self.waiting_dialog = nil
|
|
end
|
|
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:setupOPDS()
|
|
if not self.settings.server_url or not self.settings.device_id
|
|
or not self.settings.auth_token then
|
|
return false
|
|
end
|
|
|
|
local opds_base_url = self.settings.server_url
|
|
.. "/opds/devices/" .. self.settings.device_id .. "/catalog"
|
|
local opds_url = opds_base_url .. "?token=" .. self.settings.auth_token
|
|
|
|
local opds_settings_file = DataStorage:getSettingsDir() .. "/opds.lua"
|
|
local opds_settings = LuaSettings:open(opds_settings_file)
|
|
local servers = opds_settings:readSetting("servers", {})
|
|
|
|
local found = false
|
|
for i, server in ipairs(servers) do
|
|
if server.url and (
|
|
server.url == opds_url
|
|
or server.url == opds_base_url
|
|
or server.url:sub(1, #opds_base_url + 1) == opds_base_url .. "?"
|
|
) then
|
|
servers[i] = {
|
|
title = "Bookhoard",
|
|
url = opds_url,
|
|
}
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
|
|
if not found then
|
|
table.insert(servers, {
|
|
title = "Bookhoard",
|
|
url = opds_url,
|
|
})
|
|
end
|
|
|
|
opds_settings:saveSetting("servers", servers)
|
|
opds_settings:flush()
|
|
|
|
if self.ui.opds then
|
|
self.ui.opds.servers = servers
|
|
end
|
|
|
|
return true
|
|
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()
|
|
if not self.ui.doc_settings then return nil end
|
|
return self.ui.doc_settings:readSetting("bookhoard_uuid")
|
|
end
|
|
|
|
function Bookhoard:getContextText()
|
|
if not self.ui.document or self.ui.document.info.has_pages then
|
|
return ""
|
|
end
|
|
local xp = self:getLastProgress()
|
|
if not xp then return "" end
|
|
|
|
local text = self.ui.document:getTextFromXPointer(xp)
|
|
if not text or text == "" then
|
|
text = self.ui.document:getTextFromXPointer(self.ui.document:getNormalizedXPointer(xp))
|
|
end
|
|
if not text or text == "" then return "" end
|
|
|
|
local char_offset = tonumber(xp:match("text%(%)%.?(%d+)")) or 0
|
|
if char_offset > 0 and char_offset < #text then
|
|
text = text:sub(char_offset + 1)
|
|
end
|
|
|
|
if #text > 100 then
|
|
text = text:sub(1, 100)
|
|
end
|
|
|
|
text = text:gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
|
|
return text
|
|
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
|
|
json_util.InitArray(authors)
|
|
|
|
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 context_text = self:getContextText()
|
|
|
|
local book_data = {
|
|
uuid = book_uuid,
|
|
sha256 = file_sha256,
|
|
title = title,
|
|
authors = authors,
|
|
percentage = percentage,
|
|
context_text = context_text,
|
|
page = page,
|
|
total_pages = total_pages,
|
|
file_path = file_path,
|
|
device_info = {
|
|
koreader_version = require("version"):getCurrentRevision(),
|
|
device_model = Device.model,
|
|
},
|
|
}
|
|
|
|
-- epubcfi (a CREngine xpointer) is only meaningful for reflowable (rolling)
|
|
-- documents. Paging docs (PDF/comics/DjVu) carry their position in
|
|
-- page/total_pages; a bare page number here would be a JSON number into a
|
|
-- server *string field and fail the bind. Reflowable EPUBs are always
|
|
-- rolling, so they keep sending the xpointer exactly as before.
|
|
if not self.ui.document.info.has_pages then
|
|
book_data.epubcfi = progress
|
|
end
|
|
|
|
return book_data
|
|
end
|
|
|
|
function Bookhoard:collectAnnotations()
|
|
local bookmarks = json_util.InitArray({})
|
|
local highlights = json_util.InitArray({})
|
|
local notes = json_util.InitArray({})
|
|
|
|
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)))
|
|
elseif progress and progress:match("^/body/") then
|
|
self.ui:handleEvent(Event:new("GotoXPointer", progress))
|
|
elseif percentage then
|
|
self.ui.document:gotoPercent(percentage * 100)
|
|
self.ui:handleEvent(Event:new("UpdatePos"))
|
|
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
|
|
|
|
if not self.ui.document then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("No document open."),
|
|
timeout = 3,
|
|
})
|
|
end
|
|
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.book_results then
|
|
for _, br in ipairs(result.book_results) do
|
|
if br.synced and br.book_uuid and br.sha256 == book_data.sha256 then
|
|
self.ui.doc_settings:saveSetting("bookhoard_uuid", br.book_uuid)
|
|
self.ui.doc_settings:flush()
|
|
break
|
|
end
|
|
end
|
|
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
|
|
|
|
local nav_target = progress.koreader_xpointer or progress.epubcfi or progress.page
|
|
|
|
local sync_text
|
|
if not self.ui.document.info.has_pages then
|
|
local total = self.ui.document:getPageCount()
|
|
local target_page = math.min(Math.round(server_percentage * total), total)
|
|
sync_text = T(_("Sync to page %1 of %2 from server?"), target_page, total)
|
|
else
|
|
sync_text = T(_("Sync to page %1 of %2 from server?"),
|
|
Math.round(server_percentage * (progress.total_pages or 1)),
|
|
progress.total_pages or "?")
|
|
end
|
|
|
|
if self_older then
|
|
if self.settings.sync_forward == SYNC_STRATEGY.SILENT then
|
|
self:syncToProgress(nav_target, server_percentage)
|
|
self:_showSyncedMessage()
|
|
elseif self.settings.sync_forward == SYNC_STRATEGY.PROMPT then
|
|
UIManager:show(ConfirmBox:new{
|
|
text = sync_text,
|
|
ok_callback = function()
|
|
self:syncToProgress(nav_target, server_percentage)
|
|
end,
|
|
})
|
|
end
|
|
else
|
|
if self.settings.sync_backward == SYNC_STRATEGY.SILENT then
|
|
self:syncToProgress(nav_target, server_percentage)
|
|
self:_showSyncedMessage()
|
|
elseif self.settings.sync_backward == SYNC_STRATEGY.PROMPT then
|
|
UIManager:show(ConfirmBox:new{
|
|
text = sync_text,
|
|
ok_callback = function()
|
|
self:syncToProgress(nav_target, 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
|