mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
c29cfa3a07
Multiple fixes and improvements: - src/app/api/user/hardcover-shelves/[id]/route.ts: Make token testing more robust by using the existing shelf.apiToken when no new token is provided, attempt decryption only when needed, and gracefully fall back on decryption errors. - src/components/ui/AddShelfModal.tsx: Simplify token handling by passing the trimmed token directly to addHardcover (remove client-side 'Bearer ' stripping). - src/components/ui/ManageShelfModal.tsx: Stabilize form reset effect by depending on shelf?.id to avoid unnecessary re-renders when the shelf object changes identity. - src/components/ui/Modal.tsx: Simplify modal rendering by removing the mounted state and createPortal usage, cleaning up imports and rendering directly. - src/lib/services/hardcover-api.service.ts: Add a logger, introduce a MAX_PAGES cap and page counters to prevent unbounded pagination loops, and log/break when the API returns errors during pagination. These changes improve reliability (token handling and pagination safety), reduce unnecessary renders, and simplify modal lifecycle.
328 lines
9.6 KiB
TypeScript
328 lines
9.6 KiB
TypeScript
/**
|
|
* Component: Hardcover API Service
|
|
* Documentation: documentation/backend/services/hardcover-sync.md
|
|
*
|
|
* GraphQL queries and API communication with the Hardcover platform.
|
|
* Exports fetchHardcoverList for use by the sync orchestration layer.
|
|
*/
|
|
|
|
import axios from 'axios';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
|
|
const logger = RMABLogger.create('HardcoverAPI');
|
|
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
|
|
|
|
export interface HardcoverApiBook {
|
|
bookId: string;
|
|
title: string;
|
|
author: string;
|
|
coverUrl?: string;
|
|
}
|
|
|
|
/** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */
|
|
interface HardcoverBookNode {
|
|
id?: number;
|
|
title?: string;
|
|
cached_image?: string | { url?: string };
|
|
image?: { url?: string };
|
|
contributions?: Array<{ author?: { name?: string } }>;
|
|
}
|
|
|
|
/** Shape of a list object returned from the Hardcover GraphQL API */
|
|
interface HardcoverListData {
|
|
name?: string;
|
|
list_books?: Array<{ book?: HardcoverBookNode }>;
|
|
}
|
|
|
|
const PAGE_SIZE = 100;
|
|
const MAX_PAGES = 50;
|
|
|
|
/** Extract HardcoverApiBook[] from an array of book-containing items */
|
|
function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] {
|
|
const books: HardcoverApiBook[] = [];
|
|
for (const item of items) {
|
|
const book = item.book;
|
|
if (!book || !book.id) continue;
|
|
|
|
const authorName =
|
|
book.contributions?.[0]?.author?.name || 'Unknown Author';
|
|
const cachedImg = book.cached_image;
|
|
const coverUrl =
|
|
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) ||
|
|
book.image?.url ||
|
|
undefined;
|
|
|
|
books.push({
|
|
bookId: book.id.toString(),
|
|
title: book.title || 'Unknown Title',
|
|
author: authorName,
|
|
coverUrl,
|
|
});
|
|
}
|
|
return books;
|
|
}
|
|
|
|
/**
|
|
* Fetch a Hardcover List using their GraphQL API.
|
|
* This handles both 'status_id' user_books or 'list_id' list_books queries.
|
|
* For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id.
|
|
*/
|
|
export async function fetchHardcoverList(
|
|
apiToken: string,
|
|
listIdStr: string,
|
|
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
|
|
// Check if it's a status list
|
|
const isStatus = listIdStr.startsWith('status-');
|
|
|
|
if (isStatus) {
|
|
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
|
|
const query = `
|
|
query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) {
|
|
me {
|
|
user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) {
|
|
book {
|
|
id
|
|
title
|
|
contributions {
|
|
author {
|
|
name
|
|
}
|
|
}
|
|
cached_image
|
|
image {
|
|
url
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
// Map status numbers to names
|
|
const statusNames: Record<number, string> = {
|
|
1: 'Want to Read',
|
|
2: 'Currently Reading',
|
|
3: 'Read',
|
|
4: 'Did Not Finish',
|
|
};
|
|
const listName = statusNames[statusId] || `Status ${statusId}`;
|
|
|
|
const allBooks: HardcoverApiBook[] = [];
|
|
let offset = 0;
|
|
let page = 0;
|
|
|
|
// Paginate until fewer results than PAGE_SIZE are returned
|
|
while (++page <= MAX_PAGES) {
|
|
const response = await axios.post(
|
|
HARDCOVER_API_URL,
|
|
{ query, variables: { statusId, limit: PAGE_SIZE, offset } },
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${apiToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout: 30000,
|
|
},
|
|
);
|
|
|
|
if (response.data?.errors) {
|
|
throw new Error(
|
|
`Hardcover API Error: ${response.data.errors[0]?.message}`,
|
|
);
|
|
}
|
|
|
|
const userBooks: Array<{ book?: HardcoverBookNode }> =
|
|
response.data?.data?.me?.[0]?.user_books || [];
|
|
const pageBooks = extractBooks(userBooks);
|
|
allBooks.push(...pageBooks);
|
|
|
|
if (userBooks.length < PAGE_SIZE) break;
|
|
offset += PAGE_SIZE;
|
|
}
|
|
|
|
return { listName, books: allBooks };
|
|
} else {
|
|
// Custom list query
|
|
// - URL with @username → query that user's lists by slug
|
|
// - Bare slug (no username) → query authenticated user's lists via `me`
|
|
// - Numeric ID → query globally (IDs are unique)
|
|
const isIntId = /^\d+$/.test(listIdStr);
|
|
let extractedSlug = listIdStr;
|
|
let extractedUsername: string | null = null;
|
|
|
|
if (!isIntId) {
|
|
try {
|
|
if (listIdStr.includes('hardcover.app')) {
|
|
const url = new URL(
|
|
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
|
|
);
|
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
// URL format: /@username/lists/slug
|
|
if (parts.length > 0) {
|
|
extractedSlug = parts[parts.length - 1];
|
|
}
|
|
const userPart = parts.find((p) => p.startsWith('@'));
|
|
if (userPart) {
|
|
extractedUsername = userPart.slice(1);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// use extractedSlug as-is
|
|
}
|
|
}
|
|
|
|
const listBookFields = `
|
|
name
|
|
list_books(limit: $limit, offset: $offset, order_by: {id: desc}) {
|
|
book {
|
|
id title cached_image image { url }
|
|
contributions { author { name } }
|
|
}
|
|
}
|
|
`;
|
|
|
|
// Numeric ID: globally unique, query the lists table directly
|
|
const queryById = `
|
|
query GetListBooks($listId: Int!, $limit: Int!, $offset: Int!) {
|
|
lists(where: {id: {_eq: $listId}}, limit: 1) {
|
|
${listBookFields}
|
|
}
|
|
}
|
|
`;
|
|
|
|
// Slug with username: query through the users table to scope to that user
|
|
const queryByUserSlug = `
|
|
query GetUserListBySlug($username: citext!, $slug: String!, $limit: Int!, $offset: Int!) {
|
|
users(where: {username: {_eq: $username}}, limit: 1) {
|
|
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
|
${listBookFields}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
// Bare slug (no username): scope to the authenticated user via `me`
|
|
const queryByMySlug = `
|
|
query GetMyListBySlug($slug: String!, $limit: Int!, $offset: Int!) {
|
|
me {
|
|
lists(where: {slug: {_eq: $slug}}, limit: 1) {
|
|
${listBookFields}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
let activeQuery: string;
|
|
let baseVariables: Record<string, unknown>;
|
|
|
|
if (isIntId) {
|
|
activeQuery = queryById;
|
|
baseVariables = { listId: parseInt(listIdStr, 10) };
|
|
} else if (extractedUsername) {
|
|
activeQuery = queryByUserSlug;
|
|
baseVariables = { username: extractedUsername, slug: extractedSlug };
|
|
} else {
|
|
activeQuery = queryByMySlug;
|
|
baseVariables = { slug: extractedSlug };
|
|
}
|
|
|
|
// First request to discover list metadata and first page of books
|
|
const firstResponse = await axios.post(
|
|
HARDCOVER_API_URL,
|
|
{
|
|
query: activeQuery,
|
|
variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 },
|
|
},
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${apiToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout: 30000,
|
|
},
|
|
);
|
|
|
|
if (firstResponse.data?.errors) {
|
|
throw new Error(
|
|
`Hardcover API Error: ${firstResponse.data.errors[0]?.message}`,
|
|
);
|
|
}
|
|
|
|
// Extract lists array from the response based on which query was used
|
|
let listsData: HardcoverListData[];
|
|
if (isIntId) {
|
|
listsData = firstResponse.data?.data?.lists || [];
|
|
} else if (extractedUsername) {
|
|
const users = firstResponse.data?.data?.users || [];
|
|
listsData = users[0]?.lists || [];
|
|
} else {
|
|
listsData = firstResponse.data?.data?.me?.[0]?.lists || [];
|
|
}
|
|
|
|
if (listsData.length === 0) {
|
|
let identifier: string;
|
|
if (isIntId) {
|
|
identifier = `ID "${listIdStr}"`;
|
|
} else if (extractedUsername) {
|
|
identifier = `slug "${extractedSlug}" for user @${extractedUsername}`;
|
|
} else {
|
|
identifier = `slug "${extractedSlug}" in your Hardcover account`;
|
|
}
|
|
throw new Error(`Could not find a list with ${identifier}`);
|
|
}
|
|
|
|
const listName = listsData[0].name || 'Hardcover List';
|
|
const firstPageItems = listsData[0].list_books || [];
|
|
const allBooks = extractBooks(firstPageItems);
|
|
|
|
// Paginate if first page was full
|
|
if (firstPageItems.length >= PAGE_SIZE) {
|
|
let offset = PAGE_SIZE;
|
|
let page = 1; // first page already fetched
|
|
|
|
while (++page <= MAX_PAGES) {
|
|
const pageResponse = await axios.post(
|
|
HARDCOVER_API_URL,
|
|
{
|
|
query: activeQuery,
|
|
variables: { ...baseVariables, limit: PAGE_SIZE, offset },
|
|
},
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${apiToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
timeout: 30000,
|
|
},
|
|
);
|
|
|
|
if (pageResponse.data?.errors) {
|
|
logger.warn('Hardcover pagination interrupted by API error', {
|
|
errors: pageResponse.data.errors,
|
|
offset,
|
|
});
|
|
break;
|
|
}
|
|
|
|
let pageListsData: HardcoverListData[];
|
|
if (isIntId) {
|
|
pageListsData = pageResponse.data?.data?.lists || [];
|
|
} else if (extractedUsername) {
|
|
const users = pageResponse.data?.data?.users || [];
|
|
pageListsData = users[0]?.lists || [];
|
|
} else {
|
|
pageListsData = pageResponse.data?.data?.me?.[0]?.lists || [];
|
|
}
|
|
|
|
const pageItems = pageListsData[0]?.list_books || [];
|
|
allBooks.push(...extractBooks(pageItems));
|
|
|
|
if (pageItems.length < PAGE_SIZE) break;
|
|
offset += PAGE_SIZE;
|
|
}
|
|
}
|
|
|
|
return { listName, books: allBooks };
|
|
}
|
|
}
|