diff --git a/kosynccloud.koplugin/_meta.lua b/kosynccloud.koplugin/_meta.lua new file mode 100644 index 0000000..e0c2f5c --- /dev/null +++ b/kosynccloud.koplugin/_meta.lua @@ -0,0 +1,6 @@ +local _ = require("gettext") +return { + name = "kosynccloud", + fullname = _("Progress Cloudsync"), + description = _([[Synchronizes your reading progress across your KOReader devices.]]), +} diff --git a/kosynccloud.koplugin/main.lua b/kosynccloud.koplugin/main.lua new file mode 100644 index 0000000..235605d --- /dev/null +++ b/kosynccloud.koplugin/main.lua @@ -0,0 +1,366 @@ +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