Commit 099b565b authored by WorldTeacher's avatar WorldTeacher
Browse files

feat(shelf-sync): implement private shelf sync support with explicit ID...

parent 13032289
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -21,8 +21,8 @@ jobs:

      - name: Generate version information
        run: |
          chmod +x generate-version.sh
          ./generate-version.sh
          chmod +x scripts/generate-version.sh
          ./scripts/generate-version.sh

      - name: Compile gettext translations
        run: |
+2 −2
Original line number Diff line number Diff line
@@ -131,7 +131,7 @@ build-koplugin-artifact:
  script:
    - echo "Generating version information"
    - export PLUGIN_VERSION="dev-build-$(date -u +%Y%m%d%H%M%S)"
    - VERSION_OVERRIDE="$PLUGIN_VERSION" bash generate-version.sh
    - VERSION_OVERRIDE="$PLUGIN_VERSION" bash scripts/generate-version.sh

    - echo "Compiling gettext translations"
    - |
@@ -184,7 +184,7 @@ zip-koplugin-release:
      --git-protocol https
  script:
    - echo "Generating version information from tag $CI_COMMIT_TAG"
    - bash generate-version.sh
    - bash scripts/generate-version.sh

    - echo "Compiling gettext translations"
    - |
+10 −3
Original line number Diff line number Diff line
@@ -2,17 +2,24 @@ SHELL := /usr/bin/env bash

.DEFAULT_GOAL := help

.PHONY: help test documentation version
.PHONY: help test documentation version docs translate generate_version

help: ## Show available commands
	@echo "Available commands:"
	@awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z_-]+:.*## / {printf "  %-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST)

test: ## Run test suite
	./run_tests.sh
	bash ./scripts/run_tests.sh

documentation: ## Serve docs site locally
	cd docs && zola serve
docs: documentation ## Alias for documentation
	
version: ## Compute next version
	uv run ~/.scripts/next_version.py

translate: ## Update translation files
	sh ./scripts/update-translation.sh bookloresync.koplugin

generate_version: ## Generate version file
	sh ./scripts/generate-version.sh
+430 −16
Original line number Diff line number Diff line
@@ -166,7 +166,7 @@ Make HTTP request with improved error handling
@return table|string|nil Response data or error message
--]]
---APIClient:request.
function APIClient:request(method, path, body, headers)
function APIClient:request(method, path, body, headers, options)
    if not self.server_url or self.server_url == "" then
        self:logErr("BookloreSync API: Server URL not configured")
        return false, nil, "Server URL not configured"
@@ -176,9 +176,10 @@ function APIClient:request(method, path, body, headers)
    self:logInfo("BookloreSync API:", method, url)
    
    local req_headers = headers or {}
    options = options or {}
    
    -- Add authentication if username/password provided and no custom Authorization header
    if self.username and self.password and not req_headers["Authorization"] then
    -- Add default KOReader auth headers unless explicitly disabled.
    if not options.skip_default_auth and self.username and self.password and not req_headers["Authorization"] then
        local password_hash = md5(self.password)
        req_headers["x-auth-user"] = self.username
        req_headers["x-auth-key"] = password_hash
