@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { HttpError } from "@sttracker/plugin-sdk/http";
|
||||
import { collect, createFixtureHttp, createTestContext } from "@sttracker/plugin-sdk/testing";
|
||||
import plugin from "./index.ts";
|
||||
import { PAGE_SIZE } from "./src/graphql.ts";
|
||||
|
||||
const short = await Bun.file(new URL("./test/fixtures/page-short.json", import.meta.url)).json();
|
||||
const lists = await Bun.file(new URL("./test/fixtures/lists.json", import.meta.url)).json();
|
||||
const errors = await Bun.file(new URL("./test/fixtures/errors.json", import.meta.url)).json();
|
||||
const URL_ = "POST https://api.hardcover.app/v1/graphql";
|
||||
|
||||
type Body = { query: string; variables: { offset?: number; where?: unknown } };
|
||||
function ctxFor(userBooks: (b: Body) => unknown) {
|
||||
const calls: Body[] = [];
|
||||
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body)) as Body;
|
||||
calls.push(body);
|
||||
const out = body.query.includes("query Lists") ? lists : userBooks(body);
|
||||
const inner = createFixtureHttp({ [URL_]: out });
|
||||
return inner.fetch(input, init);
|
||||
}) as typeof fetch;
|
||||
return { ctx: createTestContext({ fetch: fetchImpl, config: { token: "tok" } }), calls };
|
||||
}
|
||||
|
||||
describe("hardcover pull", () => {
|
||||
test("maps status, rating, series parent, favorite", async () => {
|
||||
const { ctx } = ctxFor(() => short);
|
||||
const recs = await collect(plugin.pull!(ctx));
|
||||
const e = recs.find((r) => r.key === "ub:1");
|
||||
if (e?.kind !== "entry") throw new Error("no entry");
|
||||
expect(e.entry).toMatchObject({ status: "completed", rating: 90, review: "Great", notes: "lent to Sam", favorite: true });
|
||||
expect(e.work.externalIds).toEqual({ hardcover_book: "10", hardcover_edition: "500", isbn13: "9780441013593", isbn10: "0441013597" });
|
||||
expect(e.work.parent).toMatchObject({ title: "Dune Chronicles", externalIds: { hardcover_series: "77" } });
|
||||
expect(e.work.position).toEqual({ index: 1 });
|
||||
const e2 = recs.find((r) => r.key === "ub:2");
|
||||
if (e2?.kind !== "entry") throw new Error("no entry");
|
||||
expect(e2.entry).toMatchObject({ status: "in_progress", rating: null, favorite: false });
|
||||
});
|
||||
|
||||
test("re-reads yield two engagements with day precision", async () => {
|
||||
const { ctx } = ctxFor(() => short);
|
||||
const eng = (await collect(plugin.pull!(ctx))).filter((r) => r.kind === "engagement");
|
||||
expect(eng.map((r) => r.key)).toEqual(["read:900", "read:901"]);
|
||||
if (eng[0]?.kind !== "engagement") throw new Error();
|
||||
expect(eng[0].engagement.startedAt).toEqual({ value: "2024-01-05", precision: "day" });
|
||||
expect(eng[0].engagement.progress).toEqual({ pages: 412 });
|
||||
});
|
||||
|
||||
test("merge yields synthetic record with mergedIntoKey", async () => {
|
||||
const { ctx } = ctxFor(() => short);
|
||||
const m = (await collect(plugin.pull!(ctx))).find((r) => r.key === "ub:2@book:20");
|
||||
expect(m?.mergedIntoKey).toBe("ub:2");
|
||||
if (m?.kind !== "entry") throw new Error();
|
||||
expect(m.work.externalIds).toEqual({ hardcover_book: "20" });
|
||||
expect((await collect(plugin.pull!(ctxFor(() => short).ctx))).some((r) => r.key === "ub:1@book:10")).toBe(false);
|
||||
});
|
||||
|
||||
test("lists yield list records", async () => {
|
||||
const { ctx } = ctxFor(() => short);
|
||||
const l = (await collect(plugin.pull!(ctx))).find((r) => r.kind === "list");
|
||||
expect(l?.key).toBe("list:5");
|
||||
if (l?.kind !== "list") throw new Error();
|
||||
expect(l.list.items[0]?.work.externalIds).toEqual({ hardcover_book: "10" });
|
||||
});
|
||||
|
||||
test("sends bearer token", async () => {
|
||||
let auth = "";
|
||||
const f = (async (_i: unknown, init?: RequestInit) => {
|
||||
auth = new Headers(init?.headers).get("authorization") ?? "";
|
||||
return Response.json({ data: { me: [{ user_books: [], lists: [] }] } });
|
||||
}) as typeof fetch;
|
||||
await collect(plugin.pull!(createTestContext({ fetch: f, config: { token: "tok" } })));
|
||||
expect(auth).toBe("Bearer tok");
|
||||
});
|
||||
|
||||
test("GraphQL errors throw HttpError", async () => {
|
||||
const { ctx } = ctxFor(() => errors);
|
||||
await expect(collect(plugin.pull!(ctx))).rejects.toBeInstanceOf(HttpError);
|
||||
});
|
||||
|
||||
test("offset paging continues on full page and stops on short page", async () => {
|
||||
const row = short.data.me[0].user_books[0];
|
||||
const full = { data: { me: [{ user_books: Array.from({ length: PAGE_SIZE }, (_, i) => ({ ...row, id: 1000 + i, user_book_reads: [] })) }] } };
|
||||
const { ctx, calls } = ctxFor((b) => (b.variables.offset === 0 ? full : short));
|
||||
await collect(plugin.pull!(ctx));
|
||||
const ub = calls.filter((c) => c.query.includes("UserBooks"));
|
||||
expect(ub.map((c) => c.variables.offset)).toEqual([0, 100]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hardcover pullChanges", () => {
|
||||
test("filters by cursor and advances it", async () => {
|
||||
const { ctx, calls } = ctxFor(() => short);
|
||||
await ctx.cursor.set("2024-05-01T00:00:00+00:00");
|
||||
const recs = await collect(plugin.pullChanges!(ctx));
|
||||
expect(calls[0]?.variables.where).toEqual({ updated_at: { _gt: "2024-05-01T00:00:00+00:00" } });
|
||||
expect(recs.some((r) => r.kind === "list")).toBe(false);
|
||||
expect(ctx.saved.cursor.at(-1)).toBe("2024-06-01T10:00:00+00:00");
|
||||
});
|
||||
});
|
||||
|
||||
test("manifest", () => {
|
||||
expect(plugin.manifest).toMatchObject({ id: "hardcover", capabilities: ["pull", "pullChanges", "lookup"], mediaTypes: ["book"], attribution: "Book data from Hardcover." });
|
||||
});
|
||||
|
||||
describe("hardcover lookup", () => {
|
||||
const search = (results: unknown) => {
|
||||
const calls: unknown[] = [];
|
||||
const f = (async (_i: unknown, init?: RequestInit) => {
|
||||
calls.push(JSON.parse(String(init?.body)));
|
||||
return Response.json({ data: { search: { results } } });
|
||||
}) as typeof fetch;
|
||||
return { ctx: createTestContext({ fetch: f, config: { token: "tok" } }), calls };
|
||||
};
|
||||
|
||||
test("maps hits to WorkHints", async () => {
|
||||
const { ctx, calls } = search({
|
||||
hits: [
|
||||
{ document: { id: 10, title: "Dune", release_year: 1965, author_names: ["Frank Herbert"], image: { url: "https://img/dune.jpg" } } },
|
||||
{ document: { id: "11", title: "Bare" } },
|
||||
],
|
||||
});
|
||||
const out = await plugin.lookup!(ctx, { title: "dune" });
|
||||
expect(out).toEqual([
|
||||
{ title: "Dune", mediaType: "book", year: 1965, creators: [{ name: "Frank Herbert" }], externalIds: { hardcover_book: "10" }, coverUrl: "https://img/dune.jpg" },
|
||||
{ title: "Bare", mediaType: "book", externalIds: { hardcover_book: "11" } },
|
||||
]);
|
||||
expect((calls[0] as { variables: { q: string } }).variables.q).toBe("dune");
|
||||
});
|
||||
|
||||
test("null results returns []", async () => {
|
||||
expect(await plugin.lookup!(search(null).ctx, { title: "x" })).toEqual([]);
|
||||
});
|
||||
|
||||
test("non-book mediaType returns [] without a request", async () => {
|
||||
const { ctx, calls } = search({ hits: [] });
|
||||
expect(await plugin.lookup!(ctx, { title: "x", mediaType: "movie" })).toEqual([]);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status mapping", () => {
|
||||
const cases: [number | null, string][] = [[1, "planned"], [2, "in_progress"], [3, "completed"], [4, "paused"], [5, "dropped"], [6, "none"], [null, "none"]];
|
||||
for (const [id, want] of cases) {
|
||||
test(`status_id ${id} -> ${want}`, async () => {
|
||||
const row = { ...short.data.me[0].user_books[1], status_id: id, merged_at: null };
|
||||
const ctx = createTestContext({
|
||||
fetch: (async () => Response.json({ data: { me: [{ user_books: [row], lists: [] }] } })) as unknown as typeof fetch,
|
||||
});
|
||||
const e = (await collect(plugin.pull!(ctx))).find((r) => r.key === "ub:2");
|
||||
if (e?.kind !== "entry") throw new Error();
|
||||
expect(e.entry.status).toBe(want as never);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { definePlugin } from "@sttracker/plugin-sdk/types";
|
||||
import type { MediaType, PluginContext, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types";
|
||||
import { gql, LISTS_QUERY, PAGE_SIZE, SEARCH_QUERY, USER_BOOKS_QUERY } from "./src/graphql.ts";
|
||||
import { mapList, mapUserBook } from "./src/map.ts";
|
||||
import type { HcList, HcUserBook } from "./src/map.ts";
|
||||
|
||||
export { RATE_PER_MINUTE } from "./src/graphql.ts";
|
||||
|
||||
async function* pageUserBooks(ctx: PluginContext, where: Record<string, unknown> | null): AsyncGenerator<HcUserBook> {
|
||||
for (let offset = 0; ; offset += PAGE_SIZE) {
|
||||
const d = await gql<{ me: { user_books: HcUserBook[] }[] }>(ctx, USER_BOOKS_QUERY, { limit: PAGE_SIZE, offset, where });
|
||||
const rows = d.me[0]?.user_books ?? [];
|
||||
yield* rows;
|
||||
if (rows.length < PAGE_SIZE) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function* pullChanges(ctx: PluginContext): AsyncGenerator<SourceRecord> {
|
||||
const cursor = await ctx.cursor.get();
|
||||
const where = cursor ? { updated_at: { _gt: cursor } } : null;
|
||||
let max = cursor;
|
||||
for await (const ub of pageUserBooks(ctx, where)) {
|
||||
if (max === null || ub.updated_at > max) max = ub.updated_at;
|
||||
yield* mapUserBook(ub);
|
||||
}
|
||||
if (max !== null && max !== cursor) await ctx.cursor.set(max);
|
||||
}
|
||||
|
||||
async function* pull(ctx: PluginContext): AsyncGenerator<SourceRecord> {
|
||||
let max: string | null = null;
|
||||
for await (const ub of pageUserBooks(ctx, null)) {
|
||||
if (max === null || ub.updated_at > max) max = ub.updated_at;
|
||||
yield* mapUserBook(ub);
|
||||
}
|
||||
const d = await gql<{ me: { lists: HcList[] }[] }>(ctx, LISTS_QUERY, {});
|
||||
for (const l of d.me[0]?.lists ?? []) yield mapList(l);
|
||||
if (max !== null) await ctx.cursor.set(max);
|
||||
}
|
||||
|
||||
interface SearchDoc { id: number | string; title: string; release_year?: number; author_names?: string[]; image?: { url?: string } }
|
||||
|
||||
export default definePlugin({
|
||||
manifest: {
|
||||
id: "hardcover",
|
||||
name: "Hardcover",
|
||||
version: "0.1.0",
|
||||
sdkVersion: 1,
|
||||
mediaTypes: ["book"],
|
||||
capabilities: ["pull", "pullChanges", "lookup"],
|
||||
config: [{ key: "token", label: "Personal access token", type: "secret", required: true }],
|
||||
attribution: "Book data from Hardcover.",
|
||||
},
|
||||
pull,
|
||||
pullChanges,
|
||||
async lookup(ctx, q: { title: string; mediaType?: MediaType; year?: number }): Promise<WorkHint[]> {
|
||||
if (q.mediaType && q.mediaType !== "book") return [];
|
||||
const d = await gql<{ search: { results: { hits?: { document: SearchDoc }[] } | null } }>(ctx, SEARCH_QUERY, { q: q.title });
|
||||
return (d.search.results?.hits ?? []).map(({ document: x }) => ({
|
||||
title: x.title,
|
||||
mediaType: "book" as const,
|
||||
externalIds: { hardcover_book: String(x.id) },
|
||||
...(x.release_year ? { year: x.release_year } : {}),
|
||||
...(x.author_names?.length ? { creators: x.author_names.map((name) => ({ name })) } : {}),
|
||||
...(x.image?.url ? { coverUrl: x.image.url } : {}),
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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) })),
|
||||
},
|
||||
};
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"errors":[{"message":"Invalid token"}]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"data":{"me":[{"lists":[{"id":5,"name":"Favorites","description":"best","list_books":[{"book":{"id":10,"title":"Dune","release_year":1965,"contributions":[{"author":{"name":"Frank Herbert"}}]}}]}]}]}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{"data":{"me":[{"user_books":[
|
||||
{"id":1,"status_id":3,"rating":4.5,"review_raw":"Great","private_notes":"lent to Sam","starred":true,"updated_at":"2024-05-02T10:00:00+00:00","merged_at":null,"original_book_id":10,
|
||||
"book":{"id":10,"title":"Dune","release_year":1965,"contributions":[{"author":{"name":"Frank Herbert"}}],"book_series":[{"position":1,"series":{"id":77,"name":"Dune Chronicles"}}]},
|
||||
"edition":{"id":500,"isbn_13":"9780441013593","isbn_10":"0441013597","asin":null},
|
||||
"user_book_reads":[{"id":900,"started_at":"2024-01-05","finished_at":"2024-01-20","progress_pages":412},{"id":901,"started_at":"2024-04-01","finished_at":"2024-04-15","progress_pages":null}]},
|
||||
{"id":2,"status_id":2,"rating":null,"review_raw":null,"private_notes":null,"starred":false,"updated_at":"2024-06-01T10:00:00+00:00","merged_at":"2024-05-30T00:00:00+00:00","original_book_id":20,
|
||||
"book":{"id":21,"title":"Standalone","release_year":null,"contributions":[],"book_series":[]},
|
||||
"edition":null,"user_book_reads":[]}
|
||||
]}]}}
|
||||
Reference in New Issue
Block a user