Commit a2d09266 authored by WorldTeacher's avatar WorldTeacher
Browse files

feat(reading-status): add functionality to update reading status for books

parent bfe118c3
Loading
Loading
Loading
Loading
+85 −0
Original line number Diff line number Diff line
@@ -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
+124 −0
Original line number Diff line number Diff line
@@ -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()

@@ -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()
@@ -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.

+57 −0
Original line number Diff line number Diff line
@@ -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)