@@ -625,7 +626,7 @@ function APIClient:loginBooklore(username, password)
    }
    
    -- Make request without auth headers (login doesn't need auth)
    local success, code, response = self:request("POST", endpoint, body)
    local success, code, response = self:request("POST", endpoint, body, nil, { skip_default_auth = true })
    
    if success and type(response) == "table" and response.accessToken then
        self:logInfo("BookloreSync API: Successfully obtained Bearer token")
@@ -1555,26 +1556,25 @@ function APIClient:getBooksInShelf(shelf_id, username, password)

    local err_msg = (type(response) == "string" and response) or ("HTTP " .. tostring(code))
    self:logWarn("BookloreSync API: getBooksInShelf failed:", err_msg)
    return false, err_msg
    return false, { code = code, message = err_msg }
end

--[[--
Find an existing shelf by name (case-insensitive), or create it if absent.
Fetch all shelves visible to the current user.

@param name      string  Shelf name to find or create
@param username  string
@param password  string
@return boolean success
@return number|string  shelf_id on success, error message on failure
@return table|table  Shelf array on success, or { code, message } on failure
--]]
---APIClient:getOrCreateShelf.
function APIClient:getOrCreateShelf(name, username, password)
    self:logInfo("BookloreSync API: getOrCreateShelf - name:", name)
---APIClient:getAccessibleShelves.
function APIClient:getAccessibleShelves(username, password)
    self:logInfo("BookloreSync API: getAccessibleShelves")

    local token_ok, token = self:getOrRefreshBearerToken(username, password)
    if not token_ok then
        self:logErr("BookloreSync API: getOrCreateShelf - auth failed:", token)
        return false, token
        self:logErr("BookloreSync API: getAccessibleShelves - auth failed:", token)
        return false, { code = 401, message = tostring(token or "Authentication failed") }
    end

    local headers = { ["Authorization"] = "Bearer " .. token }
@@ -1591,10 +1591,419 @@ function APIClient:getOrCreateShelf(name, username, password)

    if not ok or type(response) ~= "table" then
        local err_msg = (type(response) == "string" and response) or ("HTTP " .. tostring(code))
        self:logWarn("BookloreSync API: getOrCreateShelf - could not list shelves:", err_msg)
        return false, err_msg
        self:logWarn("BookloreSync API: getAccessibleShelves failed:", err_msg)
        return false, { code = code, message = err_msg }
    end

    return true, response
end

--[[--
List available Magic Shelves.

@param username  string
@param password  string
@return boolean success
@return table|table  Shelf list on success, or { code, message } on failure
--]]
---APIClient:getMagicShelves.
function APIClient:getMagicShelves(username, password)
    self:logInfo("BookloreSync API: getMagicShelves")

    local token_ok, token = self:getOrRefreshBearerToken(username, password)
    if not token_ok then
        return false, { code = 401, message = tostring(token or "Authentication failed") }
    end

    local headers = { ["Authorization"] = "Bearer " .. token }
    local ok, code, response = self:request("GET", "/api/magic-shelves", nil, headers)

    if not ok 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
            ok, code, response = self:request("GET", "/api/magic-shelves", nil, headers)
        end
    end

    if not ok then
        if code == 404 then
            return false, {
                code = 404,
                message = "Magic Shelves endpoint unsupported on this BookLore version",
            }
        end
        local err_msg = (type(response) == "string" and response) or ("HTTP " .. tostring(code))
        return false, { code = code, message = err_msg }
    end

    if type(response) ~= "table" then
        return false, { code = code, message = "Invalid Magic Shelves response" }
    end

    return true, response
end

--[[--
Fetch a specific Magic Shelf by ID.

@param magic_shelf_id number|string
@param username string
@param password string
@return boolean success
@return table|table  Shelf object on success, or { code, message } on failure
--]]
---APIClient:getMagicShelfById.
function APIClient:getMagicShelfById(magic_shelf_id, username, password)
    local id = tonumber(magic_shelf_id)
    if not id then
        return false, { code = 400, message = "Invalid Magic Shelf ID" }
    end

    local token_ok, token = self:getOrRefreshBearerToken(username, password)
    if not token_ok then
        return false, { code = 401, message = tostring(token or "Authentication failed") }
    end

    local headers = { ["Authorization"] = "Bearer " .. token }
    local ok, code, response = self:request("GET", "/api/magic-shelves/" .. id, nil, headers)

    if not ok 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
            ok, code, response = self:request("GET", "/api/magic-shelves/" .. id, nil, headers)
        end
    end

    if not ok then
        if code == 404 then
            return false, {
                code = 404,
                message = "Magic Shelf not found or Magic Shelves endpoint unsupported",
            }
        end
        local err_msg = (type(response) == "string" and response) or ("HTTP " .. tostring(code))
        return false, { code = code, message = err_msg }
    end

    if type(response) ~= "table" then
        return false, { code = code, message = "Invalid Magic Shelf response" }
    end

    return true, response
end

--[[--
Fetch and normalize books for a Magic Shelf.

@param magic_shelf_id number|string
@param username string
@param password string
@return boolean success
@return table|table  Normalized books array on success, or { code, message } on failure
--]]
---APIClient:getBooksInMagicShelf.
function APIClient:getMagicShelfBookCount(magic_shelf_id, username, password)
    local id = tonumber(magic_shelf_id)
    if not id then
        return false, { code = 400, message = "Invalid Magic Shelf ID" }
    end

    local token_ok, token = self:getOrRefreshBearerToken(username, password)
    if not token_ok then
        return false, { code = 401, message = tostring(token or "Authentication failed") }
    end

    local headers = { ["Authorization"] = "Bearer " .. token }
    local path = string.format("/api/v1/app/shelves/magic/%d/books?page=0&size=1", id)
    local ok, code, response = self:request("GET", path, nil, headers)

    if not ok 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
            ok, code, response = self:request("GET", path, nil, headers)
        end
    end

    if not ok then
        local err_msg = (type(response) == "string" and response) or ("HTTP " .. tostring(code))
        return false, { code = code, message = err_msg }
    end

    if type(response) ~= "table" then
        return false, { code = code, message = "Invalid Magic Shelf books response" }
    end

    local total = tonumber(response.totalElements)
    if total == nil and type(response.content) == "table" then
        total = #response.content
    end
    if total == nil then
        return false, { code = code, message = "Magic Shelf books response missing totalElements" }
    end

    return true, total
end

---APIClient:getBooksInMagicShelf.
function APIClient:getBooksInMagicShelf(magic_shelf_id, username, password)
    local id = tonumber(magic_shelf_id)
    if not id then
        return false, { code = 400, message = "Invalid Magic Shelf ID" }
    end

    local token_ok, token = self:getOrRefreshBearerToken(username, password)
    if not token_ok then
        return false, { code = 401, message = tostring(token or "Authentication failed") }
    end

    local headers = { ["Authorization"] = "Bearer " .. token }
    local normalized = {}
    local page = 0
    local size = 100

    while true do
        local path = string.format("/api/v1/app/shelves/magic/%d/books?page=%d&size=%d", id, page, size)
        local ok, code, response = self:request("GET", path, nil, headers)

        if not ok 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
                ok, code, response = self:request("GET", path, nil, headers)
            end
        end

        if not ok then
            local err_msg = (type(response) == "string" and response) or ("HTTP " .. tostring(code))
            return false, { code = code, message = err_msg }
        end

        if type(response) ~= "table" or type(response.content) ~= "table" then
            return false, { code = code, message = "Invalid Magic Shelf books response" }
        end

        for _, entry in ipairs(response.content) do
            if type(entry) == "table" then
                local mapped = {
                    id = entry.id,
                    title = entry.title,
                    author = (type(entry.authors) == "table" and entry.authors[1]) or entry.author,
                    metadata = {
                        authors = (type(entry.authors) == "table") and entry.authors or nil,
                    },
                    bookType = entry.primaryFileType or entry.bookType,
                }
                table.insert(normalized, self:_normalizeShelfBookObject(mapped))
            end
        end

        if response.hasNext ~= true then
            break
        end
        page = page + 1
    end

    return true, normalized
end

--[[--
Resolve a Magic Shelf source for sync.

Resolution order:
1) If magic_shelf_id is set, ensure that Magic Shelf exists in list response.
2) Otherwise, resolve by magic_shelf_name (case-insensitive).

@param magic_shelf_id    number|string|nil
@param magic_shelf_name  string|nil
@param username          string
@param password          string
@return boolean success
@return table|table  { id, name, source } on success; { code, message } on failure
--]]
---APIClient:resolveMagicShelfSource.
function APIClient:resolveMagicShelfSource(magic_shelf_id, magic_shelf_name, username, password)
    self:logInfo("BookloreSync API: resolveMagicShelfSource - id:", tostring(magic_shelf_id), "name:", tostring(magic_shelf_name))

    local explicit_id = tonumber(magic_shelf_id)
    local shelf_name = tostring(magic_shelf_name or "")

    local list_ok, shelves_or_err = self:getMagicShelves(username, password)
    if not list_ok then
        return false, shelves_or_err
    end

    local shelves = shelves_or_err

    if explicit_id then
        for _, shelf in ipairs(shelves) do
            local sid = tonumber(shelf.id)
            if sid and sid == explicit_id then
                return true, {
                    id = sid,
                    name = shelf.name or ("Magic Shelf " .. tostring(sid)),
                    source = "id",
                }
            end
        end
        return false, {
            code = 404,
            message = "Configured Magic Shelf ID is not accessible for this account",
        }
    end

    if shelf_name ~= "" then
        local name_lower = shelf_name:lower()
        for _, shelf in ipairs(shelves) do
            if shelf.name and shelf.name:lower() == name_lower then
                local sid = tonumber(shelf.id)
                if sid then
                    return true, {
                        id = sid,
                        name = shelf.name,
                        source = "name",
                    }
                end
            end
        end
        return false, {
            code = 404,
            message = "Configured Magic Shelf name is not accessible for this account",
        }
    end

    return false, {
        code = 400,
        message = "No Magic Shelf source configured",
    }
