Initial commit
This commit is contained in:
@@ -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).]]) }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
return { VERSION = "2026.09.25" }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user