Files
andwati.com/apps/cms/src/utils/content-sync.ts
T
andwati 22302d8db9 Fix: a manually-set cover_image URL was never cached locally
The enrichment guard was `if (entry.cover_image) return entry;` —
meaning once *any* cover_image was set, including a raw external URL
you pasted into Strapi by hand, it was treated as "already done" and
never downloaded. The site's coverExists() check only looks for local
/covers/... files, so a remote URL silently never rendered.

- content-sync.ts: enrichBookshelfEntry now branches on whether
  cover_image is an http(s) URL (cache it via the new
  cacheRemoteImage) vs. unset (existing ISBN/DOI lookup) vs. already
  a local path (left alone).
- bookshelf-covers.ts: cacheRemoteImage() downloads and caches any
  image URL. Also fixes a real bug caught while testing this against
  the actual O'Reilly cover URL from the reported entry: their CDN
  labels WebP responses as Content-Type: image/jpeg, so trusting the
  header would've cached it with the wrong extension. Now sniffs the
  real file signature (RIFF/WEBP, PNG, JPEG, GIF magic bytes) instead
  of trusting either the header or the URL.

Verified the download+sniff logic directly against the real cover URL
from the reported entry (Designing Data-Intensive Applications) —
correctly detects and saves as .webp. Didn't touch the running dev
Strapi/DB while testing.
2026-08-31 08:50:16 +03:00

390 lines
11 KiB
TypeScript

