Verified Commit 556b075c authored by WorldTeacher's avatar WorldTeacher
Browse files

feat(bookmarks): sync bookmarks to Booklore via api/v1/bookmarks

- Capture bookmarks from live ReaderBookmark module (self.ui.bookmark.bookmarks)
  at onCloseDocument time — same in-memory hijack used for annotations
- syncBookmarks(): dedup via synced_bookmarks, queue via pending_bookmarks,
  EPUB CFI via buildCfi, PDF mock CFI via buildMockPdfCfi, sidecar fallback
- syncPendingBookmarks(): retry queued bookmarks on next syncPendingSessions call
- APIClient:submitBookmark(): POST /api/v1/bookmarks with bookId, cfi,
  chapterTitle, notes; handles 401/403 token refresh
- DB migration 16: pending_bookmarks + synced_bookmarks tables with indexes
- Phase 2b in syncPendingSessions() retries pending bookmarks alongside
  pending annotations and ratings
parent 163867d9
Loading
Loading
Loading
Loading
Loading
+61 −0
Original line number Diff line number Diff line
@@ -1117,4 +1117,65 @@ function APIClient:submitBookloreNote(book_id, content, title, username, passwor
    end
end

--[[--
Submit a bookmark to Booklore.

Endpoint: POST /api/v1/bookmarks

@param book_id     number  Booklore book ID
@param cfi         string  EPUB CFI (or mock CFI for PDF) identifying the bookmark position
@param opts        table   Optional fields: chapter_title, notes
@param username    string
@param password    string
@return boolean, number|string  success flag + server id or error message
--]]
function APIClient:submitBookmark(book_id, cfi, opts, username, password)
    opts = opts or {}

    if not book_id or not cfi then
        return false, "Missing required bookmark fields (book_id, cfi)"
    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_tbl = {
        bookId = tonumber(book_id),
        cfi    = cfi,
    }
    if opts.chapter_title and opts.chapter_title ~= "" then body_tbl.chapterTitle = opts.chapter_title end
    if opts.notes         and opts.notes ~= ""         then body_tbl.notes        = opts.notes        end

    local body = json.encode(body_tbl)
    local headers = {
        ["Authorization"] = "Bearer " .. token,
        ["Content-Type"]  = "application/json",
    }

    local success, code, response = self:request("POST", "/api/v1/bookmarks", 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/bookmarks", body, headers)
        else
            return false, new_token or "Authentication failed after refresh"
        end
    end

    if success and (code == 200 or code == 201) then
        local server_id = type(response) == "table" and response.id or nil
        self:logInfo("BookloreSync API: Bookmark submitted, server id:", server_id)
        return true, server_id
    else
        local err = (type(response) == "string" and response ~= "") and response or ("HTTP " .. tostring(code))
        self:logWarn("BookloreSync API: Failed to submit bookmark:", err)
        return false, err
    end
end

return APIClient
+202 −1
Original line number Diff line number Diff line
@@ -12,7 +12,7 @@ local LuaSettings = require("luasettings")
local logger = require("logger")

local Database = {
    VERSION = 15,  -- Current database schema version
    VERSION = 16,  -- Current database schema version
    db_path = nil,
    conn = nil,
}
@@ -418,6 +418,49 @@ Database.migrations = {
            ON pending_ratings(book_cache_id)
        ]],
    },

    -- Migration 16: Bookmark sync tables.
    -- pending_bookmarks: queue of bookmarks waiting to be uploaded.
    -- synced_bookmarks:  dedup log of bookmarks already on the server.
    [16] = {
        [[
            CREATE TABLE IF NOT EXISTS pending_bookmarks (
                id            INTEGER PRIMARY KEY AUTOINCREMENT,
                book_cache_id INTEGER NOT NULL,
                book_id       INTEGER,
                datetime      TEXT    NOT NULL,
                payload       TEXT    NOT NULL,
                retry_count   INTEGER DEFAULT 0,
                last_retry_at INTEGER,
                created_at    INTEGER DEFAULT (strftime('%s', 'now')),
                UNIQUE(book_cache_id, datetime),
                FOREIGN KEY(book_cache_id) REFERENCES book_cache(id)
            )
        ]],
        [[
            CREATE INDEX IF NOT EXISTS idx_pending_bookmarks_book_cache_id
            ON pending_bookmarks(book_cache_id)
        ]],
        [[
            CREATE INDEX IF NOT EXISTS idx_pending_bookmarks_created_at
            ON pending_bookmarks(created_at)
        ]],
        [[
            CREATE TABLE IF NOT EXISTS synced_bookmarks (
                id            INTEGER PRIMARY KEY AUTOINCREMENT,
                book_cache_id INTEGER NOT NULL,
                datetime      TEXT    NOT NULL,
                server_id     INTEGER,
                synced_at     INTEGER DEFAULT (strftime('%s', 'now')),
                UNIQUE(book_cache_id, datetime),
                FOREIGN KEY(book_cache_id) REFERENCES book_cache(id)
            )
        ]],
        [[
            CREATE INDEX IF NOT EXISTS idx_synced_bookmarks_book_cache_id
            ON synced_bookmarks(book_cache_id)
        ]],
    },
}

