Loading bookloresync.koplugin/booklore_api_client.lua +85 −0 Original line number Diff line number Diff line Loading @@ -1052,6 +1052,91 @@ function APIClient:submitRating(book_id, rating, username, password) end end --[[-- Update reading status for one or more books. Endpoint: POST /api/v1/books/status Body: { "bookIds": [book_id, ...], "status": "UNREAD" } @param book_ids table|number One book ID or an array of book IDs @param status string Status value (must be uppercase) @param username string Booklore username for Bearer token @param password string Booklore password for Bearer token @return boolean success @return string|nil error message on failure --]] ---APIClient:updateBookStatus. function APIClient:updateBookStatus(book_ids, status, username, password) if type(book_ids) ~= "table" then book_ids = { book_ids } end local normalized_ids = {} for _, book_id in ipairs(book_ids) do local id = tonumber(book_id) if id then table.insert(normalized_ids, id) end end if #normalized_ids == 0 then return false, "At least one valid book ID is required" end status = tostring(status or ""):upper() if status == "" then return false, "Status is required" end if not username or username == "" then return false, "Booklore username required for status update" end if not password or password == "" then return false, "Booklore password required for status update" end local token_ok, token = self:getOrRefreshBearerToken(username, password, false) if not token_ok then return false, token or "Failed to obtain auth token" end local body_ids = normalized_ids if json.util and json.util.InitArray then body_ids = json.util.InitArray(normalized_ids) end local body = { bookIds = body_ids, status = status, } local headers = { ["Authorization"] = "Bearer " .. token, ["Content-Type"] = "application/json", } local success, code, response = self:request("POST", "/api/v1/books/status", body, headers) if not success and (code == 401 or code == 403) then if self.db then self.db:deleteBearerToken(username) end local ref_ok, new_token = self:getOrRefreshBearerToken(username, password, true) if ref_ok then headers["Authorization"] = "Bearer " .. new_token success, code, response = self:request("POST", "/api/v1/books/status", body, headers) else return false, new_token or "Authentication failed after refresh" end end if success then self:logInfo("BookloreSync API: Updated book status to", status, "for", #normalized_ids, "book(s)") return true, nil end local err_msg = (type(response) == "string" and response ~= "") and response or ("HTTP " .. tostring(code)) self:logWarn("BookloreSync API: Failed to update book status:", err_msg) return false, err_msg end --[[-- Endpoint: POST /api/v1/annotations Loading bookloresync.koplugin/main.lua +124 −0 Original line number Diff line number Diff line Loading @@ -1010,6 +1010,20 @@ function BookloreSync:init() }, } end) -- Row 4: reading status (full width) FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row4", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Set Reading Status"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogSetReadingStatus(file) end, }, } end) self:registerDispatcherActions() Loading Loading @@ -1068,6 +1082,7 @@ function BookloreSync:onExit() FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row1") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row2") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row3") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row4") if self.db then self.db:close() Loading Loading @@ -3905,6 +3920,115 @@ function BookloreSync:_fileDialogSyncBoth(file_path) }) end --[[-- File manager long-press: set Booklore reading status for a single matched book. @param file_path string Absolute path to the book file --]] ---BookloreSync:fileDialogSetReadingStatus. function BookloreSync:fileDialogSetReadingStatus(file_path) if not self.db then UIManager:show(InfoMessage:new{ text = _("Booklore: database not initialised") }) return end local username, password = self:_refreshCredentials() if username == "" or password == "" then UIManager:show(InfoMessage:new{ text = _("Booklore: credentials not configured") }) return end local book = self.db:getBookByFilePath(file_path) if not book then UIManager:show(InfoMessage:new{ text = _("Booklore: book not found in local database.\nOpen the book first to register it."), }) return end if not book.book_id then UIManager:show(InfoMessage:new{ text = _("Booklore: book is not yet matched to Booklore.\nUse \"Match Book\" first."), }) return end local status_dialog local status_options = { { label = _("Unread"), value = "UNREAD" }, { label = _("Paused"), value = "PAUSED" }, { label = _("Partially read"), value = "PARTIALLY_READ" }, { label = _("Abandoned"), value = "ABANDONED" }, { label = _("Won't read"), value = "WONT_READ" }, } local buttons = {} for _, option in ipairs(status_options) do local selected_option = option table.insert(buttons, {{ text = selected_option.label, callback = function() UIManager:close(status_dialog) self:_fileDialogApplyReadingStatus(file_path, selected_option.value, selected_option.label) end, }}) end table.insert(buttons, {{ text = _("Cancel"), callback = function() UIManager:close(status_dialog) end, }}) status_dialog = ButtonDialog:new{ title = _("Set Reading Status"), buttons = buttons, } UIManager:show(status_dialog) end --[[-- Apply selected Booklore reading status for a single matched book. @param file_path string Absolute path to the book file @param status string Uppercase Booklore status value @param label string Human-readable status label for UI --]] ---BookloreSync:_fileDialogApplyReadingStatus. function BookloreSync:_fileDialogApplyReadingStatus(file_path, status, label) local username, password = self:_refreshCredentials() if username == "" or password == "" then UIManager:show(InfoMessage:new{ text = _("Booklore: credentials not configured") }) return end local book = self.db and self.db:getBookByFilePath(file_path) local book_id = book and tonumber(book.book_id) if not book_id then UIManager:show(InfoMessage:new{ text = _("Booklore: book is not yet matched to Booklore.\nUse \"Match Book\" first."), }) return end self:refreshEffectiveServerUrl() self.api:init(self.server_url, self.username, self.password, self.db) local ok, err = self.api:updateBookStatus({ book_id }, status, username, password) if ok then UIManager:show(InfoMessage:new{ text = T(_("Booklore: reading status set to %1"), label), timeout = 3, }) return end UIManager:show(InfoMessage:new{ text = T(_("Booklore: failed to update reading status (%1)"), tostring(err or _("Unknown error"))), timeout = 3, }) end --[[-- Toggle tracking for a book from the file manager long-press menu. Loading test/api_client_spec.lua +57 −0 Original line number Diff line number Diff line Loading @@ -141,4 +141,61 @@ describe("APIClient helper methods", function() assert.are.equal("222", normalized.isbn13) assert.are.equal("pdf", normalized.extension) end) it("updates reading status for a book", function() local captured = {} client.getOrRefreshBearerToken = function(_, username, password) assert.are.equal("user", username) assert.are.equal("pass", password) return true, "token-1" end client.request = function(_, method, path, body, headers) captured.method = method captured.path = path captured.body = body captured.headers = headers return true, 200, {} end local ok, err = client:updateBookStatus({ 42 }, "partially_read", "user", "pass") assert.is_true(ok) assert.is_nil(err) assert.are.equal("POST", captured.method) assert.are.equal("/api/v1/books/status", captured.path) assert.are.equal("PARTIALLY_READ", captured.body.status) assert.are.equal(42, captured.body.bookIds[1]) assert.are.equal("Bearer token-1", captured.headers["Authorization"]) end) it("retries reading-status update after token refresh", function() local auth_calls = 0 local request_calls = 0 client.db = { deleteBearerToken = function() end } client.getOrRefreshBearerToken = function(_, _, _, force_refresh) auth_calls = auth_calls + 1 if force_refresh then return true, "token-2" end return true, "token-1" end client.request = function(_, _, _, _, headers) request_calls = request_calls + 1 if request_calls == 1 then return false, 401, "Unauthorized" end assert.are.equal("Bearer token-2", headers["Authorization"]) return true, 200, {} end local ok, err = client:updateBookStatus(7, "UNREAD", "user", "pass") assert.is_true(ok) assert.is_nil(err) assert.are.equal(2, request_calls) assert.are.equal(2, auth_calls) end) end) Loading
bookloresync.koplugin/booklore_api_client.lua +85 −0 Original line number Diff line number Diff line Loading @@ -1052,6 +1052,91 @@ function APIClient:submitRating(book_id, rating, username, password) end end --[[-- Update reading status for one or more books. Endpoint: POST /api/v1/books/status Body: { "bookIds": [book_id, ...], "status": "UNREAD" } @param book_ids table|number One book ID or an array of book IDs @param status string Status value (must be uppercase) @param username string Booklore username for Bearer token @param password string Booklore password for Bearer token @return boolean success @return string|nil error message on failure --]] ---APIClient:updateBookStatus. function APIClient:updateBookStatus(book_ids, status, username, password) if type(book_ids) ~= "table" then book_ids = { book_ids } end local normalized_ids = {} for _, book_id in ipairs(book_ids) do local id = tonumber(book_id) if id then table.insert(normalized_ids, id) end end if #normalized_ids == 0 then return false, "At least one valid book ID is required" end status = tostring(status or ""):upper() if status == "" then return false, "Status is required" end if not username or username == "" then return false, "Booklore username required for status update" end if not password or password == "" then return false, "Booklore password required for status update" end local token_ok, token = self:getOrRefreshBearerToken(username, password, false) if not token_ok then return false, token or "Failed to obtain auth token" end local body_ids = normalized_ids if json.util and json.util.InitArray then body_ids = json.util.InitArray(normalized_ids) end local body = { bookIds = body_ids, status = status, } local headers = { ["Authorization"] = "Bearer " .. token, ["Content-Type"] = "application/json", } local success, code, response = self:request("POST", "/api/v1/books/status", body, headers) if not success and (code == 401 or code == 403) then if self.db then self.db:deleteBearerToken(username) end local ref_ok, new_token = self:getOrRefreshBearerToken(username, password, true) if ref_ok then headers["Authorization"] = "Bearer " .. new_token success, code, response = self:request("POST", "/api/v1/books/status", body, headers) else return false, new_token or "Authentication failed after refresh" end end if success then self:logInfo("BookloreSync API: Updated book status to", status, "for", #normalized_ids, "book(s)") return true, nil end local err_msg = (type(response) == "string" and response ~= "") and response or ("HTTP " .. tostring(code)) self:logWarn("BookloreSync API: Failed to update book status:", err_msg) return false, err_msg end --[[-- Endpoint: POST /api/v1/annotations Loading
bookloresync.koplugin/main.lua +124 −0 Original line number Diff line number Diff line Loading @@ -1010,6 +1010,20 @@ function BookloreSync:init() }, } end) -- Row 4: reading status (full width) FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row4", function(file, is_file, _book_props) if not is_file then return nil end return { { text = _("Set Reading Status"), callback = function() local fc = FileManager.instance and FileManager.instance.file_chooser if fc and fc.file_dialog then UIManager:close(fc.file_dialog) end self:fileDialogSetReadingStatus(file) end, }, } end) self:registerDispatcherActions() Loading Loading @@ -1068,6 +1082,7 @@ function BookloreSync:onExit() FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row1") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row2") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row3") FileManager.removeFileDialogButtons(FileManager, "booklore_sync_actions_row4") if self.db then self.db:close() Loading Loading @@ -3905,6 +3920,115 @@ function BookloreSync:_fileDialogSyncBoth(file_path) }) end --[[-- File manager long-press: set Booklore reading status for a single matched book. @param file_path string Absolute path to the book file --]] ---BookloreSync:fileDialogSetReadingStatus. function BookloreSync:fileDialogSetReadingStatus(file_path) if not self.db then UIManager:show(InfoMessage:new{ text = _("Booklore: database not initialised") }) return end local username, password = self:_refreshCredentials() if username == "" or password == "" then UIManager:show(InfoMessage:new{ text = _("Booklore: credentials not configured") }) return end local book = self.db:getBookByFilePath(file_path) if not book then UIManager:show(InfoMessage:new{ text = _("Booklore: book not found in local database.\nOpen the book first to register it."), }) return end if not book.book_id then UIManager:show(InfoMessage:new{ text = _("Booklore: book is not yet matched to Booklore.\nUse \"Match Book\" first."), }) return end local status_dialog local status_options = { { label = _("Unread"), value = "UNREAD" }, { label = _("Paused"), value = "PAUSED" }, { label = _("Partially read"), value = "PARTIALLY_READ" }, { label = _("Abandoned"), value = "ABANDONED" }, { label = _("Won't read"), value = "WONT_READ" }, } local buttons = {} for _, option in ipairs(status_options) do local selected_option = option table.insert(buttons, {{ text = selected_option.label, callback = function() UIManager:close(status_dialog) self:_fileDialogApplyReadingStatus(file_path, selected_option.value, selected_option.label) end, }}) end table.insert(buttons, {{ text = _("Cancel"), callback = function() UIManager:close(status_dialog) end, }}) status_dialog = ButtonDialog:new{ title = _("Set Reading Status"), buttons = buttons, } UIManager:show(status_dialog) end --[[-- Apply selected Booklore reading status for a single matched book. @param file_path string Absolute path to the book file @param status string Uppercase Booklore status value @param label string Human-readable status label for UI --]] ---BookloreSync:_fileDialogApplyReadingStatus. function BookloreSync:_fileDialogApplyReadingStatus(file_path, status, label) local username, password = self:_refreshCredentials() if username == "" or password == "" then UIManager:show(InfoMessage:new{ text = _("Booklore: credentials not configured") }) return end local book = self.db and self.db:getBookByFilePath(file_path) local book_id = book and tonumber(book.book_id) if not book_id then UIManager:show(InfoMessage:new{ text = _("Booklore: book is not yet matched to Booklore.\nUse \"Match Book\" first."), }) return end self:refreshEffectiveServerUrl() self.api:init(self.server_url, self.username, self.password, self.db) local ok, err = self.api:updateBookStatus({ book_id }, status, username, password) if ok then UIManager:show(InfoMessage:new{ text = T(_("Booklore: reading status set to %1"), label), timeout = 3, }) return end UIManager:show(InfoMessage:new{ text = T(_("Booklore: failed to update reading status (%1)"), tostring(err or _("Unknown error"))), timeout = 3, }) end --[[-- Toggle tracking for a book from the file manager long-press menu. Loading
test/api_client_spec.lua +57 −0 Original line number Diff line number Diff line Loading @@ -141,4 +141,61 @@ describe("APIClient helper methods", function() assert.are.equal("222", normalized.isbn13) assert.are.equal("pdf", normalized.extension) end) it("updates reading status for a book", function() local captured = {} client.getOrRefreshBearerToken = function(_, username, password) assert.are.equal("user", username) assert.are.equal("pass", password) return true, "token-1" end client.request = function(_, method, path, body, headers) captured.method = method captured.path = path captured.body = body captured.headers = headers return true, 200, {} end local ok, err = client:updateBookStatus({ 42 }, "partially_read", "user", "pass") assert.is_true(ok) assert.is_nil(err) assert.are.equal("POST", captured.method) assert.are.equal("/api/v1/books/status", captured.path) assert.are.equal("PARTIALLY_READ", captured.body.status) assert.are.equal(42, captured.body.bookIds[1]) assert.are.equal("Bearer token-1", captured.headers["Authorization"]) end) it("retries reading-status update after token refresh", function() local auth_calls = 0 local request_calls = 0 client.db = { deleteBearerToken = function() end } client.getOrRefreshBearerToken = function(_, _, _, force_refresh) auth_calls = auth_calls + 1 if force_refresh then return true, "token-2" end return true, "token-1" end client.request = function(_, _, _, _, headers) request_calls = request_calls + 1 if request_calls == 1 then return false, 401, "Unauthorized" end assert.are.equal("Bearer token-2", headers["Authorization"]) return true, 200, {} end local ok, err = client:updateBookStatus(7, "UNREAD", "user", "pass") assert.is_true(ok) assert.is_nil(err) assert.are.equal(2, request_calls) assert.are.equal(2, auth_calls) end) end)