Initial commit

This commit is contained in:
2026-09-25 15:29:54 +01:00
parent ce21e350a1
commit bd53601d8c
37 changed files with 5029 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
local _ = require("gettext")
return { name = "kovi", fullname = _("kovi"), description = _([[Secure kovi reading statistics, completion, and highlights sync plugin (v2026.09.25).]]) }
+135
View File
@@ -0,0 +1,135 @@
local DocSettings = require("docsettings")
local logger = require("logger")
local lfs_ok, lfs = pcall(require, "lfs")
local Reader = {}
local function live_ui()
local ok, ReaderUI = pcall(require, "apps/reader/readerui")
if ok and ReaderUI then return ReaderUI.instance end
return nil
end
local function sidecar_for(file_path)
if not file_path then return nil end
return DocSettings:findSidecarFile(file_path)
end
local function sidecar_signature(sidecar)
if not lfs_ok or not sidecar then return nil end
local attrs = lfs.attributes(sidecar)
if not attrs then return nil end
return tostring(attrs.modification or 0) .. ":" .. tostring(attrs.size or 0)
end
local function clean_annotation(annotation, total_pages, legacy)
if type(annotation) ~= "table" then return nil end
local text = annotation.text
if (not text or text == "") and legacy then text = annotation.notes end
local kind = "bookmark"
if annotation.note and annotation.note ~= "" then kind = "note"
elseif text and text ~= "" then kind = "highlight"
elseif annotation.drawer or annotation.highlighted then kind = "highlight" end
return {
datetime = annotation.datetime,
datetime_updated = annotation.datetime_updated,
drawer = annotation.drawer,
color = annotation.color,
text = text,
note = annotation.note,
chapter = annotation.chapter,
pageno = annotation.pageno,
page = ((type(annotation.page) == "string" or type(annotation.page) == "number") and annotation.page) or nil,
total_pages = total_pages,
annotation_type = kind,
}
end
local function read_set(sidecar)
if not sidecar then return nil end
local settings = DocSettings.openSettingsFile(sidecar)
local md5 = settings:readSetting("partial_md5_checksum")
if not md5 or md5 == "" then return nil end
local total_pages = settings:readSetting("doc_pages")
local props = settings:readSetting("doc_props") or {}
local summary = settings:readSetting("summary") or {}
local annotations = settings:readSetting("annotations")
local legacy = false
if type(annotations) ~= "table" then
annotations = settings:readSetting("bookmarks")
legacy = true
end
if type(annotations) ~= "table" then annotations = {} end
local cleaned = {}
for _, annotation in ipairs(annotations) do
local item = clean_annotation(annotation, total_pages, legacy)
if item then table.insert(cleaned, item) end
end
return { book_md5 = md5, identifiers = props.identifiers, reading_status = summary.status, reading_status_modified = summary.modified, annotations = cleaned }
end
-- Scanning history is cheap; opening every sidecar is not. Persist each sidecar's
-- mtime/size signature and only parse/transmit books whose sidecar changed.
function Reader.changed(previous_versions)
local ui = live_ui()
if ui and ui.doc_settings then pcall(function() ui.doc_settings:flush() end) end
previous_versions = type(previous_versions) == "table" and previous_versions or {}
local ok, ReadHistory = pcall(require, "readhistory")
if not ok or not ReadHistory or type(ReadHistory.hist) ~= "table" then
logger.warn("[kovi] Could not read KOReader history for annotation sync")
return {}, previous_versions
end
local sets, versions, seen_md5, present_md5, current_paths = {}, {}, {}, {}, {}
local scanned, opened, unchanged, errors = 0, 0, 0, 0
for _, entry in ipairs(ReadHistory.hist) do
if entry.file and not entry.dim then
scanned = scanned + 1
local sidecar = sidecar_for(entry.file)
if sidecar then
current_paths[entry.file] = true
local signature = sidecar_signature(sidecar)
local previous = previous_versions[entry.file]
local previous_signature = type(previous) == "table" and previous.signature or previous
local previous_md5 = type(previous) == "table" and previous.book_md5 or nil
if signature and previous_signature == signature and previous_md5 then
versions[entry.file] = { signature = signature, book_md5 = previous_md5 }
present_md5[previous_md5] = true
unchanged = unchanged + 1
else
local success, set = pcall(read_set, sidecar)
if success and set then
present_md5[set.book_md5] = true
if not seen_md5[set.book_md5] then
seen_md5[set.book_md5] = true
table.insert(sets, set)
end
if signature then versions[entry.file] = { signature = signature, book_md5 = set.book_md5 } end
opened = opened + 1
elseif not success then
errors = errors + 1
logger.warn("[kovi] Failed reading annotation sidecar", entry.file)
end
end
end
end
end
-- If a previously synced sidecar disappeared, send an empty set once so the
-- server can remove annotations that no longer exist on this reader.
for file_path, previous in pairs(previous_versions) do
if not current_paths[file_path] and type(previous) == "table" and previous.book_md5 and not present_md5[previous.book_md5] and not seen_md5[previous.book_md5] then
table.insert(sets, { book_md5 = previous.book_md5, annotations = {} })
seen_md5[previous.book_md5] = true
end
end
logger.info("[kovi] Incremental annotation scan", scanned, "history entries,", opened, "changed sidecars,", unchanged, "unchanged,", errors, "errors")
return sets, versions
end
function Reader.all()
return Reader.changed({})
end
return Reader
+102
View File
@@ -0,0 +1,102 @@
local socketutil = require("socketutil")
local ltn12 = require("ltn12")
local logger = require("logger")
local socket = require("socket")
local http = require("socket.http")
local JSON = require("json")
local RawHttp = require("raw_http")
local const = require("./const")
local function decode_json(content)
if not content or content == "" then return {} end
local ok, decoded = pcall(JSON.decode, content)
if ok and decoded then return decoded end
return nil
end
local function format_network_error(detail, stage, host, port)
detail = tostring(detail or "network unreachable")
local where = ""
if host and port then where = " to " .. tostring(host) .. ":" .. tostring(port) end
local hint = " Check the server URL and Wi-Fi."
if stage == "tcp" then
hint = " kovi is not reachable on that TCP port from the reader. Check the host firewall and that both devices are on the same LAN."
elseif stage == "url" then
hint = " Check the server URL."
end
return false, {
error = "kovi " .. tostring(stage or "network") .. " error" .. where .. ": " .. detail .. "." .. hint,
kind = "network",
stage = stage,
detail = detail,
}
end
local function interpret(code, resp_headers, status, content)
local parsed = decode_json(content)
local numeric_code = tonumber(code)
if numeric_code and numeric_code >= 200 and numeric_code < 300 then
if parsed then return true, parsed end
return false, { error = "kovi returned a successful response that was not valid JSON.", kind = "response" }
end
logger.err("[kovi] HTTP error", code, status, content)
if parsed and parsed.error then return false, parsed end
return false, {
error = "kovi returned HTTP " .. tostring(code) .. (status and (" (" .. tostring(status) .. ")") or ""),
kind = "http",
status = numeric_code,
}
end
local function koreader_http(method, target, headers, body)
local sink = {}
local request = {
method = method,
url = target,
headers = headers,
sink = ltn12.sink.table(sink),
redirect = false,
}
if body ~= nil then request.source = ltn12.source.string(body) end
socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
local ok, code, resp_headers, status = pcall(function()
return socket.skip(1, http.request(request))
end)
socketutil:reset_timeout()
if not ok then
return nil, nil, nil, nil, { stage = "http", detail = tostring(code) }
end
if resp_headers == nil then
return nil, nil, nil, nil, { stage = "http", detail = tostring(status or code or "network unreachable") }
end
return code, resp_headers, status, table.concat(sink), nil
end
return function(method, target, headers, body)
headers = headers or {}
headers["Accept"] = headers["Accept"] or "application/json"
headers["Accept-Encoding"] = "identity"
headers["User-Agent"] = "kovi-KOReader/" .. tostring(const.VERSION)
logger.dbg("[kovi] API", method, target)
-- LAN pairing is normally plain HTTP. Use a minimal direct TCP HTTP client first;
-- this avoids platform-specific LuaSocket HTTP quirks and gives useful TCP errors.
if target:match("^http://") then
local code, resp_headers, status, content, raw_err = RawHttp.request(method, target, headers, body, 12)
if code then return interpret(code, resp_headers, status, content) end
logger.warn("[kovi] direct HTTP failed", raw_err and raw_err.stage, raw_err and raw_err.detail)
-- Fall back to KOReader's standard HTTP stack, matching built-in plugins.
local f_code, f_headers, f_status, f_content, fallback_err = koreader_http(method, target, headers, body)
if f_code then return interpret(f_code, f_headers, f_status, f_content) end
local best = raw_err or fallback_err or { stage = "network", detail = "network unreachable" }
return format_network_error(best.detail, best.stage, best.host, best.port)
end
-- KOReader's bundled socket.http is used by built-in plugins for HTTPS URLs.
local code, resp_headers, status, content, err = koreader_http(method, target, headers, body)
if not code then return format_network_error(err and err.detail, err and err.stage) end
return interpret(code, resp_headers, status, content)
end
+1
View File
@@ -0,0 +1 @@
return { VERSION = "2026.09.25" }
+96
View File
@@ -0,0 +1,96 @@
local DocSettings = require("docsettings")
local FileManagerBookInfo = require("apps/filemanager/filemanagerbookinfo")
local DataStorage = require("datastorage")
local random = require("random")
local logger = require("logger")
local lfs = require("libs/libkoreader-lfs")
local Reader = {}
local MAX_BYTES = 4 * 1024 * 1024
local function sidecar_md5(file_path)
if not file_path then return nil end
local sidecar = DocSettings:findSidecarFile(file_path)
if not sidecar then return nil end
local settings = DocSettings.openSettingsFile(sidecar)
return settings:readSetting("partial_md5_checksum")
end
local function paths_for(md5s)
local wanted, remaining = {}, 0
for _, md5 in ipairs(md5s or {}) do
if md5 and md5 ~= "" and not wanted[md5] then
wanted[md5] = false
remaining = remaining + 1
end
end
if remaining == 0 then return wanted end
local ok, ReadHistory = pcall(require, "readhistory")
if not ok or not ReadHistory or type(ReadHistory.hist) ~= "table" then
logger.warn("[kovi] Could not read KOReader history for embedded covers")
return wanted
end
for _, entry in ipairs(ReadHistory.hist) do
if remaining == 0 then break end
if entry.file and not entry.dim then
local success, md5 = pcall(sidecar_md5, entry.file)
if success and md5 and wanted[md5] == false then
wanted[md5] = entry.file
remaining = remaining - 1
end
end
end
return wanted
end
local function read_all(file_path)
local f, err = io.open(file_path, "rb")
if not f then return nil, tostring(err or "cannot open temporary cover") end
local data = f:read("*all")
f:close()
if not data or data == "" then return nil, "temporary cover is empty" end
if #data > MAX_BYTES then return nil, "embedded cover is larger than 4 MB" end
return data
end
local function extract_jpeg(file_path)
local cover = FileManagerBookInfo:getCoverImage(nil, file_path)
if not cover then return nil, "book has no extractable embedded cover" end
local cache_dir = DataStorage:getDataDir() .. "/cache/"
lfs.mkdir(cache_dir)
local tmp = cache_dir .. "kovi-cover-" .. random.uuid() .. ".jpg"
local ok = cover:writeToFile(tmp, "jpg", 82, false)
cover:free()
if not ok then
os.remove(tmp)
return nil, "KOReader could not encode the cover as JPEG"
end
local data, err = read_all(tmp)
os.remove(tmp)
return data, err
end
function Reader.extractRequested(md5s)
local paths = paths_for(md5s)
local out = {}
for _, md5 in ipairs(md5s or {}) do
local file_path = paths[md5]
if file_path then
local ok, data, err = pcall(extract_jpeg, file_path)
if ok and data then
table.insert(out, { book_md5 = md5, data = data })
logger.info("[kovi] Extracted embedded cover", md5, #data, "bytes")
else
logger.warn("[kovi] Embedded cover extraction failed", md5, tostring(ok and err or data))
end
else
logger.info("[kovi] No local file found for requested cover", md5)
end
end
return out
end
return Reader
+68
View File
@@ -0,0 +1,68 @@
local SQ3 = require("lua-ljsqlite3/init")
local DataStorage = require("datastorage")
local logger = require("logger")
local db_location = DataStorage:getSettingsDir() .. "/statistics.sqlite3"
local Reader = {}
local OVERLAP_SECONDS = 24 * 60 * 60
function Reader.bookData()
local conn = SQ3.open(db_location)
local sql = "SELECT id,title,authors,notes,last_open,highlights,pages,series,language,md5,total_read_time,total_read_pages FROM book"
local result, rows = conn:exec(sql)
local books = {}
for i = 1, rows do
table.insert(books, {
id = tonumber(result[1][i]), title = result[2][i], authors = result[3][i],
notes = tonumber(result[4][i]), last_open = tonumber(result[5][i]), highlights = tonumber(result[6][i]),
pages = tonumber(result[7][i]), series = result[8][i], language = result[9][i], md5 = result[10][i],
total_read_time = tonumber(result[11][i]), total_read_pages = tonumber(result[12][i]),
})
end
conn:close()
return books
end
local function md5_by_id(books)
local map = {}
for _, b in ipairs(books) do map[b.id] = b.md5 end
return map
end
local function flush_statistics()
local ok, ReaderUI = pcall(require, "apps/reader/readerui")
if ok and ReaderUI and ReaderUI.instance and ReaderUI.instance.statistics and ReaderUI.instance.statistics.is_doc then
pcall(function() ReaderUI.instance.statistics:insertDB() end)
end
end
-- The first sync sends the complete statistics history. Successful syncs store a
-- high-water mark on the reader; later syncs only re-send a one-day overlap.
-- kovi de-duplicates identical page_stat_data events, so the overlap protects
-- against clock/order edge cases without double-counting reading time.
function Reader.progressData(sync_cursor, books)
flush_statistics()
books = books or Reader.bookData()
local map = md5_by_id(books)
local cursor = math.max(0, tonumber(sync_cursor) or 0)
local threshold = math.max(0, cursor - OVERLAP_SECONDS)
local conn = SQ3.open(db_location)
local sql = "SELECT id_book,page,start_time,duration,total_pages FROM page_stat_data"
if threshold > 0 then sql = sql .. " WHERE start_time >= " .. tostring(math.floor(threshold)) end
local result, rows = conn:exec(sql)
local stats = {}
local device_id = G_reader_settings:readSetting("device_id")
for i = 1, rows do
local md5 = map[tonumber(result[1][i])]
if md5 then
table.insert(stats, {
book_md5 = md5, page = tonumber(result[2][i]), start_time = tonumber(result[3][i]),
duration = tonumber(result[4][i]), total_pages = tonumber(result[5][i]), device_id = device_id,
})
else
logger.warn("[kovi] statistic row references unknown book")
end
end
conn:close()
return stats
end
return Reader
+58
View File
@@ -0,0 +1,58 @@
local _ = require("gettext")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local UIManager = require("ui/uimanager")
local InfoMessage = require("ui/widget/infomessage")
local NetworkMgr = require("ui/network/manager")
local Settings = require("settings")
local Upload = require("upload")
local const = require("./const")
local kovi = WidgetContainer:extend({ name="kovi", is_doc_only=false })
function kovi:init() self.ui.menu:registerToMainMenu(self); self.settings=Settings:new() end
function kovi:showMessage(text) UIManager:show(InfoMessage:new({text=_(text),timeout=4})) end
function kovi:pair()
self.settings:showSetup(function(url,code)
NetworkMgr:runWhenOnline(function()
local ok,response=Upload.pair(url,code,self.settings)
if ok then self:showMessage("kovi paired successfully.") else self:showMessage("Pairing failed: "..tostring(response and response.error or "unknown error")) end
end)
end)
end
function kovi:sync(silent)
local url=self.settings:getServerURL(); local token=self.settings:getToken()
if url=="" or not token then if not silent then self:showMessage("Pair this reader with kovi first.") end; return end
NetworkMgr:runWhenOnline(function()
local ok,response=Upload.sync(url,token)
if not silent then
if ok then self:showMessage(response and response.message or "kovi sync complete.")
else self:showMessage("Sync failed: "..tostring(response and response.error or "server error")) end
end
end)
end
function kovi:testConnection()
local url=self.settings:getServerURL()
if url=="" then self:showMessage("Configure the kovi server URL first."); return end
NetworkMgr:runWhenOnline(function()
local ok,response=Upload.diagnose(url)
if ok then
self:showMessage("kovi connection OK"..(response and response.version and (" · v"..tostring(response.version)) or "."))
else
self:showMessage("Connection test failed: "..tostring(response and response.error or "unknown error"))
end
end)
end
function kovi:onSuspend() if self.settings:getSyncOnSuspend() then self:sync(true) end end
function kovi:onPowerOff() if self.settings:getSyncOnSuspend() then self:sync(true) end end
function kovi:addToMainMenu(menu_items)
menu_items.kovi={text=_("kovi"),sorting_hint="tools",sub_item_table={
{text=_("Sync now"),callback=function() self:sync(false) end,separator=true},
{text=_("Pair / change server"),callback=function() self:pair() end},
{text=_("Test server connection"),callback=function() self:testConnection() end},
{text=_("Sync on suspend"),checked_func=function() return self.settings:getSyncOnSuspend() end,callback=function() self.settings:toggleSyncOnSuspend() end},
{text=_("About kovi"),callback=function() self:showMessage("kovi secure sync plugin · v"..const.VERSION) end},
}}
end
return kovi
+176
View File
@@ -0,0 +1,176 @@
local socket = require("socket")
local urlmod = require("socket.url")
local M = {}
local function parse_target(target)
local ok, parsed = pcall(urlmod.parse, target)
if not ok or type(parsed) ~= "table" then
return nil, "invalid URL"
end
if parsed.scheme ~= "http" then
return nil, "direct transport only supports http:// URLs"
end
if not parsed.host or parsed.host == "" then
return nil, "URL has no host"
end
local port = tonumber(parsed.port) or 80
if port < 1 or port > 65535 then
return nil, "invalid port"
end
local path = parsed.path
if not path or path == "" then path = "/" end
if parsed.params and parsed.params ~= "" then path = path .. ";" .. parsed.params end
if parsed.query and parsed.query ~= "" then path = path .. "?" .. parsed.query end
local host_header = parsed.host
if parsed.host:find(":", 1, true) and parsed.host:sub(1, 1) ~= "[" then
host_header = "[" .. parsed.host .. "]"
end
if port ~= 80 then host_header = host_header .. ":" .. tostring(port) end
return {
host = parsed.host,
port = port,
path = path,
host_header = host_header,
}
end
local function close_quietly(tcp)
if tcp then pcall(function() tcp:close() end) end
end
function M.probe(target, timeout)
local parsed, parse_err = parse_target(target)
if not parsed then
return false, { stage = "url", detail = parse_err }
end
local tcp, socket_err = socket.tcp()
if not tcp then
return false, { stage = "socket", detail = tostring(socket_err or "cannot create TCP socket") }
end
tcp:settimeout(timeout or 6)
local connected, connect_err = tcp:connect(parsed.host, parsed.port)
close_quietly(tcp)
if not connected then
return false, {
stage = "tcp",
detail = tostring(connect_err or "connection failed"),
host = parsed.host,
port = parsed.port,
}
end
return true, { host = parsed.host, port = parsed.port }
end
local function decode_chunked(body)
local out = {}
local pos = 1
while true do
local line_end = body:find("\r\n", pos, true)
if not line_end then return nil, "invalid chunked response" end
local size_line = body:sub(pos, line_end - 1):match("^%s*([^;]+)")
local size = tonumber(size_line, 16)
if not size then return nil, "invalid chunk size" end
pos = line_end + 2
if size == 0 then return table.concat(out) end
local chunk_end = pos + size - 1
if chunk_end > #body then return nil, "truncated chunked response" end
out[#out + 1] = body:sub(pos, chunk_end)
pos = chunk_end + 1
if body:sub(pos, pos + 1) ~= "\r\n" then return nil, "invalid chunk separator" end
pos = pos + 2
end
end
function M.request(method, target, headers, body, timeout)
local parsed, parse_err = parse_target(target)
if not parsed then
return nil, nil, nil, nil, { stage = "url", detail = parse_err }
end
local tcp, socket_err = socket.tcp()
if not tcp then
return nil, nil, nil, nil, { stage = "socket", detail = tostring(socket_err or "cannot create TCP socket") }
end
tcp:settimeout(timeout or 12)
local connected, connect_err = tcp:connect(parsed.host, parsed.port)
if not connected then
close_quietly(tcp)
return nil, nil, nil, nil, {
stage = "tcp",
detail = tostring(connect_err or "connection failed"),
host = parsed.host,
port = parsed.port,
}
end
headers = headers or {}
local has_host, has_connection, has_length = false, false, false
local lines = { tostring(method or "GET") .. " " .. parsed.path .. " HTTP/1.1" }
for k, v in pairs(headers) do
local lower = tostring(k):lower()
if lower == "host" then has_host = true end
if lower == "connection" then has_connection = true end
if lower == "content-length" then has_length = true end
lines[#lines + 1] = tostring(k) .. ": " .. tostring(v)
end
if not has_host then lines[#lines + 1] = "Host: " .. parsed.host_header end
if not has_connection then lines[#lines + 1] = "Connection: close" end
if body ~= nil and not has_length then lines[#lines + 1] = "Content-Length: " .. tostring(#body) end
lines[#lines + 1] = ""
lines[#lines + 1] = ""
local payload = table.concat(lines, "\r\n") .. (body or "")
local sent, send_err, sent_partial = tcp:send(payload)
if not sent then
close_quietly(tcp)
return nil, nil, nil, nil, {
stage = "send",
detail = tostring(send_err or "send failed") .. (sent_partial and (" after " .. tostring(sent_partial) .. " bytes") or ""),
}
end
local chunks = {}
while true do
local chunk, receive_err, partial = tcp:receive(4096)
if chunk and #chunk > 0 then chunks[#chunks + 1] = chunk end
if partial and #partial > 0 then chunks[#chunks + 1] = partial end
if receive_err then
if receive_err ~= "closed" then
close_quietly(tcp)
return nil, nil, nil, nil, { stage = "receive", detail = tostring(receive_err) }
end
break
end
end
close_quietly(tcp)
local raw = table.concat(chunks)
local header_block, response_body = raw:match("^(.-)\r\n\r\n(.*)$")
if not header_block then
return nil, nil, nil, nil, { stage = "http", detail = "malformed HTTP response" }
end
local status_line, rest = header_block:match("^([^\r\n]+)\r\n(.*)$")
if not status_line then status_line, rest = header_block, "" end
local code = tonumber(status_line:match("^HTTP/%d+%.%d+%s+(%d%d%d)"))
if not code then
return nil, nil, nil, nil, { stage = "http", detail = "invalid status line: " .. tostring(status_line) }
end
local response_headers = {}
for line in (rest .. "\r\n"):gmatch("(.-)\r\n") do
local key, value = line:match("^([^:]+):%s*(.*)$")
if key then response_headers[key:lower()] = value end
end
if response_headers["transfer-encoding"] and response_headers["transfer-encoding"]:lower():find("chunked", 1, true) then
local decoded, chunk_err = decode_chunked(response_body)
if not decoded then
return nil, nil, nil, nil, { stage = "http", detail = chunk_err }
end
response_body = decoded
end
return code, response_headers, status_line, response_body, nil
end
return M
+51
View File
@@ -0,0 +1,51 @@
local _ = require("gettext")
local DataStorage = require("datastorage")
local LuaSettings = require("luasettings")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local InfoMessage = require("ui/widget/infomessage")
local UIManager = require("ui/uimanager")
local Settings = {}; Settings.__index = Settings
local KEY = "kovi"
local function local_only_url(url)
local lower = url:lower()
return lower:match("^https?://localhost[:/]")
or lower:match("^https?://127%.")
or lower:match("^https?://0%.0%.0%.0[:/]")
or lower:match("^https?://%[::1%][:/]")
end
function Settings:new()
local o=setmetatable({},self); o.handle=LuaSettings:open(DataStorage:getSettingsDir().."/kovi.lua"); o.data=o.handle:readSetting(KEY,{}) or {}; return o
end
function Settings:save() self.handle:saveSetting(KEY,self.data); self.handle:flush() end
function Settings:getServerURL() return (self.data.server_url or ""):gsub("/*$","") end
function Settings:getToken() return self.data.device_token end
function Settings:isPaired() return self:getToken() ~= nil and self:getToken() ~= "" end
function Settings:setToken(v) self.data.device_token=v; self:save() end
function Settings:getSyncOnSuspend() return self.data.sync_on_suspend == true end
function Settings:toggleSyncOnSuspend() self.data.sync_on_suspend=not self:getSyncOnSuspend(); self:save() end
function Settings:showSetup(on_pair)
local dialog
dialog = MultiInputDialog:new({
title = _("kovi setup"),
fields = {
{ text=self:getServerURL(), description=_("Server URL:"), hint=_("http://192.168.1.10:3000") },
{ text="", description=_("Pairing code:"), hint=_("8-character code") },
},
buttons = {{
{ text=_("Cancel"), id="close", callback=function() UIManager:close(dialog) end },
{ text=_("Pair"), callback=function()
local f=dialog:getFields(); local url=tostring(f[1] or ""):gsub("%s+",""):gsub("/*$",""); local code=tostring(f[2] or ""):upper():gsub("[^A-Z0-9]","")
if not url:match("^https?://") then UIManager:show(InfoMessage:new({text=_("Server URL must start with http:// or https://")})); return end
if local_only_url(url) then UIManager:show(InfoMessage:new({text=_("Use the kovi computer's LAN IP, not localhost/127.0.0.1/0.0.0.0. Example: http://192.168.1.23:3000")})); return end
if #code < 6 then UIManager:show(InfoMessage:new({text=_("Enter the pairing code shown in kovi.")})); return end
self.data.server_url=url; self:save(); UIManager:close(dialog); on_pair(url,code)
end },
}},
})
UIManager:show(dialog); dialog:onShowKeyboard()
end
return Settings
+128
View File
@@ -0,0 +1,128 @@
local JSON = require("json")
local Device = require("device")
local random = require("random")
local logger = require("logger")
local callApi = require("call_api")
local RawHttp = require("raw_http")
local Db = require("db_reader")
local Annotations = require("annotation_reader")
local Covers = require("cover_reader")
local const = require("./const")
local U = {}
local function headers(body, token, content_type)
local h = { ["Content-Type"] = content_type or "application/json" }
if body ~= nil then h["Content-Length"] = tostring(#body) end
if token and token ~= "" then h["Authorization"] = "Bearer " .. token end
return h
end
local function device_id()
local id = G_reader_settings:readSetting("device_id")
if id == nil or id == "" then
id = random.uuid()
G_reader_settings:saveSetting("device_id", id)
if G_reader_settings.flush then G_reader_settings:flush() end
end
return id
end
function U.ping(server_url)
return callApi("GET", server_url .. "/api/plugin/ping", { ["Accept"] = "application/json" }, nil)
end
function U.diagnose(server_url)
if server_url:match("^http://") then
local tcp_ok, tcp_info = RawHttp.probe(server_url .. "/api/plugin/ping", 6)
if not tcp_ok then
local host_port = ""
if tcp_info and tcp_info.host and tcp_info.port then
host_port = " " .. tostring(tcp_info.host) .. ":" .. tostring(tcp_info.port)
end
return false, {
error = "TCP connection to" .. host_port .. " failed: " .. tostring(tcp_info and tcp_info.detail or "unknown error") .. ". If kovi works on the computer itself, allow inbound TCP port 3000 (or your configured port) in the computer's firewall and make sure the reader is on the same LAN.",
stage = tcp_info and tcp_info.stage or "tcp",
}
end
end
local ok, response = U.ping(server_url)
if not ok then return false, response end
return true, {
version = response and response.version,
message = "TCP and HTTP checks passed.",
}
end
function U.pair(server_url, code, settings)
local reachable, diagnostic = U.diagnose(server_url)
if not reachable then return false, diagnostic end
local body = JSON.encode({
code = code,
device_id = device_id(),
model = Device.model,
version = const.VERSION,
})
local ok, response = callApi("POST", server_url .. "/api/plugin/pair", headers(body, nil), body)
if ok and response and response.token then
settings:setToken(response.token)
-- Pairing represents a fresh server-side sync identity. Reset incremental
-- cursors so a rebuilt kovi database receives a complete first sync.
G_reader_settings:saveSetting("kovi_sync_cursor", 0)
G_reader_settings:saveSetting("kovi_annotation_versions", {})
G_reader_settings:saveSetting("kovi_completion_sync_version", 0)
if G_reader_settings.flush then G_reader_settings:flush() end
return true, response
end
return false, response
end
function U.sync(server_url, token)
local cursor = tonumber(G_reader_settings:readSetting("kovi_sync_cursor")) or 0
local previous_versions = G_reader_settings:readSetting("kovi_annotation_versions") or {}
local needs_completion_scan = G_reader_settings:readSetting("kovi_completion_sync_version") ~= 1
if needs_completion_scan then previous_versions = {} end
local books = Db.bookData()
local stats = Db.progressData(cursor, books)
local annotation_sets, annotation_versions = Annotations.changed(previous_versions)
local body = JSON.encode({
books = books, stats = stats, annotation_sets = annotation_sets,
sync_cursor = cursor, version = const.VERSION
})
local ok, response = callApi("POST", server_url .. "/api/plugin/import", headers(body, token), body)
if not ok then return false, response end
-- Advance only after the server committed the import. If a request fails, the
-- same overlap/change set is retried on the next manual sync.
if response and response.sync_cursor then
G_reader_settings:saveSetting("kovi_sync_cursor", tonumber(response.sync_cursor) or cursor)
end
G_reader_settings:saveSetting("kovi_annotation_versions", annotation_versions)
G_reader_settings:saveSetting("kovi_completion_sync_version", 1)
if G_reader_settings.flush then G_reader_settings:flush() end
local requests = response and response.cover_requests
local uploaded = 0
if type(requests) == "table" and #requests > 0 then
local covers = Covers.extractRequested(requests)
for _, cover in ipairs(covers) do
local cover_headers = headers(cover.data, token, "image/jpeg")
cover_headers["X-Kovi-Book-MD5"] = cover.book_md5
local cover_ok, cover_response = callApi("POST", server_url .. "/api/plugin/cover", cover_headers, cover.data)
if cover_ok then
uploaded = uploaded + 1
else
local detail = cover_response and cover_response.error or "server rejected cover"
logger.warn("[kovi] Embedded cover upload failed", cover.book_md5, tostring(detail))
end
end
end
if response then
response.embedded_covers = uploaded
if uploaded > 0 then
response.message = tostring(response.message or "kovi sync complete.") .. " Uploaded " .. tostring(uploaded) .. " embedded cover" .. (uploaded == 1 and "." or "s.")
end
end
return true, response
end
return U