AnthoLume/client/syncninja.koplugin/main.lua
2023-09-18 22:13:09 -04:00

954 lines
32 KiB
Lua

local DataStorage = require("datastorage")
local Device = require("device")
local Dispatcher = require("dispatcher")
local DocSettings = require("docsettings")
local InfoMessage = require("ui/widget/infomessage")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local NetworkMgr = require("ui/network/manager")
local ReadHistory = require("readhistory")
local SQ3 = require("lua-ljsqlite3/init")
local T = require("ffi/util").template
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local _ = require("gettext")
local logger = require("logger")
local md5 = require("ffi/sha2").md5
-- TODO:
-- - Handle ReadHistory missing files (statistics.sqlite3, bookinfo_cache.sqlite3)
-- - Handle document uploads (Manual push only, warning saying this may take awhile)
-- - Configure activity bulk size? 1000, 5000, 10000? Separate manual settings to upload ALL?
------------------------------------------
------------ Helper Functions ------------
------------------------------------------
local function dump(o)
if type(o) == 'table' then
local s = '{ '
for k, v in pairs(o) do
if type(k) ~= 'number' then k = '"' .. k .. '"' end
s = s .. '[' .. k .. '] = ' .. dump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
local function validate(entry)
if not entry then return false end
if type(entry) == "string" then
if entry == "" or not entry:match("%S") then return false end
end
return true
end
local function validateUser(user, pass)
local error_message = nil
local user_ok = validate(user)
local pass_ok = validate(pass)
if not user_ok and not pass_ok then
error_message = _("invalid username and password")
elseif not user_ok then
error_message = _("invalid username")
elseif not pass_ok then
error_message = _("invalid password")
end
if not error_message then
return user_ok and pass_ok
else
return user_ok and pass_ok, error_message
end
end
------------------------------------------
-------------- Plugin Start --------------
------------------------------------------
local MERGE_SETTINGS_IN = "IN"
local MERGE_SETTINGS_OUT = "OUT"
local STATISTICS_ACTIVITY_SINCE_QUERY = [[
SELECT
b.md5 AS document,
psd.start_time AS start_time,
psd.duration AS duration,
psd.page AS current_page,
psd.total_pages
FROM page_stat_data AS psd
JOIN book AS b
ON b.id = psd.id_book
WHERE start_time > %d
ORDER BY start_time ASC LIMIT 1000;
]]
local STATISTICS_BOOK_QUERY = [[
SELECT
md5,
title,
authors,
series,
language
FROM book;
]]
local BOOKINFO_BOOK_QUERY = [[
SELECT
(directory || filename) as filepath,
title,
authors,
series,
series_index,
language,
description
FROM bookinfo;
]]
-- Validate Device ID Exists
if G_reader_settings:hasNot("device_id") then
G_reader_settings:saveSetting("device_id", random.uuid())
end
-- Define DB Location
local statistics_db = DataStorage:getSettingsDir() .. "/statistics.sqlite3"
local bookinfo_db = DataStorage:getSettingsDir() .. "/bookinfo_cache.sqlite3"
local SyncNinja = WidgetContainer:extend{
name = "syncninja",
settings = nil,
is_doc_only = false
}
SyncNinja.default_settings = {
server = nil,
username = nil,
password = nil,
sync_frequency = 30,
sync_activity = true,
sync_documents = true,
sync_document_files = true
}
function SyncNinja:init()
logger.dbg("SyncNinja: init")
-- Instance Specific (Non Interactive)
self.periodic_push_task = function() self:performSync(false) end
-- Load Settings
self.device_id = G_reader_settings:readSetting("device_id")
self.settings = G_reader_settings:readSetting("syncninja",
self.default_settings)
-- Register Menu Items
self.ui.menu:registerToMainMenu(self)
-- Initial Periodic Push Schedule (5 Minutes)
self:schedulePeriodicPush(5)
end
------------------------------------------
-------------- UX Functions --------------
------------------------------------------
function SyncNinja:addToMainMenu(menu_items)
logger.dbg("SyncNinja: addToMainMenu")
menu_items.syncninja = {
text = _("Sync Ninja"),
sorting_hint = "tools",
sub_item_table = {
{
text = _("Sync Server"),
keep_menu_open = true,
tap_input_func = function(menu)
return {
title = _("Sync server address"),
input = self.settings.server or "https://",
type = "text",
callback = function(input)
self.settings.server = input ~= "" and input or nil
if menu then
menu:updateItems()
end
end
}
end
}, {
text_func = function()
return self.settings.password and (_("Logout")) or
_("Register") .. " / " .. _("Login")
end,
enabled_func = function()
return self.settings.server ~= nil
end,
keep_menu_open = true,
callback_func = function()
if self.settings.password then
return function(menu)
self:logoutUI(menu)
end
else
return function(menu)
self:loginUI(menu)
end
end
end
}, {
text = _("Manual Sync"),
keep_menu_open = true,
enabled_func = function()
return self.settings.password ~= nil and
self.settings.username ~= nil and
self.settings.server ~= nil
end,
callback = function()
UIManager:unschedule(self.performSync)
self:performSync(true) -- Interactive
end
}, {
text = _("KOSync Auth Merge"),
sub_item_table = {
{
text = _("KOSync Merge In"),
keep_menu_open = true,
callback = function()
self:mergeKOSync(MERGE_SETTINGS_IN)
end
}, {
text = _("KOSync Merge Out"),
keep_menu_open = true,
callback = function()
self:mergeKOSync(MERGE_SETTINGS_OUT)
end
}
},
separator = true
}, {
text_func = function()
return T(_("Sync Frequency (%1 Minutes)"),
self.settings.sync_frequency or 30)
end,
keep_menu_open = true,
callback = function(touchmenu_instance)
local SpinWidget = require("ui/widget/spinwidget")
local items = SpinWidget:new{
text = _(
[[This value determines the cadence at which the syncs will be performed.
If set to 0, periodic sync will be disabled.]]),
value = self.settings.sync_frequency or 30,
value_min = 0,
value_max = 1440,
value_step = 30,
value_hold_step = 60,
ok_text = _("Set"),
title_text = _("Minutes between syncs"),
default_value = 30,
callback = function(spin)
self.settings.sync_frequency = spin.value > 0 and
spin.value or 30
if touchmenu_instance then
touchmenu_instance:updateItems()
end
self:schedulePeriodicPush()
end
}
UIManager:show(items)
end
}, {
text_func = function()
return T(_("Sync Activity (%1)"), self.settings
.sync_activity == true and (_("Enabled")) or
(_("Disabled")))
end,
sub_item_table = {
{
text = _("Enabled"),
checked_func = function()
return self.settings.sync_activity == true
end,
callback = function()
self.settings.sync_activity = true
end
}, {
text = _("Disabled"),
checked_func = function()
return self.settings.sync_activity ~= true
end,
callback = function()
self.settings.sync_activity = false
end
}
}
}, {
text_func = function()
return T(_("Sync Documents (%1)"), self.settings
.sync_documents == true and (_("Enabled")) or
(_("Disabled")))
end,
sub_item_table = {
{
text = _("Enabled"),
checked_func = function()
return self.settings.sync_documents == true
end,
callback = function()
self.settings.sync_documents = true
end
}, {
text = _("Disabled"),
checked_func = function()
return self.settings.sync_documents ~= true
end,
callback = function()
self.settings.sync_documents = false
end
}
}
}, {
text_func = function()
return T(_("Sync Document Files (%1)"),
self.settings.sync_documents == true and
self.settings.sync_document_files == true and
(_("Enabled")) or (_("Disabled")))
end,
enabled_func = function()
return self.settings.sync_documents == true
end,
sub_item_table = {
{
text = _("Enabled"),
checked_func = function()
return self.settings.sync_document_files == true
end,
callback = function()
self.settings.sync_document_files = true
end
}, {
text = _("Disabled"),
checked_func = function()
return self.settings.sync_document_files ~= true
end,
callback = function()
self.settings.sync_document_files = false
end
}
}
}
}
}
end
function SyncNinja:loginUI(menu)
logger.dbg("SyncNinja: loginUI")
if NetworkMgr:willRerunWhenOnline(function() self:loginUI(menu) end) then
return
end
local dialog
dialog = MultiInputDialog:new{
title = _("Register/login to SyncNinja server"),
fields = {
{text = self.settings.username, hint = "username"},
{hint = "password", text_type = "password"}
},
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(dialog)
end
}, {
text = _("Login"),
callback = function()
local username, password = unpack(dialog:getFields())
local ok, err = validateUser(username, password)
if not ok then
UIManager:show(InfoMessage:new{
text = T(_("Cannot login: %1"), err),
timeout = 2
})
else
UIManager:close(dialog)
UIManager:scheduleIn(0.5, function()
self:userLogin(username, password, menu)
end)
UIManager:show(InfoMessage:new{
text = _("Logging in. Please wait…"),
timeout = 1
})
end
end
}, {
text = _("Register"),
callback = function()
local username, password = unpack(dialog:getFields())
local ok, err = validateUser(username, password)
if not ok then
UIManager:show(InfoMessage:new{
text = T(_("Cannot register: %1"), err),
timeout = 2
})
else
UIManager:close(dialog)
UIManager:scheduleIn(0.5, function()
self:userRegister(username, password, menu)
end)
UIManager:show(InfoMessage:new{
text = _("Registering. Please wait…"),
timeout = 1
})
end
end
}
}
}
}
UIManager:show(dialog)
dialog:onShowKeyboard()
end
function SyncNinja:logoutUI(menu)
logger.dbg("SyncNinja: logoutUI")
self.settings.username = nil
self.settings.password = nil
if menu then menu:updateItems() end
UIManager:unschedule(self.periodic_push_task)
end
function SyncNinja:mergeKOSync(direction)
logger.dbg("SyncNinja: mergeKOSync")
local kosync_settings = G_reader_settings:readSetting("kosync")
if kosync_settings == nil then return end
if direction == MERGE_SETTINGS_OUT then
-- Validate Configured
if not self.settings.server or not self.settings.username or
not self.settings.password then
return UIManager:show(InfoMessage:new{
text = _("Error: SyncNinja not configured")
})
end
kosync_settings.custom_server = self.settings.server ..
(self.settings.server:sub(-#"/") ==
"/" and "api/ko" or "/api/ko")
kosync_settings.username = self.settings.username
kosync_settings.userkey = self.settings.password
UIManager:show(InfoMessage:new{text = _("Synced to KOSync")})
elseif direction == MERGE_SETTINGS_IN then
-- Validate Configured
if not kosync_settings.custom_server or not kosync_settings.username or
not kosync_settings.userkey then
return UIManager:show(InfoMessage:new{
text = _("Error: KOSync not configured")
})
end
-- Validate Compatible Server
if kosync_settings.custom_server:sub(-#"/api/ko") ~= "/api/ko" and
kosync_settings.custom_server:sub(-#"/api/ko/") ~= "/api/ko/" then
return UIManager:show(InfoMessage:new{
text = _("Error: Configured KOSync server not compatible")
})
end
self.settings.server = string.gsub(kosync_settings.custom_server,
"/api/ko/?$", "")
self.settings.username = kosync_settings.username
self.settings.password = kosync_settings.userkey
UIManager:show(InfoMessage:new{text = _("Synced from KOSync")})
end
end
------------------------------------------
------------- Login Functions ------------
------------------------------------------
function SyncNinja:userLogin(username, password, menu)
logger.dbg("SyncNinja: userLogin")
if not self.settings.server then return end
local SyncNinjaClient = require("SyncNinjaClient")
local client = SyncNinjaClient:new{
custom_url = self.settings.server,
service_spec = self.path .. "/api.json"
}
Device:setIgnoreInput(true)
local userkey = md5(password)
local ok, status, body = pcall(client.authorize, client, username, userkey)
if not ok then
if status then
UIManager:show(InfoMessage:new{
text = _("An error occurred while logging in:") .. "\n" ..
status
})
else
UIManager:show(InfoMessage:new{
text = _("An unknown error occurred while logging in.")
})
end
Device:setIgnoreInput(false)
return
elseif status then
self.settings.username = username
self.settings.password = userkey
if menu then menu:updateItems() end
UIManager:show(InfoMessage:new{
text = _("Logged in to KOReader server.")
})
self:schedulePeriodicPush(0)
else
logger.dbg("SyncNinja: userLogin Error:", dump(body))
end
Device:setIgnoreInput(false)
end
function SyncNinja:userRegister(username, password, menu)
logger.dbg("SyncNinja: userRegister")
if not self.settings.server then return end
local SyncNinjaClient = require("SyncNinjaClient")
local client = SyncNinjaClient:new{
custom_url = self.settings.server,
service_spec = self.path .. "/api.json"
}
-- on Android to avoid ANR (no-op on other platforms)
Device:setIgnoreInput(true)
local userkey = md5(password)
local ok, status, body = pcall(client.register, client, username, userkey)
if not ok then
if status then
UIManager:show(InfoMessage:new{
text = _("An error occurred while registering:") .. "\n" ..
status
})
else
UIManager:show(InfoMessage:new{
text = _("An unknown error occurred while registering.")
})
end
elseif status then
self.settings.username = username
self.settings.password = userkey
if menu then menu:updateItems() end
UIManager:show(InfoMessage:new{
text = _("Registered to KOReader server.")
})
self:schedulePeriodicPush(0)
else
UIManager:show(InfoMessage:new{
text = body and body.message or _("Unknown server error")
})
end
Device:setIgnoreInput(false)
end
------------------------------------------
------------- Sync Functions -------------
------------------------------------------
function SyncNinja:schedulePeriodicPush(minutes)
logger.dbg("SyncNinja: schedulePeriodicPush")
-- Validate Configured
if not self.settings then return end
if not self.settings.username then return end
if not self.settings.password then return end
if not self.settings.server then return end
-- Unschedule & Schedule
local sync_frequency = minutes or self.settings.sync_frequency or 30
UIManager:unschedule(self.periodic_push_task)
UIManager:scheduleIn(60 * sync_frequency, self.periodic_push_task)
end
function SyncNinja:performSync(interactive)
logger.dbg("SyncNinja: performSync")
-- Upload Activity & Check Documents
self:checkActivity(interactive)
self:checkDocuments(interactive)
if interactive == true then
UIManager:show(InfoMessage:new{
text = _("SyncNinja: Manual Sync Success"),
timeout = 3
})
end
-- Schedule Push Again
self:schedulePeriodicPush()
end
function SyncNinja:checkActivity(interactive)
logger.dbg("SyncNinja: checkActivity")
-- Ensure Activity Sync Enabled
if self.settings.sync_activity ~= true then return end
-- API Callback Function
local callback_func = function(ok, body)
if not ok then
-- TODO: if interactive
UIManager:show(InfoMessage:new{
text = _("SyncNinja: checkActivity Error"),
timeout = 3
})
return logger.dbg("SyncNinja: checkActivity Error:", dump(body))
end
local last_sync = body.last_sync
local activity_data = self:getStatisticsActivity(last_sync)
-- Activity Data Exists
if not (next(activity_data) == nil) then
self:uploadActivity(activity_data, interactive)
end
end
-- API Call
local SyncNinjaClient = require("SyncNinjaClient")
local client = SyncNinjaClient:new{
custom_url = self.settings.server,
service_spec = self.path .. "/api.json"
}
local ok, err = pcall(client.check_activity, client, self.settings.username,
self.settings.password, self.device_id, callback_func)
end
function SyncNinja:uploadActivity(activity_data, interactive)
logger.dbg("SyncNinja: uploadActivity")
-- API Callback Function
local callback_func = function(ok, body)
if not ok then
-- TODO: if interactive
UIManager:show(InfoMessage:new{
text = _("SyncNinja: uploadActivity Error"),
timeout = 3
})
return logger.dbg("SyncNinja: uploadActivity Error:", dump(body))
end
end
-- API Call
local SyncNinjaClient = require("SyncNinjaClient")
local client = SyncNinjaClient:new{
custom_url = self.settings.server,
service_spec = self.path .. "/api.json"
}
local ok, err = pcall(client.add_activity, client, self.settings.username,
self.settings.password, self.device_id, Device.model,
activity_data, callback_func)
end
function SyncNinja:checkDocuments(interactive)
logger.dbg("SyncNinja: checkDocuments")
-- ensure document sync enabled
if self.settings.sync_documents ~= true then return end
-- API Request Data
local doc_metadata = self:getLocalDocumentMetadata()
local doc_ids = self:getLocalDocumentIDs(doc_metadata)
-- API Callback Function
local callback_func = function(ok, body)
if not ok then
-- TODO: if interactive
UIManager:show(InfoMessage:new{
text = _("SyncNinja: checkDocuments Error"),
timeout = 3
})
return logger.dbg("SyncNinja: checkDocuments Error:", dump(body))
end
-- Documents Wanted
if not (next(body.want) == nil) then
local hash_want = {}
for _, v in pairs(body.want) do hash_want[v] = true end
local upload_doc_metadata = {}
for _, v in pairs(doc_metadata) do
if hash_want[v.id] == true then
table.insert(upload_doc_metadata, v)
end
end
self:uploadDocuments(upload_doc_metadata, interactive)
end
-- Documents Provided
if not (next(body.give) == nil) then
self:downloadDocuments(body.give, interactive)
end
end
-- API Call
local SyncNinjaClient = require("SyncNinjaClient")
local client = SyncNinjaClient:new{
custom_url = self.settings.server,
service_spec = self.path .. "/api.json"
}
local ok, err = pcall(client.check_documents, client,
self.settings.username, self.settings.password,
self.device_id, Device.model, doc_ids, callback_func)
end
function SyncNinja:downloadDocuments(doc_metadata, interactive)
logger.dbg("SyncNinja: downloadDocuments")
-- TODO
end
function SyncNinja:uploadDocuments(doc_metadata, interactive)
logger.dbg("SyncNinja: uploadDocuments")
-- Ensure Document Sync Enabled
if self.settings.sync_documents ~= true then return end
-- API Callback Function
local callback_func = function(ok, body)
if not ok then
-- TODO: if interactive
UIManager:show(InfoMessage:new{
text = _("SyncNinja: uploadDocuments Error"),
timeout = 3
})
return logger.dbg("SyncNinja: uploadDocuments Error:", dump(body))
end
end
-- API Client
local SyncNinjaClient = require("SyncNinjaClient")
local client = SyncNinjaClient:new{
custom_url = self.settings.server,
service_spec = self.path .. "/api.json"
}
-- API Initial Metadata
local ok, err = pcall(client.add_documents, client, self.settings.username,
self.settings.password, doc_metadata, callback_func)
-- Ensure Document File Sync Enabled
if self.settings.sync_document_files ~= true then return end
if interactive ~= true then return end
-- API File Upload
local confirm_upload_callback = function()
for _, v in pairs(doc_metadata) do
if v.filepath ~= nil then
local ok, err = pcall(client.upload_document, client,
self.settings.username,
self.settings.password, v.id, v.filepath,
callback_func)
end
end
end
UIManager:show(ConfirmBox:new{
text = _("Upload documents? This can take awhile."),
ok_text = _("Yes"),
ok_callback = confirm_upload_callback
})
end
------------------------------------------
------------ Getter Functions ------------
------------------------------------------
function SyncNinja:getLocalDocumentIDs(doc_metadata)
logger.dbg("SyncNinja: getLocalDocumentIDs")
local document_ids = {}
if doc_metadata == nil then
doc_metadata = self:getLocalDocumentMetadata()
end
for _, v in pairs(doc_metadata) do table.insert(document_ids, v.id) end
return document_ids
end
function SyncNinja:getLocalDocumentMetadata()
logger.dbg("SyncNinja: getLocalDocumentMetadata")
local all_documents = {}
local documents_kv = self:getStatisticsBookKV()
local bookinfo_books = self:getBookInfoBookKV()
for _, v in pairs(ReadHistory.hist) do
if DocSettings:hasSidecarFile(v.file) then
local docsettings = DocSettings:open(v.file)
-- Ensure Partial MD5 Exists
local pmd5 = docsettings:readSetting("partial_md5_checksum")
if not pmd5 then
pmd5 = self:getPartialMd5(v.file)
docsettings:saveSetting("partial_md5_checksum", pmd5)
end
-- Get Document Props
local doc_props = docsettings:readSetting("doc_props")
local fdoc = bookinfo_books[v.file] or {}
-- Update or Create
if documents_kv[pmd5] ~= nil then
local doc = documents_kv[pmd5]
-- Merge Statistics, History, and BookInfo
doc.title = doc.title or doc_props.title or fdoc.title
doc.author = doc.author or doc_props.authors or fdoc.author
doc.series = doc.series or doc_props.series or fdoc.series
doc.lang = doc.lang or doc_props.language or fdoc.lang
-- Merge History and BookInfo
doc.series_index = doc_props.series_index or fdoc.series_index
doc.description = doc_props.description or fdoc.description
doc.filepath = v.file
else
-- Merge History and BookInfo
documents_kv[pmd5] = {
title = doc_props.title or fdoc.title,
author = doc_props.authors or fdoc.author,
series = doc_props.series or fdoc.series,
series_index = doc_props.series_index or fdoc.series_index,
lang = doc_props.language or fdoc.lang,
description = doc_props.description or fdoc.description,
filepath = v.file
}
end
end
end
-- Convert KV -> Array
for pmd5, v in pairs(documents_kv) do
table.insert(all_documents, {
id = pmd5,
title = v.title,
author = v.author,
series = v.series,
series_index = v.series_index,
lang = v.lang,
description = v.description,
filepath = v.filepath
})
end
return all_documents
end
function SyncNinja:getStatisticsActivity(timestamp)
logger.dbg("SyncNinja: getStatisticsActivity")
local all_data = {}
local conn = SQ3.open(statistics_db)
local stmt = conn:prepare(string.format(STATISTICS_ACTIVITY_SINCE_QUERY,
timestamp))
local rows = stmt:resultset("i", 1000)
conn:close()
-- No Results
if rows == nil then return all_data end
-- Normalize
for i, v in pairs(rows[1]) do
table.insert(all_data, {
document = rows[1][i],
start_time = tonumber(rows[2][i]),
duration = tonumber(rows[3][i]),
current_page = tonumber(rows[4][i]),
total_pages = tonumber(rows[5][i])
})
end
return all_data
end
-- Returns KEY:VAL (MD5:<TABLE>)
function SyncNinja:getStatisticsBookKV()
logger.dbg("SyncNinja: getStatisticsBookKV")
local all_data = {}
local conn = SQ3.open(statistics_db)
local stmt = conn:prepare(STATISTICS_BOOK_QUERY)
local rows = stmt:resultset("i", 1000)
conn:close()
-- No Results
if rows == nil then return all_data end
-- Normalize
for i, v in pairs(rows[1]) do
local pmd5 = rows[1][i]
all_data[pmd5] = {
title = rows[2][i],
author = rows[3][i],
series = rows[4][i],
lang = rows[5][i]
}
end
return all_data
end
-- Returns KEY:VAL (FILEPATH:<TABLE>)
function SyncNinja:getBookInfoBookKV()
logger.dbg("SyncNinja: getBookInfoBookKV")
local all_data = {}
local conn = SQ3.open(bookinfo_db)
local stmt = conn:prepare(BOOKINFO_BOOK_QUERY)
local rows = stmt:resultset("i", 1000)
conn:close()
-- No Results
if rows == nil then return all_data end
-- Normalize
for i, v in pairs(rows[1]) do
filepath = rows[1][i]
all_data[filepath] = {
title = rows[2][i],
author = rows[3][i],
series = rows[4][i],
series_index = tonumber(rows[5][i]),
lang = rows[6][i],
description = rows[7][i]
}
end
return all_data
end
function SyncNinja:getPartialMd5(file)
logger.dbg("SyncNinja: getPartialMd5")
if file == nil then return nil end
local bit = require("bit")
local lshift = bit.lshift
local step, size = 1024, 1024
local update = md5()
local file_handle = io.open(file, 'rb')
if file_handle == nil then return nil end
for i = -1, 10 do
file_handle:seek("set", lshift(step, 2 * i))
local sample = file_handle:read(size)
if sample then
update(sample)
else
break
end
end
file_handle:close()
return update()
end
return SyncNinja