Unofficial Paperbnd/Popfeed plugin for KOReader
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at bfefdb0d6d473dac64bd77aef94815278d323e68 539 lines 16 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.auto_sync_enabled = self.settings:readSetting("auto_sync_enabled") 30 if self.auto_sync_enabled == nil then 31 self.auto_sync_enabled = false -- Default OFF for safety 32 end 33 34 -- Auto-sync state tracking 35 self.auto_sync_task = nil -- Scheduled sync task reference 36 self.last_synced_page = nil -- Prevent duplicate syncs 37 self.auto_sync_delay = 10.0 -- 10 second debounce delay 38 39 -- Initialize XRPC client 40 self.xrpc = XRPCClient:new() 41 if self.pds_url then 42 self.xrpc:setPDS(self.pds_url) 43 end 44 if self.access_token then 45 self.xrpc:setAuth(self.access_token, self.refresh_token) 46 end 47 if self.handle and self.app_password then 48 self.xrpc:setCredentials(self.handle, self.app_password) 49 end 50 51 -- Set up callback for credential updates 52 self.xrpc:setCredentialsCallback(function(access_token, refresh_token) 53 self:onTokenRefresh(access_token, refresh_token) 54 end) 55 56 -- Validate session at startup if we have credentials 57 if self:isAuthenticated() then 58 self:validateSessionAtStartup() 59 end 60end 61 62function Paperbnd:saveSettings() 63 self.settings:saveSetting("handle", self.handle) 64 self.settings:saveSetting("app_password", self.app_password) 65 self.settings:saveSetting("pds_url", self.pds_url) 66 self.settings:saveSetting("access_token", self.access_token) 67 self.settings:saveSetting("refresh_token", self.refresh_token) 68 self.settings:saveSetting("did", self.did) 69 self.settings:saveSetting("document_mappings", self.document_mappings) 70 self.settings:saveSetting("auto_sync_enabled", self.auto_sync_enabled) 71 self.settings:flush() 72end 73 74function Paperbnd:onTokenRefresh(access_token, refresh_token) 75 self.access_token = access_token 76 self.refresh_token = refresh_token 77 self:saveSettings() 78end 79 80function Paperbnd:validateSessionAtStartup() 81 -- Validate session asynchronously to avoid blocking plugin startup 82 -- This will trigger automatic renewal if the session is expired 83 -- Session validation errors are intentionally ignored at startup 84 -- renewSession will be called automatically on the next authenticated request 85 self.xrpc:validateSession() 86end 87 88function Paperbnd:addToMainMenu(menu_items) 89 menu_items.paperbnd = { 90 text = _("Paperbnd"), 91 sub_item_table = { 92 { 93 text = _("Set credentials"), 94 keep_menu_open = true, 95 callback = function() 96 self:setCredentials() 97 end, 98 }, 99 { 100 text = _("Link current book"), 101 enabled_func = function() 102 return self:isAuthenticated() and self.ui.document ~= nil 103 end, 104 callback = function() 105 self:linkCurrentBook() 106 end, 107 }, 108 { 109 text = _("Sync progress now"), 110 enabled_func = function() 111 return self:isAuthenticated() and self:isCurrentBookLinked() 112 end, 113 callback = function() 114 self:syncProgress() 115 end, 116 }, 117 { 118 text = _("Auto-sync on page turn"), 119 checked_func = function() 120 return self.auto_sync_enabled 121 end, 122 callback = function() 123 self.auto_sync_enabled = not self.auto_sync_enabled 124 self:saveSettings() 125 126 -- Cancel pending sync when disabling 127 if not self.auto_sync_enabled and self.auto_sync_task then 128 UIManager:unschedule(self.auto_sync_task) 129 self.auto_sync_task = nil 130 self.last_synced_page = nil 131 end 132 133 local status = self.auto_sync_enabled and _("enabled") or _("disabled") 134 UIManager:show(InfoMessage:new { 135 text = T(_("Auto-sync %1"), status), 136 timeout = 2, 137 }) 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 }, 149 }, 150 } 151end 152 153function Paperbnd:isAuthenticated() 154 return self.handle ~= nil and self.access_token ~= nil and self.did ~= nil 155end 156 157function Paperbnd:getCurrentDocumentPath() 158 if self.ui.document then 159 return self.ui.document.file 160 end 161 return nil 162end 163 164function Paperbnd:isCurrentBookLinked() 165 local doc_path = self:getCurrentDocumentPath() 166 return doc_path ~= nil and self.document_mappings[doc_path] ~= nil 167end 168 169function Paperbnd:setCredentials() 170 local handle_input 171 handle_input = InputDialog:new { 172 title = _("Enter your handle"), 173 input = self.handle or "", 174 buttons = { 175 { 176 { 177 text = _("Cancel"), 178 callback = function() 179 UIManager:close(handle_input) 180 end, 181 }, 182 { 183 text = _("Next"), 184 is_enter_default = true, 185 callback = function() 186 local handle = handle_input:getInputText() 187 UIManager:close(handle_input) 188 189 if handle and handle ~= "" then 190 self:setAppPassword(handle) 191 end 192 end, 193 }, 194 }, 195 }, 196 } 197 UIManager:show(handle_input) 198 handle_input:onShowKeyboard() 199end 200 201function Paperbnd:setAppPassword(handle) 202 local password_input 203 password_input = InputDialog:new { 204 title = _("Enter your app password"), 205 input = self.app_password or "", 206 text_type = "password", 207 buttons = { 208 { 209 { 210 text = _("Cancel"), 211 callback = function() 212 UIManager:close(password_input) 213 end, 214 }, 215 { 216 text = _("Login"), 217 is_enter_default = true, 218 callback = function() 219 local password = password_input:getInputText() 220 UIManager:close(password_input) 221 222 if password and password ~= "" then 223 self:authenticate(handle, password) 224 end 225 end, 226 }, 227 }, 228 }, 229 } 230 UIManager:show(password_input) 231 password_input:onShowKeyboard() 232end 233 234function Paperbnd:authenticate(handle, password) 235 UIManager:show(InfoMessage:new { 236 text = _("Authenticating..."), 237 timeout = 1, 238 }) 239 240 -- Resolve PDS 241 local pds_url, pds_err = self.xrpc:resolvePDS(handle) 242 if pds_err then 243 UIManager:show(InfoMessage:new { 244 text = T(_("Failed to resolve PDS: %1"), pds_err), 245 }) 246 return 247 end 248 249 self.pds_url = pds_url 250 self.xrpc:setPDS(pds_url) 251 252 -- Create session 253 local session, session_err = self.xrpc:createSession(handle, password) 254 if session_err then 255 UIManager:show(InfoMessage:new { 256 text = T(_("Authentication failed: %1"), session_err), 257 }) 258 return 259 end 260 261 self.handle = handle 262 self.app_password = password 263 self.access_token = session.accessJwt 264 self.refresh_token = session.refreshJwt 265 self.did = session.did 266 267 -- Store credentials in XRPC client for automatic session renewal 268 self.xrpc:setCredentials(handle, password) 269 270 self:saveSettings() 271 272 UIManager:show(InfoMessage:new { 273 text = _("Authentication successful!"), 274 }) 275end 276 277function Paperbnd:linkCurrentBook() 278 if not self:isAuthenticated() then 279 UIManager:show(InfoMessage:new { 280 text = _("Please set credentials first"), 281 }) 282 return 283 end 284 285 UIManager:show(InfoMessage:new { 286 text = _("Fetching your books..."), 287 timeout = 1, 288 }) 289 290 -- Fetch list items 291 local response, err = self.xrpc:listRecords( 292 self.did, 293 "social.popfeed.feed.listItem", 294 100, 295 nil 296 ) 297 298 if err then 299 UIManager:show(InfoMessage:new { 300 text = T(_("Failed to fetch books: %1"), err), 301 }) 302 return 303 end 304 305 306 if not response.records or #response.records == 0 then 307 UIManager:show(InfoMessage:new { 308 text = _("No books found in your account"), 309 }) 310 return 311 end 312 313 -- Filter for books only 314 local books = {} 315 for _, record in ipairs(response.records) do 316 if record.value and record.value.creativeWorkType == "book" 317 and record.value.listType == "currently_reading_books" then 318 table.insert(books, { 319 title = record.value.title or "Unknown", 320 author = record.value.mainCredit or "Unknown", 321 rkey = record.uri:match("([^/]+)$"), 322 record = record.value, 323 }) 324 end 325 end 326 327 328 if #books == 0 then 329 UIManager:show(InfoMessage:new { 330 text = _("No books found in your account"), 331 }) 332 return 333 end 334 335 -- Show selection dialog 336 self:showBookSelectionDialog(books) 337end 338 339function Paperbnd:showBookSelectionDialog(books) 340 local Menu = require("ui/widget/menu") 341 local Screen = require("device").screen 342 343 local items = {} 344 for _, book in ipairs(books) do 345 table.insert(items, { 346 text = book.title, 347 subtitle = book.author, 348 book = book, 349 }) 350 end 351 352 local book_menu = Menu:new { 353 title = _("Select a book"), 354 item_table = items, 355 width = Screen:getWidth() - Screen:scaleBySize(100), 356 height = Screen:getHeight() - Screen:scaleBySize(100), 357 fullscreen = true, 358 single_line = false, 359 onMenuSelect = function(_, item) 360 self:confirmLinkBook(item.book) 361 end, 362 } 363 364 UIManager:show(book_menu) 365end 366 367function Paperbnd:confirmLinkBook(book) 368 local doc_path = self:getCurrentDocumentPath() 369 if not doc_path then 370 return 371 end 372 373 self.document_mappings[doc_path] = { 374 rkey = book.rkey, 375 title = book.title, 376 author = book.author, 377 } 378 379 self:saveSettings() 380 381 UIManager:show(InfoMessage:new { 382 text = T(_("Linked to: %1"), book.title), 383 }) 384end 385 386function Paperbnd:unlinkCurrentBook() 387 local doc_path = self:getCurrentDocumentPath() 388 if doc_path and self.document_mappings[doc_path] then 389 local book_title = self.document_mappings[doc_path].title 390 self.document_mappings[doc_path] = nil 391 self:saveSettings() 392 393 UIManager:show(InfoMessage:new { 394 text = T(_("Unlinked: %1"), book_title), 395 }) 396 end 397end 398 399function Paperbnd:syncProgress() 400 if not self:isAuthenticated() then 401 return 402 end 403 404 local doc_path = self:getCurrentDocumentPath() 405 if not doc_path or not self.document_mappings[doc_path] then 406 return 407 end 408 409 local mapping = self.document_mappings[doc_path] 410 411 -- Get document statistics 412 local pages = self.ui.document:getPageCount() 413 local current_page = self.ui.paging and self.ui.paging.current_page or 1 414 local percent = math.floor((current_page / pages) * 100) 415 416 -- Fetch current record 417 local record_response, err = self.xrpc:getRecord( 418 self.did, 419 "social.popfeed.feed.listItem", 420 mapping.rkey 421 ) 422 423 if err then 424 UIManager:show(InfoMessage:new { 425 text = T(_("Failed to fetch record: %1"), err), 426 }) 427 return 428 end 429 430 local record = record_response.value 431 432 -- Update bookProgress 433 record.bookProgress = { 434 status = "in_progress", 435 percent = percent, 436 currentPage = current_page, 437 totalPages = pages, 438 updatedAt = os.date("!%Y-%m-%dT%H:%M:%S.000Z"), 439 } 440 441 -- If not already in currently_reading, update listType 442 -- if record.listType ~= "currently_reading_books" then 443 -- record.listType = "currently_reading_books" 444 -- TODO: Also update the listUri to point to currently_reading list 445 -- end 446 447 -- Put updated record 448 local _, put_err = self.xrpc:putRecord( 449 self.did, 450 "social.popfeed.feed.listItem", 451 mapping.rkey, 452 record 453 ) 454 455 if put_err then 456 UIManager:show(InfoMessage:new { 457 text = T(_("Failed to sync progress: %1"), put_err), 458 }) 459 return 460 end 461 462 UIManager:show(InfoMessage:new { 463 text = T(_("Synced: %1% (%2/%3)"), percent, current_page, pages), 464 timeout = 2, 465 }) 466end 467 468function Paperbnd:scheduleDebouncedSync(pageno) 469 -- Only schedule if auto-sync enabled and authenticated/linked 470 if not self.auto_sync_enabled then 471 return 472 end 473 474 if not self:isAuthenticated() or not self:isCurrentBookLinked() then 475 return 476 end 477 478 -- Cancel any existing scheduled sync 479 if self.auto_sync_task then 480 UIManager:unschedule(self.auto_sync_task) 481 self.auto_sync_task = nil 482 end 483 484 -- Create closure-based task for this scheduling 485 self.auto_sync_task = function() 486 self:performAutoSync(pageno) 487 end 488 489 -- Schedule sync after debounce delay 490 UIManager:scheduleIn(self.auto_sync_delay, self.auto_sync_task) 491end 492 493function Paperbnd:performAutoSync(pageno) 494 -- Clear task reference 495 self.auto_sync_task = nil 496 497 -- Check if page actually changed since last sync 498 if self.last_synced_page == pageno then 499 return 500 end 501 502 -- Double-check auth and linking 503 if not self:isAuthenticated() or not self:isCurrentBookLinked() then 504 return 505 end 506 507 -- Perform sync (reuses existing logic) 508 self:syncProgress() 509 510 -- Track synced page 511 self.last_synced_page = pageno 512end 513 514function Paperbnd:onPageUpdate(pageno) 515 -- Fires on every page turn - debounce to avoid excessive syncing 516 self:scheduleDebouncedSync(pageno) 517 518 -- Return false to allow event propagation 519 return false 520end 521 522-- Hook into document close to sync progress 523function Paperbnd:onCloseDocument() 524 -- Cancel any pending auto-sync 525 if self.auto_sync_task then 526 UIManager:unschedule(self.auto_sync_task) 527 self.auto_sync_task = nil 528 end 529 530 -- Clear page tracking for next document 531 self.last_synced_page = nil 532 533 -- Always sync on close (original behavior) 534 if self:isAuthenticated() and self:isCurrentBookLinked() then 535 self:syncProgress() 536 end 537end 538 539return Paperbnd