Unofficial Paperbnd/Popfeed plugin for KOReader
at main 20 kB view raw
1local DataStorage = require("datastorage") 2local InfoMessage = require("ui/widget/infomessage") 3local InputDialog = require("ui/widget/inputdialog") 4local LuaSettings = require("luasettings") 5local UIManager = require("ui/uimanager") 6local WidgetContainer = require("ui/widget/container/widgetcontainer") 7local _ = require("gettext") 8local T = require("ffi/util").template 9 10local XRPCClient = require("xrpc") 11 12local Paperbnd = WidgetContainer:extend({ 13 name = "paperbnd", 14 is_doc_only = false, 15}) 16 17function Paperbnd:init() 18 self.ui.menu:registerToMainMenu(self) 19 20 -- Load settings 21 self.settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/paperbnd.lua") 22 self.handle = self.settings:readSetting("handle") 23 self.app_password = self.settings:readSetting("app_password") 24 self.pds_url = self.settings:readSetting("pds_url") 25 self.access_token = self.settings:readSetting("access_token") 26 self.refresh_token = self.settings:readSetting("refresh_token") 27 self.did = self.settings:readSetting("did") 28 self.document_mappings = self.settings:readSetting("document_mappings") or {} 29 self.offline_queue = self.settings:readSetting("offline_queue") or {} 30 self.auto_sync_enabled = self.settings:readSetting("auto_sync_enabled") 31 if self.auto_sync_enabled == nil then 32 self.auto_sync_enabled = false -- Default OFF for safety 33 end 34 35 -- Auto-sync state tracking 36 self.auto_sync_task = nil -- Scheduled sync task reference 37 self.last_synced_page = nil -- Prevent duplicate syncs 38 self.auto_sync_delay = 10.0 -- 10 second debounce delay 39 40 -- Initialize XRPC client 41 self.xrpc = XRPCClient:new() 42 if self.pds_url then 43 self.xrpc:setPDS(self.pds_url) 44 end 45 if self.access_token then 46 self.xrpc:setAuth(self.access_token, self.refresh_token) 47 end 48 if self.handle and self.app_password then 49 self.xrpc:setCredentials(self.handle, self.app_password) 50 end 51 52 -- Set up callback for credential updates 53 self.xrpc:setCredentialsCallback(function(access_token, refresh_token) 54 self:onTokenRefresh(access_token, refresh_token) 55 end) 56 57 -- Validate session at startup if we have credentials 58 if self:isAuthenticated() then 59 self:validateSessionAtStartup() 60 61 -- Try to flush any queued syncs from previous sessions 62 self:flushOfflineQueue() 63 end 64end 65 66function Paperbnd:saveSettings() 67 self.settings:saveSetting("handle", self.handle) 68 self.settings:saveSetting("app_password", self.app_password) 69 self.settings:saveSetting("pds_url", self.pds_url) 70 self.settings:saveSetting("access_token", self.access_token) 71 self.settings:saveSetting("refresh_token", self.refresh_token) 72 self.settings:saveSetting("did", self.did) 73 self.settings:saveSetting("document_mappings", self.document_mappings) 74 self.settings:saveSetting("offline_queue", self.offline_queue) 75 self.settings:saveSetting("auto_sync_enabled", self.auto_sync_enabled) 76 self.settings:flush() 77end 78 79function Paperbnd:onTokenRefresh(access_token, refresh_token) 80 self.access_token = access_token 81 self.refresh_token = refresh_token 82 self:saveSettings() 83end 84 85function Paperbnd:validateSessionAtStartup() 86 -- Validate session asynchronously to avoid blocking plugin startup 87 -- This will trigger automatic renewal if the session is expired 88 -- Session validation errors are intentionally ignored at startup 89 -- renewSession will be called automatically on the next authenticated request 90 self.xrpc:validateSession() 91end 92 93function Paperbnd:addToMainMenu(menu_items) 94 -- Helper function to truncate long strings 95 local function truncateString(str, max_length) 96 if str and #str > max_length then 97 return str:sub(1, max_length - 3) .. "..." 98 end 99 return str 100 end 101 102 menu_items.paperbnd = { 103 text = _("Paperbnd"), 104 sub_item_table = { 105 -- Authentication section 106 { 107 text_func = function() 108 if self:isAuthenticated() then 109 return T(_("Account: @%1"), self.handle) 110 else 111 return _("Set credentials") 112 end 113 end, 114 keep_menu_open = true, 115 callback = function() 116 self:setCredentials() 117 end, 118 separator = true, 119 }, 120 -- Book management section 121 { 122 text_func = function() 123 if self:isCurrentBookLinked() then 124 local doc_path = self:getCurrentDocumentPath() 125 if doc_path and self.document_mappings[doc_path] then 126 local mapping = self.document_mappings[doc_path] 127 local title = truncateString(mapping.title, 40) 128 return T(_("Linked: %1 by %2"), title, mapping.author) 129 end 130 end 131 return _("Link current book") 132 end, 133 enabled_func = function() 134 return self:isAuthenticated() and self.ui.document ~= nil 135 end, 136 callback = function() 137 self:linkCurrentBook() 138 end, 139 }, 140 { 141 text = _("Unlink current book"), 142 enabled_func = function() 143 return self:isCurrentBookLinked() 144 end, 145 callback = function() 146 self:unlinkCurrentBook() 147 end, 148 separator = true, 149 }, 150 -- Sync section 151 { 152 text = _("Sync progress now"), 153 enabled_func = function() 154 return self:isAuthenticated() and self:isCurrentBookLinked() 155 end, 156 callback = function() 157 self:syncProgress() 158 end, 159 }, 160 { 161 text_func = function() 162 local doc_path = self:getCurrentDocumentPath() 163 164 -- Check if there's a queued sync for this document 165 if doc_path and self.offline_queue[doc_path] then 166 local queued = self.offline_queue[doc_path] 167 return T(_("Last synced: Queued - offline (%1%)"), queued.percent) 168 end 169 170 if 171 doc_path 172 and self.document_mappings[doc_path] 173 and self.document_mappings[doc_path].last_sync_time 174 then 175 local mapping = self.document_mappings[doc_path] 176 local time_diff = os.time() - mapping.last_sync_time 177 local time_str 178 if time_diff < 60 then 179 time_str = _("Just now") 180 elseif time_diff < 3600 then 181 local mins = math.floor(time_diff / 60) 182 time_str = T(_("%1 min ago"), mins) 183 elseif time_diff < 86400 then 184 local hours = math.floor(time_diff / 3600) 185 time_str = T(_("%1 hr ago"), hours) 186 else 187 local days = math.floor(time_diff / 86400) 188 time_str = T(_("%1 days ago"), days) 189 end 190 return T(_("Last synced: %1% (%2)"), mapping.last_sync_percent, time_str) 191 else 192 return _("Last synced: Never") 193 end 194 end, 195 enabled = false, 196 }, 197 { 198 text_func = function() 199 if self.auto_sync_enabled then 200 return _("Auto-sync: Enabled") 201 else 202 return _("Auto-sync: Disabled") 203 end 204 end, 205 checked_func = function() 206 return self.auto_sync_enabled 207 end, 208 callback = function() 209 self.auto_sync_enabled = not self.auto_sync_enabled 210 self:saveSettings() 211 212 -- Cancel pending sync when disabling 213 if not self.auto_sync_enabled and self.auto_sync_task then 214 UIManager:unschedule(self.auto_sync_task) 215 self.auto_sync_task = nil 216 self.last_synced_page = nil 217 end 218 219 local status = self.auto_sync_enabled and _("enabled") or _("disabled") 220 UIManager:show(InfoMessage:new({ 221 text = T(_("Auto-sync %1"), status), 222 timeout = 2, 223 })) 224 end, 225 }, 226 }, 227 } 228end 229 230function Paperbnd:isAuthenticated() 231 return self.handle ~= nil and self.access_token ~= nil and self.did ~= nil 232end 233 234function Paperbnd:getCurrentDocumentPath() 235 if self.ui.document then 236 return self.ui.document.file 237 end 238 return nil 239end 240 241function Paperbnd:isCurrentBookLinked() 242 local doc_path = self:getCurrentDocumentPath() 243 return doc_path ~= nil and self.document_mappings[doc_path] ~= nil 244end 245 246function Paperbnd:setCredentials() 247 local handle_input 248 handle_input = InputDialog:new({ 249 title = _("Enter your handle"), 250 input = self.handle or "", 251 buttons = { 252 { 253 { 254 text = _("Cancel"), 255 callback = function() 256 UIManager:close(handle_input) 257 end, 258 }, 259 { 260 text = _("Next"), 261 is_enter_default = true, 262 callback = function() 263 local handle = handle_input:getInputText() 264 UIManager:close(handle_input) 265 266 if handle and handle ~= "" then 267 self:setAppPassword(handle) 268 end 269 end, 270 }, 271 }, 272 }, 273 }) 274 UIManager:show(handle_input) 275 handle_input:onShowKeyboard() 276end 277 278function Paperbnd:setAppPassword(handle) 279 local password_input 280 password_input = InputDialog:new({ 281 title = _("Enter your app password"), 282 input = self.app_password or "", 283 text_type = "password", 284 buttons = { 285 { 286 { 287 text = _("Cancel"), 288 callback = function() 289 UIManager:close(password_input) 290 end, 291 }, 292 { 293 text = _("Login"), 294 is_enter_default = true, 295 callback = function() 296 local password = password_input:getInputText() 297 UIManager:close(password_input) 298 299 if password and password ~= "" then 300 self:authenticate(handle, password) 301 end 302 end, 303 }, 304 }, 305 }, 306 }) 307 UIManager:show(password_input) 308 password_input:onShowKeyboard() 309end 310 311function Paperbnd:authenticate(handle, password) 312 UIManager:show(InfoMessage:new({ 313 text = _("Authenticating..."), 314 timeout = 1, 315 })) 316 317 -- Resolve PDS 318 local pds_url, pds_err = self.xrpc:resolvePDS(handle) 319 if pds_err then 320 UIManager:show(InfoMessage:new({ 321 text = T(_("Failed to resolve PDS: %1"), pds_err), 322 })) 323 return 324 end 325 326 self.pds_url = pds_url 327 self.xrpc:setPDS(pds_url) 328 329 -- Create session 330 local session, session_err = self.xrpc:createSession(handle, password) 331 if session_err then 332 UIManager:show(InfoMessage:new({ 333 text = T(_("Authentication failed: %1"), session_err), 334 })) 335 return 336 end 337 338 self.handle = handle 339 self.app_password = password 340 self.access_token = session.accessJwt 341 self.refresh_token = session.refreshJwt 342 self.did = session.did 343 344 -- Store credentials in XRPC client for automatic session renewal 345 self.xrpc:setCredentials(handle, password) 346 347 self:saveSettings() 348 349 UIManager:show(InfoMessage:new({ 350 text = _("Authentication successful!"), 351 })) 352end 353 354function Paperbnd:linkCurrentBook() 355 if not self:isAuthenticated() then 356 UIManager:show(InfoMessage:new({ 357 text = _("Please set credentials first"), 358 })) 359 return 360 end 361 362 UIManager:show(InfoMessage:new({ 363 text = _("Fetching your books..."), 364 timeout = 1, 365 })) 366 367 -- Fetch list items 368 local response, err = self.xrpc:listRecords(self.did, "social.popfeed.feed.listItem", 100, nil) 369 370 if err then 371 UIManager:show(InfoMessage:new({ 372 text = T(_("Failed to fetch books: %1"), err), 373 })) 374 return 375 end 376 377 if not response.records or #response.records == 0 then 378 UIManager:show(InfoMessage:new({ 379 text = _("No books found in your account"), 380 })) 381 return 382 end 383 384 -- Filter for books only 385 local books = {} 386 for _, record in ipairs(response.records) do 387 if 388 record.value 389 and record.value.creativeWorkType == "book" 390 and record.value.listType == "currently_reading_books" 391 then 392 table.insert(books, { 393 title = record.value.title or "Unknown", 394 author = record.value.mainCredit or "Unknown", 395 rkey = record.uri:match("([^/]+)$"), 396 record = record.value, 397 }) 398 end 399 end 400 401 if #books == 0 then 402 UIManager:show(InfoMessage:new({ 403 text = _("No books found in your account"), 404 })) 405 return 406 end 407 408 -- Show selection dialog 409 self:showBookSelectionDialog(books) 410end 411 412function Paperbnd:showBookSelectionDialog(books) 413 local Menu = require("ui/widget/menu") 414 local CenterContainer = require("ui/widget/container/centercontainer") 415 local Screen = require("device").screen 416 417 local items = {} 418 for _, book in ipairs(books) do 419 table.insert(items, { 420 text = book.title, 421 subtitle = book.author, 422 book = book, 423 }) 424 end 425 426 local book_menu_container 427 local book_menu = Menu:new({ 428 title = _("Select a book"), 429 item_table = items, 430 width = Screen:getWidth() - Screen:scaleBySize(80), 431 height = Screen:getHeight() - Screen:scaleBySize(80), 432 fullscreen = true, 433 single_line = false, 434 onMenuSelect = function(_, item) 435 UIManager:close(book_menu_container) 436 self:confirmLinkBook(item.book) 437 end, 438 }) 439 440 book_menu_container = CenterContainer:new({ 441 dimen = Screen:getSize(), 442 book_menu, 443 }) 444 445 UIManager:show(book_menu_container) 446end 447 448function Paperbnd:confirmLinkBook(book) 449 local doc_path = self:getCurrentDocumentPath() 450 if not doc_path then 451 return 452 end 453 454 self.document_mappings[doc_path] = { 455 rkey = book.rkey, 456 title = book.title, 457 author = book.author, 458 } 459 460 self:saveSettings() 461 462 UIManager:show(InfoMessage:new({ 463 text = T(_("Linked to: %1"), book.title), 464 })) 465end 466 467function Paperbnd:unlinkCurrentBook() 468 local doc_path = self:getCurrentDocumentPath() 469 if doc_path and self.document_mappings[doc_path] then 470 local book_title = self.document_mappings[doc_path].title 471 self.document_mappings[doc_path] = nil 472 self:saveSettings() 473 474 UIManager:show(InfoMessage:new({ 475 text = T(_("Unlinked: %1"), book_title), 476 })) 477 end 478end 479 480function Paperbnd:syncProgress(use_notification) 481 if not self:isAuthenticated() then 482 return 483 end 484 485 local doc_path = self:getCurrentDocumentPath() 486 if not doc_path or not self.document_mappings[doc_path] then 487 return 488 end 489 490 -- Cancel any pending auto-sync for current document (manual sync takes priority) 491 if self.auto_sync_task then 492 UIManager:unschedule(self.auto_sync_task) 493 self.auto_sync_task = nil 494 end 495 496 -- Get document statistics 497 local pages = self.ui.document:getPageCount() 498 local current_page 499 if self.ui.paging then 500 -- Paged documents (PDF, CBZ, etc.) 501 current_page = self.ui.paging.current_page 502 elseif self.ui.rolling then 503 -- Reflowable documents (EPUB, MOBI, etc.) 504 current_page = self.ui.rolling.current_page 505 else 506 current_page = 1 507 end 508 local percent = math.floor((current_page / pages) * 100) 509 510 -- Try to sync 511 local success = self:attemptSync(doc_path, percent, current_page, pages) 512 513 if success then 514 -- Show success notification (silent for auto-sync) 515 if not use_notification then 516 -- Use InfoMessage for manual sync and document close 517 UIManager:show(InfoMessage:new({ 518 text = T(_("Synced: %1% (%2/%3)"), percent, current_page, pages), 519 timeout = 2, 520 })) 521 end 522 523 -- On success, try to flush any queued syncs 524 self:flushOfflineQueue() 525 else 526 -- Queue this sync for later (silent) 527 self:queueOfflineSync(doc_path, percent, current_page, pages) 528 end 529end 530 531-- Helper function to detect network errors 532function Paperbnd:isNetworkError(error_msg) 533 if not error_msg then 534 return false 535 end 536 local error_lower = string.lower(error_msg) 537 return string.find(error_lower, "connection") ~= nil 538 or string.find(error_lower, "timeout") ~= nil 539 or string.find(error_lower, "unreachable") ~= nil 540 or string.find(error_lower, "network") ~= nil 541 or string.find(error_lower, "no address associated") ~= nil 542end 543 544-- Attempt to sync progress, returns true on success, false on network error 545function Paperbnd:attemptSync(doc_path, percent, current_page, pages) 546 local mapping = self.document_mappings[doc_path] 547 if not mapping then 548 return false 549 end 550 551 -- Fetch current record 552 local record_response, err = self.xrpc:getRecord(self.did, "social.popfeed.feed.listItem", mapping.rkey) 553 554 if err then 555 if self:isNetworkError(err) then 556 -- Network error - return false to trigger queuing 557 return false 558 else 559 -- Other error (auth, data, etc.) - show error and return false 560 UIManager:show(InfoMessage:new({ 561 text = T(_("Failed to fetch record: %1"), err), 562 })) 563 return false 564 end 565 end 566 567 local record = record_response.value 568 569 -- Update bookProgress 570 record.bookProgress = { 571 status = "in_progress", 572 percent = percent, 573 currentPage = current_page, 574 totalPages = pages, 575 updatedAt = os.date("!%Y-%m-%dT%H:%M:%S.000Z"), 576 } 577 578 -- Put updated record 579 local _res, put_err = self.xrpc:putRecord(self.did, "social.popfeed.feed.listItem", mapping.rkey, record) 580 581 if put_err then 582 if self:isNetworkError(put_err) then 583 -- Network error - return false to trigger queuing 584 return false 585 else 586 -- Other error - show error and return false 587 UIManager:show(InfoMessage:new({ 588 text = T(_("Failed to sync progress: %1"), put_err), 589 })) 590 return false 591 end 592 end 593 594 -- Success! Update last sync info 595 self.document_mappings[doc_path].last_sync_time = os.time() 596 self.document_mappings[doc_path].last_sync_percent = percent 597 self:saveSettings() 598 599 return true 600end 601 602-- Queue a sync for offline processing 603function Paperbnd:queueOfflineSync(doc_path, percent, current_page, pages) 604 local mapping = self.document_mappings[doc_path] 605 if not mapping then 606 return 607 end 608 609 -- Cancel any pending auto-sync (this queued sync is newer) 610 if self.auto_sync_task then 611 UIManager:unschedule(self.auto_sync_task) 612 self.auto_sync_task = nil 613 end 614 615 -- Store or update queue entry (latest only per document) 616 self.offline_queue[doc_path] = { 617 percent = percent, 618 current_page = current_page, 619 total_pages = pages, 620 timestamp = os.time(), 621 rkey = mapping.rkey, 622 title = mapping.title, 623 author = mapping.author, 624 } 625 626 self:saveSettings() 627end 628 629-- Flush all queued syncs (called after successful sync) 630function Paperbnd:flushOfflineQueue() 631 if not self:isAuthenticated() then 632 return 633 end 634 635 local queue_count = 0 636 for _ in pairs(self.offline_queue) do 637 queue_count = queue_count + 1 638 end 639 640 if queue_count == 0 then 641 return 642 end 643 644 local flushed_count = 0 645 local failed_paths = {} 646 local current_doc_path = self:getCurrentDocumentPath() 647 648 -- Try to sync each queued item 649 for doc_path, queue_entry in pairs(self.offline_queue) do 650 local percent, current_page, pages 651 652 -- If this is the current document, use latest progress instead of queued 653 if doc_path == current_doc_path and self.ui.document then 654 pages = self.ui.document:getPageCount() 655 if self.ui.paging then 656 current_page = self.ui.paging.current_page 657 elseif self.ui.rolling then 658 current_page = self.ui.rolling.current_page 659 else 660 current_page = 1 661 end 662 percent = math.floor((current_page / pages) * 100) 663 else 664 -- Use queued progress for other documents 665 percent = queue_entry.percent 666 current_page = queue_entry.current_page 667 pages = queue_entry.total_pages 668 end 669 670 local success = self:attemptSync(doc_path, percent, current_page, pages) 671 if success then 672 -- Remove from queue on success 673 self.offline_queue[doc_path] = nil 674 flushed_count = flushed_count + 1 675 else 676 -- Keep in queue on failure 677 table.insert(failed_paths, doc_path) 678 end 679 end 680 681 -- Save updated queue 682 if flushed_count > 0 then 683 self:saveSettings() 684 685 -- Show notification for flushed syncs 686 UIManager:show(InfoMessage:new({ 687 text = T(_("Flushed %1 queued sync(s)"), flushed_count), 688 timeout = 2, 689 })) 690 end 691end 692 693function Paperbnd:scheduleDebouncedSync(pageno) 694 -- Only schedule if auto-sync enabled and authenticated/linked 695 if not self.auto_sync_enabled then 696 return 697 end 698 699 if not self:isAuthenticated() or not self:isCurrentBookLinked() then 700 return 701 end 702 703 -- Cancel any existing scheduled sync 704 if self.auto_sync_task then 705 UIManager:unschedule(self.auto_sync_task) 706 self.auto_sync_task = nil 707 end 708 709 -- Create closure-based task for this scheduling 710 self.auto_sync_task = function() 711 self:performAutoSync(pageno) 712 end 713 714 -- Schedule sync after debounce delay 715 UIManager:scheduleIn(self.auto_sync_delay, self.auto_sync_task) 716end 717 718function Paperbnd:performAutoSync(pageno) 719 -- Clear task reference 720 self.auto_sync_task = nil 721 722 -- Check if page actually changed since last sync 723 if self.last_synced_page == pageno then 724 return 725 end 726 727 -- Double-check auth and linking 728 if not self:isAuthenticated() or not self:isCurrentBookLinked() then 729 return 730 end 731 732 -- Perform sync with notification (less intrusive for auto-sync) 733 self:syncProgress(true) 734 735 -- Track synced page 736 self.last_synced_page = pageno 737end 738 739function Paperbnd:onPageUpdate(pageno) 740 -- Fires on every page turn - debounce to avoid excessive syncing 741 self:scheduleDebouncedSync(pageno) 742 743 -- Return false to allow event propagation 744 return false 745end 746 747-- Hook into document close to sync progress 748function Paperbnd:onCloseDocument() 749 -- Cancel any pending auto-sync 750 if self.auto_sync_task then 751 UIManager:unschedule(self.auto_sync_task) 752 self.auto_sync_task = nil 753 end 754 755 -- Clear page tracking for next document 756 self.last_synced_page = nil 757 758 -- Always sync on close (original behavior) 759 if self:isAuthenticated() and self:isCurrentBookLinked() then 760 self:syncProgress() 761 end 762end 763 764return Paperbnd