import { join } from "node:path";
import {
cacheRemoteImage,
fetchBookCover,
fetchPaperMetadata,
} from "./bookshelf-covers";
import {
deleteEntry,
readAllEntries,
toTomlDate,
writeEntry,
} from "./content-file";
const BOOKSHELF_UID = "api::bookshelf-entry.bookshelf-entry";
type Layout = "nested" | "flat";
interface SyncConfig {
uid: string;
dir: string;
layout: Layout;
/** Strapi entry -> { frontmatter, body } written to disk. */
toFile: (entry: Record<string, unknown>) => {
frontmatter: Record<string, unknown>;
body: string;
};
/** Parsed file -> Strapi entry fields, for importing content/ into Strapi on boot. */
toEntry: (
slug: string,
frontmatter: Record<string, unknown>,
body: string,
) => Record<string, unknown>;
}
const isoDate = (value: unknown): string | undefined =>
value instanceof Date
? value.toISOString().slice(0, 10)
: typeof value === "string"
? value
: undefined;
function contentRoot(): string {
// apps/cms -> repo root/content (also the mount point in the dev container)
return join(process.cwd(), "..", "..", "content");
}
function buildConfigs(): Record<string, SyncConfig> {
const root = contentRoot();
const writing: SyncConfig = {
uid: "api::writing.writing",
dir: join(root, "writings"),
layout: "nested",
toFile: (e) => ({
frontmatter: {
title: e.title,
description: e.description,
author: e.author,
date: toTomlDate(e.date as string),
updated: toTomlDate(e.updated as string),
draft: e.draft ?? false,
canonical_url: e.canonical_url,
taxonomies: { tags: e.tags ?? [] },
extra: {
series: e.series,
series_index: e.series_index,
llms_description: e.llms_description,
},
},
body: (e.body as string) ?? "",
}),
toEntry: (slug, fm, body) => {
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
const extra = fm.extra as
| { series?: string; series_index?: number; llms_description?: string }
| undefined;
return {
slug,
title: fm.title,
description: fm.description,
author: fm.author,
date: isoDate(fm.date),
updated: isoDate(fm.updated),
draft: fm.draft ?? false,
canonical_url: fm.canonical_url,
tags: taxonomies?.tags ?? [],
series: extra?.series,
series_index: extra?.series_index,
llms_description: extra?.llms_description,
body,
};
},
};
const portfolioEntry: SyncConfig = {
uid: "api::portfolio-entry.portfolio-entry",
dir: join(root, "portfolio"),
layout: "flat",
toFile: (e) => ({
frontmatter: {
title: e.title,
description: e.description,
role: e.role,
date_start: toTomlDate(e.date_start as string),
date_end: toTomlDate(e.date_end as string),
outcome: e.outcome,
draft: e.draft ?? false,
links: e.links ?? [],
taxonomies: { tags: e.tags ?? [] },
},
body: (e.body as string) ?? "",
}),
toEntry: (slug, fm, body) => {
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
return {
slug,
title: fm.title,
description: fm.description,
role: fm.role,
date_start: isoDate(fm.date_start),
date_end: isoDate(fm.date_end),
outcome: fm.outcome,
draft: fm.draft ?? false,
links: fm.links ?? [],
tags: taxonomies?.tags ?? [],
body,
};
},
};
const bookshelfEntry: SyncConfig = {
uid: "api::bookshelf-entry.bookshelf-entry",
dir: join(root, "bookshelf"),
layout: "flat",
toFile: (e) => ({
frontmatter: {
title: e.title,
kind: e.kind,
authors: e.authors ?? [],
isbn: e.isbn,
doi: e.doi,
arxiv_id: e.arxiv_id,
url: e.url,
cover_image: e.cover_image,
rating: e.rating,
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 ?? [] },
},
body: (e.body as string) ?? "",
}),
toEntry: (slug, fm, body) => {
const taxonomies = fm.taxonomies as { tags?: string[] } | undefined;
return {
slug,
title: fm.title,
kind: fm.kind,
authors: fm.authors ?? [],
isbn: fm.isbn,
doi: fm.doi,
arxiv_id: fm.arxiv_id,
url: fm.url,
cover_image: fm.cover_image,
rating: fm.rating,
read_status: fm.read_status,
date_started: isoDate(fm.date_started),
date_finished: isoDate(fm.date_finished),
tags: taxonomies?.tags ?? [],
body,
};
},
};
const curatedBlog: SyncConfig = {
uid: "api::curated-blog.curated-blog",
dir: join(root, "blogs"),
layout: "flat",
toFile: (e) => ({
frontmatter: {
title: e.title,
url: e.url,
feed_url: e.feed_url,
},
body: (e.body as string) ?? "",
}),
toEntry: (slug, fm, body) => ({
slug,
title: fm.title,
url: fm.url,
feed_url: fm.feed_url,
body,
}),
};
return {
[writing.uid]: writing,
[portfolioEntry.uid]: portfolioEntry,
[bookshelfEntry.uid]: bookshelfEntry,
[curatedBlog.uid]: curatedBlog,
};
}
export const SYNC_CONFIGS = buildConfigs();
/** Guards the outbound (DB -> file) middleware while the inbound (file -> DB) import runs. */
let importing = false;
function writeEntryForConfig(
config: SyncConfig,
entry: Record<string, unknown>,
): void {
const slug = entry.slug as string | undefined;
if (!slug) return;
const { frontmatter, body } = config.toFile(entry);
writeEntry(config.dir, slug, config.layout, frontmatter, body);
}
/**
* On boot, upserts every file under content/<type>/ into Strapi so the admin
* UI reflects on-disk edits made outside Strapi. Strapi's DB stays a
* disposable cache: this can always rebuild it from content/ alone.
*/
export async function importContentFromDisk(strapi: {
documents: (uid: string) => {
findFirst: (params: {
filters: Record<string, unknown>;
}) => Promise<Record<string, unknown> | null>;
create: (params: { data: Record<string, unknown> }) => Promise<unknown>;
update: (params: {
documentId: string;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
log: { info: (msg: string) => void; error: (msg: string) => void };
}): Promise<void> {
importing = true;
try {
for (const config of Object.values(SYNC_CONFIGS)) {
const files = readAllEntries(config.dir);
let created = 0;
let updated = 0;
for (const { slug, frontmatter, body } of files) {
const data = config.toEntry(slug, frontmatter, body);
try {
const existing = await strapi
.documents(config.uid)
.findFirst({ filters: { slug } });
if (existing) {
await strapi
.documents(config.uid)
.update({ documentId: existing.documentId as string, data });
updated += 1;
} else {
await strapi.documents(config.uid).create({ data });
created += 1;
}
} catch (err) {
strapi.log.error(
`content-sync: failed to import ${config.dir}/${slug}: ${(err as Error).message}`,
);
}
}
strapi.log.info(
`content-sync: imported ${config.uid} (${created} created, ${updated} updated, ${files.length} total)`,
);
}
} finally {
importing = false;
}
}
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 — either by caching whatever URL is
* already in `cover_image` (e.g. one pasted in by hand, or an editor's own
* hosted cover) locally, or by looking one up (books via Open Library by
* ISBN) when there's no cover_image at all yet — and, for papers, missing
* title/authors via Semantic Scholar by DOI/arXiv id. Runs once per entry:
* a `cover_image` already pointing at a local /covers/... path is treated
* as "done" and left alone.
*/
async function enrichBookshelfEntry(
documents: (uid: string) => DocumentsClient,
entry: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const slug = entry.slug as string;
const coverImage = entry.cover_image as string | undefined;
const patch: Record<string, unknown> = {};
try {
if (coverImage && /^https?:\/\//i.test(coverImage)) {
const cached = await cacheRemoteImage(coverImage, slug);
if (cached) patch.cover_image = cached;
} else if (!coverImage) {
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: {
use: (
middleware: (
ctx: { uid: string; action: string; params: Record<string, unknown> },
next: () => Promise<unknown>,
) => Promise<unknown>,
) => void;
(uid: string): DocumentsClient;
};
log: { info: (msg: string) => void; error: (msg: string) => void };
}): void {
strapi.documents.use(async (ctx, next) => {
const config = SYNC_CONFIGS[ctx.uid];
if (!config || importing) return next();
let preDeleteSlug: string | undefined;
if (ctx.action === "delete") {
const documentId = (ctx.params as { documentId?: string }).documentId;
if (documentId) {
const existing = await strapi
.documents(ctx.uid)
.findOne({ documentId });
preDeleteSlug = existing?.slug as string | undefined;
}
}
const result = await next();
try {
if (
(ctx.action === "create" || ctx.action === "update") &&
result &&
typeof result === "object"
) {
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);
}
} catch (err) {
strapi.log.error(
`content-sync: failed to write ${ctx.uid} to disk: ${(err as Error).message}`,
);
}
return result;
});
}