Unofficial Paperbnd/Popfeed plugin for KOReader

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 1 + .env
+4
.helix/languages.toml
··· 1 + [[language]] 2 + name = "lua" 3 + formatter = { command = "stylua", args = ["-"] } 4 + auto-format = true
+90 -80
CLAUDE.md
··· 1 - # Paperbnd KOReader Plugin 1 + # Paperbnd KOReader Plugin - Developer Documentation 2 + 3 + > **Document Purpose**: This is technical documentation for developers and maintainers. For user-facing installation and usage instructions, see [README.md](README.md). 2 4 3 5 A KOReader plugin for syncing reading progress to an AT Protocol PDS using the Popfeed List Item lexicon. 4 6 ··· 67 69 68 70 ## Technical Details 69 71 72 + ### External Resources 73 + 74 + - **KOReader Plugin Development**: https://github.com/koreader/koreader/wiki/Developer-documentation 75 + - **AT Protocol Specifications**: https://atproto.com/specs/atp 76 + - **Popfeed Platform**: https://popfeed.social/ 77 + - **Paperbnd Website**: https://paperbnd.club/ 78 + 70 79 ### Lexicons Used 71 80 72 81 - `social.popfeed.feed.listItem` - Individual book entries with progress tracking ··· 113 122 - Leverages existing Popfeed records as the source of truth 114 123 115 124 ### Advanced Session Management 116 - The plugin implements a sophisticated multi-level session management system: 125 + 126 + The plugin implements a sophisticated multi-level session management system that is the core reliability feature. 127 + 128 + **Architecture:** 129 + 130 + The session management system has three levels of components: 131 + 132 + 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. 133 + 134 + 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. 135 + 136 + 3. **`validateSession()` method**: Makes lightweight test request at plugin startup to verify token validity and proactively renew expired tokens before user operations. 137 + 138 + **Integration with Main Plugin:** 139 + 140 + - **Initialization**: Loads credentials from persistent storage, configures XRPC client with tokens and app password, and sets callback for automatic token persistence 141 + - **Token Callback**: Receives new tokens from XRPC client, saves to plugin settings immediately, ensuring credentials never get out of sync 142 + - **Startup Validation**: Runs `validateSession()` silently when plugin loads to ensure tokens are fresh before first use 117 143 118 144 **Automatic Renewal Flow:** 145 + 119 146 1. Authentication error detected (401 or 400 "expired") 120 147 2. Attempt to refresh using refresh token 121 148 3. If refresh token expired, create new session with stored app password 122 149 4. If renewal successful, retry the original request automatically 123 150 5. Save new tokens to persistent storage via callback 124 151 125 - **Session Validation:** 126 - - Session checked at plugin startup 127 - - Proactively renews expired tokens before user operations 128 - - Prevents mid-operation authentication failures 152 + **Example Flow - User syncs progress with expired access token:** 153 + 154 + 1. `syncProgress()` calls `xrpc:putRecord()` 155 + 2. `putRecord()` calls `call()` with POST body 156 + 3. Server returns 401 Unauthorized 157 + 4. `call()` detects auth error, calls `renewSession()` 158 + 5. `renewSession()` tries `refreshSession()` 159 + 6. If refresh token valid: new tokens received, callback fired 160 + 7. If refresh token expired: `createSession()` with app password 161 + 8. `call()` rebuilds request with new access token 162 + 9. `call()` retries POST request 163 + 10. Request succeeds, progress synced 164 + 11. User sees success message, unaware of token renewal 129 165 130 166 **Benefits:** 167 + 131 168 - No manual "Refresh Session" button needed 132 169 - Tokens can expire for weeks without user intervention 133 170 - Original requests automatically retried after renewal 134 171 - Seamless user experience without interruptions 135 172 - Callback mechanism keeps plugin and XRPC client in sync 173 + - Proactive validation prevents mid-operation failures 136 174 137 175 ### Authentication Strategy 138 176 - Uses Slingshot service to resolve PDS from handle (no manual PDS entry) ··· 165 203 166 204 ## Code Quality Assessment 167 205 168 - **Current State (January 2025):** 206 + **Current State (December 2024):** 169 207 170 208 The codebase is well-structured and production-ready with the following characteristics: 171 209 ··· 179 217 - Clear function naming and logical organization 180 218 181 219 **Architecture:** 182 - - **XRPC Client (299 lines)**: Self-contained networking layer with 11 public methods 183 - - **Main Plugin (447 lines)**: UI integration with 17 public methods 184 - - **Metadata (5 lines)**: KOReader plugin manifest 185 - - **Total**: ~750 lines of well-documented Lua code 220 + - **XRPC Client (353 lines)**: Self-contained networking layer with 15 public methods 221 + - **Main Plugin (446 lines)**: UI integration with 17 public methods 222 + - **Metadata (6 lines)**: KOReader plugin manifest 223 + - **Total**: 805 lines of well-documented Lua code 186 224 187 225 **Known Limitations:** 188 - - One TODO at `main.lua:415` for updating `listUri` when changing reading status 226 + - 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. 189 227 - Session validation at startup runs synchronously (could be async but startup is fast enough) 190 228 - No retry logic for network failures (only authentication failures) 191 229 - No offline queuing (by design, for simplicity) ··· 198 236 ## Future Enhancements (Not Implemented) 199 237 200 238 Potential features intentionally left out for simplicity: 201 - - Automatic book detection via ISBN 202 - - Batch syncing multiple books 203 - - Conflict resolution for concurrent edits 204 - - Offline queue for syncing when network unavailable 205 - - Automatic list URI updates when changing reading status (see TODO in code) 206 - - Retry logic for transient network failures 207 - - Progress indicators for long-running operations 208 239 209 - ## Files 210 - 211 - - `main.lua` - Main plugin with UI and KOReader integration 212 - - `xrpc.lua` - XRPC client for AT Protocol communication 213 - - `CLAUDE.md` - This documentation file 214 - 215 - ## Session Management Implementation Details 216 - 217 - The advanced session management system is the core reliability feature of this plugin: 240 + - **Automatic book detection via ISBN**: Query ISBN databases to automatically match books instead of manual linking 241 + - **Batch syncing multiple books**: Sync progress for all linked books at once 242 + - **Conflict resolution for concurrent edits**: Handle cases where book data is modified from multiple clients 243 + - **Offline queue for syncing**: Queue progress updates when network unavailable and sync when connection restored 244 + - **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. 245 + - **Retry logic for transient network failures**: Automatically retry failed requests due to temporary network issues 246 + - **Progress indicators for long-running operations**: Show loading spinners or progress bars for network requests 218 247 219 - ### XRPCClient Methods (xrpc.lua) 248 + ## Files 220 249 221 - **`validateSession()`** (`xrpc.lua:229-242`) 222 - - Makes lightweight test request to verify token validity 223 - - Returns boolean success status 224 - - Uses `skip_renewal` flag to prevent recursion 225 - - Called at plugin startup to proactively renew tokens 250 + - `main.lua` - Main plugin with UI and KOReader integration (446 lines) 251 + - `xrpc.lua` - XRPC client for AT Protocol communication (353 lines) 252 + - `_meta.lua` - KOReader plugin manifest (6 lines) 253 + - `README.md` - User-facing documentation with installation and usage instructions 254 + - `CLAUDE.md` - This technical documentation for developers and maintainers 226 255 227 - **`refreshSession()`** (`xrpc.lua:245-277`) 228 - - Swaps access token with refresh token temporarily 229 - - Calls `com.atproto.server.refreshSession` endpoint 230 - - Restores old token on failure 231 - - Notifies plugin of new tokens via callback 232 - - Returns new session or error 256 + ## Troubleshooting 233 257 234 - **`renewSession()`** (`xrpc.lua:280-303`) 235 - - Multi-level fallback coordinator 236 - - Level 1: Attempts `refreshSession()` 237 - - Level 2: Falls back to `createSession()` with app password 238 - - Returns boolean success and error message 239 - - Central method called by `call()` on auth errors 258 + ### Common Issues 240 259 241 - **`call()`** (`xrpc.lua:52-176`) 242 - - Generic XRPC request handler 243 - - Detects auth errors (401 or 400 "expired") 244 - - Calls `renewSession()` on auth failure 245 - - Rebuilds request with new authorization header 246 - - Retries original request after successful renewal 247 - - Single point of entry for all XRPC operations 260 + **"Failed to resolve PDS" error** 261 + - Verify handle is correct (e.g., "user.bsky.social") 262 + - Check internet connection 263 + - Confirm Slingshot service (slingshot.microcosm.blue) is accessible 248 264 249 - ### Main Plugin Integration (main.lua) 265 + **"Authentication failed" error** 266 + - Ensure app password is correct (not your main account password) 267 + - Create a new app password if needed from your account settings 268 + - Verify handle format matches your AT Protocol identifier 250 269 251 - **Initialization** (`main.lua:17-51`) 252 - - Loads credentials from persistent storage 253 - - Configures XRPC client with tokens and app password 254 - - Sets callback for automatic token persistence 255 - - Triggers startup session validation 270 + **"Failed to fetch books: No books found"** 271 + - Books must be added to Popfeed/Paperbnd first before linking 272 + - Books must be in a "currently_reading_books" list 273 + - Use the Popfeed website or app to add books to your account 256 274 257 - **Token Callback** (`main.lua:64-68`) 258 - - Receives new tokens from XRPC client 259 - - Saves to plugin settings immediately 260 - - Ensures credentials never get out of sync 275 + **"Failed to sync progress" error** 276 + - Check that the book is still linked (may have been unlinked) 277 + - Verify internet connection 278 + - Token renewal should happen automatically, but if persisting, try re-entering credentials 261 279 262 - **Startup Validation** (`main.lua:70-80`) 263 - - Runs `validateSession()` when plugin loads 264 - - Silent operation (no user-visible errors) 265 - - Ensures tokens are fresh before first use 280 + **Book not appearing in link list** 281 + - Ensure book has `creativeWorkType` set to "book" 282 + - Verify book is in "currently_reading_books" list type 283 + - Try unlinking and re-linking the book 266 284 267 - ### Flow Example 285 + ### Debug Tips 268 286 269 - User syncs progress with expired access token: 270 - 1. `syncProgress()` calls `xrpc:putRecord()` 271 - 2. `putRecord()` calls `call()` with POST body 272 - 3. Server returns 401 Unauthorized 273 - 4. `call()` detects auth error, calls `renewSession()` 274 - 5. `renewSession()` tries `refreshSession()` 275 - 6. If refresh token valid: new tokens received, callback fired 276 - 7. If refresh token expired: `createSession()` with app password 277 - 8. `call()` rebuilds request with new access token 278 - 9. `call()` retries POST request 279 - 10. Request succeeds, progress synced 280 - 11. User sees success message, unaware of token renewal 287 + - Check KOReader logs for detailed error messages 288 + - Verify credentials are saved by checking settings file at `<KOReader settings dir>/paperbnd.lua` 289 + - Test network connectivity with other KOReader plugins 290 + - Ensure document has page count metadata (required for progress percentage) 281 291 282 292 ## Development 283 293
+2 -4
README.md
··· 4 4 5 5 This plugin associates a document in KOReader with a 6 6 `social.popfeed.feed.listItem` record in your Atproto PDS, and then enables 7 - you to update that record with your reading progress at the press of a button. 7 + you to update that record with your reading progress at automatically or at the press of a button. 8 8 9 9 Some limitations: 10 10 11 - - [ ] Progress is only updated when you close the document or press "Sync progress now" 12 - - [ ] Books must already be in a `currently_reading_books` list 13 11 - [ ] Books must be added to your account from the Paperbnd website or Popfeed website/app before they can be linked 14 - - [ ] There is no offline queueing; updates while offline will error 12 + - [ ] Books must already be in a `currently_reading_books` list 15 13 16 14 ## Installation 17 15
+6 -6
_meta.lua
··· 1 - local _ = require("gettext") 2 - return { 3 - name = "paperbnd", 4 - fullname = _("Paperbnd"), 5 - description = _([[Synchronize reading progress to Paperbnd]]), 6 - } 1 + local _ = require("gettext") 2 + return { 3 + name = "paperbnd", 4 + fullname = _("Paperbnd"), 5 + description = _([[Synchronize reading progress to Paperbnd]]), 6 + }
+61
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1731533236, 9 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 1765186076, 24 + "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", 25 + "owner": "NixOS", 26 + "repo": "nixpkgs", 27 + "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", 28 + "type": "github" 29 + }, 30 + "original": { 31 + "owner": "NixOS", 32 + "ref": "nixos-unstable", 33 + "repo": "nixpkgs", 34 + "type": "github" 35 + } 36 + }, 37 + "root": { 38 + "inputs": { 39 + "flake-utils": "flake-utils", 40 + "nixpkgs": "nixpkgs" 41 + } 42 + }, 43 + "systems": { 44 + "locked": { 45 + "lastModified": 1681028828, 46 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 + "owner": "nix-systems", 48 + "repo": "default", 49 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 + "type": "github" 51 + }, 52 + "original": { 53 + "owner": "nix-systems", 54 + "repo": "default", 55 + "type": "github" 56 + } 57 + } 58 + }, 59 + "root": "root", 60 + "version": 7 61 + }
+35
flake.nix
··· 1 + { 2 + description = "Paperbnd KOReader Plugin Development Environment"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + flake-utils.url = "github:numtide/flake-utils"; 7 + }; 8 + 9 + outputs = { self, nixpkgs, flake-utils }: 10 + flake-utils.lib.eachDefaultSystem (system: 11 + let 12 + pkgs = nixpkgs.legacyPackages.${system}; 13 + in 14 + { 15 + devShells.default = pkgs.mkShell { 16 + buildInputs = with pkgs; [ 17 + lua-language-server 18 + stylua 19 + luajit 20 + luajitPackages.luacheck 21 + ]; 22 + 23 + shellHook = '' 24 + # Source .env file if it exists 25 + if [ -f .env ]; then 26 + echo "Loading environment from .env" 27 + set -a 28 + source .env 29 + set +a 30 + fi 31 + ''; 32 + }; 33 + } 34 + ); 35 + }
+662 -344
main.lua
··· 9 9 10 10 local XRPCClient = require("xrpc") 11 11 12 - local Paperbnd = WidgetContainer:extend { 13 - name = "paperbnd", 14 - is_doc_only = false, 15 - } 12 + local Paperbnd = WidgetContainer:extend({ 13 + name = "paperbnd", 14 + is_doc_only = false, 15 + }) 16 16 17 17 function Paperbnd:init() 18 - self.ui.menu:registerToMainMenu(self) 18 + self.ui.menu:registerToMainMenu(self) 19 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 {} 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 29 34 30 - -- Initialize XRPC client 31 - self.xrpc = XRPCClient:new() 32 - if self.pds_url then 33 - self.xrpc:setPDS(self.pds_url) 34 - end 35 - if self.access_token then 36 - self.xrpc:setAuth(self.access_token, self.refresh_token) 37 - end 38 - if self.handle and self.app_password then 39 - self.xrpc:setCredentials(self.handle, self.app_password) 40 - end 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 41 39 42 - -- Set up callback for credential updates 43 - self.xrpc:setCredentialsCallback(function(access_token, refresh_token) 44 - self:onTokenRefresh(access_token, refresh_token) 45 - end) 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 46 51 47 - -- Validate session at startup if we have credentials 48 - if self:isAuthenticated() then 49 - self:validateSessionAtStartup() 50 - end 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 51 64 end 52 65 53 66 function Paperbnd:saveSettings() 54 - self.settings:saveSetting("handle", self.handle) 55 - self.settings:saveSetting("app_password", self.app_password) 56 - self.settings:saveSetting("pds_url", self.pds_url) 57 - self.settings:saveSetting("access_token", self.access_token) 58 - self.settings:saveSetting("refresh_token", self.refresh_token) 59 - self.settings:saveSetting("did", self.did) 60 - self.settings:saveSetting("document_mappings", self.document_mappings) 61 - self.settings:flush() 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() 62 77 end 63 78 64 79 function Paperbnd:onTokenRefresh(access_token, refresh_token) 65 - self.access_token = access_token 66 - self.refresh_token = refresh_token 67 - self:saveSettings() 80 + self.access_token = access_token 81 + self.refresh_token = refresh_token 82 + self:saveSettings() 68 83 end 69 84 70 85 function Paperbnd:validateSessionAtStartup() 71 - -- Validate session asynchronously to avoid blocking plugin startup 72 - -- This will trigger automatic renewal if the session is expired 73 - local valid, err = self.xrpc:validateSession() 74 - 75 - if not valid then 76 - -- Session validation failed, but renewSession will be called 77 - -- automatically on the next authenticated request 78 - -- No need to show error to user at startup 79 - end 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() 80 91 end 81 92 82 93 function Paperbnd:addToMainMenu(menu_items) 83 - menu_items.paperbnd = { 84 - text = _("Paperbnd"), 85 - sub_item_table = { 86 - { 87 - text = _("Set credentials"), 88 - keep_menu_open = true, 89 - callback = function() 90 - self:setCredentials() 91 - end, 92 - }, 93 - { 94 - text = _("Link current book"), 95 - enabled_func = function() 96 - return self:isAuthenticated() and self.ui.document ~= nil 97 - end, 98 - callback = function() 99 - self:linkCurrentBook() 100 - end, 101 - }, 102 - { 103 - text = _("Sync progress now"), 104 - enabled_func = function() 105 - return self:isAuthenticated() and self:isCurrentBookLinked() 106 - end, 107 - callback = function() 108 - self:syncProgress() 109 - end, 110 - }, 111 - { 112 - text = _("Unlink current book"), 113 - enabled_func = function() 114 - return self:isCurrentBookLinked() 115 - end, 116 - callback = function() 117 - self:unlinkCurrentBook() 118 - end, 119 - }, 120 - }, 121 - } 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 + } 122 228 end 123 229 124 230 function Paperbnd:isAuthenticated() 125 - return self.handle ~= nil and self.access_token ~= nil and self.did ~= nil 231 + return self.handle ~= nil and self.access_token ~= nil and self.did ~= nil 126 232 end 127 233 128 234 function Paperbnd:getCurrentDocumentPath() 129 - if self.ui.document then 130 - return self.ui.document.file 131 - end 132 - return nil 235 + if self.ui.document then 236 + return self.ui.document.file 237 + end 238 + return nil 133 239 end 134 240 135 241 function Paperbnd:isCurrentBookLinked() 136 - local doc_path = self:getCurrentDocumentPath() 137 - return doc_path ~= nil and self.document_mappings[doc_path] ~= nil 242 + local doc_path = self:getCurrentDocumentPath() 243 + return doc_path ~= nil and self.document_mappings[doc_path] ~= nil 138 244 end 139 245 140 246 function Paperbnd:setCredentials() 141 - local handle_input 142 - handle_input = InputDialog:new { 143 - title = _("Enter your handle"), 144 - input = self.handle or "", 145 - buttons = { 146 - { 147 - { 148 - text = _("Cancel"), 149 - callback = function() 150 - UIManager:close(handle_input) 151 - end, 152 - }, 153 - { 154 - text = _("Next"), 155 - is_enter_default = true, 156 - callback = function() 157 - local handle = handle_input:getInputText() 158 - UIManager:close(handle_input) 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) 159 265 160 - if handle and handle ~= "" then 161 - self:setAppPassword(handle) 162 - end 163 - end, 164 - }, 165 - }, 166 - }, 167 - } 168 - UIManager:show(handle_input) 169 - handle_input:onShowKeyboard() 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() 170 276 end 171 277 172 278 function Paperbnd:setAppPassword(handle) 173 - local password_input 174 - password_input = InputDialog:new { 175 - title = _("Enter your app password"), 176 - input = self.app_password or "", 177 - text_type = "password", 178 - buttons = { 179 - { 180 - { 181 - text = _("Cancel"), 182 - callback = function() 183 - UIManager:close(password_input) 184 - end, 185 - }, 186 - { 187 - text = _("Login"), 188 - is_enter_default = true, 189 - callback = function() 190 - local password = password_input:getInputText() 191 - UIManager:close(password_input) 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) 192 298 193 - if password and password ~= "" then 194 - self:authenticate(handle, password) 195 - end 196 - end, 197 - }, 198 - }, 199 - }, 200 - } 201 - UIManager:show(password_input) 202 - password_input:onShowKeyboard() 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() 203 309 end 204 310 205 311 function Paperbnd:authenticate(handle, password) 206 - UIManager:show(InfoMessage:new { 207 - text = _("Authenticating..."), 208 - timeout = 1, 209 - }) 312 + UIManager:show(InfoMessage:new({ 313 + text = _("Authenticating..."), 314 + timeout = 1, 315 + })) 210 316 211 - -- Resolve PDS 212 - local pds_url, err = self.xrpc:resolvePDS(handle) 213 - if err then 214 - UIManager:show(InfoMessage:new { 215 - text = T(_("Failed to resolve PDS: %1"), err), 216 - }) 217 - return 218 - end 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 219 325 220 - self.pds_url = pds_url 221 - self.xrpc:setPDS(pds_url) 326 + self.pds_url = pds_url 327 + self.xrpc:setPDS(pds_url) 222 328 223 - -- Create session 224 - local session, err = self.xrpc:createSession(handle, password) 225 - if err then 226 - UIManager:show(InfoMessage:new { 227 - text = T(_("Authentication failed: %1"), err), 228 - }) 229 - return 230 - end 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 231 337 232 - self.handle = handle 233 - self.app_password = password 234 - self.access_token = session.accessJwt 235 - self.refresh_token = session.refreshJwt 236 - self.did = session.did 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 237 343 238 - -- Store credentials in XRPC client for automatic session renewal 239 - self.xrpc:setCredentials(handle, password) 344 + -- Store credentials in XRPC client for automatic session renewal 345 + self.xrpc:setCredentials(handle, password) 240 346 241 - self:saveSettings() 347 + self:saveSettings() 242 348 243 - UIManager:show(InfoMessage:new { 244 - text = _("Authentication successful!"), 245 - }) 349 + UIManager:show(InfoMessage:new({ 350 + text = _("Authentication successful!"), 351 + })) 246 352 end 247 353 248 354 function Paperbnd:linkCurrentBook() 249 - if not self:isAuthenticated() then 250 - UIManager:show(InfoMessage:new { 251 - text = _("Please set credentials first"), 252 - }) 253 - return 254 - end 255 - 256 - UIManager:show(InfoMessage:new { 257 - text = _("Fetching your books..."), 258 - timeout = 1, 259 - }) 260 - 261 - -- Fetch list items 262 - local response, err = self.xrpc:listRecords( 263 - self.did, 264 - "social.popfeed.feed.listItem", 265 - 100, 266 - nil 267 - ) 355 + if not self:isAuthenticated() then 356 + UIManager:show(InfoMessage:new({ 357 + text = _("Please set credentials first"), 358 + })) 359 + return 360 + end 268 361 269 - if err then 270 - UIManager:show(InfoMessage:new { 271 - text = T(_("Failed to fetch books: %1"), err), 272 - }) 273 - return 274 - end 362 + UIManager:show(InfoMessage:new({ 363 + text = _("Fetching your books..."), 364 + timeout = 1, 365 + })) 275 366 367 + -- Fetch list items 368 + local response, err = self.xrpc:listRecords(self.did, "social.popfeed.feed.listItem", 100, nil) 276 369 277 - if not response.records or #response.records == 0 then 278 - UIManager:show(InfoMessage:new { 279 - text = _("No books found in your account"), 280 - }) 281 - return 282 - end 370 + if err then 371 + UIManager:show(InfoMessage:new({ 372 + text = T(_("Failed to fetch books: %1"), err), 373 + })) 374 + return 375 + end 283 376 284 - -- Filter for books only 285 - local books = {} 286 - for _, record in ipairs(response.records) do 287 - if record.value and record.value.creativeWorkType == "book" and record.value.listType == "currently_reading_books" then 288 - table.insert(books, { 289 - title = record.value.title or "Unknown", 290 - author = record.value.mainCredit or "Unknown", 291 - rkey = record.uri:match("([^/]+)$"), 292 - record = record.value, 293 - }) 294 - end 295 - end 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 296 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 297 400 298 - if #books == 0 then 299 - UIManager:show(InfoMessage:new { 300 - text = _("No books found in your account"), 301 - }) 302 - return 303 - end 401 + if #books == 0 then 402 + UIManager:show(InfoMessage:new({ 403 + text = _("No books found in your account"), 404 + })) 405 + return 406 + end 304 407 305 - -- Show selection dialog 306 - self:showBookSelectionDialog(books) 408 + -- Show selection dialog 409 + self:showBookSelectionDialog(books) 307 410 end 308 411 309 412 function Paperbnd:showBookSelectionDialog(books) 310 - local Menu = require("ui/widget/menu") 311 - local Screen = require("device").screen 413 + local Menu = require("ui/widget/menu") 414 + local CenterContainer = require("ui/widget/container/centercontainer") 415 + local Screen = require("device").screen 312 416 313 - local items = {} 314 - for _, book in ipairs(books) do 315 - table.insert(items, { 316 - text = book.title, 317 - subtitle = book.author, 318 - book = book, 319 - }) 320 - end 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 321 425 322 - local book_menu = Menu:new { 323 - title = _("Select a book"), 324 - item_table = items, 325 - width = Screen:getWidth() - Screen:scaleBySize(100), 326 - height = Screen:getHeight() - Screen:scaleBySize(100), 327 - fullscreen = true, 328 - single_line = false, 329 - onMenuSelect = function(menu, item) 330 - self:confirmLinkBook(item.book) 331 - end, 332 - } 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 + }) 333 439 334 - UIManager:show(book_menu) 440 + book_menu_container = CenterContainer:new({ 441 + dimen = Screen:getSize(), 442 + book_menu, 443 + }) 444 + 445 + UIManager:show(book_menu_container) 335 446 end 336 447 337 448 function Paperbnd:confirmLinkBook(book) 338 - local doc_path = self:getCurrentDocumentPath() 339 - if not doc_path then 340 - return 341 - end 449 + local doc_path = self:getCurrentDocumentPath() 450 + if not doc_path then 451 + return 452 + end 342 453 343 - self.document_mappings[doc_path] = { 344 - rkey = book.rkey, 345 - title = book.title, 346 - author = book.author, 347 - } 454 + self.document_mappings[doc_path] = { 455 + rkey = book.rkey, 456 + title = book.title, 457 + author = book.author, 458 + } 348 459 349 - self:saveSettings() 460 + self:saveSettings() 350 461 351 - UIManager:show(InfoMessage:new { 352 - text = T(_("Linked to: %1"), book.title), 353 - }) 462 + UIManager:show(InfoMessage:new({ 463 + text = T(_("Linked to: %1"), book.title), 464 + })) 354 465 end 355 466 356 467 function Paperbnd:unlinkCurrentBook() 357 - local doc_path = self:getCurrentDocumentPath() 358 - if doc_path and self.document_mappings[doc_path] then 359 - local book_title = self.document_mappings[doc_path].title 360 - self.document_mappings[doc_path] = nil 361 - self:saveSettings() 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 478 + end 479 + 480 + function 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 529 + end 530 + 531 + -- Helper function to detect network errors 532 + function 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 542 + end 543 + 544 + -- Attempt to sync progress, returns true on success, false on network error 545 + function 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 600 + end 601 + 602 + -- Queue a sync for offline processing 603 + function 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() 627 + end 628 + 629 + -- Flush all queued syncs (called after successful sync) 630 + function 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() 362 684 363 - UIManager:show(InfoMessage:new { 364 - text = T(_("Unlinked: %1"), book_title), 365 - }) 366 - end 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 367 691 end 368 692 369 - function Paperbnd:syncProgress() 370 - if not self:isAuthenticated() then 371 - return 372 - end 693 + function 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 373 698 374 - local doc_path = self:getCurrentDocumentPath() 375 - if not doc_path or not self.document_mappings[doc_path] then 376 - return 377 - end 699 + if not self:isAuthenticated() or not self:isCurrentBookLinked() then 700 + return 701 + end 378 702 379 - local mapping = self.document_mappings[doc_path] 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 380 708 381 - -- Get document statistics 382 - local stats = self.ui.doc_settings:readSetting("stats") or {} 383 - local pages = self.ui.document:getPageCount() 384 - local current_page = self.ui.paging and self.ui.paging.current_page or 1 385 - local percent = math.floor((current_page / pages) * 100) 709 + -- Create closure-based task for this scheduling 710 + self.auto_sync_task = function() 711 + self:performAutoSync(pageno) 712 + end 386 713 387 - -- Fetch current record 388 - local record_response, err = self.xrpc:getRecord( 389 - self.did, 390 - "social.popfeed.feed.listItem", 391 - mapping.rkey 392 - ) 714 + -- Schedule sync after debounce delay 715 + UIManager:scheduleIn(self.auto_sync_delay, self.auto_sync_task) 716 + end 393 717 394 - if err then 395 - UIManager:show(InfoMessage:new { 396 - text = T(_("Failed to fetch record: %1"), err), 397 - }) 398 - return 399 - end 718 + function Paperbnd:performAutoSync(pageno) 719 + -- Clear task reference 720 + self.auto_sync_task = nil 400 721 401 - local record = record_response.value 722 + -- Check if page actually changed since last sync 723 + if self.last_synced_page == pageno then 724 + return 725 + end 402 726 403 - -- Update bookProgress 404 - record.bookProgress = { 405 - status = "in_progress", 406 - percent = percent, 407 - currentPage = current_page, 408 - totalPages = pages, 409 - updatedAt = os.date("!%Y-%m-%dT%H:%M:%S.000Z"), 410 - } 727 + -- Double-check auth and linking 728 + if not self:isAuthenticated() or not self:isCurrentBookLinked() then 729 + return 730 + end 411 731 412 - -- If not already in currently_reading, update listType 413 - -- if record.listType ~= "currently_reading_books" then 414 - -- record.listType = "currently_reading_books" 415 - -- TODO: Also update the listUri to point to currently_reading list 416 - -- end 732 + -- Perform sync with notification (less intrusive for auto-sync) 733 + self:syncProgress(true) 417 734 418 - -- Put updated record 419 - local put_response, put_err = self.xrpc:putRecord( 420 - self.did, 421 - "social.popfeed.feed.listItem", 422 - mapping.rkey, 423 - record 424 - ) 735 + -- Track synced page 736 + self.last_synced_page = pageno 737 + end 425 738 426 - if put_err then 427 - UIManager:show(InfoMessage:new { 428 - text = T(_("Failed to sync progress: %1"), put_err), 429 - }) 430 - return 431 - end 739 + function Paperbnd:onPageUpdate(pageno) 740 + -- Fires on every page turn - debounce to avoid excessive syncing 741 + self:scheduleDebouncedSync(pageno) 432 742 433 - UIManager:show(InfoMessage:new { 434 - text = T(_("Synced: %1% (%2/%3)"), percent, current_page, pages), 435 - timeout = 2, 436 - }) 743 + -- Return false to allow event propagation 744 + return false 437 745 end 438 746 439 747 -- Hook into document close to sync progress 440 748 function Paperbnd:onCloseDocument() 441 - if self:isAuthenticated() and self:isCurrentBookLinked() then 442 - self:syncProgress() 443 - end 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 444 762 end 445 763 446 764 return Paperbnd
+241 -241
xrpc.lua
··· 6 6 local rapidjson = require("rapidjson") 7 7 8 8 local XRPCClient = { 9 - pds_url = nil, 10 - handle = nil, 11 - app_password = nil, 12 - access_token = nil, 13 - refresh_token = nil, 14 - timeout = 30, 15 - on_credentials_updated = nil, -- Callback for when credentials are refreshed 9 + pds_url = nil, 10 + handle = nil, 11 + app_password = nil, 12 + access_token = nil, 13 + refresh_token = nil, 14 + timeout = 30, 15 + on_credentials_updated = nil, -- Callback for when credentials are refreshed 16 16 } 17 17 18 18 function XRPCClient:new(o) 19 - o = o or {} 20 - setmetatable(o, self) 21 - self.__index = self 22 - return o 19 + o = o or {} 20 + setmetatable(o, self) 21 + self.__index = self 22 + return o 23 23 end 24 24 25 25 function XRPCClient:setPDS(pds_url) 26 - self.pds_url = pds_url 26 + self.pds_url = pds_url 27 27 end 28 28 29 29 function XRPCClient:setAuth(access_token, refresh_token) 30 - self.access_token = access_token 31 - if refresh_token then 32 - self.refresh_token = refresh_token 33 - end 30 + self.access_token = access_token 31 + if refresh_token then 32 + self.refresh_token = refresh_token 33 + end 34 34 end 35 35 36 36 function XRPCClient:setCredentials(handle, app_password) 37 - self.handle = handle 38 - self.app_password = app_password 37 + self.handle = handle 38 + self.app_password = app_password 39 39 end 40 40 41 41 function XRPCClient:setCredentialsCallback(callback) 42 - self.on_credentials_updated = callback 42 + self.on_credentials_updated = callback 43 43 end 44 44 45 45 -- Notify that credentials were updated 46 46 function XRPCClient:notifyCredentialsUpdated(access_token, refresh_token) 47 - if self.on_credentials_updated then 48 - self.on_credentials_updated(access_token, refresh_token) 49 - end 47 + if self.on_credentials_updated then 48 + self.on_credentials_updated(access_token, refresh_token) 49 + end 50 50 end 51 51 52 52 function XRPCClient:call(opts) 53 - opts = opts or {} 54 - local method = opts.method 55 - local params = opts.params 56 - local body = opts.body 57 - local skip_renewal = opts.skip_renewal or false 58 - local pds = opts.pds or self.pds_url 53 + opts = opts or {} 54 + local method = opts.method 55 + local params = opts.params 56 + local body = opts.body 57 + local skip_renewal = opts.skip_renewal or false 58 + local pds = opts.pds or self.pds_url 59 59 60 - if not pds then 61 - return nil, "PDS URL not set" 62 - end 60 + if not pds then 61 + return nil, "PDS URL not set" 62 + end 63 63 64 - local endpoint = pds .. "/xrpc/" .. method 64 + local endpoint = pds .. "/xrpc/" .. method 65 65 66 - -- Add query parameters if provided 67 - if params and next(params) then 68 - local query_parts = {} 69 - for k, v in pairs(params) do 70 - table.insert(query_parts, k .. "=" .. url.escape(tostring(v))) 71 - end 72 - endpoint = endpoint .. "?" .. table.concat(query_parts, "&") 73 - end 66 + -- Add query parameters if provided 67 + if params and next(params) then 68 + local query_parts = {} 69 + for k, v in pairs(params) do 70 + table.insert(query_parts, k .. "=" .. url.escape(tostring(v))) 71 + end 72 + endpoint = endpoint .. "?" .. table.concat(query_parts, "&") 73 + end 74 74 75 - local request_body = nil 76 - local source = nil 77 - local is_post = body ~= nil 78 - local headers = { 79 - ["Accept"] = "application/json", 80 - } 75 + local request_body = nil 76 + local source = nil 77 + local is_post = body ~= nil 78 + local headers = { 79 + ["Accept"] = "application/json", 80 + } 81 81 82 - -- Handle request body (POST only) 83 - if is_post then 84 - headers["Content-Type"] = "application/json" 82 + -- Handle request body (POST only) 83 + if is_post then 84 + headers["Content-Type"] = "application/json" 85 85 86 - -- Add authorization header for POST requests if token is set 87 - if self.access_token then 88 - headers["Authorization"] = "Bearer " .. self.access_token 89 - end 86 + -- Add authorization header for POST requests if token is set 87 + if self.access_token then 88 + headers["Authorization"] = "Bearer " .. self.access_token 89 + end 90 90 91 - local encoded, err = rapidjson.encode(body) 92 - if err then 93 - return nil, "Failed to encode JSON: " .. err 94 - end 95 - request_body = encoded 96 - headers["Content-Length"] = tostring(#request_body) 97 - source = ltn12.source.string(request_body) 98 - end 91 + local encoded, err = rapidjson.encode(body) 92 + if err then 93 + return nil, "Failed to encode JSON: " .. err 94 + end 95 + request_body = encoded 96 + headers["Content-Length"] = tostring(#request_body) 97 + source = ltn12.source.string(request_body) 98 + end 99 99 100 - local sink = {} 101 - local request = { 102 - url = endpoint, 103 - method = is_post and "POST" or "GET", 104 - sink = ltn12.sink.table(sink), 105 - source = source, 106 - headers = headers, 107 - } 100 + local sink = {} 101 + local request = { 102 + url = endpoint, 103 + method = is_post and "POST" or "GET", 104 + sink = ltn12.sink.table(sink), 105 + source = source, 106 + headers = headers, 107 + } 108 108 109 - socketutil:set_timeout(self.timeout, self.timeout) 110 - local code, response_headers = socket.skip(1, http.request(request)) 111 - socketutil:reset_timeout() 109 + socketutil:set_timeout(self.timeout, self.timeout) 110 + local code, response_headers = socket.skip(1, http.request(request)) 111 + socketutil:reset_timeout() 112 112 113 - if not code then 114 - return nil, "HTTP request failed: " .. tostring(response_headers) 115 - end 113 + if not code then 114 + return nil, "HTTP request failed: " .. tostring(response_headers) 115 + end 116 116 117 - local response_body = table.concat(sink) 117 + local response_body = table.concat(sink) 118 118 119 - -- Handle authentication errors with automatic session renewal 120 - local is_auth_error = code == 401 or 121 - (code == 400 and response_body and string.find(string.lower(response_body), "expired")) 119 + -- Handle authentication errors with automatic session renewal 120 + local is_auth_error = code == 401 121 + or (code == 400 and response_body and string.find(string.lower(response_body), "expired")) 122 122 123 - if is_auth_error and not skip_renewal then 124 - -- Try to renew session (refresh token -> app password fallback) 125 - local renewed, renew_err = self:renewSession() 123 + if is_auth_error and not skip_renewal then 124 + -- Try to renew session (refresh token -> app password fallback) 125 + local renewed, renew_err = self:renewSession() 126 126 127 - if renewed then 128 - -- Session renewed successfully, retry the original request 129 - -- Rebuild request with new authorization header 130 - if is_post and self.access_token then 131 - headers["Authorization"] = "Bearer " .. self.access_token 132 - end 127 + if renewed then 128 + -- Session renewed successfully, retry the original request 129 + -- Rebuild request with new authorization header 130 + if is_post and self.access_token then 131 + headers["Authorization"] = "Bearer " .. self.access_token 132 + end 133 133 134 - sink = {} 135 - request.sink = ltn12.sink.table(sink) 136 - if is_post then 137 - request.source = ltn12.source.string(request_body) 138 - end 139 - request.headers = headers 134 + sink = {} 135 + request.sink = ltn12.sink.table(sink) 136 + if is_post then 137 + request.source = ltn12.source.string(request_body) 138 + end 139 + request.headers = headers 140 140 141 - socketutil:set_timeout(self.timeout, self.timeout) 142 - code, response_headers = socket.skip(1, http.request(request)) 143 - socketutil:reset_timeout() 141 + socketutil:set_timeout(self.timeout, self.timeout) 142 + code, response_headers = socket.skip(1, http.request(request)) 143 + socketutil:reset_timeout() 144 144 145 - if not code then 146 - return nil, "HTTP request failed after session renewal: " .. tostring(response_headers) 147 - end 145 + if not code then 146 + return nil, "HTTP request failed after session renewal: " .. tostring(response_headers) 147 + end 148 148 149 - response_body = table.concat(sink) 150 - else 151 - -- Could not renew session 152 - return nil, "Session expired and renewal failed: " .. (renew_err or "unknown error") 153 - end 154 - end 149 + response_body = table.concat(sink) 150 + else 151 + -- Could not renew session 152 + return nil, "Session expired and renewal failed: " .. (renew_err or "unknown error") 153 + end 154 + end 155 155 156 - -- Handle non-200 responses 157 - if code ~= 200 then 158 - local error_msg 159 - if response_body and response_body ~= "" then 160 - local error_data, err = rapidjson.decode(response_body) 161 - if error_data and error_data.message then 162 - error_msg = error_data.message 163 - else 164 - error_msg = response_body 165 - end 166 - else 167 - error_msg = "HTTP " .. code 168 - end 156 + -- Handle non-200 responses 157 + if code ~= 200 then 158 + local error_msg 159 + if response_body and response_body ~= "" then 160 + local error_data = rapidjson.decode(response_body) 161 + if error_data and error_data.message then 162 + error_msg = error_data.message 163 + else 164 + error_msg = response_body 165 + end 166 + else 167 + error_msg = "HTTP " .. code 168 + end 169 169 170 - return nil, error_msg 171 - end 170 + return nil, error_msg 171 + end 172 172 173 - -- Decode response 174 - if response_body and response_body ~= "" then 175 - local decoded, err = rapidjson.decode(response_body) 176 - if err then 177 - return nil, "Failed to decode JSON response: " .. err 178 - end 179 - return decoded, nil 180 - end 173 + -- Decode response 174 + if response_body and response_body ~= "" then 175 + local decoded, err = rapidjson.decode(response_body) 176 + if err then 177 + return nil, "Failed to decode JSON response: " .. err 178 + end 179 + return decoded, nil 180 + end 181 181 182 - return {}, nil 182 + return {}, nil 183 183 end 184 184 185 185 -- Resolve PDS from handle using Slingshot service 186 186 function XRPCClient:resolvePDS(handle) 187 - local response, err = self:call({ 188 - method = "com.bad-example.identity.resolveMiniDoc", 189 - params = { identifier = handle }, 190 - pds = "https://slingshot.microcosm.blue", 191 - }) 187 + local response, err = self:call({ 188 + method = "com.bad-example.identity.resolveMiniDoc", 189 + params = { identifier = handle }, 190 + pds = "https://slingshot.microcosm.blue", 191 + }) 192 192 193 - if err or not response then 194 - return nil, "Failed to resolve PDS for handle: " .. err 195 - end 193 + if err or not response then 194 + return nil, "Failed to resolve PDS for handle: " .. err 195 + end 196 196 197 - if not response.pds then 198 - return nil, "Invalid response from Slingshot service" 199 - end 197 + if not response.pds then 198 + return nil, "Invalid response from Slingshot service" 199 + end 200 200 201 - return response.pds, nil 201 + return response.pds, nil 202 202 end 203 203 204 204 -- Create session (login) 205 205 function XRPCClient:createSession(identifier, password) 206 - local response, err = self:call({ 207 - method = "com.atproto.server.createSession", 208 - body = { 209 - identifier = identifier, 210 - password = password, 211 - }, 212 - skip_renewal = true, -- Don't auto-renew during initial login 213 - }) 206 + local response, err = self:call({ 207 + method = "com.atproto.server.createSession", 208 + body = { 209 + identifier = identifier, 210 + password = password, 211 + }, 212 + skip_renewal = true, -- Don't auto-renew during initial login 213 + }) 214 214 215 - if err or not response then 216 - return nil, err 217 - end 215 + if err or not response then 216 + return nil, err 217 + end 218 218 219 - if response.accessJwt then 220 - self:setAuth(response.accessJwt, response.refreshJwt) 221 - -- Note: We don't call notifyCredentialsUpdated here since the caller 222 - -- of createSession is expected to handle storing credentials 223 - end 219 + if response.accessJwt then 220 + self:setAuth(response.accessJwt, response.refreshJwt) 221 + -- Note: We don't call notifyCredentialsUpdated here since the caller 222 + -- of createSession is expected to handle storing credentials 223 + end 224 224 225 - return response, nil 225 + return response, nil 226 226 end 227 227 228 228 -- Validate current session by making a test request 229 229 function XRPCClient:validateSession() 230 - if not self.access_token then 231 - return false, "No access token" 232 - end 230 + if not self.access_token then 231 + return false, "No access token" 232 + end 233 233 234 - -- Make a lightweight request to check if session is valid 235 - local response, err = self:call({ 236 - method = "com.atproto.server.getSession", 237 - body = {}, 238 - skip_renewal = true, -- Don't auto-renew during validation 239 - }) 234 + -- Make a lightweight request to check if session is valid 235 + local response, err = self:call({ 236 + method = "com.atproto.server.getSession", 237 + body = {}, 238 + skip_renewal = true, -- Don't auto-renew during validation 239 + }) 240 240 241 - return response ~= nil, err 241 + return response ~= nil, err 242 242 end 243 243 244 244 -- Refresh session using refresh token 245 245 function XRPCClient:refreshSession() 246 - if not self.refresh_token then 247 - return nil, "No refresh token available" 248 - end 246 + if not self.refresh_token then 247 + return nil, "No refresh token available" 248 + end 249 249 250 - -- Temporarily store the current access token 251 - local old_access_token = self.access_token 250 + -- Temporarily store the current access token 251 + local old_access_token = self.access_token 252 252 253 - -- Use refresh token for this request 254 - self.access_token = self.refresh_token 253 + -- Use refresh token for this request 254 + self.access_token = self.refresh_token 255 255 256 - local response, err = self:call({ 257 - method = "com.atproto.server.refreshSession", 258 - body = {}, 259 - skip_renewal = true, -- Don't recurse during refresh 260 - }) 256 + local response, err = self:call({ 257 + method = "com.atproto.server.refreshSession", 258 + body = {}, 259 + skip_renewal = true, -- Don't recurse during refresh 260 + }) 261 261 262 - if err or not response then 263 - -- Restore old access token on failure 264 - self.access_token = old_access_token 265 - return nil, err 266 - end 262 + if err or not response then 263 + -- Restore old access token on failure 264 + self.access_token = old_access_token 265 + return nil, err 266 + end 267 267 268 - -- Update tokens with new values 269 - if response.accessJwt then 270 - self:setAuth(response.accessJwt, response.refreshJwt) 271 - self:notifyCredentialsUpdated(response.accessJwt, response.refreshJwt) 272 - end 268 + -- Update tokens with new values 269 + if response.accessJwt then 270 + self:setAuth(response.accessJwt, response.refreshJwt) 271 + self:notifyCredentialsUpdated(response.accessJwt, response.refreshJwt) 272 + end 273 273 274 - return response, nil 274 + return response, nil 275 275 end 276 276 277 277 -- Renew session with multi-level fallback 278 278 -- 1. Try to use refresh token 279 279 -- 2. If refresh token expired, create new session with app password 280 280 function XRPCClient:renewSession() 281 - -- First, try to refresh with refresh token 282 - local response, err = self:refreshSession() 281 + -- First, try to refresh with refresh token 282 + local response, err = self:refreshSession() 283 283 284 - if response then 285 - return true, nil 286 - end 284 + if response then 285 + return true, nil 286 + end 287 287 288 - -- If refresh failed and we have app password, try to create new session 289 - if self.handle and self.app_password then 290 - local session, session_err = self:createSession(self.handle, self.app_password) 288 + -- If refresh failed and we have app password, try to create new session 289 + if self.handle and self.app_password then 290 + local session, session_err = self:createSession(self.handle, self.app_password) 291 291 292 - if session then 293 - -- New session created successfully, tokens already set by createSession 294 - self:notifyCredentialsUpdated(session.accessJwt, session.refreshJwt) 295 - return true, nil 296 - end 292 + if session then 293 + -- New session created successfully, tokens already set by createSession 294 + self:notifyCredentialsUpdated(session.accessJwt, session.refreshJwt) 295 + return true, nil 296 + end 297 297 298 - return false, "Failed to refresh session and create new session: " .. (session_err or "unknown error") 299 - end 298 + return false, "Failed to refresh session and create new session: " .. (session_err or "unknown error") 299 + end 300 300 301 - return false, 302 - "Failed to refresh session: " .. (err or "unknown error") .. ", and no app password available for fallback" 301 + return false, 302 + "Failed to refresh session: " .. (err or "unknown error") .. ", and no app password available for fallback" 303 303 end 304 304 305 305 -- List records from a collection 306 306 function XRPCClient:listRecords(repo, collection, limit, cursor) 307 - local params = { 308 - repo = repo, 309 - collection = collection, 310 - } 307 + local params = { 308 + repo = repo, 309 + collection = collection, 310 + } 311 311 312 - if limit then 313 - params.limit = limit 314 - end 312 + if limit then 313 + params.limit = limit 314 + end 315 315 316 - if cursor then 317 - params.cursor = cursor 318 - end 316 + if cursor then 317 + params.cursor = cursor 318 + end 319 319 320 - return self:call({ 321 - method = "com.atproto.repo.listRecords", 322 - params = params, 323 - }) 320 + return self:call({ 321 + method = "com.atproto.repo.listRecords", 322 + params = params, 323 + }) 324 324 end 325 325 326 326 -- Get a single record 327 327 function XRPCClient:getRecord(repo, collection, rkey) 328 - local params = { 329 - repo = repo, 330 - collection = collection, 331 - rkey = rkey, 332 - } 328 + local params = { 329 + repo = repo, 330 + collection = collection, 331 + rkey = rkey, 332 + } 333 333 334 - return self:call({ 335 - method = "com.atproto.repo.getRecord", 336 - params = params, 337 - }) 334 + return self:call({ 335 + method = "com.atproto.repo.getRecord", 336 + params = params, 337 + }) 338 338 end 339 339 340 340 -- Put (create or update) a record 341 341 function XRPCClient:putRecord(repo, collection, rkey, record) 342 - return self:call({ 343 - method = "com.atproto.repo.putRecord", 344 - body = { 345 - repo = repo, 346 - collection = collection, 347 - rkey = rkey, 348 - record = record, 349 - }, 350 - }) 342 + return self:call({ 343 + method = "com.atproto.repo.putRecord", 344 + body = { 345 + repo = repo, 346 + collection = collection, 347 + rkey = rkey, 348 + record = record, 349 + }, 350 + }) 351 351 end 352 352 353 353 return XRPCClient