local DataStorage = require("datastorage") local InfoMessage = require("ui/widget/infomessage") local InputDialog = require("ui/widget/inputdialog") local LuaSettings = require("luasettings") local UIManager = require("ui/uimanager") local WidgetContainer = require("ui/widget/container/widgetcontainer") local _ = require("gettext") local T = require("ffi/util").template local XRPCClient = require("xrpc") local Paperbnd = WidgetContainer:extend({ name = "paperbnd", is_doc_only = false, }) function Paperbnd:init() self.ui.menu:registerToMainMenu(self) -- Load settings self.settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/paperbnd.lua") self.handle = self.settings:readSetting("handle") self.app_password = self.settings:readSetting("app_password") self.pds_url = self.settings:readSetting("pds_url") self.access_token = self.settings:readSetting("access_token") self.refresh_token = self.settings:readSetting("refresh_token") self.did = self.settings:readSetting("did") self.document_mappings = self.settings:readSetting("document_mappings") or {} self.offline_queue = self.settings:readSetting("offline_queue") or {} self.auto_sync_enabled = self.settings:readSetting("auto_sync_enabled") if self.auto_sync_enabled == nil then self.auto_sync_enabled = false -- Default OFF for safety end -- Auto-sync state tracking self.auto_sync_task = nil -- Scheduled sync task reference self.last_synced_page = nil -- Prevent duplicate syncs self.auto_sync_delay = 10.0 -- 10 second debounce delay -- Initialize XRPC client self.xrpc = XRPCClient:new() if self.pds_url then self.xrpc:setPDS(self.pds_url) end if self.access_token then self.xrpc:setAuth(self.access_token, self.refresh_token) end if self.handle and self.app_password then self.xrpc:setCredentials(self.handle, self.app_password) end -- Set up callback for credential updates self.xrpc:setCredentialsCallback(function(access_token, refresh_token) self:onTokenRefresh(access_token, refresh_token) end) -- Validate session at startup if we have credentials if self:isAuthenticated() then self:validateSessionAtStartup() -- Try to flush any queued syncs from previous sessions self:flushOfflineQueue() end end function Paperbnd:saveSettings() self.settings:saveSetting("handle", self.handle) self.settings:saveSetting("app_password", self.app_password) self.settings:saveSetting("pds_url", self.pds_url) self.settings:saveSetting("access_token", self.access_token) self.settings:saveSetting("refresh_token", self.refresh_token) self.settings:saveSetting("did", self.did) self.settings:saveSetting("document_mappings", self.document_mappings) self.settings:saveSetting("offline_queue", self.offline_queue) self.settings:saveSetting("auto_sync_enabled", self.auto_sync_enabled) self.settings:flush() end function Paperbnd:onTokenRefresh(access_token, refresh_token) self.access_token = access_token self.refresh_token = refresh_token self:saveSettings() end function Paperbnd:validateSessionAtStartup() -- Validate session asynchronously to avoid blocking plugin startup -- This will trigger automatic renewal if the session is expired -- Session validation errors are intentionally ignored at startup -- renewSession will be called automatically on the next authenticated request self.xrpc:validateSession() end function Paperbnd:addToMainMenu(menu_items) -- Helper function to truncate long strings local function truncateString(str, max_length) if str and #str > max_length then return str:sub(1, max_length - 3) .. "..." end return str end menu_items.paperbnd = { text = _("Paperbnd"), sub_item_table = { -- Authentication section { text_func = function() if self:isAuthenticated() then return T(_("Account: @%1"), self.handle) else return _("Set credentials") end end, keep_menu_open = true, callback = function() self:setCredentials() end, separator = true, }, -- Book management section { text_func = function() if self:isCurrentBookLinked() then local doc_path = self:getCurrentDocumentPath() if doc_path and self.document_mappings[doc_path] then local mapping = self.document_mappings[doc_path] local title = truncateString(mapping.title, 40) return T(_("Linked: %1 by %2"), title, mapping.author) end end return _("Link current book") end, enabled_func = function() return self:isAuthenticated() and self.ui.document ~= nil end, callback = function() self:linkCurrentBook() end, }, { text = _("Unlink current book"), enabled_func = function() return self:isCurrentBookLinked() end, callback = function() self:unlinkCurrentBook() end, separator = true, }, -- Sync section { text = _("Sync progress now"), enabled_func = function() return self:isAuthenticated() and self:isCurrentBookLinked() end, callback = function() self:syncProgress() end, }, { text_func = function() local doc_path = self:getCurrentDocumentPath() -- Check if there's a queued sync for this document if doc_path and self.offline_queue[doc_path] then local queued = self.offline_queue[doc_path] return T(_("Last synced: Queued - offline (%1%)"), queued.percent) end if doc_path and self.document_mappings[doc_path] and self.document_mappings[doc_path].last_sync_time then local mapping = self.document_mappings[doc_path] local time_diff = os.time() - mapping.last_sync_time local time_str if time_diff < 60 then time_str = _("Just now") elseif time_diff < 3600 then local mins = math.floor(time_diff / 60) time_str = T(_("%1 min ago"), mins) elseif time_diff < 86400 then local hours = math.floor(time_diff / 3600) time_str = T(_("%1 hr ago"), hours) else local days = math.floor(time_diff / 86400) time_str = T(_("%1 days ago"), days) end return T(_("Last synced: %1% (%2)"), mapping.last_sync_percent, time_str) else return _("Last synced: Never") end end, enabled = false, }, { text_func = function() if self.auto_sync_enabled then return _("Auto-sync: Enabled") else return _("Auto-sync: Disabled") end end, checked_func = function() return self.auto_sync_enabled end, callback = function() self.auto_sync_enabled = not self.auto_sync_enabled self:saveSettings() -- Cancel pending sync when disabling if not self.auto_sync_enabled and self.auto_sync_task then UIManager:unschedule(self.auto_sync_task) self.auto_sync_task = nil self.last_synced_page = nil end local status = self.auto_sync_enabled and _("enabled") or _("disabled") UIManager:show(InfoMessage:new({ text = T(_("Auto-sync %1"), status), timeout = 2, })) end, }, }, } end function Paperbnd:isAuthenticated() return self.handle ~= nil and self.access_token ~= nil and self.did ~= nil end function Paperbnd:getCurrentDocumentPath() if self.ui.document then return self.ui.document.file end return nil end function Paperbnd:isCurrentBookLinked() local doc_path = self:getCurrentDocumentPath() return doc_path ~= nil and self.document_mappings[doc_path] ~= nil end function Paperbnd:setCredentials() local handle_input handle_input = InputDialog:new({ title = _("Enter your handle"), input = self.handle or "", buttons = { { { text = _("Cancel"), callback = function() UIManager:close(handle_input) end, }, { text = _("Next"), is_enter_default = true, callback = function() local handle = handle_input:getInputText() UIManager:close(handle_input) if handle and handle ~= "" then self:setAppPassword(handle) end end, }, }, }, }) UIManager:show(handle_input) handle_input:onShowKeyboard() end function Paperbnd:setAppPassword(handle) local password_input password_input = InputDialog:new({ title = _("Enter your app password"), input = self.app_password or "", text_type = "password", buttons = { { { text = _("Cancel"), callback = function() UIManager:close(password_input) end, }, { text = _("Login"), is_enter_default = true, callback = function() local password = password_input:getInputText() UIManager:close(password_input) if password and password ~= "" then self:authenticate(handle, password) end end, }, }, }, }) UIManager:show(password_input) password_input:onShowKeyboard() end function Paperbnd:authenticate(handle, password) UIManager:show(InfoMessage:new({ text = _("Authenticating..."), timeout = 1, })) -- Resolve PDS local pds_url, pds_err = self.xrpc:resolvePDS(handle) if pds_err then UIManager:show(InfoMessage:new({ text = T(_("Failed to resolve PDS: %1"), pds_err), })) return end self.pds_url = pds_url self.xrpc:setPDS(pds_url) -- Create session local session, session_err = self.xrpc:createSession(handle, password) if session_err then UIManager:show(InfoMessage:new({ text = T(_("Authentication failed: %1"), session_err), })) return end self.handle = handle self.app_password = password self.access_token = session.accessJwt self.refresh_token = session.refreshJwt self.did = session.did -- Store credentials in XRPC client for automatic session renewal self.xrpc:setCredentials(handle, password) self:saveSettings() UIManager:show(InfoMessage:new({ text = _("Authentication successful!"), })) end function Paperbnd:linkCurrentBook() if not self:isAuthenticated() then UIManager:show(InfoMessage:new({ text = _("Please set credentials first"), })) return end UIManager:show(InfoMessage:new({ text = _("Fetching your books..."), timeout = 1, })) -- Fetch list items local response, err = self.xrpc:listRecords(self.did, "social.popfeed.feed.listItem", 100, nil) if err then UIManager:show(InfoMessage:new({ text = T(_("Failed to fetch books: %1"), err), })) return end if not response.records or #response.records == 0 then UIManager:show(InfoMessage:new({ text = _("No books found in your account"), })) return end -- Filter for books only local books = {} for _, record in ipairs(response.records) do if record.value and record.value.creativeWorkType == "book" and record.value.listType == "currently_reading_books" then table.insert(books, { title = record.value.title or "Unknown", author = record.value.mainCredit or "Unknown", rkey = record.uri:match("([^/]+)$"), record = record.value, }) end end if #books == 0 then UIManager:show(InfoMessage:new({ text = _("No books found in your account"), })) return end -- Show selection dialog self:showBookSelectionDialog(books) end function Paperbnd:showBookSelectionDialog(books) local Menu = require("ui/widget/menu") local CenterContainer = require("ui/widget/container/centercontainer") local Screen = require("device").screen local items = {} for _, book in ipairs(books) do table.insert(items, { text = book.title, subtitle = book.author, book = book, }) end local book_menu_container local book_menu = Menu:new({ title = _("Select a book"), item_table = items, width = Screen:getWidth() - Screen:scaleBySize(80), height = Screen:getHeight() - Screen:scaleBySize(80), fullscreen = true, single_line = false, onMenuSelect = function(_, item) UIManager:close(book_menu_container) self:confirmLinkBook(item.book) end, }) book_menu_container = CenterContainer:new({ dimen = Screen:getSize(), book_menu, }) UIManager:show(book_menu_container) end function Paperbnd:confirmLinkBook(book) local doc_path = self:getCurrentDocumentPath() if not doc_path then return end self.document_mappings[doc_path] = { rkey = book.rkey, title = book.title, author = book.author, } self:saveSettings() UIManager:show(InfoMessage:new({ text = T(_("Linked to: %1"), book.title), })) end function Paperbnd:unlinkCurrentBook() local doc_path = self:getCurrentDocumentPath() if doc_path and self.document_mappings[doc_path] then local book_title = self.document_mappings[doc_path].title self.document_mappings[doc_path] = nil self:saveSettings() UIManager:show(InfoMessage:new({ text = T(_("Unlinked: %1"), book_title), })) end end function Paperbnd:syncProgress(use_notification) if not self:isAuthenticated() then return end local doc_path = self:getCurrentDocumentPath() if not doc_path or not self.document_mappings[doc_path] then return end -- Cancel any pending auto-sync for current document (manual sync takes priority) if self.auto_sync_task then UIManager:unschedule(self.auto_sync_task) self.auto_sync_task = nil end -- Get document statistics local pages = self.ui.document:getPageCount() local current_page if self.ui.paging then -- Paged documents (PDF, CBZ, etc.) current_page = self.ui.paging.current_page elseif self.ui.rolling then -- Reflowable documents (EPUB, MOBI, etc.) current_page = self.ui.rolling.current_page else current_page = 1 end local percent = math.floor((current_page / pages) * 100) -- Try to sync local success = self:attemptSync(doc_path, percent, current_page, pages) if success then -- Show success notification (silent for auto-sync) if not use_notification then -- Use InfoMessage for manual sync and document close UIManager:show(InfoMessage:new({ text = T(_("Synced: %1% (%2/%3)"), percent, current_page, pages), timeout = 2, })) end -- On success, try to flush any queued syncs self:flushOfflineQueue() else -- Queue this sync for later (silent) self:queueOfflineSync(doc_path, percent, current_page, pages) end end -- Helper function to detect network errors function Paperbnd:isNetworkError(error_msg) if not error_msg then return false end local error_lower = string.lower(error_msg) return string.find(error_lower, "connection") ~= nil or string.find(error_lower, "timeout") ~= nil or string.find(error_lower, "unreachable") ~= nil or string.find(error_lower, "network") ~= nil or string.find(error_lower, "no address associated") ~= nil end -- Attempt to sync progress, returns true on success, false on network error function Paperbnd:attemptSync(doc_path, percent, current_page, pages) local mapping = self.document_mappings[doc_path] if not mapping then return false end -- Fetch current record local record_response, err = self.xrpc:getRecord(self.did, "social.popfeed.feed.listItem", mapping.rkey) if err then if self:isNetworkError(err) then -- Network error - return false to trigger queuing return false else -- Other error (auth, data, etc.) - show error and return false UIManager:show(InfoMessage:new({ text = T(_("Failed to fetch record: %1"), err), })) return false end end local record = record_response.value -- Update bookProgress record.bookProgress = { status = "in_progress", percent = percent, currentPage = current_page, totalPages = pages, updatedAt = os.date("!%Y-%m-%dT%H:%M:%S.000Z"), } -- Put updated record local _res, put_err = self.xrpc:putRecord(self.did, "social.popfeed.feed.listItem", mapping.rkey, record) if put_err then if self:isNetworkError(put_err) then -- Network error - return false to trigger queuing return false else -- Other error - show error and return false UIManager:show(InfoMessage:new({ text = T(_("Failed to sync progress: %1"), put_err), })) return false end end -- Success! Update last sync info self.document_mappings[doc_path].last_sync_time = os.time() self.document_mappings[doc_path].last_sync_percent = percent self:saveSettings() return true end -- Queue a sync for offline processing function Paperbnd:queueOfflineSync(doc_path, percent, current_page, pages) local mapping = self.document_mappings[doc_path] if not mapping then return end -- Cancel any pending auto-sync (this queued sync is newer) if self.auto_sync_task then UIManager:unschedule(self.auto_sync_task) self.auto_sync_task = nil end -- Store or update queue entry (latest only per document) self.offline_queue[doc_path] = { percent = percent, current_page = current_page, total_pages = pages, timestamp = os.time(), rkey = mapping.rkey, title = mapping.title, author = mapping.author, } self:saveSettings() end -- Flush all queued syncs (called after successful sync) function Paperbnd:flushOfflineQueue() if not self:isAuthenticated() then return end local queue_count = 0 for _ in pairs(self.offline_queue) do queue_count = queue_count + 1 end if queue_count == 0 then return end local flushed_count = 0 local failed_paths = {} local current_doc_path = self:getCurrentDocumentPath() -- Try to sync each queued item for doc_path, queue_entry in pairs(self.offline_queue) do local percent, current_page, pages -- If this is the current document, use latest progress instead of queued if doc_path == current_doc_path and self.ui.document then pages = self.ui.document:getPageCount() if self.ui.paging then current_page = self.ui.paging.current_page elseif self.ui.rolling then current_page = self.ui.rolling.current_page else current_page = 1 end percent = math.floor((current_page / pages) * 100) else -- Use queued progress for other documents percent = queue_entry.percent current_page = queue_entry.current_page pages = queue_entry.total_pages end local success = self:attemptSync(doc_path, percent, current_page, pages) if success then -- Remove from queue on success self.offline_queue[doc_path] = nil flushed_count = flushed_count + 1 else -- Keep in queue on failure table.insert(failed_paths, doc_path) end end -- Save updated queue if flushed_count > 0 then self:saveSettings() -- Show notification for flushed syncs UIManager:show(InfoMessage:new({ text = T(_("Flushed %1 queued sync(s)"), flushed_count), timeout = 2, })) end end function Paperbnd:scheduleDebouncedSync(pageno) -- Only schedule if auto-sync enabled and authenticated/linked if not self.auto_sync_enabled then return end if not self:isAuthenticated() or not self:isCurrentBookLinked() then return end -- Cancel any existing scheduled sync if self.auto_sync_task then UIManager:unschedule(self.auto_sync_task) self.auto_sync_task = nil end -- Create closure-based task for this scheduling self.auto_sync_task = function() self:performAutoSync(pageno) end -- Schedule sync after debounce delay UIManager:scheduleIn(self.auto_sync_delay, self.auto_sync_task) end function Paperbnd:performAutoSync(pageno) -- Clear task reference self.auto_sync_task = nil -- Check if page actually changed since last sync if self.last_synced_page == pageno then return end -- Double-check auth and linking if not self:isAuthenticated() or not self:isCurrentBookLinked() then return end -- Perform sync with notification (less intrusive for auto-sync) self:syncProgress(true) -- Track synced page self.last_synced_page = pageno end function Paperbnd:onPageUpdate(pageno) -- Fires on every page turn - debounce to avoid excessive syncing self:scheduleDebouncedSync(pageno) -- Return false to allow event propagation return false end -- Hook into document close to sync progress function Paperbnd:onCloseDocument() -- Cancel any pending auto-sync if self.auto_sync_task then UIManager:unschedule(self.auto_sync_task) self.auto_sync_task = nil end -- Clear page tracking for next document self.last_synced_page = nil -- Always sync on close (original behavior) if self:isAuthenticated() and self:isCurrentBookLinked() then self:syncProgress() end end return Paperbnd