···11-# Paperbnd KOReader Plugin
11+# Paperbnd KOReader Plugin - Developer Documentation
22+33+> **Document Purpose**: This is technical documentation for developers and maintainers. For user-facing installation and usage instructions, see [README.md](README.md).
2435A KOReader plugin for syncing reading progress to an AT Protocol PDS using the Popfeed List Item lexicon.
46···67696870## Technical Details
69717272+### External Resources
7373+7474+- **KOReader Plugin Development**: https://github.com/koreader/koreader/wiki/Developer-documentation
7575+- **AT Protocol Specifications**: https://atproto.com/specs/atp
7676+- **Popfeed Platform**: https://popfeed.social/
7777+- **Paperbnd Website**: https://paperbnd.club/
7878+7079### Lexicons Used
71807281- `social.popfeed.feed.listItem` - Individual book entries with progress tracking
···113122- Leverages existing Popfeed records as the source of truth
114123115124### Advanced Session Management
116116-The plugin implements a sophisticated multi-level session management system:
125125+126126+The plugin implements a sophisticated multi-level session management system that is the core reliability feature.
127127+128128+**Architecture:**
129129+130130+The session management system has three levels of components:
131131+132132+1. **`call()` method in xrpc.lua**: Generic XRPC request handler that detects auth errors (401 or 400 "expired"), calls `renewSession()` on failure, rebuilds requests with new tokens, and retries automatically. This is the single point of entry for all XRPC operations.
133133+134134+2. **`renewSession()` method**: Multi-level fallback coordinator that first attempts `refreshSession()`, then falls back to `createSession()` with app password if refresh token expired. Returns boolean success status.
135135+136136+3. **`validateSession()` method**: Makes lightweight test request at plugin startup to verify token validity and proactively renew expired tokens before user operations.
137137+138138+**Integration with Main Plugin:**
139139+140140+- **Initialization**: Loads credentials from persistent storage, configures XRPC client with tokens and app password, and sets callback for automatic token persistence
141141+- **Token Callback**: Receives new tokens from XRPC client, saves to plugin settings immediately, ensuring credentials never get out of sync
142142+- **Startup Validation**: Runs `validateSession()` silently when plugin loads to ensure tokens are fresh before first use
117143118144**Automatic Renewal Flow:**
145145+1191461. Authentication error detected (401 or 400 "expired")
1201472. Attempt to refresh using refresh token
1211483. If refresh token expired, create new session with stored app password
1221494. If renewal successful, retry the original request automatically
1231505. Save new tokens to persistent storage via callback
124151125125-**Session Validation:**
126126-- Session checked at plugin startup
127127-- Proactively renews expired tokens before user operations
128128-- Prevents mid-operation authentication failures
152152+**Example Flow - User syncs progress with expired access token:**
153153+154154+1. `syncProgress()` calls `xrpc:putRecord()`
155155+2. `putRecord()` calls `call()` with POST body
156156+3. Server returns 401 Unauthorized
157157+4. `call()` detects auth error, calls `renewSession()`
158158+5. `renewSession()` tries `refreshSession()`
159159+6. If refresh token valid: new tokens received, callback fired
160160+7. If refresh token expired: `createSession()` with app password
161161+8. `call()` rebuilds request with new access token
162162+9. `call()` retries POST request
163163+10. Request succeeds, progress synced
164164+11. User sees success message, unaware of token renewal
129165130166**Benefits:**
167167+131168- No manual "Refresh Session" button needed
132169- Tokens can expire for weeks without user intervention
133170- Original requests automatically retried after renewal
134171- Seamless user experience without interruptions
135172- Callback mechanism keeps plugin and XRPC client in sync
173173+- Proactive validation prevents mid-operation failures
136174137175### Authentication Strategy
138176- Uses Slingshot service to resolve PDS from handle (no manual PDS entry)
···165203166204## Code Quality Assessment
167205168168-**Current State (January 2025):**
206206+**Current State (December 2024):**
169207170208The codebase is well-structured and production-ready with the following characteristics:
171209···179217- Clear function naming and logical organization
180218181219**Architecture:**
182182-- **XRPC Client (299 lines)**: Self-contained networking layer with 11 public methods
183183-- **Main Plugin (447 lines)**: UI integration with 17 public methods
184184-- **Metadata (5 lines)**: KOReader plugin manifest
185185-- **Total**: ~750 lines of well-documented Lua code
220220+- **XRPC Client (353 lines)**: Self-contained networking layer with 15 public methods
221221+- **Main Plugin (446 lines)**: UI integration with 17 public methods
222222+- **Metadata (6 lines)**: KOReader plugin manifest
223223+- **Total**: 805 lines of well-documented Lua code
186224187225**Known Limitations:**
188188-- One TODO at `main.lua:415` for updating `listUri` when changing reading status
226226+- One TODO in `main.lua` for updating `listUri` when changing reading status (currently commented out at lines 413-416). Implementation would require fetching the user's "currently_reading_books" list URI and updating the `listUri` field when moving a book to that list.
189227- Session validation at startup runs synchronously (could be async but startup is fast enough)
190228- No retry logic for network failures (only authentication failures)
191229- No offline queuing (by design, for simplicity)
···198236## Future Enhancements (Not Implemented)
199237200238Potential features intentionally left out for simplicity:
201201-- Automatic book detection via ISBN
202202-- Batch syncing multiple books
203203-- Conflict resolution for concurrent edits
204204-- Offline queue for syncing when network unavailable
205205-- Automatic list URI updates when changing reading status (see TODO in code)
206206-- Retry logic for transient network failures
207207-- Progress indicators for long-running operations
208239209209-## Files
210210-211211-- `main.lua` - Main plugin with UI and KOReader integration
212212-- `xrpc.lua` - XRPC client for AT Protocol communication
213213-- `CLAUDE.md` - This documentation file
214214-215215-## Session Management Implementation Details
216216-217217-The advanced session management system is the core reliability feature of this plugin:
240240+- **Automatic book detection via ISBN**: Query ISBN databases to automatically match books instead of manual linking
241241+- **Batch syncing multiple books**: Sync progress for all linked books at once
242242+- **Conflict resolution for concurrent edits**: Handle cases where book data is modified from multiple clients
243243+- **Offline queue for syncing**: Queue progress updates when network unavailable and sync when connection restored
244244+- **Automatic list URI updates**: When changing a book's reading status (e.g., to "currently_reading_books"), automatically fetch and update the `listUri` field to point to the correct list. Currently commented out in code - would require additional `listRecords` call to fetch the user's lists and find the matching URI.
245245+- **Retry logic for transient network failures**: Automatically retry failed requests due to temporary network issues
246246+- **Progress indicators for long-running operations**: Show loading spinners or progress bars for network requests
218247219219-### XRPCClient Methods (xrpc.lua)
248248+## Files
220249221221-**`validateSession()`** (`xrpc.lua:229-242`)
222222-- Makes lightweight test request to verify token validity
223223-- Returns boolean success status
224224-- Uses `skip_renewal` flag to prevent recursion
225225-- Called at plugin startup to proactively renew tokens
250250+- `main.lua` - Main plugin with UI and KOReader integration (446 lines)
251251+- `xrpc.lua` - XRPC client for AT Protocol communication (353 lines)
252252+- `_meta.lua` - KOReader plugin manifest (6 lines)
253253+- `README.md` - User-facing documentation with installation and usage instructions
254254+- `CLAUDE.md` - This technical documentation for developers and maintainers
226255227227-**`refreshSession()`** (`xrpc.lua:245-277`)
228228-- Swaps access token with refresh token temporarily
229229-- Calls `com.atproto.server.refreshSession` endpoint
230230-- Restores old token on failure
231231-- Notifies plugin of new tokens via callback
232232-- Returns new session or error
256256+## Troubleshooting
233257234234-**`renewSession()`** (`xrpc.lua:280-303`)
235235-- Multi-level fallback coordinator
236236-- Level 1: Attempts `refreshSession()`
237237-- Level 2: Falls back to `createSession()` with app password
238238-- Returns boolean success and error message
239239-- Central method called by `call()` on auth errors
258258+### Common Issues
240259241241-**`call()`** (`xrpc.lua:52-176`)
242242-- Generic XRPC request handler
243243-- Detects auth errors (401 or 400 "expired")
244244-- Calls `renewSession()` on auth failure
245245-- Rebuilds request with new authorization header
246246-- Retries original request after successful renewal
247247-- Single point of entry for all XRPC operations
260260+**"Failed to resolve PDS" error**
261261+- Verify handle is correct (e.g., "user.bsky.social")
262262+- Check internet connection
263263+- Confirm Slingshot service (slingshot.microcosm.blue) is accessible
248264249249-### Main Plugin Integration (main.lua)
265265+**"Authentication failed" error**
266266+- Ensure app password is correct (not your main account password)
267267+- Create a new app password if needed from your account settings
268268+- Verify handle format matches your AT Protocol identifier
250269251251-**Initialization** (`main.lua:17-51`)
252252-- Loads credentials from persistent storage
253253-- Configures XRPC client with tokens and app password
254254-- Sets callback for automatic token persistence
255255-- Triggers startup session validation
270270+**"Failed to fetch books: No books found"**
271271+- Books must be added to Popfeed/Paperbnd first before linking
272272+- Books must be in a "currently_reading_books" list
273273+- Use the Popfeed website or app to add books to your account
256274257257-**Token Callback** (`main.lua:64-68`)
258258-- Receives new tokens from XRPC client
259259-- Saves to plugin settings immediately
260260-- Ensures credentials never get out of sync
275275+**"Failed to sync progress" error**
276276+- Check that the book is still linked (may have been unlinked)
277277+- Verify internet connection
278278+- Token renewal should happen automatically, but if persisting, try re-entering credentials
261279262262-**Startup Validation** (`main.lua:70-80`)
263263-- Runs `validateSession()` when plugin loads
264264-- Silent operation (no user-visible errors)
265265-- Ensures tokens are fresh before first use
280280+**Book not appearing in link list**
281281+- Ensure book has `creativeWorkType` set to "book"
282282+- Verify book is in "currently_reading_books" list type
283283+- Try unlinking and re-linking the book
266284267267-### Flow Example
285285+### Debug Tips
268286269269-User syncs progress with expired access token:
270270-1. `syncProgress()` calls `xrpc:putRecord()`
271271-2. `putRecord()` calls `call()` with POST body
272272-3. Server returns 401 Unauthorized
273273-4. `call()` detects auth error, calls `renewSession()`
274274-5. `renewSession()` tries `refreshSession()`
275275-6. If refresh token valid: new tokens received, callback fired
276276-7. If refresh token expired: `createSession()` with app password
277277-8. `call()` rebuilds request with new access token
278278-9. `call()` retries POST request
279279-10. Request succeeds, progress synced
280280-11. User sees success message, unaware of token renewal
287287+- Check KOReader logs for detailed error messages
288288+- Verify credentials are saved by checking settings file at `<KOReader settings dir>/paperbnd.lua`
289289+- Test network connectivity with other KOReader plugins
290290+- Ensure document has page count metadata (required for progress percentage)
281291282292## Development
283293
···2626 self.refresh_token = self.settings:readSetting("refresh_token")
2727 self.did = self.settings:readSetting("did")
2828 self.document_mappings = self.settings:readSetting("document_mappings") or {}
2929+ self.auto_sync_enabled = self.settings:readSetting("auto_sync_enabled")
3030+ if self.auto_sync_enabled == nil then
3131+ self.auto_sync_enabled = false -- Default OFF for safety
3232+ end
3333+3434+ -- Auto-sync state tracking
3535+ self.auto_sync_task = nil -- Scheduled sync task reference
3636+ self.last_synced_page = nil -- Prevent duplicate syncs
3737+ self.auto_sync_delay = 10.0 -- 10 second debounce delay
29383039 -- Initialize XRPC client
3140 self.xrpc = XRPCClient:new()
···5867 self.settings:saveSetting("refresh_token", self.refresh_token)
5968 self.settings:saveSetting("did", self.did)
6069 self.settings:saveSetting("document_mappings", self.document_mappings)
7070+ self.settings:saveSetting("auto_sync_enabled", self.auto_sync_enabled)
6171 self.settings:flush()
6272end
6373···7080function Paperbnd:validateSessionAtStartup()
7181 -- Validate session asynchronously to avoid blocking plugin startup
7282 -- This will trigger automatic renewal if the session is expired
7373- local valid, err = self.xrpc:validateSession()
7474-7575- if not valid then
7676- -- Session validation failed, but renewSession will be called
7777- -- automatically on the next authenticated request
7878- -- No need to show error to user at startup
7979- end
8383+ -- Session validation errors are intentionally ignored at startup
8484+ -- renewSession will be called automatically on the next authenticated request
8585+ self.xrpc:validateSession()
8086end
81878288function Paperbnd:addToMainMenu(menu_items)
···109115 end,
110116 },
111117 {
118118+ text = _("Auto-sync on page turn"),
119119+ checked_func = function()
120120+ return self.auto_sync_enabled
121121+ end,
122122+ callback = function()
123123+ self.auto_sync_enabled = not self.auto_sync_enabled
124124+ self:saveSettings()
125125+126126+ -- Cancel pending sync when disabling
127127+ if not self.auto_sync_enabled and self.auto_sync_task then
128128+ UIManager:unschedule(self.auto_sync_task)
129129+ self.auto_sync_task = nil
130130+ self.last_synced_page = nil
131131+ end
132132+133133+ local status = self.auto_sync_enabled and _("enabled") or _("disabled")
134134+ UIManager:show(InfoMessage:new {
135135+ text = T(_("Auto-sync %1"), status),
136136+ timeout = 2,
137137+ })
138138+ end,
139139+ },
140140+ {
112141 text = _("Unlink current book"),
113142 enabled_func = function()
114143 return self:isCurrentBookLinked()
···209238 })
210239211240 -- Resolve PDS
212212- local pds_url, err = self.xrpc:resolvePDS(handle)
213213- if err then
241241+ local pds_url, pds_err = self.xrpc:resolvePDS(handle)
242242+ if pds_err then
214243 UIManager:show(InfoMessage:new {
215215- text = T(_("Failed to resolve PDS: %1"), err),
244244+ text = T(_("Failed to resolve PDS: %1"), pds_err),
216245 })
217246 return
218247 end
···221250 self.xrpc:setPDS(pds_url)
222251223252 -- Create session
224224- local session, err = self.xrpc:createSession(handle, password)
225225- if err then
253253+ local session, session_err = self.xrpc:createSession(handle, password)
254254+ if session_err then
226255 UIManager:show(InfoMessage:new {
227227- text = T(_("Authentication failed: %1"), err),
256256+ text = T(_("Authentication failed: %1"), session_err),
228257 })
229258 return
230259 end
···284313 -- Filter for books only
285314 local books = {}
286315 for _, record in ipairs(response.records) do
287287- if record.value and record.value.creativeWorkType == "book" and record.value.listType == "currently_reading_books" then
316316+ if record.value and record.value.creativeWorkType == "book"
317317+ and record.value.listType == "currently_reading_books" then
288318 table.insert(books, {
289319 title = record.value.title or "Unknown",
290320 author = record.value.mainCredit or "Unknown",
···326356 height = Screen:getHeight() - Screen:scaleBySize(100),
327357 fullscreen = true,
328358 single_line = false,
329329- onMenuSelect = function(menu, item)
359359+ onMenuSelect = function(_, item)
330360 self:confirmLinkBook(item.book)
331361 end,
332362 }
···379409 local mapping = self.document_mappings[doc_path]
380410381411 -- Get document statistics
382382- local stats = self.ui.doc_settings:readSetting("stats") or {}
383412 local pages = self.ui.document:getPageCount()
384413 local current_page = self.ui.paging and self.ui.paging.current_page or 1
385414 local percent = math.floor((current_page / pages) * 100)
···416445 -- end
417446418447 -- Put updated record
419419- local put_response, put_err = self.xrpc:putRecord(
448448+ local _, put_err = self.xrpc:putRecord(
420449 self.did,
421450 "social.popfeed.feed.listItem",
422451 mapping.rkey,
···436465 })
437466end
438467468468+function Paperbnd:scheduleDebouncedSync(pageno)
469469+ -- Only schedule if auto-sync enabled and authenticated/linked
470470+ if not self.auto_sync_enabled then
471471+ return
472472+ end
473473+474474+ if not self:isAuthenticated() or not self:isCurrentBookLinked() then
475475+ return
476476+ end
477477+478478+ -- Cancel any existing scheduled sync
479479+ if self.auto_sync_task then
480480+ UIManager:unschedule(self.auto_sync_task)
481481+ self.auto_sync_task = nil
482482+ end
483483+484484+ -- Create closure-based task for this scheduling
485485+ self.auto_sync_task = function()
486486+ self:performAutoSync(pageno)
487487+ end
488488+489489+ -- Schedule sync after debounce delay
490490+ UIManager:scheduleIn(self.auto_sync_delay, self.auto_sync_task)
491491+end
492492+493493+function Paperbnd:performAutoSync(pageno)
494494+ -- Clear task reference
495495+ self.auto_sync_task = nil
496496+497497+ -- Check if page actually changed since last sync
498498+ if self.last_synced_page == pageno then
499499+ return
500500+ end
501501+502502+ -- Double-check auth and linking
503503+ if not self:isAuthenticated() or not self:isCurrentBookLinked() then
504504+ return
505505+ end
506506+507507+ -- Perform sync (reuses existing logic)
508508+ self:syncProgress()
509509+510510+ -- Track synced page
511511+ self.last_synced_page = pageno
512512+end
513513+514514+function Paperbnd:onPageUpdate(pageno)
515515+ -- Fires on every page turn - debounce to avoid excessive syncing
516516+ self:scheduleDebouncedSync(pageno)
517517+518518+ -- Return false to allow event propagation
519519+ return false
520520+end
521521+439522-- Hook into document close to sync progress
440523function Paperbnd:onCloseDocument()
524524+ -- Cancel any pending auto-sync
525525+ if self.auto_sync_task then
526526+ UIManager:unschedule(self.auto_sync_task)
527527+ self.auto_sync_task = nil
528528+ end
529529+530530+ -- Clear page tracking for next document
531531+ self.last_synced_page = nil
532532+533533+ -- Always sync on close (original behavior)
441534 if self:isAuthenticated() and self:isCurrentBookLinked() then
442535 self:syncProgress()
443536 end
+1-1
xrpc.lua
···157157 if code ~= 200 then
158158 local error_msg
159159 if response_body and response_body ~= "" then
160160- local error_data, err = rapidjson.decode(response_body)
160160+ local error_data = rapidjson.decode(response_body)
161161 if error_data and error_data.message then
162162 error_msg = error_data.message
163163 else