mirror of
https://github.com/andwati/andwati.com.git
synced 2026-10-18 01:12:53 +00:00
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:
@@ -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"]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user