feat(plugins): hardcover
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
06a59e1b5c
commit
c09968086f
7 files changed
+320
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import { HttpError } from "@sttracker/plugin-sdk/http";
|
||||
import type { PluginContext } from "@sttracker/plugin-sdk/types";
|
||||
|
||||
export const ENDPOINT = "https://api.hardcover.app/v1/graphql";
|
||||
export const RATE_PER_MINUTE = 50;
|
||||
export const PAGE_SIZE = 100;
|
||||
|
||||
const BOOK = `id title release_year contributions { author { name } }`;
|
||||
const BOOK_FULL = `id title release_year contributions { author { name } } book_series { position series { id name } }`;
|
||||
|
||||
export const USER_BOOKS_QUERY = `query UserBooks($limit: Int!, $offset: Int!, $where: user_books_bool_exp) {
|
||||
me { user_books(limit: $limit, offset: $offset, where: $where, order_by: {id: asc}) {
|
||||
id status_id rating review_raw private_notes starred updated_at merged_at original_book_id
|
||||
book { ${BOOK_FULL} }
|
||||
edition { id isbn_13 isbn_10 asin }
|
||||
user_book_reads { id started_at finished_at progress_pages }
|
||||
} }
|
||||
}`;
|
||||
|
||||
export const LISTS_QUERY = `query Lists { me { lists { id name description list_books { book { ${BOOK} } } } } }`;
|
||||
|
||||
export const SEARCH_QUERY = `query Search($q: String!) { search(query: $q, query_type: "Book", per_page: 10) { results } }`;
|
||||
|
||||
export async function gql<T>(ctx: PluginContext, query: string, variables: Record<string, unknown>): Promise<T> {
|
||||
const creds = await ctx.credentials.get();
|
||||
const token = creds?.token ?? (typeof ctx.config.token === "string" ? ctx.config.token : "");
|
||||
const res = await ctx.http.json<{ data?: T; errors?: { message: string }[] }>(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (res.errors?.length || res.data === undefined) {
|
||||
throw new HttpError(200, JSON.stringify(res.errors ?? res), ENDPOINT);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
|
||||
import type { EntryFields, SourceRecord, Status, WorkHint } from "@sttracker/plugin-sdk/types";
|
||||
|
||||
export interface HcBook {
|
||||
id: number;
|
||||
title: string;
|
||||
release_year?: number | null;
|
||||
contributions?: { author: { name: string } | null }[];
|
||||
book_series?: { position: number | null; series: { id: number; name: string } }[];
|
||||
}
|
||||
export interface HcRead { id: number; started_at: string | null; finished_at: string | null; progress_pages: number | null }
|
||||
export interface HcUserBook {
|
||||
id: number;
|
||||
status_id: number | null;
|
||||
rating: number | null;
|
||||
review_raw: string | null;
|
||||
private_notes: string | null;
|
||||
starred: boolean | null;
|
||||
updated_at: string;
|
||||
merged_at: string | null;
|
||||
original_book_id: number | null;
|
||||
book: HcBook;
|
||||
edition: { id: number; isbn_13: string | null; isbn_10: string | null; asin: string | null } | null;
|
||||
user_book_reads: HcRead[];
|
||||
}
|
||||
export interface HcList { id: number; name: string; description: string | null; list_books: { book: HcBook }[] }
|
||||
|
||||
export const STATUS: Record<number, Status> = { 1: "planned", 2: "in_progress", 3: "completed", 4: "paused", 5: "dropped", 6: "none" };
|
||||
|
||||
export function bookHint(b: HcBook): WorkHint {
|
||||
const creators = (b.contributions ?? []).flatMap((c) => (c.author ? [{ name: c.author.name }] : []));
|
||||
const h: WorkHint = { title: b.title, mediaType: "book", externalIds: { hardcover_book: String(b.id) } };
|
||||
if (b.release_year != null) h.year = b.release_year;
|
||||
if (creators.length) h.creators = creators;
|
||||
return h;
|
||||
}
|
||||
|
||||
function workHint(ub: HcUserBook): WorkHint {
|
||||
const h = bookHint(ub.book);
|
||||
const e = ub.edition;
|
||||
if (e) {
|
||||
h.externalIds.hardcover_edition = String(e.id);
|
||||
if (e.isbn_13) h.externalIds.isbn13 = e.isbn_13;
|
||||
if (e.isbn_10) h.externalIds.isbn10 = e.isbn_10;
|
||||
if (e.asin) h.externalIds.asin = e.asin;
|
||||
}
|
||||
const s = ub.book.book_series?.[0];
|
||||
if (s) {
|
||||
h.parent = { title: s.series.name, mediaType: "book", externalIds: { hardcover_series: String(s.series.id) } };
|
||||
if (s.position != null) h.position = { index: s.position };
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
export function* mapUserBook(ub: HcUserBook): Generator<SourceRecord> {
|
||||
const work = workHint(ub);
|
||||
const entry: EntryFields = {
|
||||
status: STATUS[ub.status_id ?? 6] ?? "none",
|
||||
rating: ub.rating == null ? null : Math.round(ub.rating * 20),
|
||||
review: ub.review_raw,
|
||||
notes: ub.private_notes,
|
||||
favorite: ub.starred === true,
|
||||
};
|
||||
const key = `ub:${ub.id}`;
|
||||
yield { kind: "entry", key, raw: ub, work, entry };
|
||||
for (const r of ub.user_book_reads) {
|
||||
yield {
|
||||
kind: "engagement",
|
||||
key: `read:${r.id}`,
|
||||
raw: r,
|
||||
work,
|
||||
engagement: {
|
||||
startedAt: parsePartialDate(r.started_at),
|
||||
finishedAt: parsePartialDate(r.finished_at),
|
||||
...(r.progress_pages != null ? { progress: { pages: r.progress_pages } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ub.merged_at && ub.original_book_id != null && ub.original_book_id !== ub.book.id) {
|
||||
yield {
|
||||
kind: "entry",
|
||||
key: `${key}@book:${ub.original_book_id}`,
|
||||
raw: ub,
|
||||
mergedIntoKey: key,
|
||||
work: { title: ub.book.title, mediaType: "book", externalIds: { hardcover_book: String(ub.original_book_id) } },
|
||||
entry,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function mapList(l: HcList): SourceRecord {
|
||||
return {
|
||||
kind: "list",
|
||||
key: `list:${l.id}`,
|
||||
raw: l,
|
||||
list: {
|
||||
name: l.name,
|
||||
...(l.description ? { description: l.description } : {}),
|
||||
items: l.list_books.map((lb) => ({ work: bookHint(lb.book) })),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user