Unofficial Paperbnd/Popfeed plugin for KOReader
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