feat: readest koplugin for progress sync (#1753)

This commit is contained in:
Huang Xin
2025-08-06 23:12:00 +08:00
committed by GitHub
parent df074082a8
commit 6a5694d2b2
6 changed files with 903 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
local _ = require("gettext")
return {
name = "readest",
fullname = _("Readest sync"),
description = _([[Keeps your KOReader and Readest devices in sync.]]),
}
+467
View File
@@ -0,0 +1,467 @@
local Device = require("device")
local Event = require("ui/event")
local InfoMessage = require("ui/widget/infomessage")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local logger = require("logger")
local time = require("ui/time")
local util = require("util")
local sha2 = require("ffi/sha2")
local T = require("ffi/util").template
local _ = require("gettext")
local ReadestSync = WidgetContainer:new{
name = "readest",
title = _("Readest Sync"),
settings = nil,
}
local API_CALL_DEBOUNCE_DELAY = time.s(30)
local SUPABAE_ANON_KEY_BASE64 = "ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
ReadestSync.default_settings = {
supabase_url = "https://readest.supabase.co",
supabase_anon_key = sha2.base64_to_bin(SUPABAE_ANON_KEY_BASE64),
auto_sync = false,
user_email = nil,
user_id = nil,
access_token = nil,
refresh_token = nil,
expires_at = nil,
expires_in = nil,
last_sync_at = nil,
}
function ReadestSync:init()
self.last_sync_timestamp = 0
self.settings = G_reader_settings:readSetting("readest_sync", self.default_settings)
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
end
function ReadestSync:onDispatcherRegisterActions()
--
end
function ReadestSync:addToMainMenu(menu_items)
menu_items.readest_sync = {
sorting_hint = "tools",
text = _("Readest Sync"),
sub_item_table = {
{
text_func = function()
return self.settings.access_token and (_("Logout"))
or _("Login with Readest Account")
end,
callback_func = function()
if self.settings.access_token then
return function(menu)
self:logout(menu)
end
else
return function(menu)
self:login(menu)
end
end
end,
separator = true,
},
{
text = _("Auto sync book configs"),
checked_func = function() return self.settings.auto_sync end,
callback = function()
self.settings.auto_sync = not self.settings.auto_sync
if self.settings.auto_sync then
self:pullBookConfig(false)
end
end,
separator = true,
},
{
text = _("Push book config now"),
enabled_func = function()
return self.settings.access_token ~= nil
end,
callback = function()
self:pushBookConfig(true)
end,
},
{
text = _("Pull book config now"),
enabled_func = function()
return self.settings.access_token ~= nil
end,
callback = function()
self:pullBookConfig(true)
end,
},
}
}
end
function ReadestSync:getSupabaseAuthClient()
if not self.settings.supabase_url or not self.settings.supabase_anon_key then
return nil
end
local SupabaseAuthClient = require("supabaseauth")
return SupabaseAuthClient:new{
service_spec = self.path .. "/supabase-auth-api.json",
custom_url = self.settings.supabase_url .. "/auth/v1/",
api_key = self.settings.supabase_anon_key,
}
end
function ReadestSync:getReadestSyncClient()
if not self.settings.access_token or not self.settings.expires_at or self.settings.expires_at < os.time() then
return nil
end
local ReadestSyncClient = require("readestsync")
return ReadestSyncClient:new{
service_spec = self.path .. "/readest-sync-api.json",
access_token = self.settings.access_token,
}
end
function ReadestSync:login(menu)
if NetworkMgr:willRerunWhenOnline(function() self:login(menu) end) then
return
end
local dialog
dialog = MultiInputDialog:new{
title = self.title,
fields = {
{
text = self.settings.user_email,
hint = "email@example.com",
},
{
hint = "password",
text_type = "password",
},
},
buttons = {
{
{
text = _("Cancel"),
id = "close",
callback = function()
UIManager:close(dialog)
end,
},
{
text = _("Login"),
callback = function()
local email, password = unpack(dialog:getFields())
email = util.trim(email)
if email == "" or password == "" then
UIManager:show(InfoMessage:new{
text = _("Please enter both email and password"),
timeout = 2,
})
return
end
UIManager:close(dialog)
self:doLogin(email, password, menu)
end,
},
},
},
}
UIManager:show(dialog)
dialog:onShowKeyboard()
end
function ReadestSync:doLogin(email, password, menu)
local client = self:getSupabaseAuthClient()
if not client then
UIManager:show(InfoMessage:new{
text = _("Please configure Supabase URL and API key first"),
timeout = 3,
})
return
end
UIManager:show(InfoMessage:new{
text = _("Logging in..."),
timeout = 1,
})
Device:setIgnoreInput(true)
local success, response = client:sign_in_password(email, password)
Device:setIgnoreInput(false)
if success then
self.settings.user_email = email
self.settings.user_id = response.user.id
self.settings.access_token = response.access_token
self.settings.refresh_token = response.refresh_token
self.settings.expires_at = response.expires_at
self.settings.expires_in = response.expires_in
if menu then
menu:updateItems()
end
UIManager:show(InfoMessage:new{
text = _("Successfully logged in to Readest"),
timeout = 3,
})
else
UIManager:show(InfoMessage:new{
text = _("Login failed: ") .. (response.message or "Unknown error"),
timeout = 3,
})
end
end
function ReadestSync:logout(menu)
if self.access_token then
local client = self:getSupabaseAuthClient()
if client then
client:sign_out(self.settings.access_token, function(success, response)
logger.dbg("ReadestSync: Sign out result:", success)
end)
end
end
self.settings.access_token = nil
self.settings.refresh_token = nil
self.settings.expires_at = nil
self.settings.expires_in = nil
if menu then
menu:updateItems()
end
UIManager:show(InfoMessage:new{
text = _("Logged out from Readest Sync"),
timeout = 2,
})
end
function ReadestSync:getDocumentIdentifier()
return self.ui.doc_settings:readSetting("partial_md5_checksum")
end
function ReadestSync:applyBookConfig(config)
logger.dbg("ReadestSync: Applying book config:", config)
local location_xp = config.location_xp
local progress = config.progress
-- Check if it's the bracket format: [page,total_pages]
local progress_pattern = "^%[(%d+),(%d+)%]$"
local page, total_pages = progress:match(progress_pattern)
if location_xp then
-- TODO
return
end
if page and total_pages then
local percentage = tonumber(page) / tonumber(total_pages)
local current_page = self.ui.document:getCurrentPage()
local page_count = self.ui.document:getPageCount()
if page_count > 0 and current_page / page_count < percentage then
self.ui.link:addCurrentLocationToStack()
self.ui:handleEvent(Event:new("GotoPercent", percentage * 100))
end
end
end
function ReadestSync:pushBookConfig(interactive)
if not self.settings.access_token or not self.settings.user_id then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please login first"),
timeout = 2,
})
end
return
end
local now = UIManager:getElapsedTimeSinceBoot()
if not interactive and now - self.last_sync_timestamp <= API_CALL_DEBOUNCE_DELAY then
logger.dbg("ReadestSync: Debouncing push request")
return
end
local book_hash = self:getDocumentIdentifier()
if not book_hash then return end
local config = self:getCurrentBookConfig()
if not config then return end
if NetworkMgr:willRerunWhenOnline(function() self:pushBookConfig(interactive) end) then
return
end
-- Use Supabase REST API to upsert book config
local url = self.settings.supabase_url .. "/rest/v1/book_configs"
local payload = {
user_id = self.user_id,
hash = document_id,
config = config,
updated_at = os.date("!%Y-%m-%dT%H:%M:%SZ")
}
local client = self:getReadestSyncClient()
if not client then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please configure Supabase settings first"),
timeout = 3,
})
end
return
end
if interactive then
UIManager:show(InfoMessage:new{
text = _("Pushing book config..."),
timeout = 1,
})
end
client:pushChanges(
config,
function(success, response)
logger.dbg("ReadestSync: Push result:", success, response)
if interactive then
if success then
UIManager:show(InfoMessage:new{
text = _("Book config pushed successfully"),
timeout = 2,
})
else
UIManager:show(InfoMessage:new{
text = _("Failed to push book config"),
timeout = 2,
})
end
end
if success then
self.last_sync_timestamp = time.now()
end
end
)
end
function ReadestSync:pullBookConfig(interactive)
if not self.settings.access_token or not self.settings.user_id then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please login first"),
timeout = 2,
})
end
return
end
local book_hash = self:getDocumentIdentifier()
if not book_hash then return end
if NetworkMgr:willRerunWhenOnline(function() self:pullBookConfig(interactive) end) then
return
end
local client = self:getReadestSyncClient()
if not client then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please configure Supabase settings first"),
timeout = 3,
})
end
return
end
if interactive then
UIManager:show(InfoMessage:new{
text = _("Pulling book config..."),
timeout = 1,
})
end
client:pullChanges(
{
since = 0,
type = "configs",
book = book_hash,
},
function(success, response)
logger.dbg("ReadestSync: Pull result:", success, response)
if not success then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Failed to pull book config"),
timeout = 2,
})
end
return
end
local data = response.configs
if data and #data > 0 then
local config = data[1]
if config then
self:applyBookConfig(config)
if interactive then
UIManager:show(InfoMessage:new{
text = _("Book config synchronized"),
timeout = 2,
})
end
return
end
end
if interactive then
UIManager:show(InfoMessage:new{
text = _("No saved config found for this book"),
timeout = 2,
})
end
end
)
end
function ReadestSync:onReaderReady()
if self.settings.auto_sync and self.settings.access_token then
UIManager:nextTick(function()
self:pullBookConfig(false)
end)
end
end
function ReadestSync:onCloseDocument()
if self.settings.auto_sync and self.settings.access_token then
NetworkMgr:goOnlineToRun(function()
self:pushBookConfig(false)
end)
end
end
function ReadestSync:onPageUpdate(page)
if self.settings.auto_sync and self.settings.access_token and page then
-- Schedule a delayed push to avoid too frequent updates
UIManager:unschedule(self.delayed_push_task)
self.delayed_push_task = function()
self:pushBookConfig(false)
end
UIManager:scheduleIn(5, self.delayed_push_task)
end
end
function ReadestSync:onCloseWidget()
if self.delayed_push_task then
UIManager:unschedule(self.delayed_push_task)
self.delayed_push_task = nil
end
end
return ReadestSync
@@ -0,0 +1,33 @@
{
"base_url": "http://localhost:3000/api",
"name": "readest-sync-api",
"methods": {
"pullChanges": {
"path": "/sync",
"method": "GET",
"required_params": [
"since",
"type",
"book"
],
"expected_status": [
200,
400,
401
]
},
"pushChanges": {
"path": "/sync",
"method": "POST",
"required_params": [],
"payload": [],
"expected_status": [
200,
201,
400,
401,
422
]
}
}
}
+118
View File
@@ -0,0 +1,118 @@
local UIManager = require("ui/uimanager")
local logger = require("logger")
local socketutil = require("socketutil")
-- Sync operation timeouts
local SYNC_TIMEOUTS = { 5, 10 }
local ReadestSyncClient = {
service_spec = nil,
access_token = nil,
}
function ReadestSyncClient:new(o)
if o == nil then o = {} end
setmetatable(o, self)
self.__index = self
if o.init then o:init() end
return o
end
function ReadestSyncClient:init()
local Spore = require("Spore")
self.client = Spore.new_from_spec(self.service_spec)
-- Readest API headers middleware
package.loaded["Spore.Middleware.ReadestHeaders"] = {}
require("Spore.Middleware.ReadestHeaders").call = function(args, req)
req.headers["content-type"] = "application/json"
req.headers["accept"] = "application/json"
end
-- Readest Bearer token auth middleware
package.loaded["Spore.Middleware.ReadestAuth"] = {}
require("Spore.Middleware.ReadestAuth").call = function(args, req)
if self.access_token then
req.headers["authorization"] = "Bearer " .. self.access_token
else
logger.err("ReadestSyncClient:access_token is not set, cannot authenticate")
return false, "Access token is required for Readest API"
end
end
package.loaded["Spore.Middleware.AsyncHTTP"] = {}
require("Spore.Middleware.AsyncHTTP").call = function(args, req)
-- disable async http if Turbo looper is missing
if not UIManager.looper then return end
req:finalize()
local result
require("httpclient"):new():request({
url = req.url,
method = req.method,
body = req.env.spore.payload,
on_headers = function(headers)
for header, value in pairs(req.headers) do
if type(header) == "string" then
headers:add(header, value)
end
end
end,
}, function(res)
result = res
-- Turbo HTTP client uses code instead of status
-- change to status so that Spore can understand
result.status = res.code
coroutine.resume(args.thread)
end)
return coroutine.create(function() coroutine.yield(result) end)
end
end
function ReadestSyncClient:pullChanges(params, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("ReadestHeaders", {})
self.client:enable("ReadestAuth", {})
socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:pullChanges({
since = params.since,
type = params.type,
book = params.book,
})
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("ReadestSyncClient:pullChanges failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
function ReadestSyncClient:pushChanges(changes)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("ReadestHeaders", {})
self.client:enable("ReadestAuth", {})
socketutil:set_timeout(SYNC_TIMEOUTS[1], SYNC_TIMEOUTS[2])
local ok, res = pcall(function()
return self.client:pushChanges(changes or {})
end)
socketutil:reset_timeout()
if ok then
return res.status == 200 or res.status == 201, res.body
else
logger.dbg("ReadestSyncClient:pushChanges failure:", res)
return false, res.body
end
end
return ReadestSyncClient
@@ -0,0 +1,57 @@
{
"base_url": "https://readest.supabase.co/auth/v1/",
"name": "supabase-auth-api",
"methods": {
"sign_in_password": {
"path": "/token?grant_type=password",
"method": "POST",
"required_params": [
"email",
"password"
],
"payload": [
"email",
"password"
],
"expected_status": [
200,
400,
401
]
},
"refresh_token": {
"path": "/token?grant_type=refresh_token",
"method": "POST",
"required_params": [
"refresh_token"
],
"payload": [
"refresh_token"
],
"expected_status": [
200,
400,
401
]
},
"sign_out": {
"path": "/logout",
"method": "POST",
"required_params": [],
"payload": [],
"expected_status": [
204,
401
]
},
"get_user": {
"path": "/user",
"method": "GET",
"required_params": [],
"expected_status": [
200,
401
]
}
}
}
+222
View File
@@ -0,0 +1,222 @@
local UIManager = require("ui/uimanager")
local logger = require("logger")
local socketutil = require("socketutil")
-- Auth operation timeouts
local AUTH_TIMEOUTS = { 5, 10 }
-- Token refresh timeouts
local REFRESH_TIMEOUTS = { 3, 7 }
local SupabaseAuthClient = {
service_spec = nil,
api_key = nil,
}
function SupabaseAuthClient:new(o)
if o == nil then o = {} end
setmetatable(o, self)
self.__index = self
if o.init then o:init() end
return o
end
function SupabaseAuthClient:init()
local Spore = require("Spore")
self.client = Spore.new_from_spec(self.service_spec)
-- Supabase API headers middleware
package.loaded["Spore.Middleware.SupabaseHeaders"] = {}
require("Spore.Middleware.SupabaseHeaders").call = function(args, req)
req.headers["apikey"] = args.api_key
req.headers["content-type"] = "application/json"
req.headers["accept"] = "application/json"
end
-- Supabase Bearer token auth middleware
package.loaded["Spore.Middleware.SupabaseAuth"] = {}
require("Spore.Middleware.SupabaseAuth").call = function(args, req)
if args.access_token then
req.headers["authorization"] = "Bearer " .. args.access_token
end
end
package.loaded["Spore.Middleware.AsyncHTTP"] = {}
require("Spore.Middleware.AsyncHTTP").call = function(args, req)
-- disable async http if Turbo looper is missing
if not UIManager.looper then return end
req:finalize()
local result
require("httpclient"):new():request({
url = req.url,
method = req.method,
body = req.env.spore.payload,
on_headers = function(headers)
for header, value in pairs(req.headers) do
if type(header) == "string" then
headers:add(header, value)
end
end
end,
}, function(res)
result = res
-- Turbo HTTP client uses code instead of status
-- change to status so that Spore can understand
result.status = res.code
coroutine.resume(args.thread)
end)
return coroutine.create(function() coroutine.yield(result) end)
end
end
function SupabaseAuthClient:sign_in_password(email, password)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local ok, res = pcall(function()
return self.client:sign_in_password({
email = email,
password = password,
})
end)
socketutil:reset_timeout()
if ok then
return res.status == 200, res.body
else
logger.dbg("SupabaseAuthClient:sign_in_password failure:", res)
return false, res.body
end
end
function SupabaseAuthClient:sign_in_otp(email, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:sign_in_otp({
email = email,
})
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("SupabaseAuthClient:sign_in_otp failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
function SupabaseAuthClient:verify_otp(email, token, type)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local ok, res = pcall(function()
return self.client:verify_otp({
email = email,
token = token,
type = type or "email",
})
end)
socketutil:reset_timeout()
if ok then
return res.status == 200, res.body
else
logger.dbg("SupabaseAuthClient:verify_otp failure:", res)
return false, res.body
end
end
function SupabaseAuthClient:refresh_token(refresh_token, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
socketutil:set_timeout(REFRESH_TIMEOUTS[1], REFRESH_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:refresh_token({
refresh_token = refresh_token,
})
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("SupabaseAuthClient:refresh_token failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
function SupabaseAuthClient:sign_out(access_token, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
self.client:enable("SupabaseAuth", {
access_token = access_token,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:sign_out()
end)
if ok then
callback(res.status == 204, res.body)
else
logger.dbg("SupabaseAuthClient:sign_out failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
function SupabaseAuthClient:get_user(access_token, callback)
self.client:reset_middlewares()
self.client:enable("Format.JSON")
self.client:enable("SupabaseHeaders", {
api_key = self.api_key,
})
self.client:enable("SupabaseAuth", {
access_token = access_token,
})
socketutil:set_timeout(AUTH_TIMEOUTS[1], AUTH_TIMEOUTS[2])
local co = coroutine.create(function()
local ok, res = pcall(function()
return self.client:get_user()
end)
if ok then
callback(res.status == 200, res.body)
else
logger.dbg("SupabaseAuthClient:get_user failure:", res)
callback(false, res.body)
end
end)
self.client:enable("AsyncHTTP", {thread = co})
coroutine.resume(co)
if UIManager.looper then UIManager:setInputTimeout() end
socketutil:reset_timeout()
end
return SupabaseAuthClient