Add bookshelf ISBN/paper enrichment, fix status field name collision

apps/cms/src/utils/bookshelf-covers.ts fetches book covers from Open
Library by ISBN (real image, cached into apps/site/public/covers/)
and backfills missing title/authors for papers from Semantic Scholar
by DOI/arXiv id. Wired into the sync middleware in content-sync.ts:
runs once per entry (cover_image already set means "done"), and only
on live create/update through Strapi, not during the boot-time
import (so a restart doesn't hammer external APIs).

Also renames the bookshelf `status` field to `read_status`
everywhere (schema, content/README.md, seed content, Astro zod
schema, bookshelf page, sync mapping). Strapi's content-manager API
treats a top-level `status` key as its own reserved draft/publish
parameter even with draftAndPublish disabled, so an attribute
literally named `status` silently rejected every write — caught this
while testing the enrichment hook.

Verified end-to-end: created a book by ISBN with no cover_image,
confirmed a real cover image was downloaded and cached, cover_image
was set on the entry and written to the file, and the cover/file were
both removed on delete.
This commit is contained in:
2026-08-30 18:52:39 +03:00
parent 85efb824ae
commit 62db772dad
10 changed files with 167 additions and 28 deletions
@@ -55,7 +55,7 @@
"min": 1,
"max": 5
},
"status": {
"read_status": {
"type": "enumeration",
"enum": ["reading", "read", "abandoned"]
},
+65
View File
@@ -0,0 +1,65 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
/**
* Covers are cached into the Astro app's public/ dir directly, so the built
* site serves them from /covers/<slug>.jpg with no runtime dependency on the
* source (Open Library, Semantic Scholar, ...).
*/
const COVERS_DIR = join(process.cwd(), "..", "site", "public", "covers");
async function download(url: string): Promise<Buffer | undefined> {
const res = await fetch(url);
if (!res.ok) return undefined;
const buf = Buffer.from(await res.arrayBuffer());
// Open Library serves a tiny placeholder image for ISBNs it doesn't have a cover for.
if (buf.byteLength < 500) return undefined;
return buf;
}
/** Fetches a book cover by ISBN from Open Library and caches it locally. */
export async function fetchBookCover(
isbn: string,
slug: string,
): Promise<string | undefined> {
const buf = await download(
`https://covers.openlibrary.org/b/isbn/${encodeURIComponent(isbn)}-L.jpg?default=false`,
);
if (!buf) return undefined;
const file = join(COVERS_DIR, `${slug}.jpg`);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, buf);
return `/covers/${slug}.jpg`;
}
interface PaperMetadata {
title?: string;
authors?: string[];
}
/**
* Fetches title/author metadata for a paper from Semantic Scholar by DOI or
* arXiv id, used to backfill fields left blank in Strapi. Semantic Scholar
* has no reliable thumbnail/cover source for papers, so unlike books this
* does not produce a cached image.
*/
export async function fetchPaperMetadata(identifier: {
doi?: string;
arxivId?: string;
}): Promise<PaperMetadata | undefined> {
const id = identifier.doi
? `DOI:${identifier.doi}`
: identifier.arxivId
? `ARXIV:${identifier.arxivId}`
: undefined;
if (!id) return undefined;
const res = await fetch(
`https://api.semanticscholar.org/graph/v1/paper/${encodeURIComponent(id)}?fields=title,authors`,
);
if (!res.ok) return undefined;
const data = (await res.json()) as {
title?: string;
authors?: { name: string }[];
};
return { title: data.title, authors: data.authors?.map((a) => a.name) };
}
+68 -10
View File
@@ -1,4 +1,5 @@
import { join } from "node:path";
import { fetchBookCover, fetchPaperMetadata } from "./bookshelf-covers";
import {
deleteEntry,
readAllEntries,
@@ -6,6 +7,8 @@ import {
writeEntry,
} from "./content-file";
const BOOKSHELF_UID = "api::bookshelf-entry.bookshelf-entry";
type Layout = "nested" | "flat";
interface SyncConfig {
@@ -136,7 +139,7 @@ function buildConfigs(): Record<string, SyncConfig> {
url: e.url,
cover_image: e.cover_image,
rating: e.rating,
status: e.status,
read_status: e.read_status,
date_started: toTomlDate(e.date_started as string),
date_finished: toTomlDate(e.date_finished as string),
taxonomies: { tags: e.tags ?? [] },
@@ -156,7 +159,7 @@ function buildConfigs(): Record<string, SyncConfig> {
url: fm.url,
cover_image: fm.cover_image,
rating: fm.rating,
status: fm.status,
read_status: fm.read_status,
date_started: isoDate(fm.date_started),
date_finished: isoDate(fm.date_finished),
tags: taxonomies?.tags ?? [],
@@ -263,6 +266,60 @@ export async function importContentFromDisk(strapi: {
}
}
interface DocumentsClient {
findOne: (params: {
documentId: string;
}) => Promise<Record<string, unknown> | null>;
update: (params: {
documentId: string;
data: Record<string, unknown>;
}) => Promise<Record<string, unknown>>;
}
/**
* Backfills a bookshelf entry's cover (books, via Open Library by ISBN) or
* missing title/authors (papers, via Semantic Scholar by DOI/arXiv id) the
* first time it's created/updated with an identifier but no cached cover.
* Runs once per entry: `cover_image` already set is treated as "done".
*/
async function enrichBookshelfEntry(
documents: (uid: string) => DocumentsClient,
entry: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (entry.cover_image) return entry;
const slug = entry.slug as string;
const patch: Record<string, unknown> = {};
try {
if (entry.kind === "book" && entry.isbn) {
const cover = await fetchBookCover(entry.isbn as string, slug);
if (cover) patch.cover_image = cover;
} else if (entry.kind === "paper" && (entry.doi || entry.arxiv_id)) {
const meta = await fetchPaperMetadata({
doi: entry.doi as string | undefined,
arxivId: entry.arxiv_id as string | undefined,
});
if (meta?.title && !entry.title) patch.title = meta.title;
if (
meta?.authors?.length &&
!(entry.authors as string[] | undefined)?.length
) {
patch.authors = meta.authors;
}
}
} catch {
// Network/API failures shouldn't block saving the entry itself.
return entry;
}
if (Object.keys(patch).length === 0) return entry;
return documents(BOOKSHELF_UID).update({
documentId: entry.documentId as string,
data: patch,
});
}
/** Registers the outbound (DB -> file) sync as a Document Service middleware. */
export function registerContentSync(strapi: {
documents: {
@@ -272,13 +329,7 @@ export function registerContentSync(strapi: {
next: () => Promise<unknown>,
) => Promise<unknown>,
) => void;
(
uid: string,
): {
findOne: (params: {
documentId: string;
}) => Promise<Record<string, unknown> | null>;
};
(uid: string): DocumentsClient;
};
log: { info: (msg: string) => void; error: (msg: string) => void };
}): void {
@@ -305,7 +356,14 @@ export function registerContentSync(strapi: {
result &&
typeof result === "object"
) {
writeEntryForConfig(config, result as Record<string, unknown>);
const entry =
ctx.uid === BOOKSHELF_UID
? await enrichBookshelfEntry(
strapi.documents,
result as Record<string, unknown>,
)
: (result as Record<string, unknown>);
writeEntryForConfig(config, entry);
} else if (ctx.action === "delete" && preDeleteSlug) {
deleteEntry(config.dir, preDeleteSlug, config.layout);
}
+1 -1
View File
@@ -484,8 +484,8 @@ export interface ApiBookshelfEntryBookshelfEntry
},
number
>;
read_status: Schema.Attribute.Enumeration<['reading', 'read', 'abandoned']>;
slug: Schema.Attribute.UID<'title'> & Schema.Attribute.Required;
status: Schema.Attribute.Enumeration<['reading', 'read', 'abandoned']>;
tags: Schema.Attribute.JSON;
title: Schema.Attribute.String & Schema.Attribute.Required;
updatedAt: Schema.Attribute.DateTime;