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.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