Unofficial Paperbnd/Popfeed plugin for KOReader

run formatter

Changed files
+647 -660
+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 + }
+400 -413
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 {} 29 - self.auto_sync_enabled = self.settings:readSetting("auto_sync_enabled") 30 - if self.auto_sync_enabled == nil then 31 - self.auto_sync_enabled = false -- Default OFF for safety 32 - end 20 + -- Load settings 21 + self.settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/paperbnd.lua") 22 + self.handle = self.settings:readSetting("handle") 23 + self.app_password = self.settings:readSetting("app_password") 24 + self.pds_url = self.settings:readSetting("pds_url") 25 + self.access_token = self.settings:readSetting("access_token") 26 + self.refresh_token = self.settings:readSetting("refresh_token") 27 + self.did = self.settings:readSetting("did") 28 + self.document_mappings = self.settings:readSetting("document_mappings") or {} 29 + self.auto_sync_enabled = self.settings:readSetting("auto_sync_enabled") 30 + if self.auto_sync_enabled == nil then 31 + self.auto_sync_enabled = false -- Default OFF for safety 32 + end 33 33 34 - -- Auto-sync state tracking 35 - self.auto_sync_task = nil -- Scheduled sync task reference 36 - self.last_synced_page = nil -- Prevent duplicate syncs 37 - self.auto_sync_delay = 10.0 -- 10 second debounce delay 34 + -- Auto-sync state tracking 35 + self.auto_sync_task = nil -- Scheduled sync task reference 36 + self.last_synced_page = nil -- Prevent duplicate syncs 37 + self.auto_sync_delay = 10.0 -- 10 second debounce delay 38 38 39 - -- Initialize XRPC client 40 - self.xrpc = XRPCClient:new() 41 - if self.pds_url then 42 - self.xrpc:setPDS(self.pds_url) 43 - end 44 - if self.access_token then 45 - self.xrpc:setAuth(self.access_token, self.refresh_token) 46 - end 47 - if self.handle and self.app_password then 48 - self.xrpc:setCredentials(self.handle, self.app_password) 49 - end 39 + -- Initialize XRPC client 40 + self.xrpc = XRPCClient:new() 41 + if self.pds_url then 42 + self.xrpc:setPDS(self.pds_url) 43 + end 44 + if self.access_token then 45 + self.xrpc:setAuth(self.access_token, self.refresh_token) 46 + end 47 + if self.handle and self.app_password then 48 + self.xrpc:setCredentials(self.handle, self.app_password) 49 + end 50 50 51 - -- Set up callback for credential updates 52 - self.xrpc:setCredentialsCallback(function(access_token, refresh_token) 53 - self:onTokenRefresh(access_token, refresh_token) 54 - end) 51 + -- Set up callback for credential updates 52 + self.xrpc:setCredentialsCallback(function(access_token, refresh_token) 53 + self:onTokenRefresh(access_token, refresh_token) 54 + end) 55 55 56 - -- Validate session at startup if we have credentials 57 - if self:isAuthenticated() then 58 - self:validateSessionAtStartup() 59 - end 56 + -- Validate session at startup if we have credentials 57 + if self:isAuthenticated() then 58 + self:validateSessionAtStartup() 59 + end 60 60 end 61 61 62 62 function Paperbnd:saveSettings() 63 - self.settings:saveSetting("handle", self.handle) 64 - self.settings:saveSetting("app_password", self.app_password) 65 - self.settings:saveSetting("pds_url", self.pds_url) 66 - self.settings:saveSetting("access_token", self.access_token) 67 - self.settings:saveSetting("refresh_token", self.refresh_token) 68 - self.settings:saveSetting("did", self.did) 69 - self.settings:saveSetting("document_mappings", self.document_mappings) 70 - self.settings:saveSetting("auto_sync_enabled", self.auto_sync_enabled) 71 - self.settings:flush() 63 + self.settings:saveSetting("handle", self.handle) 64 + self.settings:saveSetting("app_password", self.app_password) 65 + self.settings:saveSetting("pds_url", self.pds_url) 66 + self.settings:saveSetting("access_token", self.access_token) 67 + self.settings:saveSetting("refresh_token", self.refresh_token) 68 + self.settings:saveSetting("did", self.did) 69 + self.settings:saveSetting("document_mappings", self.document_mappings) 70 + self.settings:saveSetting("auto_sync_enabled", self.auto_sync_enabled) 71 + self.settings:flush() 72 72 end 73 73 74 74 function Paperbnd:onTokenRefresh(access_token, refresh_token) 75 - self.access_token = access_token 76 - self.refresh_token = refresh_token 77 - self:saveSettings() 75 + self.access_token = access_token 76 + self.refresh_token = refresh_token 77 + self:saveSettings() 78 78 end 79 79 80 80 function Paperbnd:validateSessionAtStartup() 81 - -- Validate session asynchronously to avoid blocking plugin startup 82 - -- This will trigger automatic renewal if the session is expired 83 - -- Session validation errors are intentionally ignored at startup 84 - -- renewSession will be called automatically on the next authenticated request 85 - self.xrpc:validateSession() 81 + -- Validate session asynchronously to avoid blocking plugin startup 82 + -- This will trigger automatic renewal if the session is expired 83 + -- Session validation errors are intentionally ignored at startup 84 + -- renewSession will be called automatically on the next authenticated request 85 + self.xrpc:validateSession() 86 86 end 87 87 88 88 function Paperbnd:addToMainMenu(menu_items) 89 - menu_items.paperbnd = { 90 - text = _("Paperbnd"), 91 - sub_item_table = { 92 - { 93 - text = _("Set credentials"), 94 - keep_menu_open = true, 95 - callback = function() 96 - self:setCredentials() 97 - end, 98 - }, 99 - { 100 - text = _("Link current book"), 101 - enabled_func = function() 102 - return self:isAuthenticated() and self.ui.document ~= nil 103 - end, 104 - callback = function() 105 - self:linkCurrentBook() 106 - end, 107 - }, 108 - { 109 - text = _("Sync progress now"), 110 - enabled_func = function() 111 - return self:isAuthenticated() and self:isCurrentBookLinked() 112 - end, 113 - callback = function() 114 - self:syncProgress() 115 - end, 116 - }, 117 - { 118 - text = _("Auto-sync on page turn"), 119 - checked_func = function() 120 - return self.auto_sync_enabled 121 - end, 122 - callback = function() 123 - self.auto_sync_enabled = not self.auto_sync_enabled 124 - self:saveSettings() 89 + menu_items.paperbnd = { 90 + text = _("Paperbnd"), 91 + sub_item_table = { 92 + { 93 + text = _("Set credentials"), 94 + keep_menu_open = true, 95 + callback = function() 96 + self:setCredentials() 97 + end, 98 + }, 99 + { 100 + text = _("Link current book"), 101 + enabled_func = function() 102 + return self:isAuthenticated() and self.ui.document ~= nil 103 + end, 104 + callback = function() 105 + self:linkCurrentBook() 106 + end, 107 + }, 108 + { 109 + text = _("Sync progress now"), 110 + enabled_func = function() 111 + return self:isAuthenticated() and self:isCurrentBookLinked() 112 + end, 113 + callback = function() 114 + self:syncProgress() 115 + end, 116 + }, 117 + { 118 + text = _("Auto-sync on page turn"), 119 + checked_func = function() 120 + return self.auto_sync_enabled 121 + end, 122 + callback = function() 123 + self.auto_sync_enabled = not self.auto_sync_enabled 124 + self:saveSettings() 125 125 126 - -- Cancel pending sync when disabling 127 - if not self.auto_sync_enabled and self.auto_sync_task then 128 - UIManager:unschedule(self.auto_sync_task) 129 - self.auto_sync_task = nil 130 - self.last_synced_page = nil 131 - end 126 + -- Cancel pending sync when disabling 127 + if not self.auto_sync_enabled and self.auto_sync_task then 128 + UIManager:unschedule(self.auto_sync_task) 129 + self.auto_sync_task = nil 130 + self.last_synced_page = nil 131 + end 132 132 133 - local status = self.auto_sync_enabled and _("enabled") or _("disabled") 134 - UIManager:show(InfoMessage:new { 135 - text = T(_("Auto-sync %1"), status), 136 - timeout = 2, 137 - }) 138 - end, 139 - }, 140 - { 141 - text = _("Unlink current book"), 142 - enabled_func = function() 143 - return self:isCurrentBookLinked() 144 - end, 145 - callback = function() 146 - self:unlinkCurrentBook() 147 - end, 148 - }, 149 - }, 150 - } 133 + local status = self.auto_sync_enabled and _("enabled") or _("disabled") 134 + UIManager:show(InfoMessage:new({ 135 + text = T(_("Auto-sync %1"), status), 136 + timeout = 2, 137 + })) 138 + end, 139 + }, 140 + { 141 + text = _("Unlink current book"), 142 + enabled_func = function() 143 + return self:isCurrentBookLinked() 144 + end, 145 + callback = function() 146 + self:unlinkCurrentBook() 147 + end, 148 + }, 149 + }, 150 + } 151 151 end 152 152 153 153 function Paperbnd:isAuthenticated() 154 - return self.handle ~= nil and self.access_token ~= nil and self.did ~= nil 154 + return self.handle ~= nil and self.access_token ~= nil and self.did ~= nil 155 155 end 156 156 157 157 function Paperbnd:getCurrentDocumentPath() 158 - if self.ui.document then 159 - return self.ui.document.file 160 - end 161 - return nil 158 + if self.ui.document then 159 + return self.ui.document.file 160 + end 161 + return nil 162 162 end 163 163 164 164 function Paperbnd:isCurrentBookLinked() 165 - local doc_path = self:getCurrentDocumentPath() 166 - return doc_path ~= nil and self.document_mappings[doc_path] ~= nil 165 + local doc_path = self:getCurrentDocumentPath() 166 + return doc_path ~= nil and self.document_mappings[doc_path] ~= nil 167 167 end 168 168 169 169 function Paperbnd:setCredentials() 170 - local handle_input 171 - handle_input = InputDialog:new { 172 - title = _("Enter your handle"), 173 - input = self.handle or "", 174 - buttons = { 175 - { 176 - { 177 - text = _("Cancel"), 178 - callback = function() 179 - UIManager:close(handle_input) 180 - end, 181 - }, 182 - { 183 - text = _("Next"), 184 - is_enter_default = true, 185 - callback = function() 186 - local handle = handle_input:getInputText() 187 - UIManager:close(handle_input) 170 + local handle_input 171 + handle_input = InputDialog:new({ 172 + title = _("Enter your handle"), 173 + input = self.handle or "", 174 + buttons = { 175 + { 176 + { 177 + text = _("Cancel"), 178 + callback = function() 179 + UIManager:close(handle_input) 180 + end, 181 + }, 182 + { 183 + text = _("Next"), 184 + is_enter_default = true, 185 + callback = function() 186 + local handle = handle_input:getInputText() 187 + UIManager:close(handle_input) 188 188 189 - if handle and handle ~= "" then 190 - self:setAppPassword(handle) 191 - end 192 - end, 193 - }, 194 - }, 195 - }, 196 - } 197 - UIManager:show(handle_input) 198 - handle_input:onShowKeyboard() 189 + if handle and handle ~= "" then 190 + self:setAppPassword(handle) 191 + end 192 + end, 193 + }, 194 + }, 195 + }, 196 + }) 197 + UIManager:show(handle_input) 198 + handle_input:onShowKeyboard() 199 199 end 200 200 201 201 function Paperbnd:setAppPassword(handle) 202 - local password_input 203 - password_input = InputDialog:new { 204 - title = _("Enter your app password"), 205 - input = self.app_password or "", 206 - text_type = "password", 207 - buttons = { 208 - { 209 - { 210 - text = _("Cancel"), 211 - callback = function() 212 - UIManager:close(password_input) 213 - end, 214 - }, 215 - { 216 - text = _("Login"), 217 - is_enter_default = true, 218 - callback = function() 219 - local password = password_input:getInputText() 220 - UIManager:close(password_input) 202 + local password_input 203 + password_input = InputDialog:new({ 204 + title = _("Enter your app password"), 205 + input = self.app_password or "", 206 + text_type = "password", 207 + buttons = { 208 + { 209 + { 210 + text = _("Cancel"), 211 + callback = function() 212 + UIManager:close(password_input) 213 + end, 214 + }, 215 + { 216 + text = _("Login"), 217 + is_enter_default = true, 218 + callback = function() 219 + local password = password_input:getInputText() 220 + UIManager:close(password_input) 221 221 222 - if password and password ~= "" then 223 - self:authenticate(handle, password) 224 - end 225 - end, 226 - }, 227 - }, 228 - }, 229 - } 230 - UIManager:show(password_input) 231 - password_input:onShowKeyboard() 222 + if password and password ~= "" then 223 + self:authenticate(handle, password) 224 + end 225 + end, 226 + }, 227 + }, 228 + }, 229 + }) 230 + UIManager:show(password_input) 231 + password_input:onShowKeyboard() 232 232 end 233 233 234 234 function Paperbnd:authenticate(handle, password) 235 - UIManager:show(InfoMessage:new { 236 - text = _("Authenticating..."), 237 - timeout = 1, 238 - }) 235 + UIManager:show(InfoMessage:new({ 236 + text = _("Authenticating..."), 237 + timeout = 1, 238 + })) 239 239 240 - -- Resolve PDS 241 - local pds_url, pds_err = self.xrpc:resolvePDS(handle) 242 - if pds_err then 243 - UIManager:show(InfoMessage:new { 244 - text = T(_("Failed to resolve PDS: %1"), pds_err), 245 - }) 246 - return 247 - end 240 + -- Resolve PDS 241 + local pds_url, pds_err = self.xrpc:resolvePDS(handle) 242 + if pds_err then 243 + UIManager:show(InfoMessage:new({ 244 + text = T(_("Failed to resolve PDS: %1"), pds_err), 245 + })) 246 + return 247 + end 248 248 249 - self.pds_url = pds_url 250 - self.xrpc:setPDS(pds_url) 249 + self.pds_url = pds_url 250 + self.xrpc:setPDS(pds_url) 251 251 252 - -- Create session 253 - local session, session_err = self.xrpc:createSession(handle, password) 254 - if session_err then 255 - UIManager:show(InfoMessage:new { 256 - text = T(_("Authentication failed: %1"), session_err), 257 - }) 258 - return 259 - end 252 + -- Create session 253 + local session, session_err = self.xrpc:createSession(handle, password) 254 + if session_err then 255 + UIManager:show(InfoMessage:new({ 256 + text = T(_("Authentication failed: %1"), session_err), 257 + })) 258 + return 259 + end 260 260 261 - self.handle = handle 262 - self.app_password = password 263 - self.access_token = session.accessJwt 264 - self.refresh_token = session.refreshJwt 265 - self.did = session.did 261 + self.handle = handle 262 + self.app_password = password 263 + self.access_token = session.accessJwt 264 + self.refresh_token = session.refreshJwt 265 + self.did = session.did 266 266 267 - -- Store credentials in XRPC client for automatic session renewal 268 - self.xrpc:setCredentials(handle, password) 267 + -- Store credentials in XRPC client for automatic session renewal 268 + self.xrpc:setCredentials(handle, password) 269 269 270 - self:saveSettings() 270 + self:saveSettings() 271 271 272 - UIManager:show(InfoMessage:new { 273 - text = _("Authentication successful!"), 274 - }) 272 + UIManager:show(InfoMessage:new({ 273 + text = _("Authentication successful!"), 274 + })) 275 275 end 276 276 277 277 function Paperbnd:linkCurrentBook() 278 - if not self:isAuthenticated() then 279 - UIManager:show(InfoMessage:new { 280 - text = _("Please set credentials first"), 281 - }) 282 - return 283 - end 278 + if not self:isAuthenticated() then 279 + UIManager:show(InfoMessage:new({ 280 + text = _("Please set credentials first"), 281 + })) 282 + return 283 + end 284 284 285 - UIManager:show(InfoMessage:new { 286 - text = _("Fetching your books..."), 287 - timeout = 1, 288 - }) 285 + UIManager:show(InfoMessage:new({ 286 + text = _("Fetching your books..."), 287 + timeout = 1, 288 + })) 289 289 290 - -- Fetch list items 291 - local response, err = self.xrpc:listRecords( 292 - self.did, 293 - "social.popfeed.feed.listItem", 294 - 100, 295 - nil 296 - ) 290 + -- Fetch list items 291 + local response, err = self.xrpc:listRecords(self.did, "social.popfeed.feed.listItem", 100, nil) 297 292 298 - if err then 299 - UIManager:show(InfoMessage:new { 300 - text = T(_("Failed to fetch books: %1"), err), 301 - }) 302 - return 303 - end 293 + if err then 294 + UIManager:show(InfoMessage:new({ 295 + text = T(_("Failed to fetch books: %1"), err), 296 + })) 297 + return 298 + end 304 299 300 + if not response.records or #response.records == 0 then 301 + UIManager:show(InfoMessage:new({ 302 + text = _("No books found in your account"), 303 + })) 304 + return 305 + end 305 306 306 - if not response.records or #response.records == 0 then 307 - UIManager:show(InfoMessage:new { 308 - text = _("No books found in your account"), 309 - }) 310 - return 311 - end 307 + -- Filter for books only 308 + local books = {} 309 + for _, record in ipairs(response.records) do 310 + if 311 + record.value 312 + and record.value.creativeWorkType == "book" 313 + and record.value.listType == "currently_reading_books" 314 + then 315 + table.insert(books, { 316 + title = record.value.title or "Unknown", 317 + author = record.value.mainCredit or "Unknown", 318 + rkey = record.uri:match("([^/]+)$"), 319 + record = record.value, 320 + }) 321 + end 322 + end 312 323 313 - -- Filter for books only 314 - local books = {} 315 - for _, record in ipairs(response.records) do 316 - if record.value and record.value.creativeWorkType == "book" 317 - and record.value.listType == "currently_reading_books" then 318 - table.insert(books, { 319 - title = record.value.title or "Unknown", 320 - author = record.value.mainCredit or "Unknown", 321 - rkey = record.uri:match("([^/]+)$"), 322 - record = record.value, 323 - }) 324 - end 325 - end 324 + if #books == 0 then 325 + UIManager:show(InfoMessage:new({ 326 + text = _("No books found in your account"), 327 + })) 328 + return 329 + end 326 330 327 - 328 - if #books == 0 then 329 - UIManager:show(InfoMessage:new { 330 - text = _("No books found in your account"), 331 - }) 332 - return 333 - end 334 - 335 - -- Show selection dialog 336 - self:showBookSelectionDialog(books) 331 + -- Show selection dialog 332 + self:showBookSelectionDialog(books) 337 333 end 338 334 339 335 function Paperbnd:showBookSelectionDialog(books) 340 - local Menu = require("ui/widget/menu") 341 - local Screen = require("device").screen 336 + local Menu = require("ui/widget/menu") 337 + local Screen = require("device").screen 342 338 343 - local items = {} 344 - for _, book in ipairs(books) do 345 - table.insert(items, { 346 - text = book.title, 347 - subtitle = book.author, 348 - book = book, 349 - }) 350 - end 339 + local items = {} 340 + for _, book in ipairs(books) do 341 + table.insert(items, { 342 + text = book.title, 343 + subtitle = book.author, 344 + book = book, 345 + }) 346 + end 351 347 352 - local book_menu = Menu:new { 353 - title = _("Select a book"), 354 - item_table = items, 355 - width = Screen:getWidth() - Screen:scaleBySize(100), 356 - height = Screen:getHeight() - Screen:scaleBySize(100), 357 - fullscreen = true, 358 - single_line = false, 359 - onMenuSelect = function(_, item) 360 - self:confirmLinkBook(item.book) 361 - end, 362 - } 348 + local book_menu = Menu:new({ 349 + title = _("Select a book"), 350 + item_table = items, 351 + width = Screen:getWidth() - Screen:scaleBySize(100), 352 + height = Screen:getHeight() - Screen:scaleBySize(100), 353 + fullscreen = true, 354 + single_line = false, 355 + onMenuSelect = function(_, item) 356 + self:confirmLinkBook(item.book) 357 + end, 358 + }) 363 359 364 - UIManager:show(book_menu) 360 + UIManager:show(book_menu) 365 361 end 366 362 367 363 function Paperbnd:confirmLinkBook(book) 368 - local doc_path = self:getCurrentDocumentPath() 369 - if not doc_path then 370 - return 371 - end 364 + local doc_path = self:getCurrentDocumentPath() 365 + if not doc_path then 366 + return 367 + end 372 368 373 - self.document_mappings[doc_path] = { 374 - rkey = book.rkey, 375 - title = book.title, 376 - author = book.author, 377 - } 369 + self.document_mappings[doc_path] = { 370 + rkey = book.rkey, 371 + title = book.title, 372 + author = book.author, 373 + } 378 374 379 - self:saveSettings() 375 + self:saveSettings() 380 376 381 - UIManager:show(InfoMessage:new { 382 - text = T(_("Linked to: %1"), book.title), 383 - }) 377 + UIManager:show(InfoMessage:new({ 378 + text = T(_("Linked to: %1"), book.title), 379 + })) 384 380 end 385 381 386 382 function Paperbnd:unlinkCurrentBook() 387 - local doc_path = self:getCurrentDocumentPath() 388 - if doc_path and self.document_mappings[doc_path] then 389 - local book_title = self.document_mappings[doc_path].title 390 - self.document_mappings[doc_path] = nil 391 - self:saveSettings() 383 + local doc_path = self:getCurrentDocumentPath() 384 + if doc_path and self.document_mappings[doc_path] then 385 + local book_title = self.document_mappings[doc_path].title 386 + self.document_mappings[doc_path] = nil 387 + self:saveSettings() 392 388 393 - UIManager:show(InfoMessage:new { 394 - text = T(_("Unlinked: %1"), book_title), 395 - }) 396 - end 389 + UIManager:show(InfoMessage:new({ 390 + text = T(_("Unlinked: %1"), book_title), 391 + })) 392 + end 397 393 end 398 394 399 395 function Paperbnd:syncProgress() 400 - if not self:isAuthenticated() then 401 - return 402 - end 396 + if not self:isAuthenticated() then 397 + return 398 + end 403 399 404 - local doc_path = self:getCurrentDocumentPath() 405 - if not doc_path or not self.document_mappings[doc_path] then 406 - return 407 - end 400 + local doc_path = self:getCurrentDocumentPath() 401 + if not doc_path or not self.document_mappings[doc_path] then 402 + return 403 + end 408 404 409 - local mapping = self.document_mappings[doc_path] 405 + local mapping = self.document_mappings[doc_path] 410 406 411 - -- Get document statistics 412 - local pages = self.ui.document:getPageCount() 413 - local current_page = self.ui.paging and self.ui.paging.current_page or 1 414 - local percent = math.floor((current_page / pages) * 100) 407 + -- Get document statistics 408 + local pages = self.ui.document:getPageCount() 409 + local current_page = self.ui.paging and self.ui.paging.current_page or 1 410 + local percent = math.floor((current_page / pages) * 100) 415 411 416 - -- Fetch current record 417 - local record_response, err = self.xrpc:getRecord( 418 - self.did, 419 - "social.popfeed.feed.listItem", 420 - mapping.rkey 421 - ) 412 + -- Fetch current record 413 + local record_response, err = self.xrpc:getRecord(self.did, "social.popfeed.feed.listItem", mapping.rkey) 422 414 423 - if err then 424 - UIManager:show(InfoMessage:new { 425 - text = T(_("Failed to fetch record: %1"), err), 426 - }) 427 - return 428 - end 415 + if err then 416 + UIManager:show(InfoMessage:new({ 417 + text = T(_("Failed to fetch record: %1"), err), 418 + })) 419 + return 420 + end 429 421 430 - local record = record_response.value 422 + local record = record_response.value 431 423 432 - -- Update bookProgress 433 - record.bookProgress = { 434 - status = "in_progress", 435 - percent = percent, 436 - currentPage = current_page, 437 - totalPages = pages, 438 - updatedAt = os.date("!%Y-%m-%dT%H:%M:%S.000Z"), 439 - } 424 + -- Update bookProgress 425 + record.bookProgress = { 426 + status = "in_progress", 427 + percent = percent, 428 + currentPage = current_page, 429 + totalPages = pages, 430 + updatedAt = os.date("!%Y-%m-%dT%H:%M:%S.000Z"), 431 + } 440 432 441 - -- If not already in currently_reading, update listType 442 - -- if record.listType ~= "currently_reading_books" then 443 - -- record.listType = "currently_reading_books" 444 - -- TODO: Also update the listUri to point to currently_reading list 445 - -- end 433 + -- If not already in currently_reading, update listType 434 + -- if record.listType ~= "currently_reading_books" then 435 + -- record.listType = "currently_reading_books" 436 + -- TODO: Also update the listUri to point to currently_reading list 437 + -- end 446 438 447 - -- Put updated record 448 - local _, put_err = self.xrpc:putRecord( 449 - self.did, 450 - "social.popfeed.feed.listItem", 451 - mapping.rkey, 452 - record 453 - ) 439 + -- Put updated record 440 + local _, put_err = self.xrpc:putRecord(self.did, "social.popfeed.feed.listItem", mapping.rkey, record) 454 441 455 - if put_err then 456 - UIManager:show(InfoMessage:new { 457 - text = T(_("Failed to sync progress: %1"), put_err), 458 - }) 459 - return 460 - end 442 + if put_err then 443 + UIManager:show(InfoMessage:new({ 444 + text = T(_("Failed to sync progress: %1"), put_err), 445 + })) 446 + return 447 + end 461 448 462 - UIManager:show(InfoMessage:new { 463 - text = T(_("Synced: %1% (%2/%3)"), percent, current_page, pages), 464 - timeout = 2, 465 - }) 449 + UIManager:show(InfoMessage:new({ 450 + text = T(_("Synced: %1% (%2/%3)"), percent, current_page, pages), 451 + timeout = 2, 452 + })) 466 453 end 467 454 468 455 function Paperbnd:scheduleDebouncedSync(pageno) 469 - -- Only schedule if auto-sync enabled and authenticated/linked 470 - if not self.auto_sync_enabled then 471 - return 472 - end 456 + -- Only schedule if auto-sync enabled and authenticated/linked 457 + if not self.auto_sync_enabled then 458 + return 459 + end 473 460 474 - if not self:isAuthenticated() or not self:isCurrentBookLinked() then 475 - return 476 - end 461 + if not self:isAuthenticated() or not self:isCurrentBookLinked() then 462 + return 463 + end 477 464 478 - -- Cancel any existing scheduled sync 479 - if self.auto_sync_task then 480 - UIManager:unschedule(self.auto_sync_task) 481 - self.auto_sync_task = nil 482 - end 465 + -- Cancel any existing scheduled sync 466 + if self.auto_sync_task then 467 + UIManager:unschedule(self.auto_sync_task) 468 + self.auto_sync_task = nil 469 + end 483 470 484 - -- Create closure-based task for this scheduling 485 - self.auto_sync_task = function() 486 - self:performAutoSync(pageno) 487 - end 471 + -- Create closure-based task for this scheduling 472 + self.auto_sync_task = function() 473 + self:performAutoSync(pageno) 474 + end 488 475 489 - -- Schedule sync after debounce delay 490 - UIManager:scheduleIn(self.auto_sync_delay, self.auto_sync_task) 476 + -- Schedule sync after debounce delay 477 + UIManager:scheduleIn(self.auto_sync_delay, self.auto_sync_task) 491 478 end 492 479 493 480 function Paperbnd:performAutoSync(pageno) 494 - -- Clear task reference 495 - self.auto_sync_task = nil 481 + -- Clear task reference 482 + self.auto_sync_task = nil 496 483 497 - -- Check if page actually changed since last sync 498 - if self.last_synced_page == pageno then 499 - return 500 - end 484 + -- Check if page actually changed since last sync 485 + if self.last_synced_page == pageno then 486 + return 487 + end 501 488 502 - -- Double-check auth and linking 503 - if not self:isAuthenticated() or not self:isCurrentBookLinked() then 504 - return 505 - end 489 + -- Double-check auth and linking 490 + if not self:isAuthenticated() or not self:isCurrentBookLinked() then 491 + return 492 + end 506 493 507 - -- Perform sync (reuses existing logic) 508 - self:syncProgress() 494 + -- Perform sync (reuses existing logic) 495 + self:syncProgress() 509 496 510 - -- Track synced page 511 - self.last_synced_page = pageno 497 + -- Track synced page 498 + self.last_synced_page = pageno 512 499 end 513 500 514 501 function Paperbnd:onPageUpdate(pageno) 515 - -- Fires on every page turn - debounce to avoid excessive syncing 516 - self:scheduleDebouncedSync(pageno) 502 + -- Fires on every page turn - debounce to avoid excessive syncing 503 + self:scheduleDebouncedSync(pageno) 517 504 518 - -- Return false to allow event propagation 519 - return false 505 + -- Return false to allow event propagation 506 + return false 520 507 end 521 508 522 509 -- Hook into document close to sync progress 523 510 function Paperbnd:onCloseDocument() 524 - -- Cancel any pending auto-sync 525 - if self.auto_sync_task then 526 - UIManager:unschedule(self.auto_sync_task) 527 - self.auto_sync_task = nil 528 - end 511 + -- Cancel any pending auto-sync 512 + if self.auto_sync_task then 513 + UIManager:unschedule(self.auto_sync_task) 514 + self.auto_sync_task = nil 515 + end 529 516 530 - -- Clear page tracking for next document 531 - self.last_synced_page = nil 517 + -- Clear page tracking for next document 518 + self.last_synced_page = nil 532 519 533 - -- Always sync on close (original behavior) 534 - if self:isAuthenticated() and self:isCurrentBookLinked() then 535 - self:syncProgress() 536 - end 520 + -- Always sync on close (original behavior) 521 + if self:isAuthenticated() and self:isCurrentBookLinked() then 522 + self:syncProgress() 523 + end 537 524 end 538 525 539 526 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 = 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