···11+# Paperbnd KOReader Plugin
22+33+A KOReader plugin for syncing reading progress to an AT Protocol PDS using the Popfeed List Item lexicon.
44+55+## Overview
66+77+Paperbnd allows KOReader users to sync their reading progress to their AT Protocol Personal Data Server (PDS) via the Popfeed social reading platform. The plugin tracks page progress and synchronizes it with existing Popfeed book entries.
88+99+## Architecture
1010+1111+The plugin consists of two main components:
1212+1313+### 1. XRPC Client (`xrpc.lua`)
1414+1515+A dedicated module for AT Protocol/XRPC communication that handles:
1616+1717+- **Generic XRPC method invocation** with support for GET and POST requests
1818+- **PDS resolution** via the Slingshot service (Microcosm)
1919+- **Advanced session management** with multi-level token renewal and automatic retry
2020+- **Authentication** using handle and app password
2121+- **Record operations** (list, get, put) for AT Protocol collections
2222+2323+**Key Features:**
2424+- Table-based parameters for flexibility (method, params, body, pds, skip_renewal)
2525+- Multi-level session renewal: refresh token → app password fallback → automatic retry
2626+- Session validation on plugin startup to proactively renew expired tokens
2727+- Authorization headers only on POST requests (GET requests never authenticated)
2828+- Callback support for persisting refreshed tokens to plugin settings
2929+- Handles both 401 and 400 "expired" errors as authentication failures
3030+- Prevents infinite recursion during renewal with `skip_renewal` flag
3131+3232+### 2. Main Plugin (`main.lua`)
3333+3434+The primary plugin file that integrates with KOReader:
3535+3636+- **Settings management** with persistent storage of credentials and book mappings
3737+- **Authentication flow** with two-step credential input (handle, then app password)
3838+- **Book linking** allows users to select from their existing Popfeed list items
3939+- **Progress sync** tracks current page, total pages, and percentage
4040+- **Document hooks** for automatic sync on document close
4141+- **Menu integration** with conditional menu items based on authentication state
4242+4343+## User Workflow
4444+4545+1. **Set Credentials**: User enters their AT Protocol handle and app password
4646+ - Plugin resolves PDS URL via Slingshot service
4747+ - Creates session and stores access/refresh tokens
4848+4949+2. **Link Book**: User selects current document and links it to an existing Popfeed book
5050+ - Fetches user's list items from their PDS
5151+ - Filters for books only
5252+ - Stores mapping between document path and list item rkey
5353+5454+3. **Sync Progress**: Reading progress is synchronized automatically
5555+ - Updates `bookProgress` field with current page, total pages, and percentage
5656+ - Updates `listType` to "currently_reading_books" if needed
5757+ - Can be triggered manually or automatically on document close
5858+5959+4. **Session Management**: Tokens are automatically renewed with multi-level fallback
6060+ - Session validated at plugin startup to ensure fresh tokens
6161+ - Authentication errors (401 or 400 "expired") trigger automatic renewal
6262+ - First attempts refresh with refresh token
6363+ - Falls back to creating new session with app password if refresh token expired
6464+ - Original request automatically retried after successful renewal
6565+ - New tokens saved to settings transparently via callback
6666+ - No user intervention required at any point
6767+6868+## Technical Details
6969+7070+### Lexicons Used
7171+7272+- `social.popfeed.feed.listItem` - Individual book entries with progress tracking
7373+- `social.popfeed.feed.list` - Reading lists (e.g., "currently_reading_books")
7474+- `com.atproto.server.createSession` - Initial authentication
7575+- `com.atproto.server.refreshSession` - Token refresh
7676+- `com.atproto.repo.listRecords` - Fetch book collections
7777+- `com.atproto.repo.getRecord` - Fetch individual book records
7878+- `com.atproto.repo.putRecord` - Update book records with progress
7979+8080+### Data Storage
8181+8282+Settings are persisted in `paperbnd.lua` within KOReader's settings directory:
8383+- Handle and app password
8484+- PDS URL
8585+- Access and refresh tokens
8686+- User DID (Decentralized Identifier)
8787+- Document-to-listItem mappings (document path -> {rkey, title, author})
8888+8989+### Book Progress Format
9090+9191+```lua
9292+bookProgress = {
9393+ status = "in_progress",
9494+ percent = 42, -- Calculated percentage
9595+ currentPage = 150, -- From KOReader document stats
9696+ totalPages = 357, -- From KOReader document stats
9797+ updatedAt = "2025-10-20T14:30:00.000Z" -- ISO 8601 timestamp
9898+}
9999+```
100100+101101+## Design Decisions
102102+103103+### Simplicity First
104104+- Single-file XRPC client for clean separation of concerns
105105+- No future feature speculation - only implemented what's needed
106106+- Direct and straightforward code without unnecessary abstractions
107107+108108+### User Book Selection
109109+Rather than attempting to match books by ISBN or title, users manually link documents to existing Popfeed list items. This approach:
110110+- Avoids complex ISBN DB API integration
111111+- Ensures accuracy (user confirms the correct book)
112112+- Works with any book format supported by KOReader
113113+- Leverages existing Popfeed records as the source of truth
114114+115115+### Advanced Session Management
116116+The plugin implements a sophisticated multi-level session management system:
117117+118118+**Automatic Renewal Flow:**
119119+1. Authentication error detected (401 or 400 "expired")
120120+2. Attempt to refresh using refresh token
121121+3. If refresh token expired, create new session with stored app password
122122+4. If renewal successful, retry the original request automatically
123123+5. Save new tokens to persistent storage via callback
124124+125125+**Session Validation:**
126126+- Session checked at plugin startup
127127+- Proactively renews expired tokens before user operations
128128+- Prevents mid-operation authentication failures
129129+130130+**Benefits:**
131131+- No manual "Refresh Session" button needed
132132+- Tokens can expire for weeks without user intervention
133133+- Original requests automatically retried after renewal
134134+- Seamless user experience without interruptions
135135+- Callback mechanism keeps plugin and XRPC client in sync
136136+137137+### Authentication Strategy
138138+- Uses Slingshot service to resolve PDS from handle (no manual PDS entry)
139139+- App passwords for security (no main account passwords stored)
140140+- Refresh tokens enable long-lived sessions without re-authentication
141141+142142+## Implementation Notes
143143+144144+### Reference Implementation
145145+Built by studying the ReadwiseReader KOReader plugin for:
146146+- Plugin structure and initialization patterns
147147+- UI component creation (menus, dialogs, input forms)
148148+- HTTP request handling with `socket.http` and `ltn12`
149149+- JSON encoding/decoding with `rapidjson`
150150+- Settings persistence with `LuaSettings`
151151+- Document metadata access and event hooks
152152+153153+### AT Protocol Specifics
154154+- GET requests never require authentication headers
155155+- POST requests use Bearer token authentication
156156+- Refresh tokens are used as access tokens for the refresh endpoint
157157+- Both access and refresh tokens are rotated on each refresh
158158+- Session validation uses `com.atproto.server.getSession` endpoint
159159+- Authentication errors can return 401 or 400 with "expired" message
160160+161161+### Error Handling
162162+- User-friendly error messages via `InfoMessage` widgets
163163+- Graceful degradation when operations fail
164164+- Network errors handled with appropriate fallbacks
165165+166166+## Code Quality Assessment
167167+168168+**Current State (January 2025):**
169169+170170+The codebase is well-structured and production-ready with the following characteristics:
171171+172172+**Strengths:**
173173+- Clean separation of concerns (XRPC client vs. plugin logic)
174174+- Comprehensive error handling with user-friendly messages
175175+- Robust session management with multiple fallback mechanisms
176176+- Proper state synchronization between components via callbacks
177177+- No memory leaks or blocking operations
178178+- Defensive programming with validation at critical points
179179+- Clear function naming and logical organization
180180+181181+**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
186186+187187+**Known Limitations:**
188188+- One TODO at `main.lua:415` for updating `listUri` when changing reading status
189189+- Session validation at startup runs synchronously (could be async but startup is fast enough)
190190+- No retry logic for network failures (only authentication failures)
191191+- No offline queuing (by design, for simplicity)
192192+193193+**Testing Considerations:**
194194+- Manual testing required (KOReader plugin environment)
195195+- Test scenarios: expired access token, expired refresh token, network errors
196196+- Edge cases: malformed responses, missing credentials, unlinked books
197197+198198+## Future Enhancements (Not Implemented)
199199+200200+Potential 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
208208+209209+## 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:
218218+219219+### XRPCClient Methods (xrpc.lua)
220220+221221+**`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
226226+227227+**`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
233233+234234+**`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
240240+241241+**`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
248248+249249+### Main Plugin Integration (main.lua)
250250+251251+**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
256256+257257+**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
261261+262262+**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
266266+267267+### Flow Example
268268+269269+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
281281+282282+## Development
283283+284284+The plugin was developed collaboratively with Claude (Anthropic's AI assistant) with these priorities:
285285+- Clean, readable code
286286+- Comprehensive error handling
287287+- User experience focused on simplicity
288288+- No emojis in documentation or messages
289289+- Direct communication style in code comments
290290+- Proactive reliability through startup validation
+36
README.md
···11+# paperbnd.koplugin
22+33+A [KOReader] plugin for syncing your reading progress to [Paperbnd]/[Popfeed].
44+55+This plugin associates a document in KOReader with a
66+`social.popfeed.feed.listItem` record in your Atproto PDS, and then enables
77+you to update that record with your reading progress at the press of a button.
88+99+Some limitations:
1010+1111+- [ ] Progress is only updated when you close the document or press "Sync progress now"
1212+- [ ] Books must already be in a `currently_reading_books` list
1313+- [ ] Books must be added to your account from the Paperbnd website or Popfeed website/app before they can be linked
1414+- [ ] There is no offline queueing; updates while offline will error
1515+1616+## Installation
1717+1818+1. Download/clone this repository
1919+2. Drag the `paperbnd.koplugin` folder into your KOReader plugins directory
2020+3. Restart KOReader
2121+2222+## Usage
2323+2424+1. Create an App Password wherever your Atproto account is managed (Bluesky, Tangled, elsewhere)
2525+2. Open the KOReader menu
2626+3. Navigate to the second page of the first tab
2727+4. Select the "Paperbnd" option
2828+5. Select "Set credentials"
2929+6. Enter your Handle and App Password when prompted
3030+7. Select "Link current book"
3131+8. Select your book from the list
3232+9. From the Paperbnd menu, select "Sync progress now"
3333+3434+[Paperbnd]: https://paperbnd.club/
3535+[Popfeed]: https://popfeed.social/
3636+[KOReader]: https://koreader.rocks/
···11+local DataStorage = require("datastorage")
22+local InfoMessage = require("ui/widget/infomessage")
33+local InputDialog = require("ui/widget/inputdialog")
44+local LuaSettings = require("luasettings")
55+local UIManager = require("ui/uimanager")
66+local WidgetContainer = require("ui/widget/container/widgetcontainer")
77+local _ = require("gettext")
88+local T = require("ffi/util").template
99+1010+local XRPCClient = require("xrpc")
1111+1212+local Paperbnd = WidgetContainer:extend {
1313+ name = "paperbnd",
1414+ is_doc_only = false,
1515+}
1616+1717+function Paperbnd:init()
1818+ self.ui.menu:registerToMainMenu(self)
1919+2020+ -- Load settings
2121+ self.settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/paperbnd.lua")
2222+ self.handle = self.settings:readSetting("handle")
2323+ self.app_password = self.settings:readSetting("app_password")
2424+ self.pds_url = self.settings:readSetting("pds_url")
2525+ self.access_token = self.settings:readSetting("access_token")
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+3030+ -- Initialize XRPC client
3131+ self.xrpc = XRPCClient:new()
3232+ if self.pds_url then
3333+ self.xrpc:setPDS(self.pds_url)
3434+ end
3535+ if self.access_token then
3636+ self.xrpc:setAuth(self.access_token, self.refresh_token)
3737+ end
3838+ if self.handle and self.app_password then
3939+ self.xrpc:setCredentials(self.handle, self.app_password)
4040+ end
4141+4242+ -- Set up callback for credential updates
4343+ self.xrpc:setCredentialsCallback(function(access_token, refresh_token)
4444+ self:onTokenRefresh(access_token, refresh_token)
4545+ end)
4646+4747+ -- Validate session at startup if we have credentials
4848+ if self:isAuthenticated() then
4949+ self:validateSessionAtStartup()
5050+ end
5151+end
5252+5353+function Paperbnd:saveSettings()
5454+ self.settings:saveSetting("handle", self.handle)
5555+ self.settings:saveSetting("app_password", self.app_password)
5656+ self.settings:saveSetting("pds_url", self.pds_url)
5757+ self.settings:saveSetting("access_token", self.access_token)
5858+ self.settings:saveSetting("refresh_token", self.refresh_token)
5959+ self.settings:saveSetting("did", self.did)
6060+ self.settings:saveSetting("document_mappings", self.document_mappings)
6161+ self.settings:flush()
6262+end
6363+6464+function Paperbnd:onTokenRefresh(access_token, refresh_token)
6565+ self.access_token = access_token
6666+ self.refresh_token = refresh_token
6767+ self:saveSettings()
6868+end
6969+7070+function Paperbnd:validateSessionAtStartup()
7171+ -- Validate session asynchronously to avoid blocking plugin startup
7272+ -- 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
8080+end
8181+8282+function Paperbnd:addToMainMenu(menu_items)
8383+ menu_items.paperbnd = {
8484+ text = _("Paperbnd"),
8585+ sub_item_table = {
8686+ {
8787+ text = _("Set credentials"),
8888+ keep_menu_open = true,
8989+ callback = function()
9090+ self:setCredentials()
9191+ end,
9292+ },
9393+ {
9494+ text = _("Link current book"),
9595+ enabled_func = function()
9696+ return self:isAuthenticated() and self.ui.document ~= nil
9797+ end,
9898+ callback = function()
9999+ self:linkCurrentBook()
100100+ end,
101101+ },
102102+ {
103103+ text = _("Sync progress now"),
104104+ enabled_func = function()
105105+ return self:isAuthenticated() and self:isCurrentBookLinked()
106106+ end,
107107+ callback = function()
108108+ self:syncProgress()
109109+ end,
110110+ },
111111+ {
112112+ text = _("Unlink current book"),
113113+ enabled_func = function()
114114+ return self:isCurrentBookLinked()
115115+ end,
116116+ callback = function()
117117+ self:unlinkCurrentBook()
118118+ end,
119119+ },
120120+ },
121121+ }
122122+end
123123+124124+function Paperbnd:isAuthenticated()
125125+ return self.handle ~= nil and self.access_token ~= nil and self.did ~= nil
126126+end
127127+128128+function Paperbnd:getCurrentDocumentPath()
129129+ if self.ui.document then
130130+ return self.ui.document.file
131131+ end
132132+ return nil
133133+end
134134+135135+function Paperbnd:isCurrentBookLinked()
136136+ local doc_path = self:getCurrentDocumentPath()
137137+ return doc_path ~= nil and self.document_mappings[doc_path] ~= nil
138138+end
139139+140140+function Paperbnd:setCredentials()
141141+ local handle_input
142142+ handle_input = InputDialog:new {
143143+ title = _("Enter your handle"),
144144+ input = self.handle or "",
145145+ buttons = {
146146+ {
147147+ {
148148+ text = _("Cancel"),
149149+ callback = function()
150150+ UIManager:close(handle_input)
151151+ end,
152152+ },
153153+ {
154154+ text = _("Next"),
155155+ is_enter_default = true,
156156+ callback = function()
157157+ local handle = handle_input:getInputText()
158158+ UIManager:close(handle_input)
159159+160160+ if handle and handle ~= "" then
161161+ self:setAppPassword(handle)
162162+ end
163163+ end,
164164+ },
165165+ },
166166+ },
167167+ }
168168+ UIManager:show(handle_input)
169169+ handle_input:onShowKeyboard()
170170+end
171171+172172+function Paperbnd:setAppPassword(handle)
173173+ local password_input
174174+ password_input = InputDialog:new {
175175+ title = _("Enter your app password"),
176176+ input = self.app_password or "",
177177+ text_type = "password",
178178+ buttons = {
179179+ {
180180+ {
181181+ text = _("Cancel"),
182182+ callback = function()
183183+ UIManager:close(password_input)
184184+ end,
185185+ },
186186+ {
187187+ text = _("Login"),
188188+ is_enter_default = true,
189189+ callback = function()
190190+ local password = password_input:getInputText()
191191+ UIManager:close(password_input)
192192+193193+ if password and password ~= "" then
194194+ self:authenticate(handle, password)
195195+ end
196196+ end,
197197+ },
198198+ },
199199+ },
200200+ }
201201+ UIManager:show(password_input)
202202+ password_input:onShowKeyboard()
203203+end
204204+205205+function Paperbnd:authenticate(handle, password)
206206+ UIManager:show(InfoMessage:new {
207207+ text = _("Authenticating..."),
208208+ timeout = 1,
209209+ })
210210+211211+ -- Resolve PDS
212212+ local pds_url, err = self.xrpc:resolvePDS(handle)
213213+ if err then
214214+ UIManager:show(InfoMessage:new {
215215+ text = T(_("Failed to resolve PDS: %1"), err),
216216+ })
217217+ return
218218+ end
219219+220220+ self.pds_url = pds_url
221221+ self.xrpc:setPDS(pds_url)
222222+223223+ -- Create session
224224+ local session, err = self.xrpc:createSession(handle, password)
225225+ if err then
226226+ UIManager:show(InfoMessage:new {
227227+ text = T(_("Authentication failed: %1"), err),
228228+ })
229229+ return
230230+ end
231231+232232+ self.handle = handle
233233+ self.app_password = password
234234+ self.access_token = session.accessJwt
235235+ self.refresh_token = session.refreshJwt
236236+ self.did = session.did
237237+238238+ -- Store credentials in XRPC client for automatic session renewal
239239+ self.xrpc:setCredentials(handle, password)
240240+241241+ self:saveSettings()
242242+243243+ UIManager:show(InfoMessage:new {
244244+ text = _("Authentication successful!"),
245245+ })
246246+end
247247+248248+function Paperbnd:linkCurrentBook()
249249+ if not self:isAuthenticated() then
250250+ UIManager:show(InfoMessage:new {
251251+ text = _("Please set credentials first"),
252252+ })
253253+ return
254254+ end
255255+256256+ UIManager:show(InfoMessage:new {
257257+ text = _("Fetching your books..."),
258258+ timeout = 1,
259259+ })
260260+261261+ -- Fetch list items
262262+ local response, err = self.xrpc:listRecords(
263263+ self.did,
264264+ "social.popfeed.feed.listItem",
265265+ 100,
266266+ nil
267267+ )
268268+269269+ if err then
270270+ UIManager:show(InfoMessage:new {
271271+ text = T(_("Failed to fetch books: %1"), err),
272272+ })
273273+ return
274274+ end
275275+276276+277277+ if not response.records or #response.records == 0 then
278278+ UIManager:show(InfoMessage:new {
279279+ text = _("No books found in your account"),
280280+ })
281281+ return
282282+ end
283283+284284+ -- Filter for books only
285285+ local books = {}
286286+ for _, record in ipairs(response.records) do
287287+ if record.value and record.value.creativeWorkType == "book" and record.value.listType == "currently_reading_books" then
288288+ table.insert(books, {
289289+ title = record.value.title or "Unknown",
290290+ author = record.value.mainCredit or "Unknown",
291291+ rkey = record.uri:match("([^/]+)$"),
292292+ record = record.value,
293293+ })
294294+ end
295295+ end
296296+297297+298298+ if #books == 0 then
299299+ UIManager:show(InfoMessage:new {
300300+ text = _("No books found in your account"),
301301+ })
302302+ return
303303+ end
304304+305305+ -- Show selection dialog
306306+ self:showBookSelectionDialog(books)
307307+end
308308+309309+function Paperbnd:showBookSelectionDialog(books)
310310+ local Menu = require("ui/widget/menu")
311311+ local Screen = require("device").screen
312312+313313+ local items = {}
314314+ for _, book in ipairs(books) do
315315+ table.insert(items, {
316316+ text = book.title,
317317+ subtitle = book.author,
318318+ book = book,
319319+ })
320320+ end
321321+322322+ local book_menu = Menu:new {
323323+ title = _("Select a book"),
324324+ item_table = items,
325325+ width = Screen:getWidth() - Screen:scaleBySize(100),
326326+ height = Screen:getHeight() - Screen:scaleBySize(100),
327327+ fullscreen = true,
328328+ single_line = false,
329329+ onMenuSelect = function(menu, item)
330330+ self:confirmLinkBook(item.book)
331331+ end,
332332+ }
333333+334334+ UIManager:show(book_menu)
335335+end
336336+337337+function Paperbnd:confirmLinkBook(book)
338338+ local doc_path = self:getCurrentDocumentPath()
339339+ if not doc_path then
340340+ return
341341+ end
342342+343343+ self.document_mappings[doc_path] = {
344344+ rkey = book.rkey,
345345+ title = book.title,
346346+ author = book.author,
347347+ }
348348+349349+ self:saveSettings()
350350+351351+ UIManager:show(InfoMessage:new {
352352+ text = T(_("Linked to: %1"), book.title),
353353+ })
354354+end
355355+356356+function Paperbnd:unlinkCurrentBook()
357357+ local doc_path = self:getCurrentDocumentPath()
358358+ if doc_path and self.document_mappings[doc_path] then
359359+ local book_title = self.document_mappings[doc_path].title
360360+ self.document_mappings[doc_path] = nil
361361+ self:saveSettings()
362362+363363+ UIManager:show(InfoMessage:new {
364364+ text = T(_("Unlinked: %1"), book_title),
365365+ })
366366+ end
367367+end
368368+369369+function Paperbnd:syncProgress()
370370+ if not self:isAuthenticated() then
371371+ return
372372+ end
373373+374374+ local doc_path = self:getCurrentDocumentPath()
375375+ if not doc_path or not self.document_mappings[doc_path] then
376376+ return
377377+ end
378378+379379+ local mapping = self.document_mappings[doc_path]
380380+381381+ -- Get document statistics
382382+ local stats = self.ui.doc_settings:readSetting("stats") or {}
383383+ local pages = self.ui.document:getPageCount()
384384+ local current_page = self.ui.paging and self.ui.paging.current_page or 1
385385+ local percent = math.floor((current_page / pages) * 100)
386386+387387+ -- Fetch current record
388388+ local record_response, err = self.xrpc:getRecord(
389389+ self.did,
390390+ "social.popfeed.feed.listItem",
391391+ mapping.rkey
392392+ )
393393+394394+ if err then
395395+ UIManager:show(InfoMessage:new {
396396+ text = T(_("Failed to fetch record: %1"), err),
397397+ })
398398+ return
399399+ end
400400+401401+ local record = record_response.value
402402+403403+ -- Update bookProgress
404404+ record.bookProgress = {
405405+ status = "in_progress",
406406+ percent = percent,
407407+ currentPage = current_page,
408408+ totalPages = pages,
409409+ updatedAt = os.date("!%Y-%m-%dT%H:%M:%S.000Z"),
410410+ }
411411+412412+ -- If not already in currently_reading, update listType
413413+ -- if record.listType ~= "currently_reading_books" then
414414+ -- record.listType = "currently_reading_books"
415415+ -- TODO: Also update the listUri to point to currently_reading list
416416+ -- end
417417+418418+ -- Put updated record
419419+ local put_response, put_err = self.xrpc:putRecord(
420420+ self.did,
421421+ "social.popfeed.feed.listItem",
422422+ mapping.rkey,
423423+ record
424424+ )
425425+426426+ if put_err then
427427+ UIManager:show(InfoMessage:new {
428428+ text = T(_("Failed to sync progress: %1"), put_err),
429429+ })
430430+ return
431431+ end
432432+433433+ UIManager:show(InfoMessage:new {
434434+ text = T(_("Synced: %1% (%2/%3)"), percent, current_page, pages),
435435+ timeout = 2,
436436+ })
437437+end
438438+439439+-- Hook into document close to sync progress
440440+function Paperbnd:onCloseDocument()
441441+ if self:isAuthenticated() and self:isCurrentBookLinked() then
442442+ self:syncProgress()
443443+ end
444444+end
445445+446446+return Paperbnd
+353
xrpc.lua
···11+local http = require("socket.http")
22+local url = require("socket.url")
33+local ltn12 = require("ltn12")
44+local socket = require("socket")
55+local socketutil = require("socketutil")
66+local rapidjson = require("rapidjson")
77+88+local XRPCClient = {
99+ pds_url = nil,
1010+ handle = nil,
1111+ app_password = nil,
1212+ access_token = nil,
1313+ refresh_token = nil,
1414+ timeout = 30,
1515+ on_credentials_updated = nil, -- Callback for when credentials are refreshed
1616+}
1717+1818+function XRPCClient:new(o)
1919+ o = o or {}
2020+ setmetatable(o, self)
2121+ self.__index = self
2222+ return o
2323+end
2424+2525+function XRPCClient:setPDS(pds_url)
2626+ self.pds_url = pds_url
2727+end
2828+2929+function XRPCClient:setAuth(access_token, refresh_token)
3030+ self.access_token = access_token
3131+ if refresh_token then
3232+ self.refresh_token = refresh_token
3333+ end
3434+end
3535+3636+function XRPCClient:setCredentials(handle, app_password)
3737+ self.handle = handle
3838+ self.app_password = app_password
3939+end
4040+4141+function XRPCClient:setCredentialsCallback(callback)
4242+ self.on_credentials_updated = callback
4343+end
4444+4545+-- Notify that credentials were updated
4646+function XRPCClient:notifyCredentialsUpdated(access_token, refresh_token)
4747+ if self.on_credentials_updated then
4848+ self.on_credentials_updated(access_token, refresh_token)
4949+ end
5050+end
5151+5252+function XRPCClient:call(opts)
5353+ opts = opts or {}
5454+ local method = opts.method
5555+ local params = opts.params
5656+ local body = opts.body
5757+ local skip_renewal = opts.skip_renewal or false
5858+ local pds = opts.pds or self.pds_url
5959+6060+ if not pds then
6161+ return nil, "PDS URL not set"
6262+ end
6363+6464+ local endpoint = pds .. "/xrpc/" .. method
6565+6666+ -- Add query parameters if provided
6767+ if params and next(params) then
6868+ local query_parts = {}
6969+ for k, v in pairs(params) do
7070+ table.insert(query_parts, k .. "=" .. url.escape(tostring(v)))
7171+ end
7272+ endpoint = endpoint .. "?" .. table.concat(query_parts, "&")
7373+ end
7474+7575+ local request_body = nil
7676+ local source = nil
7777+ local is_post = body ~= nil
7878+ local headers = {
7979+ ["Accept"] = "application/json",
8080+ }
8181+8282+ -- Handle request body (POST only)
8383+ if is_post then
8484+ headers["Content-Type"] = "application/json"
8585+8686+ -- Add authorization header for POST requests if token is set
8787+ if self.access_token then
8888+ headers["Authorization"] = "Bearer " .. self.access_token
8989+ end
9090+9191+ local encoded, err = rapidjson.encode(body)
9292+ if err then
9393+ return nil, "Failed to encode JSON: " .. err
9494+ end
9595+ request_body = encoded
9696+ headers["Content-Length"] = tostring(#request_body)
9797+ source = ltn12.source.string(request_body)
9898+ end
9999+100100+ local sink = {}
101101+ local request = {
102102+ url = endpoint,
103103+ method = is_post and "POST" or "GET",
104104+ sink = ltn12.sink.table(sink),
105105+ source = source,
106106+ headers = headers,
107107+ }
108108+109109+ socketutil:set_timeout(self.timeout, self.timeout)
110110+ local code, response_headers = socket.skip(1, http.request(request))
111111+ socketutil:reset_timeout()
112112+113113+ if not code then
114114+ return nil, "HTTP request failed: " .. tostring(response_headers)
115115+ end
116116+117117+ local response_body = table.concat(sink)
118118+119119+ -- Handle authentication errors with automatic session renewal
120120+ local is_auth_error = code == 401 or
121121+ (code == 400 and response_body and string.find(string.lower(response_body), "expired"))
122122+123123+ if is_auth_error and not skip_renewal then
124124+ -- Try to renew session (refresh token -> app password fallback)
125125+ local renewed, renew_err = self:renewSession()
126126+127127+ if renewed then
128128+ -- Session renewed successfully, retry the original request
129129+ -- Rebuild request with new authorization header
130130+ if is_post and self.access_token then
131131+ headers["Authorization"] = "Bearer " .. self.access_token
132132+ end
133133+134134+ sink = {}
135135+ request.sink = ltn12.sink.table(sink)
136136+ if is_post then
137137+ request.source = ltn12.source.string(request_body)
138138+ end
139139+ request.headers = headers
140140+141141+ socketutil:set_timeout(self.timeout, self.timeout)
142142+ code, response_headers = socket.skip(1, http.request(request))
143143+ socketutil:reset_timeout()
144144+145145+ if not code then
146146+ return nil, "HTTP request failed after session renewal: " .. tostring(response_headers)
147147+ end
148148+149149+ response_body = table.concat(sink)
150150+ else
151151+ -- Could not renew session
152152+ return nil, "Session expired and renewal failed: " .. (renew_err or "unknown error")
153153+ end
154154+ end
155155+156156+ -- Handle non-200 responses
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)
161161+ if error_data and error_data.message then
162162+ error_msg = error_data.message
163163+ else
164164+ error_msg = response_body
165165+ end
166166+ else
167167+ error_msg = "HTTP " .. code
168168+ end
169169+170170+ return nil, error_msg
171171+ end
172172+173173+ -- Decode response
174174+ if response_body and response_body ~= "" then
175175+ local decoded, err = rapidjson.decode(response_body)
176176+ if err then
177177+ return nil, "Failed to decode JSON response: " .. err
178178+ end
179179+ return decoded, nil
180180+ end
181181+182182+ return {}, nil
183183+end
184184+185185+-- Resolve PDS from handle using Slingshot service
186186+function XRPCClient:resolvePDS(handle)
187187+ local response, err = self:call({
188188+ method = "com.bad-example.identity.resolveMiniDoc",
189189+ params = { identifier = handle },
190190+ pds = "https://slingshot.microcosm.blue",
191191+ })
192192+193193+ if err or not response then
194194+ return nil, "Failed to resolve PDS for handle: " .. err
195195+ end
196196+197197+ if not response.pds then
198198+ return nil, "Invalid response from Slingshot service"
199199+ end
200200+201201+ return response.pds, nil
202202+end
203203+204204+-- Create session (login)
205205+function XRPCClient:createSession(identifier, password)
206206+ local response, err = self:call({
207207+ method = "com.atproto.server.createSession",
208208+ body = {
209209+ identifier = identifier,
210210+ password = password,
211211+ },
212212+ skip_renewal = true, -- Don't auto-renew during initial login
213213+ })
214214+215215+ if err or not response then
216216+ return nil, err
217217+ end
218218+219219+ if response.accessJwt then
220220+ self:setAuth(response.accessJwt, response.refreshJwt)
221221+ -- Note: We don't call notifyCredentialsUpdated here since the caller
222222+ -- of createSession is expected to handle storing credentials
223223+ end
224224+225225+ return response, nil
226226+end
227227+228228+-- Validate current session by making a test request
229229+function XRPCClient:validateSession()
230230+ if not self.access_token then
231231+ return false, "No access token"
232232+ end
233233+234234+ -- Make a lightweight request to check if session is valid
235235+ local response, err = self:call({
236236+ method = "com.atproto.server.getSession",
237237+ body = {},
238238+ skip_renewal = true, -- Don't auto-renew during validation
239239+ })
240240+241241+ return response ~= nil, err
242242+end
243243+244244+-- Refresh session using refresh token
245245+function XRPCClient:refreshSession()
246246+ if not self.refresh_token then
247247+ return nil, "No refresh token available"
248248+ end
249249+250250+ -- Temporarily store the current access token
251251+ local old_access_token = self.access_token
252252+253253+ -- Use refresh token for this request
254254+ self.access_token = self.refresh_token
255255+256256+ local response, err = self:call({
257257+ method = "com.atproto.server.refreshSession",
258258+ body = {},
259259+ skip_renewal = true, -- Don't recurse during refresh
260260+ })
261261+262262+ if err or not response then
263263+ -- Restore old access token on failure
264264+ self.access_token = old_access_token
265265+ return nil, err
266266+ end
267267+268268+ -- Update tokens with new values
269269+ if response.accessJwt then
270270+ self:setAuth(response.accessJwt, response.refreshJwt)
271271+ self:notifyCredentialsUpdated(response.accessJwt, response.refreshJwt)
272272+ end
273273+274274+ return response, nil
275275+end
276276+277277+-- Renew session with multi-level fallback
278278+-- 1. Try to use refresh token
279279+-- 2. If refresh token expired, create new session with app password
280280+function XRPCClient:renewSession()
281281+ -- First, try to refresh with refresh token
282282+ local response, err = self:refreshSession()
283283+284284+ if response then
285285+ return true, nil
286286+ end
287287+288288+ -- If refresh failed and we have app password, try to create new session
289289+ if self.handle and self.app_password then
290290+ local session, session_err = self:createSession(self.handle, self.app_password)
291291+292292+ if session then
293293+ -- New session created successfully, tokens already set by createSession
294294+ self:notifyCredentialsUpdated(session.accessJwt, session.refreshJwt)
295295+ return true, nil
296296+ end
297297+298298+ return false, "Failed to refresh session and create new session: " .. (session_err or "unknown error")
299299+ end
300300+301301+ return false,
302302+ "Failed to refresh session: " .. (err or "unknown error") .. ", and no app password available for fallback"
303303+end
304304+305305+-- List records from a collection
306306+function XRPCClient:listRecords(repo, collection, limit, cursor)
307307+ local params = {
308308+ repo = repo,
309309+ collection = collection,
310310+ }
311311+312312+ if limit then
313313+ params.limit = limit
314314+ end
315315+316316+ if cursor then
317317+ params.cursor = cursor
318318+ end
319319+320320+ return self:call({
321321+ method = "com.atproto.repo.listRecords",
322322+ params = params,
323323+ })
324324+end
325325+326326+-- Get a single record
327327+function XRPCClient:getRecord(repo, collection, rkey)
328328+ local params = {
329329+ repo = repo,
330330+ collection = collection,
331331+ rkey = rkey,
332332+ }
333333+334334+ return self:call({
335335+ method = "com.atproto.repo.getRecord",
336336+ params = params,
337337+ })
338338+end
339339+340340+-- Put (create or update) a record
341341+function XRPCClient:putRecord(repo, collection, rkey, record)
342342+ return self:call({
343343+ method = "com.atproto.repo.putRecord",
344344+ body = {
345345+ repo = repo,
346346+ collection = collection,
347347+ rkey = rkey,
348348+ record = record,
349349+ },
350350+ })
351351+end
352352+353353+return XRPCClient