end

--[[--
Resolve a shelf source for sync.

Resolution order:
1) If source_shelf_id is set, ensure that shelf exists in accessible shelves.
2) Otherwise, resolve by source_shelf_name (case-insensitive).
3) If name is not found, create the shelf by name.

@param source_shelf_id    number|string|nil
@param source_shelf_name  string|nil
@param username           string
@param password           string
@return boolean success
@return table|table  { id, name, source, is_private } on success; { code, message } on failure
--]]
---APIClient:resolveShelfSource.
function APIClient:resolveShelfSource(source_shelf_id, source_shelf_name, username, password)
    self:logInfo("BookloreSync API: resolveShelfSource - id:", tostring(source_shelf_id), "name:", tostring(source_shelf_name))

    local explicit_id = tonumber(source_shelf_id)
    local shelf_name = tostring(source_shelf_name or "")

    local list_ok, shelves_or_err = self:getAccessibleShelves(username, password)
    if not list_ok then
        return false, shelves_or_err
    end

    local shelves = shelves_or_err

    if explicit_id then
        for _, shelf in ipairs(shelves) do
            local sid = tonumber(shelf.id)
            if sid and sid == explicit_id then
                return true, {
                    id = sid,
                    name = shelf.name or ("Shelf " .. tostring(sid)),
                    source = "id",
                    is_private = (shelf.publicShelf == false),
                }
            end
        end
        return false, {
            code = 404,
            message = "Configured shelf ID is not accessible for this account",
        }
    end

    if shelf_name ~= "" then
        local name_lower = shelf_name:lower()
        for _, shelf in ipairs(shelves) do
            if shelf.name and shelf.name:lower() == name_lower then
                local sid = tonumber(shelf.id)
                if sid then
                    return true, {
                        id = sid,
                        name = shelf.name,
                        source = "name",
                        is_private = (shelf.publicShelf == false),
                    }
                end
            end
        end

        local token_ok, token = self:getOrRefreshBearerToken(username, password)
        if not token_ok then
            return false, {
                code = 401,
                message = tostring(token or "Authentication failed"),
            }
        end

        local body = { name = shelf_name, icon = "pi pi-shield", iconType = "PRIME_NG", publicShelf = true }
        local create_headers = {
            ["Authorization"] = "Bearer " .. token,
            ["Content-Type"]  = "application/json",
        }
        local c_ok, c_code, c_resp = self:request("POST", "/api/v1/shelves", body, create_headers)
        if c_ok and c_code == 201 and type(c_resp) == "table" and c_resp.id then
            return true, {
                id = tonumber(c_resp.id),
                name = c_resp.name or shelf_name,
                source = "created",
                is_private = false,
            }
        end

        local c_err = (type(c_resp) == "string" and c_resp) or ("HTTP " .. tostring(c_code))
        return false, {
            code = c_code,
            message = c_err,
        }
    end

    return false, {
        code = 400,
        message = "No shelf source configured",
    }
