local BD = require("ui/bidi") local ButtonDialog = require("ui/widget/buttondialog") local ConfirmBox = require("ui/widget/confirmbox") local DataStorage = require("datastorage") local Device = require("device") local Dispatcher = require("dispatcher") local Event = require("ui/event") local InfoMessage = require("ui/widget/infomessage") local Math = require("optmath") local MultiInputDialog = require("ui/widget/multiinputdialog") local NetworkMgr = require("ui/network/manager") local SQ3 = require("lua-ljsqlite3/init") local SyncService = require("frontend/apps/cloudstorage/syncservice") local UIManager = require("ui/uimanager") local WidgetContainer = require("ui/widget/container/widgetcontainer") local logger = require("logger") local md5 = require("ffi/sha2").md5 local random = require("random") local time = require("ui/time") local util = require("util") local T = require("ffi/util").template local _ = require("gettext") -- Current DB schema version local DB_SCHEMA_VERSION = 20250120 local db_location = DataStorage:getSettingsDir() .. "/kosynccloud.sqlite3" if G_reader_settings:hasNot("device_id") then G_reader_settings:saveSetting("device_id", random.uuid()) end local KOSyncCloud = WidgetContainer:extend { name = "kosynccloud", --is_doc = false, last_page = nil, is_enabled = nil, } KOSyncCloud.default_settings = { is_enabled = true, } function KOSyncCloud:init() self.last_page = -1 self.device_id = G_reader_settings:readSetting("device_id") self.settings = G_reader_settings:readSetting("kosynccloud", self.default_settings) self:checkInitDatabase() self.ui.menu:registerToMainMenu(self) end function KOSyncCloud:addToMainMenu(menu_items) menu_items.kosynccloud = { text = _("Reading Progress Cloud"), sorting_hint = "more_tools", { text = _("Cloud sync settings"), callback = function(touchmenu_instance) local server = self.settings.sync_server local edit_cb = function() local sync_settings = SyncService:new {} sync_settings.onClose = function(this) UIManager:close(this) end sync_settings.onConfirm = function(sv) if server and (server.type ~= sv.type or server.url ~= sv.url or server.address ~= sv.address) then SyncService.removeLastSyncDB(db_location) end self.settings.sync_server = sv touchmenu_instance:updateItems() end UIManager:show(sync_settings) end if not server then edit_cb() return end local dialogue local delete_button = { text = _("Delete"), callback = function() UIManager:close(dialogue) UIManager:show(ConfirmBox:new { text = _("Delete server info?"), cancel_text = _("Cancel"), cancel_callback = function() return end, ok_text = _("Delete"), ok_callback = function() self.settings.sync_server = nil SyncService.removeLastSyncDB(db_location) touchmenu_instance:updateItems() end, }) end, } local edit_button = { text = _("Edit"), callback = function() UIManager:close(dialogue) edit_cb() end } local close_button = { text = _("Close"), callback = function() UIManager:close(dialogue) end } local type = server.type == "dropbox" and " (DropBox)" or " (WebDAV)" dialogue = ButtonDialog:new { title = T(_("Cloud storage:\n%1\n\nFolder path:\n%2\n\nSet up the same cloud folder on each device to sync across your devices."), server.name .. " " .. type, SyncService.getReadablePath(server)), buttons = { { delete_button, edit_button, close_button } }, } UIManager:show(dialogue) end, enabled_func = function() return self.settings.is_enabled end, keep_menu_open = true, separator = true, }, { text = _("Synchronize now"), callback = function() self:onSyncProgress() end, enabled_func = function() return self:canSync() end, keep_menu_open = true, separator = true, }, } end function KOSyncCloud:canSync() return self.settings.sync_server ~= nil and self.settings.is_enabled end function KOSyncCloud:checkInitDatabase() local conn = SQ3.open(db_location) if not conn:exec("PRAGMA table_info('progresses');") then self:createDB(conn) end conn:close() end function KOSyncCloud:createDB(conn) -- Make it WAL, if possible if Device:canUseWAL() then conn:exec("PRAGMA journal_mode=WAL;") else conn:exec("PRAGMA journal_mode=TRUNCATE;") end local sql_stmt = [[ CREATE TABLE IF NOT EXISTS progresses ( id INTEGER PRIMARY KEY AUTOINCREMENT, document TEXT UNIQUE, progress TEXT, percentage REAL, device TEXT, device_id TEXT, timestamp INTEGER ); ]] conn:exec(sql_stmt) -- DB schema version conn:exec(string.format("PRAGMA user_version=%d;", DB_SCHEMA_VERSION)) end function KOSyncCloud: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 KOSyncCloud:getLastProgress() if self.ui.document.info.has_pages then return self.ui.paging:getLastProgress() else return self.ui.rolling:getLastProgress() end end function KOSyncCloud:getDocumentDigest() return self.ui.doc_settings:readSetting("partial_md5_checksum") end function KOSyncCloud:getDocValuesFromDb() local doc_digest = self:getDocumentDigest() local conn = SQ3.open(db_location) local stmt = conn:prepare("SELECT 1 FROM progresses WHERE document = ?;") local result = stmt:reset():bind(doc_digest):step() local progress, percentage, device_id = nil, nil, nil if result ~= nil then local sql_stmt = "SELECT progress, percentage, device_id FROM progresses WHERE document = '%s';" progress, percentage, device_id = conn:rowexec(string.format(sql_stmt, doc_digest)) end logger.dbg(progress, percentage, device_id) conn:close() return progress, percentage, device_id end function KOSyncCloud:showSyncedMessage() UIManager:show(InfoMessage:new { text = _("Progress has been synchronized."), timeout = 3, }) end function KOSyncCloud:syncToProgress() local progress, percentage, device_id = self:getDocValuesFromDb() if device_id ~= nil and device_id ~= self.device_id then if percentage ~= self:getLastPercent() then logger.dbg("KOSyncCloud: [Sync] progress to", 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 self:showSyncedMessage() end end end function KOSyncCloud:onReaderReady() UIManager:nextTick(function() self:syncToProgress() end) self:registerEvents() self.last_page = self.ui:getCurrentPage() end function KOSyncCloud.onSync(local_path, cached_path, income_path) -- TODO: Implement sync logic local conn_income = SQ3.open(income_path) local ok1, v1 = pcall(conn_income.rowexec, conn_income, "PRAGMA schema_version") if not ok1 or tonumber(v1) == 0 then -- no income db or wrong db, first time sync logger.warn("Opening progress income DB failed", v1) return true end local sql = "attach '" .. income_path:gsub("'", "''") .. "' as income_db;" local conn_cached = SQ3.open(cached_path) local ok2, v2 = pcall(conn_cached.rowexec, conn_cached, "PRAGMA schema_version") local attached_cache if not ok2 or tonumber(v2) == 0 then -- no cached or error, no item to delete logger.warn("Opening progress cached DB failed", v2) else attached_cache = true sql = sql .. "attach '" .. cached_path:gsub("'", "''") .. [[' as cached_db; ]] -- Skip this part for now end conn_cached:close() conn_income:close() local conn = SQ3.open(local_path) local ok3, v3 = pcall(conn.exec, conn, "PRAGMA schema_version") if not ok3 or tonumber(v3) == 0 then -- no local db, this is an error logger.err("progress open local DB", v3) return false end sql = sql .. [[ UPDATE progresses AS p SET progress = i.progress, percentage = i.percentage, device = i.device, device_id = i.device_id, timestamp = i.timestamp FROM income_db.progresses AS i WHERE p.document = i.document AND p.timestamp < i.timestamp; INSERT INTO progresses ( document, progress, percentage, device, device_id, timestamp ) SELECT document, progress, percentage, device, device_id, timestamp FROM income_db.progresses WHERE document NOT IN (SELECT document FROM progresses); ]] conn:exec(sql) pcall(conn.exec, conn, "COMMIT;") conn:exec("DETACH income_db;" .. (attached_cache and "DETACH cached_db;" or "")) conn:close() return true end function KOSyncCloud:onSyncProgress() if not self:canSync() then return end UIManager:show(InfoMessage:new { text = _("Syncing reading progress. This may take a while."), timeout = 1, }) UIManager:nextTick(function() SyncService.sync(self.settings.sync_server, db_location, self.onSync) end) end function KOSyncCloud:registerEvents() self.onPageUpdate = self._onPageUpdate end function KOSyncCloud:_onPageUpdate(page) if page == nil then return end if self.last_page ~= page then self.last_page = page self:updateProgress() end end function KOSyncCloud:updateProgress() local doc_digest = self:getDocumentDigest() local progress = self:getLastProgress() local percentage = self:getLastPercent() local conn = SQ3.open(db_location) local stmt = conn:prepare("SELECT 1 FROM progresses WHERE document = ?;") local result = stmt:reset():bind(doc_digest):step() if result == nil then stmt = conn:prepare("INSERT INTO progresses VALUES (NULL, ?, ?, ?, ?, ?, (SELECT unixepoch()));") stmt:reset():bind(doc_digest, progress, percentage, Device.model, self.device_id):step() else stmt = conn:prepare( "UPDATE progresses SET progress = ?, percentage = ?, device = ?, device_id = ?, timestamp = (SELECT unixepoch()) WHERE document = ?;") stmt:reset():bind(progress, percentage, Device.model, self.device_id, doc_digest):step() end conn:close() end return KOSyncCloud