diff --git a/apps/readest.koplugin/_meta.lua b/apps/readest.koplugin/_meta.lua new file mode 100644 index 00000000..f20b0593 --- /dev/null +++ b/apps/readest.koplugin/_meta.lua @@ -0,0 +1,6 @@ +local _ = require("gettext") +return { + name = "readest", + fullname = _("Readest sync"), + description = _([[Keeps your KOReader and Readest devices in sync.]]), +} diff --git a/apps/readest.koplugin/main.lua b/apps/readest.koplugin/main.lua new file mode 100644 index 00000000..fcbdadb9 --- /dev/null +++ b/apps/readest.koplugin/main.lua @@ -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 \ No newline at end of file diff --git a/apps/readest.koplugin/readest-sync-api.json b/apps/readest.koplugin/readest-sync-api.json new file mode 100644 index 00000000..af6a0347 --- /dev/null +++ b/apps/readest.koplugin/readest-sync-api.json @@ -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 + ] + } + } +} \ No newline at end of file diff --git a/apps/readest.koplugin/readestsync.lua b/apps/readest.koplugin/readestsync.lua new file mode 100644 index 00000000..8dc6facf --- /dev/null +++ b/apps/readest.koplugin/readestsync.lua @@ -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 \ No newline at end of file diff --git a/apps/readest.koplugin/supabase-auth-api.json b/apps/readest.koplugin/supabase-auth-api.json new file mode 100644 index 00000000..9ca02934 --- /dev/null +++ b/apps/readest.koplugin/supabase-auth-api.json @@ -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 + ] + } + } +} \ No newline at end of file diff --git a/apps/readest.koplugin/supabaseauth.lua b/apps/readest.koplugin/supabaseauth.lua new file mode 100644 index 00000000..33eb83c9 --- /dev/null +++ b/apps/readest.koplugin/supabaseauth.lua @@ -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 \ No newline at end of file