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, self.ui) 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 -- Waking the device with a book already open should pull like reopening -- the book does (issue #4924). Delayed because Wi-Fi is usually still -- coming back up right after wake (kosync uses the same 1s delay); the -- pull helpers rerun themselves when online if that isn't enough. On -- devices where Suspend/Resume also fire on focus changes (Android), -- the debounce keeps this from hammering the API. function ReadestSync:onResume() if not (self.settings.auto_sync and self.settings.access_token and self.ui.document) then return end local now = os.time() if now - (self.last_resume_sync_timestamp or 0) <= API_CALL_DEBOUNCE_DELAY then return end self.last_resume_sync_timestamp = now if self.resume_pull_task then UIManager:unschedule(self.resume_pull_task) end self.resume_pull_task = function() self.resume_pull_task = nil self:pullBookConfig(false) self:pullBookNotes(false) self:pullBookStats(false) end UIManager:scheduleIn(1, self.resume_pull_task) 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 if self.resume_pull_task then UIManager:unschedule(self.resume_pull_task) self.resume_pull_task = nil end end function ReadestSync:deletePluginSettings() G_reader_settings:delSetting("readest_sync") self.settings = self.default_settings return true end return ReadestSync