From 9d2310d87ea8b8168bdf97de301ab3555c2eef54 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 29 May 2026 20:27:50 -0400 Subject: [PATCH] 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 --- BookhoardAPI.lua | 139 +++++++ _meta.lua | 5 + main.lua | 973 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1117 insertions(+) create mode 100644 BookhoardAPI.lua create mode 100644 _meta.lua create mode 100644 main.lua diff --git a/BookhoardAPI.lua b/BookhoardAPI.lua new file mode 100644 index 0000000..8aedcec --- /dev/null +++ b/BookhoardAPI.lua @@ -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 diff --git a/_meta.lua b/_meta.lua new file mode 100644 index 0000000..9e94607 --- /dev/null +++ b/_meta.lua @@ -0,0 +1,5 @@ +local _ = require("gettext") +return { + fullname = _("Bookhoard sync"), + description = _([[Synchronizes reading progress, bookmarks, highlights, and notes with your Bookhoard server.]]), +} diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..2afc83f --- /dev/null +++ b/main.lua @@ -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