-- Post-migration hooks: Lua functions run after the SQL transaction commits.
@@ -2899,4 +2942,162 @@ function Database:getRatingSyncHistory(book_cache_id)
    return rows
end

-- ── Bookmark helpers ──────────────────────────────────────────────────────────

--[[-- Return true if the bookmark identified by datetime has already been synced. --]]
function Database:isBookmarkSynced(book_cache_id, datetime)
    if not book_cache_id or not datetime or datetime == "" then return false end
    local stmt = self.conn:prepare([[
        SELECT COUNT(*) FROM synced_bookmarks
        WHERE book_cache_id = ? AND datetime = ?
    ]])
    if not stmt then
        logger.err("BookloreSync Database: Failed to prepare isBookmarkSynced:", self.conn:errmsg())
        return false
    end
    local ok, err = stmt:bind(1, book_cache_id)
    if ok then ok, err = stmt:bind(2, datetime) end
    if not ok then
        logger.err("BookloreSync Database: Bind failed in isBookmarkSynced:", err)
        stmt:close()
        return false
    end
    local row = stmt:first_row()
    stmt:close()
    return row and tonumber(row[1]) and tonumber(row[1]) > 0
end

--[[-- Mark a bookmark as synced (insert into synced_bookmarks). --]]
function Database:markBookmarkSynced(book_cache_id, datetime, server_id)
    if not book_cache_id or not datetime or datetime == "" then
        logger.err("BookloreSync Database: markBookmarkSynced called with missing args")
        return false
    end
    local stmt = self.conn:prepare([[
        INSERT OR IGNORE INTO synced_bookmarks (book_cache_id, datetime, server_id)
        VALUES (?, ?, ?)
    ]])
    if not stmt then
        logger.err("BookloreSync Database: Failed to prepare markBookmarkSynced:", self.conn:errmsg())
        return false
    end
    local ok, err = stmt:bind(1, book_cache_id)
    if ok then ok, err = stmt:bind(2, datetime) end
    if ok then ok, err = stmt:bind(3, server_id or 0) end
    if not ok then
        logger.err("BookloreSync Database: Bind failed in markBookmarkSynced:", err)
        stmt:close()
        return false
    end
    local res = stmt:step()
    stmt:close()
    return res == sqlite3.DONE
end

--[[-- Add a bookmark to the pending queue for retry. --]]
function Database:addPendingBookmark(book_cache_id, book_id, datetime, payload)
    if not book_cache_id or not datetime or datetime == "" or not payload then
        logger.err("BookloreSync Database: addPendingBookmark called with missing args")
        return false
    end
    if type(book_cache_id) ~= "number" then
        logger.err("BookloreSync Database: addPendingBookmark: non-numeric book_cache_id")
        return false
    end
    local stmt = self.conn:prepare([[
        INSERT OR IGNORE INTO pending_bookmarks
            (book_cache_id, book_id, datetime, payload)
        VALUES (?, ?, ?, ?)
    ]])
    if not stmt then
        logger.err("BookloreSync Database: Failed to prepare addPendingBookmark:", self.conn:errmsg())
        return false
    end
    local ok, err = stmt:bind(1, book_cache_id)
    if ok then ok, err = stmt:bind(2, book_id) end
    if ok then ok, err = stmt:bind(3, datetime) end
    if ok then ok, err = stmt:bind(4, payload) end
    if not ok then
        logger.err("BookloreSync Database: Bind failed in addPendingBookmark:", err)
        stmt:close()
        return false
    end
    local res = stmt:step()
    stmt:close()
    if res ~= sqlite3.DONE then
        logger.err("BookloreSync Database: addPendingBookmark step failed:", self.conn:errmsg())
        return false
    end
    return true
end

