Start the rebuild: note schema, vault kit and data migration

Phase 1 of the functional design plan.

- Move the old site into legacy/, unchanged, for reference. It still
  builds from there until cutover.
- Add packages/schema: the one definition of all 20 note types, with
  field titles and descriptions, note and vault checks, and JSON Schema
  export so later editing tools and the MCP server share it.
- Add vault-kit: Obsidian templates generated from the schema, and the
  Site guide for 24.01_PersonalWebsite.
- Add tools/migrate: turns the legacy JSON, collections, store data and
  page text into 545 Site folder notes and 84 images, and checks the
  result with the schema.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 18:40:55 +00:00
parent 4ded4b277b
commit a2f723ca2d
324 changed files with 11538 additions and 7556 deletions
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env node
/**
* fetch-tmdb-art.mjs
* ---------------------------------------------------------------------------
* Support tool for the Movies and TV pages. For each favorite item (those with
* `favorite: true`) in src/data/media/movies.json and src/data/media/tv.json
* that is missing an `image`, it looks the title up on the TMDB API and
* downloads the poster to public/images/<kind>/<slug>.jpg, then writes the
* local public path back into the JSON.
*
* Idempotent: items that already have an `image` are skipped. Be gentle on TMDB
* — there's a small delay between requests.
*
* PREREQUISITES
* 1. Get a free TMDB API key: https://www.themoviedb.org/settings/api
* 2. Put it in a `.env` file at the project root:
* TMDB_API_KEY=your_key_here
* (`.env` is gitignored; `.env.example` is the template.)
*
* USAGE
* node scripts/fetch-tmdb-art.mjs # fetch both movies + tv
* node scripts/fetch-tmdb-art.mjs --kind movies # movies only
* node scripts/fetch-tmdb-art.mjs --kind tv # tv only
* node scripts/fetch-tmdb-art.mjs --force # re-fetch even if image set
* node scripts/fetch-tmdb-art.mjs --sleep 250 # ms between TMDB calls
* node scripts/fetch-tmdb-art.mjs --help
*
* Run with `--env-file=.env` to auto-load the key (Node 20.6+):
* node --env-file=.env scripts/fetch-tmdb-art.mjs
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '..');
const DATA_DIR = join(PROJECT_ROOT, 'src', 'data', 'media');
const IMAGES_ROOT = join(PROJECT_ROOT, 'public', 'images');
const TMDB_BASE = 'https://api.themoviedb.org/3';
const TMDB_IMG = 'https://image.tmdb.org/t/p/w500';
// --- args -------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) {
console.log(`Usage: node scripts/fetch-tmdb-art.mjs [options]
Reads movies.json + tv.json favorites and fills in the "image" field for any
favorite missing one, using the TMDB API (free key required).
Options:
--kind <k> only fetch one kind: "movies" or "tv" (default: both)
--force re-fetch even for items that already have an image
--sleep <ms> delay between TMDB API calls (default 250)
-h, --help show this help
Tip: run with \`node --env-file=.env scripts/fetch-tmdb-art.mjs ...\` to auto-load
the TMDB_API_KEY from your .env file.`);
process.exit(0);
}
const force = args.includes('--force');
const sleepMsIdx = args.indexOf('--sleep');
const sleepMs = sleepMsIdx !== -1 ? Number(args[sleepMsIdx + 1]) || 250 : 250;
const kindIdx = args.indexOf('--kind');
const kindFilter = kindIdx !== -1 ? args[kindIdx + 1] : null;
if (kindFilter && !['movies', 'tv'].includes(kindFilter)) {
console.error(`✖ Unknown --kind "${kindFilter}". Use "movies" or "tv".`);
process.exit(1);
}
const API_KEY = process.env.TMDB_API_KEY;
if (!API_KEY) {
console.error(
'✖ TMDB_API_KEY is not set. Add it to .env (see .env.example) and run with\n' +
' node --env-file=.env scripts/fetch-tmdb-art.mjs',
);
process.exit(1);
}
// --- helpers ----------------------------------------------------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const slugify = (s) =>
s
.toLowerCase()
.replace(/['’.:()]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
/** Search TMDB for a title, returning the best result's poster path. */
async function fetchPoster(title, kind, year) {
const mediaType = kind === 'movies' ? 'movie' : 'tv';
const params = new URLSearchParams({
api_key: API_KEY,
query: title,
include_adult: 'false',
});
if (year) {
// TMDB uses different year param names per type.
params.set(mediaType === 'movie' ? 'year' : 'first_air_date_year', String(year));
}
const url = `${TMDB_BASE}/search/${mediaType}?${params}`;
const res = await fetch(url);
if (res.status === 429) {
console.warn(' ! rate-limited by TMDB (429). Wait and re-run; skipping rest.');
return { rateLimited: true };
}
if (!res.ok) throw new Error(`search ${res.status} ${res.statusText}`);
const data = await res.json();
const result = data.results?.find((r) => r.poster_path) ?? data.results?.[0];
if (!result?.poster_path) return null;
return { posterPath: result.poster_path, name: result.title ?? result.name };
}
// --- per-kind processing ----------------------------------------------------
async function processKind(kind) {
const DATA_FILE = join(DATA_DIR, `${kind}.json`);
const OUT_DIR = join(IMAGES_ROOT, kind);
const PUBLIC_PREFIX = `/images/${kind}`;
let items;
try {
items = JSON.parse(await readFile(DATA_FILE, 'utf8'));
} catch (err) {
if (err?.code === 'ENOENT') {
console.warn(` ! ${DATA_FILE} not found; skipping ${kind}.`);
return;
}
throw err;
}
const favorites = items.filter((i) => i.favorite);
console.log(
`\n→ ${kind}: ${favorites.length} favorite(s) in ${kind}.json` +
(force ? ' (force re-fetch)' : ''),
);
await mkdir(OUT_DIR, { recursive: true });
let changed = false;
for (let i = 0; i < favorites.length; i++) {
const item = favorites[i];
if (item.image && !force) {
console.log(` ✓ ${item.title} — already has image, skipping.`);
continue;
}
if (i > 0) await sleep(sleepMs);
console.log(`→ [${i + 1}/${favorites.length}] ${item.title}`);
let result;
try {
result = await fetchPoster(item.title, kind, item.year);
} catch (err) {
console.warn(` ! network error: ${err?.message ?? err}; skipping.`);
continue;
}
if (result?.rateLimited) break;
if (!result?.posterPath) {
console.warn(` ! no poster found for "${item.title}"; skipping.`);
continue;
}
const imageUrl = `${TMDB_IMG}${result.posterPath}`;
const fileName = `${slugify(item.title)}.jpg`;
let buf;
try {
const imgRes = await fetch(imageUrl);
if (!imgRes.ok) throw new Error(`${imgRes.status} ${imgRes.statusText}`);
buf = Buffer.from(await imgRes.arrayBuffer());
} catch (err) {
console.warn(` ! image download failed (${err?.message ?? err}); skipping.`);
continue;
}
await writeFile(join(OUT_DIR, fileName), buf);
item.image = `${PUBLIC_PREFIX}/${fileName}`;
// Write the path back onto the original array entry (same object reference).
const orig = items.find((o) => o.title === item.title && o.favorite);
if (orig) orig.image = item.image;
changed = true;
console.log(` ✔ ${item.title} → ${item.image}`);
}
if (changed) {
await writeFile(DATA_FILE, JSON.stringify(items, null, 2) + '\n', 'utf8');
console.log(`✔ Updated ${DATA_FILE}`);
} else {
console.log(`— Nothing to update for ${kind} (all favorites already had images).`);
}
}
// --- main -------------------------------------------------------------------
const kinds = kindFilter ? [kindFilter] : ['movies', 'tv'];
for (const k of kinds) {
await processKind(k);
}