Commit 4a9598ec authored by WorldTeacher's avatar WorldTeacher
Browse files

feat(redommendations): Add post-finish book recommendations feature

- Introduced a new recommendations module to fetch and display book recommendations after a user finishes reading a book.
- Updated database schema to include `book_hash` and `show_post_rating_recommendations` settings.
- Enhanced the long press file dialog to allow users to fetch recommendations directly.
- Implemented logic to schedule recommendations after finishing a book, with appropriate logging for errors.
- Added tests for the new recommendations feature, ensuring proper handling of requests and responses.
parent 67e077e8
Loading
Loading
Loading
Loading
+108 −0
Original line number Diff line number Diff line
@@ -2220,4 +2220,112 @@ function APIClient:getBookFileSize(book_id, username, password)
    return file_size, nil
end

--[[--
Fetch recommendations for a given Booklore book.

Endpoint: GET /api/v1/books/{id}/recommendations?limit=N

@param book_id   number  Booklore book ID
@param username  string  Booklore username
@param password  string  Booklore password
@param limit     number|nil  Max recommendations (default 10)
@return boolean success
@return table|string recommendations array or error message
--]]
---APIClient:getBookRecommendations.
function APIClient:getBookRecommendations(book_id, username, password, limit)
    book_id = tonumber(book_id)
    if not book_id then
        return false, "Invalid book_id"
    end
    limit = tonumber(limit) or 10
    if limit < 1 then limit = 1 end

    self:logInfo("BookloreSync API: getBookRecommendations - id:", book_id, "limit:", limit)

    local token_ok, token = self:getOrRefreshBearerToken(username, password)
    if not token_ok then
        self:logErr("BookloreSync API: getBookRecommendations - auth failed:", token)
        return false, "Authentication failed: " .. tostring(token)
    end

    local headers = { ["Authorization"] = "Bearer " .. token }
    local endpoint = "/api/v1/books/" .. tostring(book_id) .. "/recommendations?limit=" .. tostring(limit)

    local success, code, response = self:request("GET", endpoint, nil, headers)

    if not success and (code == 401 or code == 403) then
        self:logWarn("BookloreSync API: getBookRecommendations - token rejected, refreshing")
        if self.db then self.db:deleteBearerToken(username) end
        local refresh_ok, new_token = self:getOrRefreshBearerToken(username, password, true)
        if refresh_ok then
            headers["Authorization"] = "Bearer " .. new_token
            success, code, response = self:request("GET", endpoint, nil, headers)
        else
            return false, new_token or "Authentication failed after refresh"
        end
    end

    if not success then
        local err = (type(response) == "string" and response ~= "") and response
                    or ("HTTP " .. tostring(code or "?"))
        self:logWarn("BookloreSync API: getBookRecommendations failed:", err)
        return false, err
    end

    -- Server may return either a bare array or an object wrapping the array
    -- (e.g. { recommendations = [...] } or { content = [...] }). Accept both.
    local items = nil
    if type(response) == "table" then
        if #response > 0 or next(response) == nil then
            items = response
        elseif type(response.recommendations) == "table" then
            items = response.recommendations
        elseif type(response.content) == "table" then
            items = response.content
        elseif type(response.items) == "table" then
            items = response.items
        else
            items = response
        end
    end

    if type(items) ~= "table" then
        return false, "Unexpected recommendations response shape"
    end

    -- Per OpenAPI: each item is a BookRecommendation { book: Book, similarityScore }.
    -- Unwrap the inner Book and carry similarityScore onto the normalised object
    -- as matchScore so the existing UI conventions work unchanged. Tolerate older
    -- shapes that returned bare Book objects directly.
    local books = {}
    for _, item in ipairs(items) do
        local inner = item
        local score = nil
        if type(item) == "table" then
            if type(item.book) == "table" then
                inner = item.book
                score = tonumber(item.similarityScore)
                    or tonumber(item.matchScore)
                    or tonumber(item.score)
            elseif type(item.recommendedBook) == "table" then
                inner = item.recommendedBook
                score = tonumber(item.similarityScore)
                    or tonumber(item.matchScore)
                    or tonumber(item.score)
            end
        end
        if type(inner) == "table" then
            inner = self:_normalizeBookObject(inner)
            if score and not inner.matchScore then
                inner.matchScore = score
            end
            table.insert(books, inner)
        end
    end

    self:logInfo("BookloreSync API: getBookRecommendations - got", #books, "items")
    return true, books
end

return APIClient
+25 −22
Original line number Diff line number Diff line
@@ -808,6 +808,7 @@ Database.migration_hooks = {
            "historical_sync_ack",
            "booklore_username", "booklore_password",
            "rating_sync_enabled", "rating_sync_mode",
            "show_post_rating_recommendations",
            "highlights_notes_sync_enabled", "notes_destination", "upload_strategy",
            "auto_update_check", "last_update_check",
        }
@@ -2078,7 +2079,7 @@ end
function Database:getHistoricalSessionsForBook(koreader_book_id)
    local stmt = self.conn:prepare([[
        SELECT 
            id, book_id, book_type, start_time, end_time,
            id, book_id, book_hash, book_type, start_time, end_time,
            duration_seconds, start_progress, end_progress, progress_delta,
            start_location, end_location, synced
        FROM historical_sessions
@@ -2098,16 +2099,17 @@ function Database:getHistoricalSessionsForBook(koreader_book_id)
        table.insert(sessions, {
            id = tonumber(row[1]),
            book_id = tonumber(row[2]),
            book_type = tostring(row[3]),
            start_time = tostring(row[4]),
            end_time = tostring(row[5]),
            duration_seconds = tonumber(row[6]),
            start_progress = tonumber(row[7]),
            end_progress = tonumber(row[8]),
            progress_delta = tonumber(row[9]),
            start_location = tostring(row[10]),
            end_location = tostring(row[11]),
            synced = tonumber(row[12]) or 0,
            book_hash = tostring(row[3] or ""),
            book_type = tostring(row[4]),
            start_time = tostring(row[5]),
            end_time = tostring(row[6]),
            duration_seconds = tonumber(row[7]),
            start_progress = tonumber(row[8]),
            end_progress = tonumber(row[9]),
            progress_delta = tonumber(row[10]),
            start_location = tostring(row[11]),
            end_location = tostring(row[12]),
            synced = tonumber(row[13]) or 0,
        })
    end
    
@@ -2119,7 +2121,7 @@ end
function Database:getHistoricalSessionsForBookUnsynced(koreader_book_id)
    local stmt = self.conn:prepare([[
        SELECT 
            id, book_id, book_type, start_time, end_time,
            id, book_id, book_hash, book_type, start_time, end_time,
            duration_seconds, start_progress, end_progress, progress_delta,
            start_location, end_location, synced
        FROM historical_sessions
@@ -2139,16 +2141,17 @@ function Database:getHistoricalSessionsForBookUnsynced(koreader_book_id)
        table.insert(sessions, {
            id = tonumber(row[1]),
            book_id = tonumber(row[2]),
            book_type = tostring(row[3]),
            start_time = tostring(row[4]),
            end_time = tostring(row[5]),
            duration_seconds = tonumber(row[6]),
            start_progress = tonumber(row[7]),
            end_progress = tonumber(row[8]),
            progress_delta = tonumber(row[9]),
            start_location = tostring(row[10] or ""),
            end_location = tostring(row[11] or ""),
            synced = tonumber(row[12]) or 0,
            book_hash = tostring(row[3] or ""),
            book_type = tostring(row[4]),
            start_time = tostring(row[5]),
            end_time = tostring(row[6]),
            duration_seconds = tonumber(row[7]),
            start_progress = tonumber(row[8]),
            end_progress = tonumber(row[9]),
            progress_delta = tonumber(row[10]),
            start_location = tostring(row[11] or ""),
            end_location = tostring(row[12] or ""),
            synced = tonumber(row[13]) or 0,
        })
    end
    
+15 −0
Original line number Diff line number Diff line
@@ -57,6 +57,20 @@ function M.new(deps)
            }
        end)

        -- Row 4: recommendations
        FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row4", function(file, is_file, _book_props)
            if not is_file then return nil end
            return {
                {
                    text = _("Get recommendations"),
                    callback = function()
                        closeFileDialog()
                        plugin:fileDialogGetRecommendations(file)
                    end,
                },
            }
        end)

        -- Row 3: tracking + status actions
        FileManager.addFileDialogButtons(FileManager, "booklore_sync_actions_row3", function(file, is_file, _book_props)
            if not is_file then return nil end
@@ -88,6 +102,7 @@ function M.new(deps)
        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")
    end

    return module
+15 −0
Original line number Diff line number Diff line
@@ -199,6 +199,18 @@ function module.syncPendingDeletions(self, silent)
        return 0, 0
    end

    local function scheduleDeferredRecommendations(book_id)
        if not book_id or not self.show_post_rating_recommendations then
            return
        end
        local ok_rec, rec_err = pcall(function()
            self:scheduleRecommendationsAfterFinish(book_id)
        end)
        if not ok_rec then
            self:logWarn("BookloreSync: Failed to schedule deferred recommendations:", tostring(rec_err))
        end
    end

    -- ── Phase 1: deferred rating prompts ─────────────────────────────────
    -- These are books that were completed while the book_id was unknown.
    -- Now that the cache may have been updated, check if a book_id exists.
@@ -221,6 +233,7 @@ function module.syncPendingDeletions(self, silent)
                    -- Flag cleared below: rating was either sent or queued for retry
                    -- in pending_ratings, so the deferred-prompt entry is no longer needed.
                    self.db:setPendingRatingPrompt(row.book_cache_id, false)
                    scheduleDeferredRecommendations(row.book_id)
                else
                    self:logWarn("BookloreSync: Deferred rating skipped - Booklore credentials not configured")
                    -- Leave flag set; will retry on the next sync once credentials are added.
@@ -236,6 +249,7 @@ function module.syncPendingDeletions(self, silent)
                    -- onto the pending_ratings row and clear the deferred flag.
                    self.db:addPendingRating(row.book_cache_id, row.book_id, meta.rating)
                    self.db:setPendingRatingPrompt(row.book_cache_id, false)
                    scheduleDeferredRecommendations(row.book_id)
                    self:logInfo("BookloreSync: Stamped book_id", row.book_id,
                                 "onto pending rating for book_cache_id:", row.book_cache_id)
                else
@@ -245,6 +259,7 @@ function module.syncPendingDeletions(self, silent)
                    local file_path = row.file_path
                    local book_id   = row.book_id
                    self.db:setPendingRatingPrompt(row.book_cache_id, false)
                    scheduleDeferredRecommendations(book_id)
                    UIManager:scheduleIn(2, function()
                        self:showRatingDialog(file_path, book_id)
                    end)
+515 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading