Files
readest/apps/readest.koplugin/syncauth.lua
T
Huang Xin 76b239f382 feat(koplugin): add support for annotation sync (#3579)
Add bidirectional annotation/highlight sync between KOReader and Readest:

- Add xpointer0/xpointer1 fields to BookNote and DBBookNote types for
  KOReader XPointer positions alongside Readest's CFI format
- Extend transform layer to pass through xpointer fields to/from DB
- Convert CFI→XPointer on push and XPointer→CFI on pull in useNotesSync,
  discarding notes that fail conversion
- Support KOReader's text()[K].N indexed text node format in xcfi.ts for
  paragraphs with inline elements (e.g. <a> page anchors)
- Generate KOReader-compatible XPointers: text().N for single text nodes,
  text()[K].N only when multiple direct text nodes exist
- Skip cfi-inert elements (injected by Readest at runtime) in XPointer
  path building and resolution
- Map highlight colors between KOReader and Readest color systems
- Implement KOReader plugin annotation push/pull with deterministic IDs,
  auto-sync on document open/close, and UIManager refresh on pull
- Refactor koplugin into focused modules: syncauth, syncconfig,
  syncannotations, selfupdate

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:43:51 +01:00

182 lines
5.7 KiB
Lua

local Device = require("device")
local InfoMessage = require("ui/widget/infomessage")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local util = require("util")
local _ = require("gettext")
local SyncAuth = {}
function SyncAuth:needsLogin(settings)
return not settings.access_token or not settings.expires_at
or settings.expires_at < os.time() + 60
end
function SyncAuth:tryRefreshToken(settings, path)
if settings.refresh_token and settings.expires_at
and settings.expires_at < os.time() + settings.expires_in / 2 then
local client = self:getSupabaseAuthClient(settings, path)
client:refresh_token(settings.refresh_token, function(success, response)
if success then
settings.access_token = response.access_token
settings.refresh_token = response.refresh_token
settings.expires_at = response.expires_at
settings.expires_in = response.expires_in
G_reader_settings:saveSetting("readest_sync", settings)
else
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
end
end)
end
end
function SyncAuth:getSupabaseAuthClient(settings, path)
if not settings.supabase_url or not settings.supabase_anon_key then
return nil
end
local SupabaseAuthClient = require("supabaseauth")
return SupabaseAuthClient:new{
service_spec = path .. "/supabase-auth-api.json",
custom_url = settings.supabase_url .. "/auth/v1/",
api_key = settings.supabase_anon_key,
}
end
function SyncAuth:getReadestSyncClient(settings, path)
if not settings.access_token or not settings.expires_at or settings.expires_at < os.time() then
return nil
end
local ReadestSyncClient = require("readestsync")
return ReadestSyncClient:new{
service_spec = path .. "/readest-sync-api.json",
access_token = settings.access_token,
}
end
function SyncAuth:login(settings, path, title, menu)
if NetworkMgr:willRerunWhenOnline(function() self:login(settings, path, title, menu) end) then
return
end
local dialog
dialog = MultiInputDialog:new{
title = title,
fields = {
{
text = 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(settings, path, email, password, menu)
end,
},
},
},
}
UIManager:show(dialog)
dialog:onShowKeyboard()
end
function SyncAuth:doLogin(settings, path, email, password, menu)
local client = self:getSupabaseAuthClient(settings, path)
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
settings.user_email = email
settings.user_id = response.user.id
settings.user_name = response.user.user_metadata.user_name or email
settings.access_token = response.access_token
settings.refresh_token = response.refresh_token
settings.expires_at = response.expires_at
settings.expires_in = response.expires_in
G_reader_settings:saveSetting("readest_sync", settings)
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.msg or "Unknown error"),
timeout = 3,
})
end
end
function SyncAuth:logout(settings, menu)
if settings.access_token then
local client = self:getSupabaseAuthClient(settings, "")
if client then
client:sign_out(settings.access_token, function(success, _response)
logger.dbg("ReadestSync: Sign out result:", success)
end)
end
end
settings.access_token = nil
settings.refresh_token = nil
settings.expires_at = nil
settings.expires_in = nil
G_reader_settings:saveSetting("readest_sync", settings)
if menu then
menu:updateItems()
end
UIManager:show(InfoMessage:new{
text = _("Logged out from Readest Sync"),
timeout = 2,
})
end
return SyncAuth