--[[-- Return all pending bookmarks, oldest first. --]]
function Database:getPendingBookmarks()
    local rows = {}
    local stmt = self.conn:prepare([[
        SELECT id, book_cache_id, book_id, datetime, payload, retry_count
        FROM pending_bookmarks
        ORDER BY created_at ASC
    ]])
    if not stmt then
        logger.err("BookloreSync Database: Failed to prepare getPendingBookmarks:", self.conn:errmsg())
        return rows
    end
    for row in stmt:rows() do
        table.insert(rows, {
            id            = tonumber(row[1]),
            book_cache_id = tonumber(row[2]),
            book_id       = tonumber(row[3]),
            datetime      = tostring(row[4]),
            payload       = tostring(row[5]),
            retry_count   = tonumber(row[6]) or 0,
        })
    end
    stmt:close()
    return rows
end

--[[-- Remove a single pending bookmark by id (after successful sync). --]]
function Database:deletePendingBookmark(id)
    if not id then return false end
    local stmt = self.conn:prepare("DELETE FROM pending_bookmarks WHERE id = ?")
    if not stmt then
        logger.err("BookloreSync Database: Failed to prepare deletePendingBookmark:", self.conn:errmsg())
        return false
    end
    local ok, err = stmt:bind(1, id)
    if not ok then
        logger.err("BookloreSync Database: Bind failed in deletePendingBookmark:", err)
        stmt:close()
        return false
    end
    local res = stmt:step()
    stmt:close()
    return res == sqlite3.DONE
end

--[[-- Increment retry_count and update last_retry_at for a pending bookmark. --]]
function Database:incrementBookmarkRetry(id)
    if not id then return false end
    local stmt = self.conn:prepare([[
        UPDATE pending_bookmarks
        SET retry_count = retry_count + 1,
            last_retry_at = strftime('%s', 'now')
        WHERE id = ?
    ]])
    if not stmt then
        logger.err("BookloreSync Database: Failed to prepare incrementBookmarkRetry:", self.conn:errmsg())
        return false
    end
    local ok, err = stmt:bind(1, id)
    if not ok then
        logger.err("BookloreSync Database: Bind failed in incrementBookmarkRetry:", err)
        stmt:close()
        return false
    end
    local res = stmt:step()
    stmt:close()
    return res == sqlite3.DONE
end

