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
+16 -8
View File
@@ -27,14 +27,22 @@ approved plan for full rationale on each decision referenced below.
## Content pipeline ## Content pipeline
- [ ] Strapi custom provider/lifecycle hooks that read + write the same - [x] Strapi custom provider/lifecycle hooks that read + write the same
Markdown files in `content/` (Strapi's DB stays a disposable cache) Markdown files in `content/` (Strapi's DB stays a disposable cache) —
- [ ] Verify round-trip: edit in Strapi admin → file on disk updates, and `apps/cms/src/utils/content-{file,sync}.ts`, wired in `src/index.ts`
vice versa, without drift - [x] Verify round-trip: edit in Strapi admin → file on disk updates, and
- [ ] Bookshelf ISBN lookup hook (Open Library / Google Books) with local vice versa, without drift — verified create/update/delete via the
cover caching content-manager API, plus idempotent re-import across a restart
- [ ] Bookshelf paper lookup hook (Semantic Scholar / arXiv) with local - [x] Bookshelf ISBN lookup hook with local cover caching —
cover/thumbnail caching `apps/cms/src/utils/bookshelf-covers.ts` fetches from Open Library by
ISBN and caches into `apps/site/public/covers/`; verified end-to-end
(real cover downloaded, `cover_image` synced to file, cleaned up on
delete)
- [x] Bookshelf paper lookup hook — Semantic Scholar backfills title/authors
by DOI/arXiv id when missing. No cover/thumbnail: neither Semantic
Scholar nor arXiv offers a reliable image source for papers, so
`cover_image` stays manually-set for papers (or gets one later from
the OG-image pipeline below)
- [ ] OG image auto-generation extended from the legacy per-post script to - [ ] OG image auto-generation extended from the legacy per-post script to
all three content types (writings, portfolio, bookshelf) all three content types (writings, portfolio, bookshelf)
- [ ] `llms.txt` generation extended to cover all content types, not just - [ ] `llms.txt` generation extended to cover all content types, not just
@@ -55,7 +55,7 @@
"min": 1, "min": 1,
"max": 5 "max": 5
}, },
"status": { "read_status": {
"type": "enumeration", "type": "enumeration",
"enum": ["reading", "read", "abandoned"] "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 { join } from "node:path";
import { fetchBookCover, fetchPaperMetadata } from "./bookshelf-covers";
import { import {
deleteEntry, deleteEntry,
readAllEntries, readAllEntries,
@@ -6,6 +7,8 @@ import {
writeEntry, writeEntry,
} from "./content-file"; } from "./content-file";
const BOOKSHELF_UID = "api::bookshelf-entry.bookshelf-entry";
type Layout = "nested" | "flat"; type Layout = "nested" | "flat";
interface SyncConfig { interface SyncConfig {
@@ -136,7 +139,7 @@ function buildConfigs(): Record<string, SyncConfig> {
url: e.url, url: e.url,
cover_image: e.cover_image, cover_image: e.cover_image,
rating: e.rating, rating: e.rating,
status: e.status, read_status: e.read_status,
date_started: toTomlDate(e.date_started as string), date_started: toTomlDate(e.date_started as string),
date_finished: toTomlDate(e.date_finished as string), date_finished: toTomlDate(e.date_finished as string),
taxonomies: { tags: e.tags ?? [] }, taxonomies: { tags: e.tags ?? [] },
@@ -156,7 +159,7 @@ function buildConfigs(): Record<string, SyncConfig> {
url: fm.url, url: fm.url,
cover_image: fm.cover_image, cover_image: fm.cover_image,
rating: fm.rating, rating: fm.rating,
status: fm.status, read_status: fm.read_status,
date_started: isoDate(fm.date_started), date_started: isoDate(fm.date_started),
date_finished: isoDate(fm.date_finished), date_finished: isoDate(fm.date_finished),
tags: taxonomies?.tags ?? [], 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. */ /** Registers the outbound (DB -> file) sync as a Document Service middleware. */
export function registerContentSync(strapi: { export function registerContentSync(strapi: {
documents: { documents: {
@@ -272,13 +329,7 @@ export function registerContentSync(strapi: {
next: () => Promise<unknown>, next: () => Promise<unknown>,
) => Promise<unknown>, ) => Promise<unknown>,
) => void; ) => void;
( (uid: string): DocumentsClient;
uid: string,
): {
findOne: (params: {
documentId: string;
}) => Promise<Record<string, unknown> | null>;
};
}; };
log: { info: (msg: string) => void; error: (msg: string) => void }; log: { info: (msg: string) => void; error: (msg: string) => void };
}): void { }): void {
@@ -305,7 +356,14 @@ export function registerContentSync(strapi: {
result && result &&
typeof result === "object" 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) { } else if (ctx.action === "delete" && preDeleteSlug) {
deleteEntry(config.dir, preDeleteSlug, config.layout); deleteEntry(config.dir, preDeleteSlug, config.layout);
} }
+1 -1
View File
@@ -484,8 +484,8 @@ export interface ApiBookshelfEntryBookshelfEntry
}, },
number number
>; >;
read_status: Schema.Attribute.Enumeration<['reading', 'read', 'abandoned']>;
slug: Schema.Attribute.UID<'title'> & Schema.Attribute.Required; slug: Schema.Attribute.UID<'title'> & Schema.Attribute.Required;
status: Schema.Attribute.Enumeration<['reading', 'read', 'abandoned']>;
tags: Schema.Attribute.JSON; tags: Schema.Attribute.JSON;
title: Schema.Attribute.String & Schema.Attribute.Required; title: Schema.Attribute.String & Schema.Attribute.Required;
updatedAt: Schema.Attribute.DateTime; updatedAt: Schema.Attribute.DateTime;
+1 -1
View File
@@ -58,7 +58,7 @@ const bookshelf = defineCollection({
url: z.string().optional(), url: z.string().optional(),
cover_image: z.string().optional(), cover_image: z.string().optional(),
rating: z.number().optional(), rating: z.number().optional(),
status: z.enum(["reading", "read", "abandoned"]).optional(), read_status: z.enum(["reading", "read", "abandoned"]).optional(),
date_started: z.coerce.date().optional(), date_started: z.coerce.date().optional(),
date_finished: z.coerce.date().optional(), date_finished: z.coerce.date().optional(),
taxonomies, taxonomies,
+1 -1
View File
@@ -19,7 +19,7 @@ const items = (await getCollection("bookshelf")).sort(
</h3> </h3>
<p class="meta">{entry.data.authors.join(", ")}</p> <p class="meta">{entry.data.authors.join(", ")}</p>
{entry.data.rating && <p class="meta">{"★".repeat(entry.data.rating)}</p>} {entry.data.rating && <p class="meta">{"★".repeat(entry.data.rating)}</p>}
{entry.data.status && <p class="meta">{entry.data.status}</p>} {entry.data.read_status && <p class="meta">{entry.data.read_status}</p>}
{entry.data.kind === "book" && entry.data.isbn && <p class="meta">ISBN {entry.data.isbn}</p>} {entry.data.kind === "book" && entry.data.isbn && <p class="meta">ISBN {entry.data.isbn}</p>}
{entry.data.kind === "paper" && entry.data.arxiv_id && ( {entry.data.kind === "paper" && entry.data.arxiv_id && (
<p class="meta"> <p class="meta">
+12 -4
View File
@@ -59,9 +59,17 @@ Body is a free-form Markdown case study.
## `bookshelf/` (books *and* papers) ## `bookshelf/` (books *and* papers)
`kind` selects which identifier fields apply — books use `isbn`, papers use `kind` selects which identifier fields apply — books use `isbn`, papers use
`doi`/`arxiv_id`. Cover images are fetched by a lookup (Open Library/Google `doi`/`arxiv_id`. Book covers are fetched from Open Library by ISBN and
Books for ISBNs; Semantic Scholar/arXiv for papers) and cached under cached into `apps/site/public/covers/` (served at `/covers/...`), never
`static/covers/`, never fetched at page-render time. fetched at page-render time. Papers have no reliable cover source, so
`cover_image` is set manually for them, if at all; Semantic Scholar is only
used to backfill a missing title/authors by DOI/arXiv id.
Note: the field is named `read_status`, not `status` — 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 breaks writes. Don't rename it
back.
```toml ```toml
+++ +++
@@ -74,7 +82,7 @@ arxiv_id = "2101.00001" # papers only, optional
url = "https://..." # papers only: landing page / source link url = "https://..." # papers only: landing page / source link
cover_image = "/covers/slug.jpg" cover_image = "/covers/slug.jpg"
rating = 5 # optional, 1-5 rating = 5 # optional, 1-5
status = "read" # "reading" | "read" | "abandoned" read_status = "read" # "reading" | "read" | "abandoned"
date_started = 2026-01-01 date_started = 2026-01-01
date_finished = 2026-01-20 # optional, omit if still in progress date_finished = 2026-01-20 # optional, omit if still in progress
[taxonomies] [taxonomies]
@@ -7,7 +7,7 @@ arxiv_id = "1706.03762"
url = "https://arxiv.org/abs/1706.03762" url = "https://arxiv.org/abs/1706.03762"
cover_image = "/covers/attention-is-all-you-need.jpg" cover_image = "/covers/attention-is-all-you-need.jpg"
rating = 5 rating = 5
status = "read" read_status = "read"
date_started = 2026-02-01 date_started = 2026-02-01
date_finished = 2026-02-03 date_finished = 2026-02-03
[taxonomies] [taxonomies]
@@ -5,7 +5,7 @@ authors = ["Harold Abelson", "Gerald Jay Sussman"]
isbn = "9780262510875" isbn = "9780262510875"
cover_image = "/covers/structure-and-interpretation-of-computer-programs.jpg" cover_image = "/covers/structure-and-interpretation-of-computer-programs.jpg"
rating = 5 rating = 5
status = "read" read_status = "read"
date_started = 2026-01-01 date_started = 2026-01-01
date_finished = 2026-01-20 date_finished = 2026-01-20
[taxonomies] [taxonomies]