Initial commit

This commit is contained in:
Martin Brodbeck 2025-01-23 10:21:45 +01:00
parent bba2b611c2
commit 3d051a93cc
2 changed files with 372 additions and 0 deletions

View file

@ -0,0 +1,6 @@
local _ = require("gettext")
return {
name = "kosynccloud",
fullname = _("Progress Cloudsync"),
description = _([[Synchronizes your reading progress across your KOReader devices.]]),
}

View file

@ -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