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 sha2 = require("ffi/sha2") local T = require("ffi/util").template local _ = require("gettext") local SyncAuth = require("syncauth") local SyncConfig = require("syncconfig") local SyncAnnotations = require("syncannotations") local SelfUpdate = require("selfupdate") local ReadestSync = WidgetContainer:new{ name = "readest", title = _("Readest Sync"), 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) end function ReadestSync:onDispatcherRegisterActions() 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) end) end self:onDispatcherRegisterActions() end -- ── Menu ─────────────────────────────────────────────────────────── function ReadestSync:addToMainMenu(menu_items) menu_items.readest_sync = { sorting_hint = "tools", text = _("Readest Sync"), sub_item_table = { { text_func = function() return SyncAuth:needsLogin(self.settings) and _("Log in Readest Account") or _("Log out as ") .. (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, separator = true, }, { text = _("Auto sync progress and annotations"), checked_func = function() return self.settings.auto_sync end, callback = function() self:onReadestSyncToggleAutoSync() end, separator = true, }, { text = _("Push book config now"), enabled_func = function() return self.settings.access_token ~= nil and self.ui.document ~= nil end, callback = function() self:pushBookConfig(true) end, }, { text = _("Pull book config 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 = _("Metadata hash info"), enabled_func = function() return self.ui.document ~= nil end, callback = function() self:showMetaHashInfo() 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:showMetaHashInfo() 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 kv_pairs = { { _("Meta Hash"), stored_meta_hash or info.meta_hash }, } if stored_meta_hash and stored_meta_hash ~= info.meta_hash then table.insert(kv_pairs, { _("Computed Hash"), info.meta_hash }) end 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, }) UIManager:show(KeyValuePage:new{ title = _("Metadata Hash"), 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 -- ── 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:onCloseDocument() if self.settings.auto_sync and self.settings.access_token then NetworkMgr:goOnlineToRun(function() self:pushBookConfig(false) self:pushBookNotes(false) end) end 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() 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