return Database
+267 −0
Original line number Diff line number Diff line
@@ -3021,6 +3021,17 @@ function BookloreSync:onCloseDocument()
            self:logInfo("BookloreSync: ReaderAnnotation module not available — will fall back to doc_settings / sidecar")
        end

        -- Capture bookmarks from the live ReaderBookmark module.
        -- KOReader stores bookmarks in ReaderBookmark's in-memory table
        -- (self.ui.bookmark.bookmarks) — same timing hazard as annotations.
        local live_bookmarks = nil
        if self.ui and self.ui.bookmark and self.ui.bookmark.bookmarks then
            live_bookmarks = self.ui.bookmark.bookmarks
            self:logInfo("BookloreSync: Captured", #live_bookmarks, "bookmarks from ReaderBookmark module")
        else
            self:logInfo("BookloreSync: ReaderBookmark module not available — will fall back to sidecar")
        end

        if strategy == "on_session" then
            ann_synced_now, ann_queued_now, ann_failed_now =
                self:syncHighlightsAndNotes(pre_file_path, pre_book_id, open_doc, live_settings, live_annotations)
@@ -3031,6 +3042,11 @@ function BookloreSync:onCloseDocument()
        ann_synced_now  = tonumber(ann_synced_now)  or 0
        ann_queued_now  = tonumber(ann_queued_now)  or 0
        ann_failed_now  = tonumber(ann_failed_now)  or 0

        -- Sync bookmarks (same strategy gate as annotations)
        if strategy == "on_session" or (strategy == "on_complete" and pre_end_progress and pre_end_progress >= 99) then
            self:syncBookmarks(pre_file_path, pre_book_id, open_doc, live_bookmarks)
        end
    end

    -- ── Step 5: Fire the combined sync (only if book_id is confirmed) ─────
@@ -3175,6 +3191,252 @@ function BookloreSync:onResume()
    return false
end

--[[--
Sync bookmarks for a document to Booklore.

Reads the bookmark list from the live in-memory ReaderBookmark module
(self.ui.bookmark.bookmarks) if available, otherwise falls back to the
on-disk sidecar.  Each bookmark is deduplicated via synced_bookmarks
and queued in pending_bookmarks when offline or no book_id is known.

For EPUB files a full CFI is built from pos0/pos1 when available.
For PDF files a mock CFI anchored to the page number is used.

@param doc_path         string       Full path to the document file
@param book_id          number|nil   Booklore book ID (nil = queue-only mode)
@param document         object|nil   Live CREngine document (for EPUB CFI)
@param live_bookmarks   table|nil    Raw bookmark array from ReaderBookmark.bookmarks
@return integer synced_count, integer queued_count, integer failed_count
--]]
function BookloreSync:syncBookmarks(doc_path, book_id, document, live_bookmarks)
    if not doc_path then
        self:logWarn("BookloreSync: syncBookmarks called without doc_path")
        return 0, 0, 0
    end
    if not self.highlights_notes_sync_enabled then
        return 0, 0, 0
    end
    if self.booklore_username == "" or self.booklore_password == "" then
        self:logWarn("BookloreSync: Bookmark sync skipped — credentials not configured")
        return 0, 0, 0
    end

    local queue_only = (book_id == nil) or self.manual_sync_only

    -- Resolve bookmark list: live module → sidecar fallback
    local bookmarks
    if live_bookmarks and type(live_bookmarks) == "table" then
        bookmarks = live_bookmarks
        self:logInfo(string.format("BookloreSync: Bookmarks from live ReaderBookmark module (%d)", #bookmarks))
    else
        bookmarks = self.metadata_extractor:getBookmarks(doc_path)
        self:logInfo(string.format("BookloreSync: Bookmarks from on-disk sidecar (%d)", #bookmarks))
    end

    if not bookmarks or #bookmarks == 0 then
        self:logInfo("BookloreSync: No bookmarks found for:", doc_path)
        return 0, 0, 0
    end

    -- Build EPUB spine map for CFI resolution
    local spine = nil
    local html_cache = {}
    local is_epub = doc_path:lower():match("%.epub$") ~= nil
    local is_pdf  = doc_path:lower():match("%.pdf$")  ~= nil
    if is_epub and document then
        spine = self:buildEpubSpineMap(document)
        if not spine then
            self:logWarn("BookloreSync: Could not build EPUB spine map for bookmarks — will skip EPUB CFI")
        end
    end

    -- Ensure book_cache_id
    local book_cache_id = self.db:getBookCacheIdByFilePath(doc_path)
    if not book_cache_id then
        self.db:saveBookCache(doc_path, "", nil, nil, nil, nil, nil)
        book_cache_id = self.db:getBookCacheIdByFilePath(doc_path)
    end
    if not book_cache_id then
        self:logWarn("BookloreSync: Could not obtain book_cache_id for bookmark sync")
        return 0, 0, 0
    end

    local synced_count  = 0
    local queued_count  = 0
    local failed_count  = 0

    for _, bm in ipairs(bookmarks) do
        local datetime = bm.datetime or ""
        if datetime == "" then
            self:logWarn("BookloreSync: Skipping bookmark with no datetime")
            goto bm_continue
        end

        -- Deduplicate
        if self.db:isBookmarkSynced(book_cache_id, datetime) then
            self:logInfo("BookloreSync: Bookmark already synced, skipping:", datetime)
            goto bm_continue
        end

        local bm_ok, bm_err = pcall(function()
            -- Resolve CFI
            local cfi
            if is_epub and spine and bm.pos0 and bm.pos1 then
                cfi = self:buildCfi(bm.pos0, bm.pos1, spine, document, html_cache)
            elseif is_epub and spine and bm.pos0 then
                -- Single-position bookmark: use pos0 for both endpoints
                cfi = self:buildCfi(bm.pos0, bm.pos0, spine, document, html_cache)
            elseif is_pdf and bm.page then
                cfi = self:buildMockPdfCfi(bm.page)
            end

            if not cfi then
                self:logInfo("BookloreSync: No CFI for bookmark at:", datetime, "— queuing without CFI")
                -- Queue with a page-based fallback so it can be retried later
                local pending_payload = json.encode({
                    datetime      = datetime,
                    page          = bm.page,
                    notes         = bm.notes,
                    chapter       = bm.chapter,
                    pos0          = bm.pos0,
                    pos1          = bm.pos1,
                })
                self.db:addPendingBookmark(book_cache_id, book_id, datetime, pending_payload)
                failed_count = failed_count + 1
                return
            end

            local opts = {
                chapter_title = bm.chapter,
                notes         = bm.notes,
            }

            if queue_only then
                local pending_payload = json.encode({
                    datetime = datetime,
                    cfi      = cfi,
                    page     = bm.page,
                    notes    = bm.notes,
                    chapter  = bm.chapter,
                    pos0     = bm.pos0,
                    pos1     = bm.pos1,
                })
                self.db:addPendingBookmark(book_cache_id, book_id, datetime, pending_payload)
                if self.manual_sync_only then
                    queued_count = queued_count + 1
                else
                    failed_count = failed_count + 1
                end
                return
            end

            local ok, server_id = self.api:submitBookmark(
                book_id, cfi, opts,
                self.booklore_username, self.booklore_password
            )

            if ok then
                self.db:markBookmarkSynced(book_cache_id, datetime, server_id)
                synced_count = synced_count + 1
            else
                self:logWarn("BookloreSync: Failed to sync bookmark at:", datetime, "-", server_id)
                failed_count = failed_count + 1
                local pending_payload = json.encode({
                    datetime = datetime,
                    cfi      = cfi,
                    page     = bm.page,
                    notes    = bm.notes,
                    chapter  = bm.chapter,
                    pos0     = bm.pos0,
                    pos1     = bm.pos1,
                })
                self.db:addPendingBookmark(book_cache_id, book_id, datetime, pending_payload)
            end
        end)

        if not bm_ok then
            self:logErr("BookloreSync: Unexpected error processing bookmark at:", datetime, "-", bm_err)
            failed_count = failed_count + 1
        end

        ::bm_continue::
    end

    self:logInfo(string.format(
        "BookloreSync: Bookmark sync complete — synced=%d queued=%d failed=%d",
        synced_count, queued_count, failed_count
    ))
    return synced_count, queued_count, failed_count
end

--[[--
Retry all pending bookmarks from the pending_bookmarks queue.

@param silent boolean  Suppress UI feedback (default false)
@return integer synced_count, integer failed_count
--]]
function BookloreSync:syncPendingBookmarks(silent)
    silent = silent or false
    local pending = self.db:getPendingBookmarks()
    if not pending or #pending == 0 then return 0, 0 end

    local synced_count = 0
    local failed_count = 0

    for _, row in ipairs(pending) do
        local ok_dec, payload = pcall(function() return json.decode(row.payload) end)
        if not ok_dec or not payload then
            self:logWarn("BookloreSync: Cannot decode pending bookmark payload, skipping id:", row.id)
            self.db:incrementBookmarkRetry(row.id)
            failed_count = failed_count + 1
            goto pb_continue
        end

        local cfi = payload.cfi
        if not cfi then
            self:logInfo("BookloreSync: Pending bookmark has no CFI, skipping id:", row.id)
            self.db:incrementBookmarkRetry(row.id)
            failed_count = failed_count + 1
            goto pb_continue
        end

        local book_id = row.book_id
        if not book_id then
            -- Try to resolve book_id from cache
            local cached_id = self.db:getBookIdByBookCacheId(row.book_cache_id)
            if cached_id then
                book_id = cached_id
            else
                self:logInfo("BookloreSync: No book_id for pending bookmark id:", row.id, "— deferring")
                goto pb_continue
            end
        end

        local ok, server_id = self.api:submitBookmark(
            book_id, cfi,
            { chapter_title = payload.chapter, notes = payload.notes },
            self.booklore_username, self.booklore_password
        )

        if ok then
            self.db:markBookmarkSynced(row.book_cache_id, row.datetime, server_id)
            self.db:deletePendingBookmark(row.id)
            synced_count = synced_count + 1
        else
            self.db:incrementBookmarkRetry(row.id)
            failed_count = failed_count + 1
        end

        ::pb_continue::
    end

    self:logInfo(string.format(
        "BookloreSync: Pending bookmark retry complete — synced=%d failed=%d",
        synced_count, failed_count
    ))
    return synced_count, failed_count
end

--[[--
Retry all ratings that are sitting in the pending_ratings queue, and
process any deferred rating prompts whose book_id has now been resolved.
@@ -3646,6 +3908,11 @@ function BookloreSync:syncPendingSessions(silent)
    ann_synced = tonumber(ann_synced) or 0
    ann_failed = tonumber(ann_failed) or 0

    -- ── Phase 2b: Bookmarks ───────────────────────────────────────────────
    local bm_synced, bm_failed = self:syncPendingBookmarks(true)
    bm_synced = tonumber(bm_synced) or 0
    bm_failed = tonumber(bm_failed) or 0

    -- ── Phase 3: Sessions ─────────────────────────────────────────────────
    local pending_count = self.db:getPendingSessionCount()
    pending_count = tonumber(pending_count) or 0