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 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 = 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) 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 = {} 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, 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: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: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 book_data = { uuid = book_uuid, sha256 = file_sha256, title = title, authors = authors, percentage = percentage, 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