mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
c28ea29671
Introduces a custom KOReader plugin to synchronize reading progress, stats, and annotations. Adds backend controllers, services, and database schema updates to ingest plugin data. Updates the frontend to display KOReader annotations alongside built-in reader highlights. Closes #337
1215 lines
42 KiB
Lua
1215 lines
42 KiB
Lua
--[[--
|
|
BookOrbit Sync plugin.
|
|
|
|
Live progress sync mirrors the stock kosync plugin (pull on open with
|
|
conflict strategies, periodic push every N page turns, push on close and
|
|
suspend, 25 second debounce) against BookOrbit's kosync-compatible endpoints.
|
|
Closing or suspending additionally snapshots the open book from live memory
|
|
(progress, highlights, status/rating, page stats) and uploads it per book.
|
|
The full-library sweep is manual-only.
|
|
]]
|
|
|
|
local ConfirmBox = require("ui/widget/confirmbox")
|
|
local Device = require("device")
|
|
local Dispatcher = require("dispatcher")
|
|
local Event = require("ui/event")
|
|
local InfoMessage = require("ui/widget/infomessage")
|
|
local InputDialog = require("ui/widget/inputdialog")
|
|
local Math = require("optmath")
|
|
local MultiInputDialog = require("ui/widget/multiinputdialog")
|
|
local NetworkMgr = require("ui/network/manager")
|
|
local Notification = require("ui/widget/notification")
|
|
local UIManager = require("ui/uimanager")
|
|
local WidgetContainer = require("ui/widget/container/widgetcontainer")
|
|
local logger = require("logger")
|
|
local md5 = require("ffi/sha2").md5
|
|
local time = require("ui/time")
|
|
local util = require("util")
|
|
local T = require("ffi/util").template
|
|
local _ = require("gettext")
|
|
|
|
local BookOrbitAnnotations = require("bookorbit_annotations")
|
|
local BookOrbitApi = require("bookorbit_api")
|
|
local BookOrbitBookSync = require("bookorbit_book_sync")
|
|
local BookOrbitCatalog = require("bookorbit_catalog")
|
|
local BookOrbitState = require("bookorbit_state")
|
|
local BookOrbitMenuPin = require("bookorbit_menu_pin")
|
|
local BookOrbitSweep = require("bookorbit_sweep")
|
|
local BookOrbitUpdater = require("bookorbit_updater")
|
|
|
|
local PLUGIN_VERSION = "0.1.0"
|
|
|
|
local SYNC_STRATEGY = {
|
|
PROMPT = 1,
|
|
SILENT = 2,
|
|
DISABLE = 3,
|
|
}
|
|
|
|
local API_CALL_DEBOUNCE_DELAY = time.s(25)
|
|
local UPDATE_CHECK_INTERVAL = 24 * 60 * 60
|
|
|
|
local BookOrbit = WidgetContainer:extend{
|
|
name = "bookorbit",
|
|
title = _("Login to BookOrbit"),
|
|
|
|
push_timestamp = nil,
|
|
pull_timestamp = nil,
|
|
last_page = nil,
|
|
last_page_turn_timestamp = nil,
|
|
|
|
settings = nil,
|
|
}
|
|
|
|
BookOrbit.default_settings = {
|
|
settings_version = 1,
|
|
server_url = nil,
|
|
username = nil,
|
|
userkey = nil,
|
|
auto_sync = false,
|
|
annotation_sync = true,
|
|
pages_before_update = 10,
|
|
sync_forward = SYNC_STRATEGY.PROMPT,
|
|
sync_backward = SYNC_STRATEGY.DISABLE,
|
|
catalog_view_mode = "mosaic",
|
|
catalog_sort = "recently_added",
|
|
catalog_grid_cols = 3,
|
|
catalog_grid_rows = 3,
|
|
catalog_recent_searches = {},
|
|
update_check_last_at = 0,
|
|
}
|
|
|
|
function BookOrbit:init()
|
|
self.push_timestamp = 0
|
|
self.pull_timestamp = 0
|
|
self.last_page = -1
|
|
self.last_page_turn_timestamp = 0
|
|
self.page_update_counter = 0
|
|
self.periodic_push_scheduled = false
|
|
self.periodic_push_task = function()
|
|
self.periodic_push_scheduled = false
|
|
self.page_update_counter = 0
|
|
-- Push only, no pull, no network nagging: relies on the connection
|
|
-- being already up, like the stock kosync periodic push.
|
|
if self.settings.auto_sync and (self.settings.pages_before_update or 0) > 0 then
|
|
self:updateProgress(false, false)
|
|
end
|
|
end
|
|
|
|
self.settings = G_reader_settings:readSetting("bookorbit", self.default_settings)
|
|
self.device_id = G_reader_settings:readSetting("device_id")
|
|
|
|
-- Detect settings from the old bookorbit-koplugin, which used the same
|
|
-- "bookorbit" key but a different structure. Reset behavior keys to
|
|
-- defaults while preserving any existing credentials so users aren't
|
|
-- logged out.
|
|
if not self.settings.settings_version then
|
|
local server_url = self.settings.server_url
|
|
local username = self.settings.username
|
|
local userkey = self.settings.userkey
|
|
local provision_fingerprint = self.settings.provision_fingerprint
|
|
self.settings = {}
|
|
for k, v in pairs(self.default_settings) do
|
|
self.settings[k] = type(v) == "table" and {} or v
|
|
end
|
|
self.settings.server_url = server_url
|
|
self.settings.username = username
|
|
self.settings.userkey = userkey
|
|
self.settings.provision_fingerprint = provision_fingerprint
|
|
G_reader_settings:saveSetting("bookorbit", self.settings)
|
|
G_reader_settings:flush()
|
|
logger.info("BookOrbit: migrated settings from previous schema")
|
|
end
|
|
|
|
-- v1 settings cleanup: full sweeps are manual-only since 0.2.0.
|
|
self.settings.sweep_on_close = nil
|
|
self.settings.sweep_on_suspend = nil
|
|
if self.settings.pages_before_update == nil then
|
|
self.settings.pages_before_update = 10
|
|
end
|
|
if self.settings.annotation_sync == nil then
|
|
self.settings.annotation_sync = true
|
|
end
|
|
if self.settings.update_check_last_at == nil then
|
|
self.settings.update_check_last_at = 0
|
|
end
|
|
|
|
self:applyProvision()
|
|
|
|
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("BookOrbit: auto sync disabled because wifi_enable_action is not turn_on")
|
|
end
|
|
|
|
pcall(BookOrbitMenuPin.ensure)
|
|
self:onDispatcherRegisterActions()
|
|
self.ui.menu:registerToMainMenu(self)
|
|
UIManager:scheduleIn(5, function()
|
|
self:maybeCheckForUpdate(false)
|
|
end)
|
|
end
|
|
|
|
local PROVISION_FILE = "bookorbit_provision.lua"
|
|
|
|
-- Applies the connection settings bundled by "Download preconfigured plugin"
|
|
-- in BookOrbit web settings. The fingerprint ties one download to one apply:
|
|
-- a freshly generated zip always (re)configures, while reinstalling an old
|
|
-- one never overrides a logout.
|
|
function BookOrbit:applyProvision()
|
|
if not self.path then return end
|
|
local provision_path = self.path .. "/" .. PROVISION_FILE
|
|
local chunk = loadfile(provision_path)
|
|
if not chunk then return end
|
|
|
|
setfenv(chunk, {})
|
|
local ok, provision = pcall(chunk)
|
|
if not ok or type(provision) ~= "table" then
|
|
logger.warn("BookOrbit: ignoring unreadable provision file", provision_path)
|
|
return
|
|
end
|
|
|
|
local server_url = BookOrbitApi.normalizeServerUrl(provision.server_url)
|
|
if not server_url or type(provision.username) ~= "string" or type(provision.userkey) ~= "string" then
|
|
logger.warn("BookOrbit: ignoring incomplete provision file", provision_path)
|
|
return
|
|
end
|
|
|
|
local fingerprint = md5(table.concat({
|
|
server_url, provision.username, provision.userkey, tostring(provision.generated_at),
|
|
}, "\0"))
|
|
if self.settings.provision_fingerprint ~= fingerprint then
|
|
self.settings.server_url = server_url
|
|
self.settings.username = provision.username
|
|
self.settings.userkey = provision.userkey
|
|
self.settings.provision_fingerprint = fingerprint
|
|
G_reader_settings:flush()
|
|
logger.info("BookOrbit: applied provision file for", provision.username)
|
|
UIManager:nextTick(function()
|
|
UIManager:show(InfoMessage:new{
|
|
text = T(_("BookOrbit sync is set up for %1 as %2."), provision.server_url, provision.username),
|
|
timeout = 5,
|
|
})
|
|
end)
|
|
end
|
|
os.remove(provision_path)
|
|
end
|
|
|
|
function BookOrbit:apiOpts()
|
|
return {
|
|
server_url = self.settings.server_url,
|
|
username = self.settings.username,
|
|
userkey = self.settings.userkey,
|
|
device_id = self.device_id,
|
|
device_model = Device.model,
|
|
plugin_version = PLUGIN_VERSION,
|
|
}
|
|
end
|
|
|
|
function BookOrbit:newClient()
|
|
return BookOrbitApi.new(self:apiOpts())
|
|
end
|
|
|
|
function BookOrbit:isLoggedIn()
|
|
return self.settings.server_url ~= nil and self.settings.username ~= nil and self.settings.userkey ~= nil
|
|
end
|
|
|
|
local function showSyncError()
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Something went wrong when syncing to BookOrbit, please check your network connection and try again later."),
|
|
timeout = 3,
|
|
})
|
|
end
|
|
|
|
local function promptLogin()
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Please configure the BookOrbit server and login first."),
|
|
timeout = 3,
|
|
})
|
|
end
|
|
|
|
function BookOrbit:onDispatcherRegisterActions()
|
|
Dispatcher:registerAction("bookorbit_sync_now",
|
|
{ category = "none", event = "BookOrbitSyncNow", title = _("BookOrbit: sync all books"), general = true })
|
|
Dispatcher:registerAction("bookorbit_sync_book",
|
|
{ category = "none", event = "BookOrbitSyncBook", title = _("BookOrbit: sync this book"), reader = true })
|
|
Dispatcher:registerAction("bookorbit_push_progress",
|
|
{ category = "none", event = "BookOrbitPushProgress", title = _("BookOrbit: push progress"), reader = true })
|
|
Dispatcher:registerAction("bookorbit_pull_progress",
|
|
{ category = "none", event = "BookOrbitPullProgress", title = _("BookOrbit: pull progress"), reader = true, separator = true })
|
|
end
|
|
|
|
function BookOrbit:onReaderReady()
|
|
if self.settings.auto_sync then
|
|
UIManager:nextTick(function()
|
|
self:getProgress(true, false)
|
|
end)
|
|
if self.settings.annotation_sync then
|
|
UIManager:scheduleIn(2, function()
|
|
self:exchangeAnnotationsForOpenBook()
|
|
end)
|
|
end
|
|
end
|
|
self:registerEvents()
|
|
|
|
self.last_page = self.ui:getCurrentPage()
|
|
end
|
|
|
|
-- Two-way annotation pull/push for the open book. Runs once per book open;
|
|
-- safe to call again manually, guarded against concurrent syncs.
|
|
function BookOrbit:exchangeAnnotationsForOpenBook()
|
|
if self.annotation_exchange_running then return end
|
|
if not self:isLoggedIn() or not self.ui or not self.ui.document then return end
|
|
if BookOrbitBookSync.isRunning() or BookOrbitSweep.isRunning() then return end
|
|
|
|
local digest = self:getDocumentDigest()
|
|
if not digest then return end
|
|
local state = BookOrbitState.open()
|
|
if not state:getBook(digest) then
|
|
-- Unknown or unmatched book: the close-path snapshot sync matches it.
|
|
return
|
|
end
|
|
|
|
self.annotation_exchange_running = true
|
|
local ok, result, err = pcall(BookOrbitAnnotations.exchangeOpenBook, {
|
|
client = self:newClient(),
|
|
state = state,
|
|
digest = digest,
|
|
ui = self.ui,
|
|
})
|
|
state:flush()
|
|
self.annotation_exchange_running = false
|
|
|
|
if not ok then
|
|
logger.err("BookOrbit: annotation exchange error:", result)
|
|
elseif result then
|
|
local touched = (result.applied or 0) + (result.deleted or 0)
|
|
if touched > 0 then
|
|
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"), touched))
|
|
end
|
|
elseif err and err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
|
|
logger.dbg("BookOrbit: annotation exchange skipped:", err)
|
|
end
|
|
end
|
|
|
|
-- Menu
|
|
|
|
local function getNameStrategy(strategy)
|
|
if strategy == SYNC_STRATEGY.PROMPT then
|
|
return _("Prompt")
|
|
elseif strategy == SYNC_STRATEGY.SILENT then
|
|
return _("Silently")
|
|
else
|
|
return _("Never")
|
|
end
|
|
end
|
|
|
|
function BookOrbit:strategyMenu(getter, setter)
|
|
local function item(text, value)
|
|
return {
|
|
text = text,
|
|
checked_func = function() return getter() == value end,
|
|
callback = function() setter(value) end,
|
|
}
|
|
end
|
|
return {
|
|
item(_("Silently"), SYNC_STRATEGY.SILENT),
|
|
item(_("Prompt"), SYNC_STRATEGY.PROMPT),
|
|
item(_("Never"), SYNC_STRATEGY.DISABLE),
|
|
}
|
|
end
|
|
|
|
function BookOrbit:hasKnownUpdate()
|
|
return BookOrbitUpdater.isNewer(self.settings.update_latest_version, PLUGIN_VERSION) == true
|
|
end
|
|
|
|
function BookOrbit:updateCheckMenuText()
|
|
if self:hasKnownUpdate() then
|
|
return T(_("Plugin update available: v%1 -> v%2"), PLUGIN_VERSION, self.settings.update_latest_version)
|
|
end
|
|
if self:isLoggedIn() then
|
|
return T(_("Installed plugin: v%1 (Check for update)"), PLUGIN_VERSION)
|
|
end
|
|
return T(_("Installed plugin: v%1 (Login required)"), PLUGIN_VERSION)
|
|
end
|
|
|
|
function BookOrbit:addToMainMenu(menu_items)
|
|
menu_items.bookorbit = {
|
|
text = _("BookOrbit"),
|
|
-- Fallback placement only: BookOrbitMenuPin normally pins this entry
|
|
-- right below calibre on the first page of the Tools menu.
|
|
sorting_hint = "tools",
|
|
sub_item_table = {
|
|
{
|
|
text = _("BookOrbit server address"),
|
|
keep_menu_open = true,
|
|
callback = function()
|
|
self:setServerAddress()
|
|
end,
|
|
},
|
|
{
|
|
text_func = function()
|
|
return self.settings.userkey and _("Logout") or _("Login")
|
|
end,
|
|
enabled_func = function()
|
|
return self.settings.server_url ~= nil
|
|
end,
|
|
keep_menu_open = true,
|
|
callback_func = function()
|
|
if self.settings.userkey then
|
|
return function(menu)
|
|
self:logout(menu)
|
|
end
|
|
else
|
|
return function(menu)
|
|
self:login(menu)
|
|
end
|
|
end
|
|
end,
|
|
separator = true,
|
|
},
|
|
{
|
|
text = _("Browse library"),
|
|
enabled_func = function()
|
|
return self:isLoggedIn()
|
|
end,
|
|
callback = function()
|
|
self:browseCatalog()
|
|
end,
|
|
separator = true,
|
|
},
|
|
{
|
|
text = _("Auto sync this book"),
|
|
checked_func = function() return self.settings.auto_sync end,
|
|
help_text = _([[Pulls progress when a book is opened; pushes progress, highlights, status, rating and reading time when it is closed and on suspend.]]),
|
|
callback = function()
|
|
self:onBookOrbitToggleAutoSync(nil, true)
|
|
end,
|
|
},
|
|
{
|
|
text = _("Two-way highlight sync"),
|
|
checked_func = function() return self.settings.annotation_sync end,
|
|
help_text = _([[Also applies highlights, notes and deletions made in BookOrbit to this device: on book open, after the manual book sync, and during the full sweep for closed books. Turning this off keeps uploads only.]]),
|
|
callback = function()
|
|
self.settings.annotation_sync = not self.settings.annotation_sync
|
|
end,
|
|
},
|
|
{
|
|
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 SpinWidget = require("ui/widget/spinwidget")
|
|
local items = SpinWidget:new{
|
|
text = _([[This value determines how many page turns it takes to push book progress.
|
|
If set to 0, updating progress based on page turns will be disabled.]]),
|
|
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 = _("Number of pages before update"),
|
|
default_value = 10,
|
|
callback = function(spin)
|
|
self:setPagesBeforeUpdate(spin.value)
|
|
if touchmenu_instance then touchmenu_instance:updateItems() end
|
|
end,
|
|
}
|
|
UIManager:show(items)
|
|
end,
|
|
},
|
|
{
|
|
text = _("Sync behavior"),
|
|
sub_item_table = {
|
|
{
|
|
text_func = function()
|
|
return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))
|
|
end,
|
|
sub_item_table = self:strategyMenu(
|
|
function() return self.settings.sync_forward end,
|
|
function(value) self.settings.sync_forward = value end
|
|
),
|
|
},
|
|
{
|
|
text_func = function()
|
|
return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))
|
|
end,
|
|
sub_item_table = self:strategyMenu(
|
|
function() return self.settings.sync_backward end,
|
|
function(value) self.settings.sync_backward = value end
|
|
),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
text = _("Sync this book now"),
|
|
enabled_func = function()
|
|
return self:isLoggedIn() and self.ui.document ~= nil
|
|
and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
|
|
end,
|
|
callback = function()
|
|
self:onBookOrbitSyncBook()
|
|
end,
|
|
},
|
|
{
|
|
text = _("Sync all books now"),
|
|
enabled_func = function()
|
|
return self:isLoggedIn() and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
|
|
end,
|
|
callback = function()
|
|
self:startSweep()
|
|
end,
|
|
separator = true,
|
|
},
|
|
{
|
|
text_func = function()
|
|
return self:updateCheckMenuText()
|
|
end,
|
|
enabled_func = function()
|
|
return self:isLoggedIn()
|
|
and not BookOrbitSweep.isRunning()
|
|
and not BookOrbitBookSync.isRunning()
|
|
end,
|
|
keep_menu_open = true,
|
|
callback = function()
|
|
self:checkForUpdate()
|
|
end,
|
|
separator = true,
|
|
},
|
|
{
|
|
text_func = function()
|
|
local status = BookOrbitSweep.syncStatus()
|
|
local when = (status.lastSweepAt == 0) and _("never")
|
|
or os.date("%Y-%m-%d %H:%M", status.lastSweepAt)
|
|
if status.unmatched > 0 then
|
|
return T(_("Last sync: %1 (%2 linked, %3 unmatched)"), when, status.matched, status.unmatched)
|
|
elseif status.matched > 0 then
|
|
return T(_("Last sync: %1 (%2 linked)"), when, status.matched)
|
|
end
|
|
return T(_("Last sync: %1"), when)
|
|
end,
|
|
enabled = false,
|
|
separator = true,
|
|
},
|
|
},
|
|
}
|
|
end
|
|
|
|
function BookOrbit:browseCatalog()
|
|
if not self:isLoggedIn() then
|
|
promptLogin()
|
|
return
|
|
end
|
|
|
|
NetworkMgr:runWhenConnected(function()
|
|
self.catalog_browser = BookOrbitCatalog:new{
|
|
title = _("BookOrbit"),
|
|
api = self:apiOpts(),
|
|
settings = self.settings,
|
|
save_settings = function()
|
|
G_reader_settings:flush()
|
|
end,
|
|
_manager = self,
|
|
close_callback = function()
|
|
UIManager:close(self.catalog_browser)
|
|
self.catalog_browser = nil
|
|
end,
|
|
}
|
|
UIManager:show(self.catalog_browser)
|
|
end)
|
|
end
|
|
|
|
function BookOrbit:setServerAddress()
|
|
local dialog
|
|
dialog = InputDialog:new{
|
|
title = _("BookOrbit server address"),
|
|
input = self.settings.server_url or "https://",
|
|
input_hint = "https://bookorbit.example.com",
|
|
buttons = {
|
|
{
|
|
{
|
|
text = _("Cancel"),
|
|
id = "close",
|
|
callback = function()
|
|
UIManager:close(dialog)
|
|
end,
|
|
},
|
|
{
|
|
text = _("OK"),
|
|
is_enter_default = true,
|
|
callback = function()
|
|
local normalized = BookOrbitApi.normalizeServerUrl(dialog:getInputText())
|
|
self.settings.server_url = normalized
|
|
UIManager:close(dialog)
|
|
if normalized then
|
|
Notification:notify(T(_("BookOrbit server set to %1"), normalized))
|
|
end
|
|
end,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
UIManager:show(dialog)
|
|
dialog:onShowKeyboard()
|
|
end
|
|
|
|
function BookOrbit:login(menu)
|
|
if NetworkMgr:willRerunWhenOnline(function() self:login(menu) end) then
|
|
return
|
|
end
|
|
|
|
local dialog
|
|
dialog = MultiInputDialog:new{
|
|
title = self.title,
|
|
fields = {
|
|
{
|
|
text = self.settings.username,
|
|
hint = "username",
|
|
},
|
|
{
|
|
hint = "password",
|
|
text_type = "password",
|
|
},
|
|
},
|
|
description = _("Credentials are created in BookOrbit web settings under Settings, KOReader."),
|
|
buttons = {
|
|
{
|
|
{
|
|
text = _("Cancel"),
|
|
id = "close",
|
|
callback = function()
|
|
UIManager:close(dialog)
|
|
end,
|
|
},
|
|
{
|
|
text = _("Login"),
|
|
is_enter_default = true,
|
|
callback = function()
|
|
local username, password = unpack(dialog:getFields())
|
|
username = util.trim(username or "")
|
|
if username == "" or not password or password == "" then
|
|
UIManager:show(InfoMessage:new{ text = _("Please enter a username and password."), timeout = 2 })
|
|
return
|
|
end
|
|
UIManager:close(dialog)
|
|
UIManager:scheduleIn(0.5, function()
|
|
self:doLogin(username, password, menu)
|
|
end)
|
|
UIManager:show(InfoMessage:new{ text = _("Logging in. Please wait."), timeout = 1 })
|
|
end,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
UIManager:show(dialog)
|
|
dialog:onShowKeyboard()
|
|
end
|
|
|
|
function BookOrbit:doLogin(username, password, menu)
|
|
Device:setIgnoreInput(true)
|
|
local userkey = md5(password)
|
|
local client = BookOrbitApi.new{
|
|
server_url = self.settings.server_url,
|
|
username = username,
|
|
userkey = userkey,
|
|
device_id = self.device_id,
|
|
device_model = Device.model,
|
|
plugin_version = PLUGIN_VERSION,
|
|
}
|
|
local body, err = client:auth()
|
|
Device:setIgnoreInput(false)
|
|
|
|
if body then
|
|
self.settings.username = username
|
|
self.settings.userkey = userkey
|
|
if menu then menu:updateItems() end
|
|
UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") })
|
|
UIManager:scheduleIn(1, function()
|
|
self:maybeCheckForUpdate(false)
|
|
end)
|
|
elseif err == 401 or err == 403 then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Login failed. Create or check your KOReader credentials in BookOrbit web settings."),
|
|
})
|
|
else
|
|
UIManager:show(InfoMessage:new{ text = T(_("Could not reach the BookOrbit server: %1"), tostring(err)) })
|
|
end
|
|
end
|
|
|
|
function BookOrbit:logout(menu)
|
|
self.settings.userkey = nil
|
|
if menu then menu:updateItems() end
|
|
end
|
|
|
|
function BookOrbit: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 BookOrbit:setPagesBeforeUpdate(value)
|
|
self.settings.pages_before_update = value
|
|
end
|
|
|
|
function BookOrbit:schedulePeriodicPush()
|
|
UIManager:unschedule(self.periodic_push_task)
|
|
-- A sizable delay debounces nicely while skimming.
|
|
UIManager:scheduleIn(10, self.periodic_push_task)
|
|
self.periodic_push_scheduled = true
|
|
end
|
|
|
|
-- Live progress sync (kosync mirror)
|
|
|
|
function BookOrbit: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 BookOrbit:getLastProgress()
|
|
if self.ui.document.info.has_pages then
|
|
return self.ui.paging:getLastProgress()
|
|
else
|
|
return self.ui.rolling:getLastProgress()
|
|
end
|
|
end
|
|
|
|
-- Always the binary partial MD5: BookOrbit matches on the scanner-computed
|
|
-- partial MD5 of the file, so the filename checksum method does not exist here.
|
|
function BookOrbit:getDocumentDigest()
|
|
local digest = self.ui.doc_settings:readSetting("partial_md5_checksum")
|
|
if digest then return digest end
|
|
|
|
local file = self.ui.document.file
|
|
if not file then return nil end
|
|
local ok, computed = pcall(util.partialMD5, file)
|
|
if not ok or not computed then return nil end
|
|
self.ui.doc_settings:saveSetting("partial_md5_checksum", computed)
|
|
return computed
|
|
end
|
|
|
|
function BookOrbit:syncToProgress(progress)
|
|
logger.dbg("BookOrbit: syncing to progress", progress)
|
|
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 BookOrbit:updateProgress(ensure_networking, interactive, on_suspend)
|
|
if not self:isLoggedIn() then
|
|
if interactive then promptLogin() end
|
|
return
|
|
end
|
|
if not self.ui or not self.ui.document then return end
|
|
|
|
local now = UIManager:getElapsedTimeSinceBoot()
|
|
if not interactive and now - self.push_timestamp <= API_CALL_DEBOUNCE_DELAY then
|
|
logger.dbg("BookOrbit: push debounced")
|
|
return
|
|
end
|
|
|
|
if ensure_networking and NetworkMgr:willRerunWhenOnline(function() self:updateProgress(ensure_networking, interactive, on_suspend) end) then
|
|
return
|
|
end
|
|
|
|
local digest = self:getDocumentDigest()
|
|
if not digest then return end
|
|
|
|
local client = self:newClient()
|
|
local body, err = client:updateProgress(digest, self:getLastPercent(), self:getLastProgress(), os.time())
|
|
if interactive then
|
|
if body then
|
|
UIManager:show(InfoMessage:new{ text = _("Progress has been pushed to BookOrbit."), timeout = 3 })
|
|
if not on_suspend then self:maybeCheckForUpdate(false) end
|
|
else
|
|
showSyncError()
|
|
end
|
|
elseif not body then
|
|
logger.dbg("BookOrbit: push failed:", err)
|
|
elseif not on_suspend then
|
|
self:maybeCheckForUpdate(false)
|
|
end
|
|
|
|
if on_suspend and Device:hasWifiManager() then
|
|
NetworkMgr:disableWifi()
|
|
end
|
|
|
|
self.push_timestamp = now
|
|
end
|
|
|
|
function BookOrbit:getProgress(ensure_networking, interactive)
|
|
if not self:isLoggedIn() then
|
|
if interactive then promptLogin() end
|
|
return
|
|
end
|
|
if not self.ui or not self.ui.document then return end
|
|
|
|
local now = UIManager:getElapsedTimeSinceBoot()
|
|
if not interactive and now - self.pull_timestamp <= API_CALL_DEBOUNCE_DELAY then
|
|
logger.dbg("BookOrbit: pull debounced")
|
|
return
|
|
end
|
|
|
|
if ensure_networking and NetworkMgr:willRerunWhenOnline(function() self:getProgress(ensure_networking, interactive) end) then
|
|
return
|
|
end
|
|
|
|
local digest = self:getDocumentDigest()
|
|
if not digest then return end
|
|
|
|
local client = self:newClient()
|
|
local body, err = client:getProgress(digest)
|
|
self.pull_timestamp = now
|
|
|
|
if not body then
|
|
if interactive then showSyncError() end
|
|
logger.dbg("BookOrbit: pull failed:", err)
|
|
return
|
|
end
|
|
self:maybeCheckForUpdate(false)
|
|
|
|
if not body.percentage then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("No progress found for this document."), timeout = 3 })
|
|
end
|
|
return
|
|
end
|
|
|
|
if body.device == Device.model and body.device_id == self.device_id then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("Latest progress is coming from this device."), timeout = 3 })
|
|
end
|
|
return
|
|
end
|
|
|
|
body.percentage = Math.roundPercent(body.percentage)
|
|
local progress = self:getLastProgress()
|
|
local percentage = self:getLastPercent()
|
|
|
|
if percentage == body.percentage or body.progress == progress then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{ text = _("The progress has already been synchronized."), timeout = 3 })
|
|
end
|
|
return
|
|
end
|
|
|
|
if interactive then
|
|
self:syncToProgress(body.progress)
|
|
UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 })
|
|
return
|
|
end
|
|
|
|
local self_older
|
|
if body.timestamp ~= nil then
|
|
self_older = (body.timestamp > self.last_page_turn_timestamp)
|
|
else
|
|
self_older = (body.percentage > percentage)
|
|
end
|
|
|
|
local strategy = self_older and self.settings.sync_forward or self.settings.sync_backward
|
|
if strategy == SYNC_STRATEGY.SILENT then
|
|
self:syncToProgress(body.progress)
|
|
UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 })
|
|
elseif strategy == SYNC_STRATEGY.PROMPT then
|
|
local template = self_older and _("Sync to latest location %1% from device '%2'?")
|
|
or _("Sync to previous location %1% from device '%2'?")
|
|
UIManager:show(ConfirmBox:new{
|
|
text = T(template, Math.round(body.percentage * 100), body.device),
|
|
ok_callback = function()
|
|
self:syncToProgress(body.progress)
|
|
end,
|
|
})
|
|
end
|
|
end
|
|
|
|
-- Manual sync triggers
|
|
|
|
function BookOrbit:startSweep()
|
|
if not self:isLoggedIn() then
|
|
promptLogin()
|
|
return
|
|
end
|
|
if BookOrbitBookSync.isRunning() then
|
|
UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 })
|
|
return
|
|
end
|
|
|
|
local sweep_opts = {
|
|
api = self:apiOpts(),
|
|
interactive = true,
|
|
annotation_sync = self.settings.annotation_sync,
|
|
on_finish = function(err)
|
|
if not err then self:maybeCheckForUpdate(false) end
|
|
end,
|
|
}
|
|
if NetworkMgr:willRerunWhenOnline(function() BookOrbitSweep.run(sweep_opts) end) then
|
|
return
|
|
end
|
|
BookOrbitSweep.run(sweep_opts)
|
|
end
|
|
|
|
function BookOrbit:onBookOrbitSyncBook()
|
|
if not self:isLoggedIn() then
|
|
promptLogin()
|
|
return
|
|
end
|
|
if not self.ui or not self.ui.document then
|
|
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 })
|
|
return
|
|
end
|
|
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
|
|
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
|
|
return
|
|
end
|
|
|
|
local snap = BookOrbitBookSync.capture(self)
|
|
if not snap then
|
|
UIManager:show(InfoMessage:new{ text = _("Could not read this book's data."), timeout = 2 })
|
|
return
|
|
end
|
|
|
|
local api_opts = self:apiOpts()
|
|
local run = function()
|
|
BookOrbitBookSync.run{ api = api_opts, snap = snap, reason = "manual", interactive = true, plugin = self,
|
|
annotation_sync = self.settings.annotation_sync }
|
|
end
|
|
if NetworkMgr:willRerunWhenOnline(run) then
|
|
return
|
|
end
|
|
run()
|
|
end
|
|
|
|
-- Events
|
|
|
|
function BookOrbit:_onCloseDocument()
|
|
logger.dbg("BookOrbit: onCloseDocument")
|
|
self.onResume = nil
|
|
self.onSuspend = nil
|
|
UIManager:unschedule(self.periodic_push_task)
|
|
self.periodic_push_scheduled = false
|
|
|
|
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
|
|
logger.dbg("BookOrbit: close sync skipped, another sync is running")
|
|
return
|
|
end
|
|
|
|
-- Snapshot now: reader objects die after this handler returns. ReaderUI
|
|
-- already flushed the sidecar and statistics before broadcasting
|
|
-- CloseDocument, so memory, sidecar and stats DB agree at this point.
|
|
local snap = BookOrbitBookSync.capture(self)
|
|
if not snap then return end
|
|
|
|
local api_opts = self:apiOpts()
|
|
NetworkMgr:goOnlineToRun(function()
|
|
BookOrbitBookSync.run{ api = api_opts, snap = snap, reason = "close", interactive = false,
|
|
annotation_sync = self.settings.annotation_sync }
|
|
end)
|
|
end
|
|
|
|
function BookOrbit:_onPageUpdate(page)
|
|
if page == nil then return end
|
|
if self.last_page ~= page then
|
|
self.last_page = page
|
|
self.last_page_turn_timestamp = os.time()
|
|
self.page_update_counter = self.page_update_counter + 1
|
|
-- A pending periodic push is re-delayed on every page turn so it
|
|
-- only fires once the reader is actually idle.
|
|
if self.periodic_push_scheduled
|
|
or (self.settings.pages_before_update or 0) > 0 and self.page_update_counter >= self.settings.pages_before_update then
|
|
self:schedulePeriodicPush()
|
|
end
|
|
end
|
|
end
|
|
|
|
function BookOrbit:_onResume()
|
|
logger.dbg("BookOrbit: 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 BookOrbit:_onSuspend()
|
|
logger.dbg("BookOrbit: onSuspend")
|
|
UIManager:unschedule(self.periodic_push_task)
|
|
self.periodic_push_scheduled = false
|
|
|
|
if not self:isLoggedIn() then return end
|
|
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then return end
|
|
|
|
local snap = BookOrbitBookSync.capture(self)
|
|
if not snap then return end
|
|
|
|
local on_finish
|
|
if Device:hasWifiManager() then
|
|
on_finish = function() NetworkMgr:disableWifi() end
|
|
end
|
|
local api_opts = self:apiOpts()
|
|
-- Synchronous: the device is about to sleep, so the uploads must not be
|
|
-- parked on the scheduler (they would run after resume and then kill
|
|
-- wifi mid-read via on_finish).
|
|
local run = function()
|
|
BookOrbitBookSync.run{
|
|
api = api_opts, snap = snap, reason = "suspend",
|
|
interactive = false, synchronous = true, plugin = self, on_finish = on_finish,
|
|
annotation_sync = self.settings.annotation_sync,
|
|
}
|
|
end
|
|
if NetworkMgr:willRerunWhenOnline(run) then
|
|
return
|
|
end
|
|
run()
|
|
end
|
|
|
|
function BookOrbit:_onNetworkConnected()
|
|
logger.dbg("BookOrbit: onNetworkConnected")
|
|
UIManager:scheduleIn(0.5, function()
|
|
self:getProgress(false, false)
|
|
self:maybeCheckForUpdate(false)
|
|
end)
|
|
end
|
|
|
|
function BookOrbit:_onNetworkDisconnecting()
|
|
logger.dbg("BookOrbit: onNetworkDisconnecting")
|
|
self:updateProgress(false, false)
|
|
end
|
|
|
|
function BookOrbit:onBookOrbitPushProgress()
|
|
self:updateProgress(true, true)
|
|
end
|
|
|
|
function BookOrbit:onBookOrbitPullProgress()
|
|
self:getProgress(true, true)
|
|
end
|
|
|
|
function BookOrbit:onBookOrbitSyncNow()
|
|
self:startSweep()
|
|
end
|
|
|
|
function BookOrbit:onBookOrbitToggleAutoSync(toggle, from_menu)
|
|
if toggle == self.settings.auto_sync then
|
|
return true
|
|
end
|
|
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 = _("You will have to switch the 'Action when Wi-Fi is off' Network setting to 'turn on' to be able to enable this feature!"),
|
|
})
|
|
return true
|
|
end
|
|
self.settings.auto_sync = not self.settings.auto_sync
|
|
self:registerEvents()
|
|
|
|
if self.settings.auto_sync then
|
|
self:getProgress(true, true)
|
|
else
|
|
UIManager:unschedule(self.periodic_push_task)
|
|
self.periodic_push_scheduled = false
|
|
self.page_update_counter = 0
|
|
if from_menu then
|
|
self:updateProgress(true, true)
|
|
end
|
|
end
|
|
|
|
if not from_menu then
|
|
Notification:notify(self.settings.auto_sync and _("BookOrbit auto progress sync: on") or _("BookOrbit auto progress sync: off"))
|
|
end
|
|
return true
|
|
end
|
|
|
|
function BookOrbit: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 BookOrbit:onCloseWidget()
|
|
if self.periodic_push_task then
|
|
UIManager:unschedule(self.periodic_push_task)
|
|
self.periodic_push_task = nil
|
|
end
|
|
end
|
|
|
|
function BookOrbit:checkForUpdate()
|
|
if not self:isLoggedIn() then
|
|
promptLogin()
|
|
return
|
|
end
|
|
NetworkMgr:runWhenConnected(function()
|
|
self:doCheckForUpdate()
|
|
end)
|
|
end
|
|
|
|
function BookOrbit:maybeCheckForUpdate(interactive)
|
|
if not self:isLoggedIn() or self._checking_update or self._updating then return end
|
|
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then return end
|
|
|
|
local now = os.time()
|
|
if not interactive and now - (self.settings.update_check_last_at or 0) < UPDATE_CHECK_INTERVAL then
|
|
return
|
|
end
|
|
|
|
self._checking_update = true
|
|
local body, err = self:newClient():getPluginVersion()
|
|
self._checking_update = false
|
|
|
|
if not body then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = T(_("Could not check for update: %1"), tostring(err or "network error")),
|
|
timeout = 4,
|
|
})
|
|
else
|
|
logger.dbg("BookOrbit: plugin update check failed:", err)
|
|
end
|
|
return
|
|
end
|
|
|
|
self:handleUpdateVersionResponse(body, interactive, interactive or self.catalog_browser == nil)
|
|
end
|
|
|
|
function BookOrbit:doCheckForUpdate()
|
|
local checking = InfoMessage:new{ text = _("Checking for update...") }
|
|
UIManager:show(checking)
|
|
|
|
local body, err = self:newClient():getPluginVersion()
|
|
UIManager:close(checking)
|
|
|
|
if not body then
|
|
UIManager:show(InfoMessage:new{
|
|
text = T(_("Could not check for update: %1"), tostring(err or "network error")),
|
|
timeout = 4,
|
|
})
|
|
return
|
|
end
|
|
|
|
self:handleUpdateVersionResponse(body, true, true)
|
|
end
|
|
|
|
function BookOrbit:handleUpdateVersionResponse(body, interactive, prompt_allowed)
|
|
local server_ver = body.serverVersion or "unknown"
|
|
local plugin_latest = body.pluginVersion
|
|
self.settings.update_check_last_at = os.time()
|
|
|
|
if type(plugin_latest) ~= "string" or plugin_latest == "unknown" then
|
|
G_reader_settings:flush()
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Could not determine the latest plugin version from the server."),
|
|
timeout = 4,
|
|
})
|
|
end
|
|
return
|
|
end
|
|
|
|
self.settings.update_latest_version = plugin_latest
|
|
G_reader_settings:flush()
|
|
|
|
if BookOrbitUpdater.isNewer(plugin_latest, PLUGIN_VERSION) ~= true then
|
|
if interactive then
|
|
UIManager:show(InfoMessage:new{
|
|
text = T(_("Plugin is up to date (v%1).\nServer: v%2"), PLUGIN_VERSION, server_ver),
|
|
timeout = 4,
|
|
})
|
|
end
|
|
return
|
|
end
|
|
|
|
if not prompt_allowed then
|
|
return
|
|
end
|
|
|
|
if not interactive and self.settings.update_dismissed_version == plugin_latest then
|
|
return
|
|
end
|
|
if not interactive then
|
|
self.settings.update_dismissed_version = plugin_latest
|
|
G_reader_settings:flush()
|
|
end
|
|
|
|
UIManager:show(ConfirmBox:new{
|
|
text = T(_("Update available: v%1 -> v%2\nServer: v%3\n\nDownload and apply the update now?"),
|
|
PLUGIN_VERSION, plugin_latest, server_ver),
|
|
ok_text = _("Update"),
|
|
ok_callback = function()
|
|
self:applyUpdate(plugin_latest)
|
|
end,
|
|
})
|
|
end
|
|
|
|
function BookOrbit:applyUpdate(new_version)
|
|
if self._updating then
|
|
UIManager:show(InfoMessage:new{ text = _("An update is already in progress."), timeout = 3 })
|
|
return
|
|
end
|
|
self._updating = true
|
|
self:_doApplyUpdate(new_version)
|
|
self._updating = false
|
|
end
|
|
|
|
function BookOrbit:_doApplyUpdate(new_version)
|
|
if not self.path then
|
|
UIManager:show(InfoMessage:new{
|
|
text = _("Cannot determine plugin path. Update aborted."),
|
|
timeout = 3,
|
|
})
|
|
return
|
|
end
|
|
|
|
local progress = InfoMessage:new{ text = T(_("Downloading BookOrbit v%1..."), new_version) }
|
|
UIManager:show(progress)
|
|
UIManager:forceRePaint()
|
|
|
|
local ok, err = BookOrbitUpdater.apply(self:newClient(), self.path)
|
|
UIManager:close(progress)
|
|
|
|
if not ok then
|
|
local msg
|
|
if type(err) == "number" and err == 503 then
|
|
msg = _("Update failed: the server does not have the plugin package available.")
|
|
else
|
|
msg = T(_("Update failed: %1"), tostring(err or "unknown error"))
|
|
end
|
|
UIManager:show(InfoMessage:new{ text = msg, timeout = 6 })
|
|
return
|
|
end
|
|
|
|
UIManager:show(ConfirmBox:new{
|
|
text = T(_("BookOrbit v%1 installed. KOReader needs to restart to apply the update."), new_version),
|
|
ok_text = _("Restart now"),
|
|
ok_callback = function()
|
|
-- Exit code 85 triggers an app restart on Kobo and most e-ink platforms.
|
|
-- On other platforms KOReader exits cleanly; reopen it to apply the update.
|
|
UIManager:quit(UIManager.RETURN_CODE_REBOOT or 85)
|
|
end,
|
|
cancel_text = _("Later"),
|
|
})
|
|
end
|
|
|
|
return BookOrbit
|