+1
.gitignore
+1
.gitignore
···
1
+
.env
+4
.helix/languages.toml
+4
.helix/languages.toml
+90
-80
CLAUDE.md
+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
+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
+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
+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
+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
+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
+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