Files
readest/apps/readest.koplugin/main.lua
T
Huang Xin 7810981417 fix(koplugin): repair reading-stats sync push/pull (#4666)
* fix(koplugin): repair reading-stats sync push/pull

Reading statistics (statistics.sqlite3 page events) never reached the
Readest sync server. Three stacked defects in the koplugin stats path:

1. push/pull called settings:readSetting/saveSetting on self.settings,
   which is the plain readest_sync data table (not a LuaSettings object),
   so auto-sync crashed on every book open
   ("attempt to call method 'readSetting' (a nil value)").
2. pushChanges declares books/notes/configs as required_params, but the
   stats push sent only statBooks/statPages, so Spore rejected the request
   client-side ("books is required for method pushChanges").
3. statBooks/statPages were listed only under the spec's payload, not as
   optional_params. Spore's expected-param set is
   required_params + optional_params, so it rejected them too
   ("statBooks is not expected for method pushChanges").

Fixes:
- Read/persist the cursor as a plain field and save the whole table via
  G_reader_settings:saveSetting, mirroring readest_syncauth.
- Send empty books/notes/configs alongside statBooks/statPages; the server
  defaults each to [] and processes the stat arrays independently.
- Declare statBooks/statPages as optional_params in readest-sync-api.json.

Also add ReadestStats debug logging across pushBookStats/pullBookStats and
push/pull (cursor, collected counts, dispatch, response status, cursor
advance), and surface the push failure status/body that was previously
swallowed on non-interactive syncs.

Tests: cover push/pull cursor handling, the pushChanges required_params
contract, and a spec-level check that every stats payload key is an
expected pushChanges param.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(koplugin): add interactive Push/Pull stats now menu items

Add "Push stats now" and "Pull stats now" entries to the Readest sync
menu, placed after "Readest library" and before "Push books now" and
gated on being signed in (access_token + user_id) like the other
account-level items. They call pushBookStats(true) / pullBookStats(true)
for a manual interactive sync of reading statistics (the whole
statistics.sqlite3 delta), complementing the automatic pull-on-open /
push-on-close.

Interactive push/pull now also confirm their result (pushed / pulled /
up to date) to match the sibling "now" items, since a silent success
would look like a no-op.

Extract the five new strings into the koplugin .po catalogs (empty
msgstr, English fallback at runtime).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(koplugin): translate reading-stats sync strings (33 locales)

Fill the previously empty msgstr entries for the reading-statistics sync
strings across all koplugin locale catalogs: the two new "Push/Pull stats
now" menu items, the pushed / pulled / up-to-date confirmations, and the
push/pull failure messages. Each locale mirrors its existing
Push/Pull/Failed-to verbs and "reading" terminology for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:37:32 +02:00

830 lines
32 KiB
Lua

local Dispatcher = require("dispatcher")
local InfoMessage = require("ui/widget/infomessage")
local KeyValuePage = require("ui/widget/keyvaluepage")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local NetworkMgr = require("ui/network/manager")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local sha2 = require("ffi/sha2")
local T = require("ffi/util").template
local _ = require("readest_i18n")
local SyncAuth = require("readest_syncauth")
local SyncConfig = require("readest_syncconfig")
local SyncAnnotations = require("readest_syncannotations")
local SyncStats = require("readest_syncstats")
local SelfUpdate = require("readest_selfupdate")
local ReadestSync = WidgetContainer:new{
name = "readest",
title = _("Readest"),
settings = nil,
}
local API_CALL_DEBOUNCE_DELAY = 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_name = nil,
user_id = nil,
access_token = nil,
refresh_token = nil,
expires_at = nil,
expires_in = nil,
last_sync_at = nil,
}
-- ── Lifecycle ──────────────────────────────────────────────────────
function ReadestSync:init()
self.last_sync_timestamp = 0
self.settings = G_reader_settings:readSetting("readest_sync", self.default_settings)
local meta = dofile(self.path .. "/_meta.lua")
self.installed_version = meta and meta.version and tostring(meta.version)
self.ui.menu:registerToMainMenu(self)
-- Dispatcher actions (gestures / quick menu entries). Registering
-- here in init() rather than onReaderReady so the FileManager-only
-- actions (Readest Library, Push books, Pull books — flagged with
-- general=true) show up in FileManager → Settings → Taps and gestures
-- when no document is open. Reader-only actions (auto sync etc.)
-- still get registered on first plugin load too — they just don't
-- fire until a document opens.
self:onDispatcherRegisterActions()
-- Long-press file menu in FileManager: add an "Add to Readest"
-- entry that hashes the file, registers it in the LibraryStore, and
-- uploads it to the user's Readest cloud. Skipped in reader context
-- (FileManager.instance is nil there).
self:registerFileDialogButton()
end
-- Register Library actions (Open / Push / Pull) — available in both
-- reader and FileManager via the `general=true` flag.
function ReadestSync:onDispatcherRegisterActions()
Dispatcher:registerAction("readest_open_library", { category="none", event="ReadestOpenLibrary", title=_("Open Readest Library"), general=true,})
Dispatcher:registerAction("readest_push_books", { category="none", event="ReadestPushBooks", title=_("Push Readest book library"), general=true,})
Dispatcher:registerAction("readest_pull_books", { category="none", event="ReadestPullBooks", title=_("Pull Readest book library"), general=true,})
end
-- Reader-only actions (progress + annotations sync) — registered only
-- after a document opens, so they don't appear in the gesture picker
-- when FileManager is the foreground context. They'd never fire from
-- FileManager anyway (the handlers operate on the active ReaderUI), so
-- showing them there would just be noise.
function ReadestSync:onDispatcherRegisterReaderActions()
Dispatcher:registerAction("readest_sync_set_autosync",
{ category="string", event="ReadestSyncToggleAutoSync", title=_("Set auto progress sync"), reader=true,
args={true, false}, toggle={_("on"), _("off")},})
Dispatcher:registerAction("readest_sync_toggle_autosync", { category="none", event="ReadestSyncToggleAutoSync", title=_("Toggle auto readest sync"), reader=true,})
Dispatcher:registerAction("readest_sync_push_progress", { category="none", event="ReadestSyncPushProgress", title=_("Push readest progress from this device"), reader=true,})
Dispatcher:registerAction("readest_sync_pull_progress", { category="none", event="ReadestSyncPullProgress", title=_("Pull readest progress from other devices"), reader=true, separator=true,})
Dispatcher:registerAction("readest_sync_push_annotations", { category="none", event="ReadestSyncPushAnnotations", title=_("Push readest annotations from this device"), reader=true,})
Dispatcher:registerAction("readest_sync_pull_annotations", { category="none", event="ReadestSyncPullAnnotations", title=_("Pull readest annotations from other devices"), reader=true, separator=true,})
end
function ReadestSync:onReaderReady()
if self.settings.auto_sync and self.settings.access_token then
UIManager:nextTick(function()
self:pullBookConfig(false)
self:pullBookNotes(false)
self:pullBookStats(false)
end)
end
self:onDispatcherRegisterReaderActions()
end
-- Reverse-lookup table: file extension (lowercase) → Readest format
-- token (uppercase). Computed once at module load from EXTS so we don't
-- pay the iteration cost per long-press.
local _readest_format_for_ext = nil
local function readest_format_for_ext(ext)
if not _readest_format_for_ext then
_readest_format_for_ext = {}
local EXTS = require("library.exts")
for fmt, e in pairs(EXTS) do _readest_format_for_ext[e] = fmt end
end
return ext and _readest_format_for_ext[ext:lower()]
end
-- Register an "Add to Readest" button in FileManager's long-press file
-- dialog (the one with Paste / Cut / Delete / Copy / Reading / On hold
-- / etc.). The button only renders for supported book formats and
-- when the user is signed in to Readest.
--
-- Why scheduleIn(0): plugin init() runs while KOReader is still
-- constructing the FileManager — FileManager.instance is not yet
-- assigned. Deferring to the next event-loop tick (same trick simpleui
-- uses at plugins/simpleui.koplugin/main.lua:261) lets the FileManager
-- finish its own init first so .instance is populated when we look it
-- up. Safe in reader context too: .instance stays nil so we no-op.
function ReadestSync:registerFileDialogButton()
local plugin = self
UIManager:scheduleIn(0, function()
local ok_FM, FileManager = pcall(require, "apps/filemanager/filemanager")
if not ok_FM or not FileManager.instance then return end
FileManager.instance:addFileDialogButtons("readest_add_to_library",
function(file, is_file, _book_props)
if not is_file then return nil end
local ext = file:match("%.([^./\\]+)$")
if not readest_format_for_ext(ext) then return nil end
return {
{
text = _("Add to Readest"),
enabled = plugin.settings.access_token ~= nil,
callback = function()
local fc = FileManager.instance and FileManager.instance.file_chooser
local dlg = fc and fc.file_dialog
if dlg then UIManager:close(dlg) end
plugin:addToReadest(file)
end,
},
}
end)
end)
end
-- Hash the file, upsert a Library row, then upload to cloud. Mirrors
-- the upload flow we already use in librarywidget's "Upload to Cloud"
-- action — the only new thing here is computing the partial_md5 from
-- the file directly, since long-pressing in FileManager doesn't go
-- through the Library row path.
function ReadestSync:addToReadest(file)
local lfs = require("libs/libkoreader-lfs")
local util = require("util")
if not self.settings.access_token then
UIManager:show(InfoMessage:new{
text = _("Sign in to Readest first."), timeout = 3,
})
return
end
local attr = lfs.attributes(file)
if not attr or attr.mode ~= "file" then
UIManager:show(InfoMessage:new{
text = _("File not found."), timeout = 3,
})
return
end
local ext = file:match("%.([^./\\]+)$")
local format = readest_format_for_ext(ext)
if not format then
UIManager:show(InfoMessage:new{
text = _("Unsupported book format."), timeout = 3,
})
return
end
-- Hash via util.partialMD5 — same algorithm Readest uses, fast
-- (reads small chunks at fixed offsets, no full-file scan).
local progress = InfoMessage:new{
text = _("Hashing book…"),
}
UIManager:show(progress)
UIManager:nextTick(function()
local hash = util.partialMD5(file)
UIManager:close(progress)
if not hash then
UIManager:show(InfoMessage:new{
text = _("Could not read file."), timeout = 3,
})
return
end
self:_addLocalRow(file, hash, format, attr.size)
end)
end
function ReadestSync:_addLocalRow(file, hash, format, _size)
local store = self:getLibraryStore()
if not store then
UIManager:show(InfoMessage:new{
text = _("Sign in to Readest first."), timeout = 3,
})
return
end
-- Title: filename minus extension. BIM might have richer metadata
-- if the user has opened the book before, but a filename-derived
-- title is good enough for the row to round-trip — the user can
-- later trigger an Upload to Cloud which carries it to Readest.
local basename = file:match("([^/]+)$") or file
local title = basename:gsub("%.[^.]+$", "")
local now = math.floor(os.time() * 1000)
logger.info("ReadestSync addToReadest: hash=" .. hash:sub(1, 8)
.. " title=" .. tostring(title)
.. " format=" .. tostring(format)
.. " path=" .. tostring(file)
.. " now=" .. tostring(now))
-- Dedupe by partial_md5: if a non-tombstoned row with this hash is
-- already in the user's library, just bump its updated_at and skip.
-- Tombstoned rows (deleted_at set) are treated as "not in library"
-- — re-adding writes a fresh local-only row with cloud_present=0.
local existing = store:_getRowRaw(hash)
if existing then
logger.info("ReadestSync addToReadest: existing row found"
.. " cloud_present=" .. tostring(existing.cloud_present)
.. " local_present=" .. tostring(existing.local_present)
.. " deleted_at=" .. tostring(existing.deleted_at)
.. " updated_at=" .. tostring(existing.updated_at))
else
logger.info("ReadestSync addToReadest: no existing row — inserting")
end
if existing and existing.deleted_at == nil then
store:upsertBook({
hash = hash,
title = existing.title or title,
file_path = file,
local_present = 1,
updated_at = now,
})
logger.info("ReadestSync addToReadest dedupe: bumped updated_at for "
.. hash:sub(1, 8) .. " to " .. tostring(now))
local LibraryWidget = require("library.librarywidget")
if LibraryWidget._menu then LibraryWidget.refresh() end
UIManager:show(InfoMessage:new{
text = _("Already in your Readest library:") .. " "
.. (existing.title or title),
timeout = 2,
})
return
end
-- Add as a local-only row (cloud_present defaults to 0). Stamp
-- created_at + updated_at explicitly so the row sorts under "Date
-- Added" / "Last Updated" right away, and so the next pushChangedBooks
-- pass picks it up (its query is `updated_at > since`).
-- _clear_fields un-tombstones the row when re-adding a previously-
-- deleted book — passing deleted_at = nil alone wouldn't clear it
-- because Lua tables drop nil keys and upsertBook's preserve pass
-- would copy the existing tombstone forward.
store:upsertBook({
hash = hash,
title = title,
format = format,
file_path = file,
local_present = 1,
created_at = now,
updated_at = now,
_clear_fields = { "deleted_at" },
})
local row = store:_getRowRaw(hash)
logger.info("ReadestSync addToReadest insert: stored row"
.. " updated_at=" .. tostring(row and row.updated_at)
.. " created_at=" .. tostring(row and row.created_at)
.. " cloud_present=" .. tostring(row and row.cloud_present)
.. " local_present=" .. tostring(row and row.local_present))
local LibraryWidget = require("library.librarywidget")
if LibraryWidget._menu then LibraryWidget.refresh() end
UIManager:show(InfoMessage:new{
text = _("Added to Readest:") .. " " .. title,
timeout = 2,
})
end
function ReadestSync:onAddToReadest(file)
self:addToReadest(file)
end
-- ── Menu ───────────────────────────────────────────────────────────
function ReadestSync:addToMainMenu(menu_items)
menu_items.readest_sync = {
sorting_hint = "tools",
text = _("Readest"),
sub_item_table = {
{
text_func = function()
return SyncAuth:needsLogin(self.settings) and _("Log in Readest Account")
or T(_("Log out as %1"), self.settings.user_name or "")
end,
callback_func = function()
if SyncAuth:needsLogin(self.settings) then
return function(menu)
SyncAuth:login(self.settings, self.path, self.title, menu)
end
else
return function(menu)
SyncAuth:logout(self.settings, self.path, menu)
end
end
end,
},
{
text = _("Auto sync"),
checked_func = function() return self.settings.auto_sync end,
callback = function()
self:onReadestSyncToggleAutoSync()
end,
separator = true,
},
{
text = _("Readest library"),
enabled_func = function()
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
end,
callback = function()
self:openLibrary()
end,
},
{
text = _("Push stats now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
end,
callback = function()
self:pushBookStats(true)
end,
},
{
text = _("Pull stats now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
end,
callback = function()
self:pullBookStats(true)
end,
},
{
text = _("Push books now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
end,
callback = function()
self:syncBooksLibrary("push", true)
end,
},
{
text = _("Pull books now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.settings.user_id ~= nil
end,
callback = function()
self:syncBooksLibrary("pull", true)
end,
separator = true,
},
{
text = _("Push reading progress now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.ui.document ~= nil
end,
callback = function()
self:pushBookConfig(true)
end,
},
{
text = _("Pull reading progress now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.ui.document ~= nil
end,
callback = function()
self:pullBookConfig(true)
end,
separator = true,
},
{
text = _("Push annotations now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.ui.document ~= nil
end,
callback = function()
self:pushBookNotes(true)
end,
},
{
text = _("Pull annotations now"),
enabled_func = function()
return self.settings.access_token ~= nil and self.ui.document ~= nil
end,
callback = function()
self:pullBookNotes(true)
end,
},
{
text = _("Full sync all annotations"),
enabled_func = function()
return self.settings.access_token ~= nil and self.ui.document ~= nil
end,
callback = function()
self:fullSyncBookNotes()
end,
separator = true,
},
{
text = _("Sync info"),
enabled_func = function()
return self.ui.document ~= nil
end,
callback = function()
self:showSyncInfo()
end,
separator = true,
},
{
text_func = function()
if self.installed_version then
return T(_("Check for update (v%1)"), self.installed_version)
end
return _("Check for update")
end,
callback = function()
SelfUpdate:checkForUpdate(self.path, self.installed_version)
end,
},
}
}
end
-- ── Sync helpers (thin wrappers around modules) ────────────────────
function ReadestSync:ensureClient(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 nil
end
SyncAuth:tryRefreshToken(self.settings, self.path)
local client = SyncAuth:getReadestSyncClient(self.settings, self.path)
if not client then
if interactive then
UIManager:show(InfoMessage:new{
text = _("Please configure Readest settings first"),
timeout = 3,
})
end
return nil
end
return client
end
function ReadestSync:getBookIdentifiers()
local book_hash = SyncConfig:getDocumentIdentifier(self.ui)
local meta_hash = SyncConfig:getMetaHash(self.ui)
return book_hash, meta_hash
end
function ReadestSync:showSyncInfo()
if not self.ui.document then
UIManager:show(InfoMessage:new{
text = _("No book is open"),
timeout = 2,
})
return
end
local info = SyncConfig:getMetadataHashInfo(self.ui)
local doc_readest_sync = self.ui.doc_settings:readSetting("readest_sync") or {}
local stored_meta_hash = doc_readest_sync.meta_hash_v1
local placeholder = _("(none)")
local last_synced_at = math.max(
doc_readest_sync.last_synced_at_config or 0,
doc_readest_sync.last_synced_at_notes or 0
)
local last_synced_label = last_synced_at > 0
and os.date("%Y-%m-%d %H:%M", last_synced_at)
or _("Never synced")
local kv_pairs = {
{ _("Book Fingerprint"), stored_meta_hash or info.meta_hash },
}
table.insert(kv_pairs, { _("Title"), info.title ~= "" and info.title or placeholder })
table.insert(kv_pairs, {
_("Author"),
#info.authors > 0 and table.concat(info.authors, ", ") or placeholder,
})
table.insert(kv_pairs, {
_("Identifiers"),
#info.identifiers > 0 and table.concat(info.identifiers, ", ") or placeholder,
})
table.insert(kv_pairs, { _("Last Synced"), last_synced_label })
UIManager:show(KeyValuePage:new{
title = _("Sync Info"),
kv_pairs = kv_pairs,
})
end
-- ── Config sync ────────────────────────────────────────────────────
function ReadestSync:pushBookConfig(interactive)
local now = os.time()
if not interactive and now - self.last_sync_timestamp <= API_CALL_DEBOUNCE_DELAY then
return
end
if interactive and NetworkMgr:willRerunWhenOnline(function() self:pushBookConfig(interactive) end) then
return
end
local client = self:ensureClient(interactive)
if not client then return end
self.last_sync_timestamp = SyncConfig:push(
self.ui, self.settings, client, interactive, self.last_sync_timestamp
)
end
function ReadestSync:pullBookConfig(interactive)
local book_hash, meta_hash = self:getBookIdentifiers()
if not book_hash or not meta_hash then return end
if NetworkMgr:willRerunWhenOnline(function() self:pullBookConfig(interactive) end) then
return
end
local client = self:ensureClient(interactive)
if not client then return end
SyncConfig:pull(
self.ui, self.settings, client, book_hash, meta_hash, interactive,
function() SyncAuth:logout(self.settings, self.path) end
)
end
-- ── Reading statistics sync ────────────────────────────────────────
function ReadestSync:pushBookStats(interactive)
logger.dbg("ReadestStats pushBookStats: triggered, interactive=" .. tostring(interactive))
if interactive and NetworkMgr:willRerunWhenOnline(function() self:pushBookStats(interactive) end) then
return
end
local client = self:ensureClient(interactive)
if not client then
logger.dbg("ReadestStats pushBookStats: no client (not signed in / offline); skipping")
return
end
SyncStats:push(self.settings, client, interactive)
end
function ReadestSync:pullBookStats(interactive)
logger.dbg("ReadestStats pullBookStats: triggered, interactive=" .. tostring(interactive))
if NetworkMgr:willRerunWhenOnline(function() self:pullBookStats(interactive) end) then
return
end
local client = self:ensureClient(interactive)
if not client then
logger.dbg("ReadestStats pullBookStats: no client (not signed in / offline); skipping")
return
end
SyncStats:pull(self.settings, client, interactive,
function() SyncAuth:logout(self.settings, self.path) end)
end
-- ── Annotation sync ────────────────────────────────────────────────
function ReadestSync:pushBookNotes(interactive, full_sync)
if interactive and NetworkMgr:willRerunWhenOnline(function() self:pushBookNotes(interactive, full_sync) end) then
return
end
local client = self:ensureClient(interactive)
if not client then return end
SyncAnnotations:push(self.ui, self.settings, client, interactive, full_sync)
end
function ReadestSync:pullBookNotes(interactive, full_sync)
local book_hash, meta_hash = self:getBookIdentifiers()
if not book_hash or not meta_hash then return end
if NetworkMgr:willRerunWhenOnline(function() self:pullBookNotes(interactive, full_sync) end) then
return
end
local client = self:ensureClient(interactive)
if not client then return end
SyncAnnotations:pull(
self.ui, self.settings, client, book_hash, meta_hash, self.dialog, interactive, full_sync
)
end
function ReadestSync:fullSyncBookNotes()
-- Push all annotations first, then pull all
self:pushBookNotes(true, true)
self:pullBookNotes(true, true)
end
-- ── Event handlers ─────────────────────────────────────────────────
function ReadestSync:onReadestSyncToggleAutoSync(toggle)
if toggle == self.settings.auto_sync then
return true
end
self.settings.auto_sync = not self.settings.auto_sync
G_reader_settings:saveSetting("readest_sync", self.settings)
if self.settings.auto_sync and self.ui.document then
self:pullBookConfig(false)
end
end
function ReadestSync:onReadestSyncPushProgress()
self:pushBookConfig(true)
end
function ReadestSync:onReadestSyncPullProgress()
self:pullBookConfig(true)
end
function ReadestSync:onReadestSyncPushAnnotations()
self:pushBookNotes(true)
end
function ReadestSync:onReadestSyncPullAnnotations()
self:pullBookNotes(true)
end
function ReadestSync:openLibrary()
if not self.settings.access_token or not self.settings.user_id then
UIManager:show(InfoMessage:new{ text = _("Please login first"), timeout = 2 })
return
end
local LibraryWidget = require("library.librarywidget")
LibraryWidget.open({
settings = self.settings,
sync_path = self.path,
sync_auth = SyncAuth,
})
end
function ReadestSync:onReadestOpenLibrary()
self:openLibrary()
end
-- Lazy-open a LibraryStore for the current user. The Library widget may
-- already have one open via librarywidget._store; we share it when present
-- to avoid two SQLite handles to the same file. Returns nil if user_id
-- isn't set (shouldn't happen — caller should gate on access_token).
function ReadestSync:getLibraryStore()
if not self.settings.user_id or self.settings.user_id == "" then return nil end
local LibraryWidget = require("library.librarywidget")
if LibraryWidget._store and LibraryWidget._current_user == self.settings.user_id then
return LibraryWidget._store
end
if self.library_store and self.library_store.user_id == self.settings.user_id then
return self.library_store
end
if self.library_store then self.library_store:close() end
local LibraryStore = require("library.librarystore")
local DataStorage = require("datastorage")
self.library_store = LibraryStore.new({
user_id = self.settings.user_id,
db_path = DataStorage:getSettingsDir() .. "/readest_library.sqlite3",
})
return self.library_store
end
-- Bump updated_at + last_read_at on the local row for the currently-open
-- book. Called before a sync push so the row's timestamp is fresh. Returns
-- the touched row, or nil if there's nothing to touch (no book open, no
-- partial_md5, or hash not in the LibraryStore index).
function ReadestSync:touchOpenBook()
if not self.ui or not self.ui.doc_settings then return nil end
local hash = self.ui.doc_settings:readSetting("partial_md5_checksum")
if not hash or hash == "" then return nil end
local store = self:getLibraryStore()
if not store then return nil end
local progress_lib
if self.ui.document and self.ui.document.getPageCount and self.ui.getCurrentPage then
local cur = self.ui:getCurrentPage()
local total = self.ui.document:getPageCount()
if cur and total then
progress_lib = require("json").encode({ cur, total })
end
end
local touched = store:touchBook(hash, { progress_lib = progress_lib })
if not touched then
logger.dbg("ReadestSync touchOpenBook: no row for " .. hash
.. " (book not in LibraryStore index)")
end
return touched
end
-- syncBooksLibrary(mode, interactive) — bidirectional book-row sync,
-- mirroring useBooksSync.handleAutoSync at apps/readest-app/src/app/
-- library/hooks/useBooksSync.ts:66-78. mode: "push"|"pull"|"both".
-- The touched-row bump happens via the before_push callback so it lands
-- AFTER pull has refreshed the local row with the cloud's uploaded_at /
-- metadata / group_id — see syncbooks.syncBooks docstring + issue #4138.
-- Interactive=true shows toast feedback.
function ReadestSync:syncBooksLibrary(mode, 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 store = self:getLibraryStore()
if not store then
if interactive then
UIManager:show(InfoMessage:new{ text = _("Library not initialized"), timeout = 2 })
end
return
end
local syncbooks = require("library.syncbooks")
syncbooks.syncBooks({
sync_auth = SyncAuth,
sync_path = self.path,
settings = self.settings,
store = store,
}, mode, function(success, msg, status)
logger.info("ReadestSync syncBooksLibrary[" .. mode .. "] done: success="
.. tostring(success) .. " msg=" .. tostring(msg))
if interactive then
UIManager:show(InfoMessage:new{
text = success
and _("Books synced")
or _("Books sync failed"),
timeout = 2,
})
end
-- If the Library widget is open, refresh it so newly-pulled rows show
local LibraryWidget = require("library.librarywidget")
if LibraryWidget._menu then LibraryWidget.refresh() end
end, function()
-- before_push: bump updated_at on the open book so its row is in
-- the push delta. Runs after pull so the cloud's uploaded_at /
-- metadata / group_id have already merged into the local row;
-- touchBook then preserves those fields.
self:touchOpenBook()
end)
end
function ReadestSync:onCloseDocument()
if self.settings.auto_sync and self.settings.access_token then
NetworkMgr:goOnlineToRun(function()
self:pushBookConfig(false)
self:pushBookNotes(false)
self:pushBookStats(false)
self:syncBooksLibrary("both", false)
end)
end
end
function ReadestSync:onReadestPushBooks()
self:syncBooksLibrary("push", true)
end
function ReadestSync:onReadestPullBooks()
self:syncBooksLibrary("pull", true)
end
function ReadestSync:onPageUpdate(page)
if self.settings.auto_sync and self.settings.access_token and page then
if self.delayed_push_task then
UIManager:unschedule(self.delayed_push_task)
end
self.delayed_push_task = function()
self:pushBookConfig(false)
end
UIManager:scheduleIn(5, self.delayed_push_task)
end
end
function ReadestSync:onAnnotationsModified(items)
-- A removal fires AnnotationsModified with a negative index_modified and the
-- deleted item at items[1]. Capture a tombstone now, before the item is gone
-- for good — the push walk only sees live annotations, so without this the
-- deletion never reaches the server (issue #4119, push direction).
if self.settings.access_token and items and items.index_modified
and items.index_modified < 0 and items[1] then
SyncAnnotations:recordDeletion(self.ui.doc_settings, items[1])
end
if self.settings.auto_sync and self.settings.access_token then
UIManager:nextTick(function()
self:pushBookNotes(false)
end)
end
end
function ReadestSync:onCloseWidget()
if self.delayed_push_task then
UIManager:unschedule(self.delayed_push_task)
self.delayed_push_task = nil
end
end
function ReadestSync:deletePluginSettings()
G_reader_settings:delSetting("readest_sync")
self.settings = self.default_settings
return true
end
return ReadestSync