end

--[[--
Find an existing shelf by name (case-insensitive), or create it if absent.

@param name      string  Shelf name to find or create
@param username  string
@param password  string
@return boolean success
@return number|string  shelf_id on success, error message on failure
--]]
---APIClient:getOrCreateShelf.
function APIClient:getOrCreateShelf(name, username, password)
    self:logInfo("BookloreSync API: getOrCreateShelf - name:", name)

    local list_ok, response_or_err = self:getAccessibleShelves(username, password)
    if not list_ok then
        self:logWarn("BookloreSync API: getOrCreateShelf - could not list shelves:", response_or_err.message)
        return false, response_or_err.message
    end

    local response = response_or_err

    -- Search for existing shelf (case-insensitive)
    local name_lower = name:lower()
    for _, shelf in ipairs(response) do
@@ -1606,7 +2015,12 @@ function APIClient:getOrCreateShelf(name, username, password)

    -- Create it
    self:logInfo("BookloreSync API: Shelf not found, creating:", name)
   local body = json.encode({ name = name, icon = "pi pi-shield", iconType = "PRIME_NG", publicShelf = true })
    local token_ok, token = self:getOrRefreshBearerToken(username, password)
    if not token_ok then
        self:logErr("BookloreSync API: getOrCreateShelf - auth failed before create:", token)
        return false, tostring(token or "Authentication failed")
    end
    local body = { name = name, icon = "pi pi-shield", iconType = "PRIME_NG", publicShelf = true }
    local create_headers = {
        ["Authorization"] = "Bearer " .. token,
        ["Content-Type"]  = "application/json",
+5 −1
Original line number Diff line number Diff line
@@ -57,6 +57,10 @@ function M.new(deps)

    if not self.is_enabled then return end
    if self.booklore_username == "" or self.booklore_password == "" then return end
    if self.booklore_sync_source_type == "magic" then
        self:logInfo("BookloreSync: notifyBookloreOnDeletion - skipping shelf unassign for magic shelf source")
        return
    end

    -- If offline, queue immediately rather than attempting the async call
    if not from_queue and not self:isNetworkConnected() then
@@ -100,7 +104,7 @@ function M.new(deps)
            local token_ok, token = self.api:getOrRefreshBearerToken(self.booklore_username, self.booklore_password, false)
            if not token_ok then error(token or "Failed to obtain auth token") end

            local shelf_id = self.shelf_id
            local shelf_id = tonumber(self.booklore_source_shelf_id) or self.shelf_id
            if not shelf_id then error("No shelf_id configured - cannot unassign book from shelf") end

            local headers = {
Loading