From 8f9a7eda2fe9860f3916ead3596ea7645151cc75 Mon Sep 17 00:00:00 2001 From: Ryan Chou Date: Sun, 22 Mar 2026 20:09:23 -0700 Subject: [PATCH] add: meal ingestion CLI for images and metadata --- README.md | 17 ++- package.json | 1 + scripts/build.js | 13 +- scripts/generate-thumbnails.js | 37 ++++-- scripts/ingest-meal.js | 225 +++++++++++++++++++++++++++++++++ scripts/lib/meals.js | 27 ++++ 6 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 scripts/ingest-meal.js diff --git a/README.md b/README.md index 89329c2..d64a125 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The site is based on the HTML5 UP Lens template and currently ships as a plain s - `data/meals.json`: source of truth for gallery entries - `scripts/build.js`: renders static pages from templates and data - `scripts/generate-thumbnails.js`: regenerates thumbnails from the full-size images +- `scripts/ingest-meal.js`: ingests a new meal image and metadata in one command - `package.json`: minimal Node build entrypoint ## Content Workflow @@ -28,6 +29,17 @@ npm run build The build currently renders the main page without changing the existing Lens gallery structure, so the current client-side viewer code continues to work. +To ingest a new meal image and update the site in one command, run: + +```sh +npm run ingest -- --image /path/to/photo.jpg --title "meal title" --description "notes" +``` + +Optional ingestion flags: + +- `--position "left center"` sets the viewer image alignment +- `--focus-x 0.35 --focus-y 0.45` sets the thumbnail crop focal point + If you only need to regenerate thumbnails, run: ```sh @@ -73,6 +85,5 @@ The `x` and `y` values are normalized from `0` to `1`, where `0.5, 0.5` is the c ## Planned Features -1. Automatic image ingestion, potentially with a stronger data model if the static workflow becomes too limiting. -2. An Elo-style ranking page that shows two food images at a time and updates rankings automatically based on the selected winner. -3. General cleanup and history cleanup once the bigger structural changes are in place. +1. An Elo-style ranking page that shows two food images at a time and updates rankings automatically based on the selected winner. +2. General cleanup and history cleanup once the bigger structural changes are in place. diff --git a/package.json b/package.json index 7b9e0ac..843a8a0 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "scripts": { "build": "npm run build:thumbs && npm run build:pages", + "ingest": "node scripts/ingest-meal.js", "build:pages": "node scripts/build.js", "build:thumbs": "node scripts/generate-thumbnails.js", "build:thumbs:force": "node scripts/generate-thumbnails.js --force" diff --git a/scripts/build.js b/scripts/build.js index b22aabe..9a06b6d 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -63,4 +63,15 @@ function main() { writeFile(indexOutputPath, buildIndex()); } -main(); +function buildPages() { + main(); +} + +if (require.main === module) { + buildPages(); +} + +module.exports = { + buildPages, + buildIndex, +}; diff --git a/scripts/generate-thumbnails.js b/scripts/generate-thumbnails.js index efb539b..a61ca3a 100644 --- a/scripts/generate-thumbnails.js +++ b/scripts/generate-thumbnails.js @@ -182,6 +182,18 @@ function writeManifest(manifest) { async function main() { const options = parseArgs(process.argv.slice(2)); + const summary = await buildThumbnails(options); + + console.log( + `Thumbnail build complete: ${summary.generated} generated, ${summary.skipped} skipped, ${summary.removed} removed` + ); +} + +async function buildThumbnails(options = {}) { + const settings = { + force: false, + ...options, + }; const meals = loadMeals(); const manifest = loadManifest(); const nextManifest = {}; @@ -194,7 +206,7 @@ async function main() { let skipped = 0; for (const meal of meals) { - const result = await generateThumbnail(meal, manifest, options); + const result = await generateThumbnail(meal, manifest, settings); nextManifest[getManifestKey(meal.id)] = result.manifestEntry; if (result.changed) { @@ -206,12 +218,21 @@ async function main() { writeManifest(nextManifest); - console.log( - `Thumbnail build complete: ${generated} generated, ${skipped} skipped, ${removed} removed` - ); + return { + generated, + removed, + skipped, + total: meals.length, + }; } -main().catch((error) => { - console.error(error.message); - process.exitCode = 1; -}); +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} + +module.exports = { + buildThumbnails, +}; diff --git a/scripts/ingest-meal.js b/scripts/ingest-meal.js new file mode 100644 index 0000000..baac156 --- /dev/null +++ b/scripts/ingest-meal.js @@ -0,0 +1,225 @@ +const fs = require("fs"); +const path = require("path"); +const sharp = require("sharp"); + +const { buildPages } = require("./build"); +const { buildThumbnails } = require("./generate-thumbnails"); +const { + getNextMealId, + loadMeals, + mealsPath, + repoRoot, + saveMeals, +} = require("./lib/meals"); + +const fullsDir = path.join(repoRoot, "images", "fulls"); +const thumbsDir = path.join(repoRoot, "images", "thumbs"); +const indexPath = path.join(repoRoot, "index.html"); +const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json"); + +const FULL_IMAGE_QUALITY = 90; + +function printHelp() { + console.log(`Usage: + npm run ingest -- --image --title --description <text> [options] + +Required: + --image <path> Source image to ingest + --title <title> Meal title + --description <text> Meal description + +Optional: + --position <value> Viewer background position, e.g. "left center" + --focus-x <0..1> Thumbnail crop focal point x coordinate + --focus-y <0..1> Thumbnail crop focal point y coordinate + --help Show this help message +`); +} + +function parseArgs(argv) { + const options = {}; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + + if (!arg.startsWith("--")) { + throw new Error(`Unexpected argument "${arg}"`); + } + + const key = arg.slice(2); + const value = argv[index + 1]; + + if (value === undefined || value.startsWith("--")) { + throw new Error(`Missing value for "${arg}"`); + } + + options[key] = value; + index += 1; + } + + return options; +} + +function parseFocusValue(value, axis) { + const parsed = Number.parseFloat(value); + + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) { + throw new Error(`--focus-${axis} must be a number between 0 and 1`); + } + + return parsed; +} + +function buildMealFromOptions(id, options) { + const meal = { + id, + title: options.title, + description: options.description, + }; + + if (options.position) { + meal.position = options.position; + } + + if (options["focus-x"] !== undefined || options["focus-y"] !== undefined) { + if (options["focus-x"] === undefined || options["focus-y"] === undefined) { + throw new Error("Both --focus-x and --focus-y must be provided together"); + } + + meal.thumbnail = { + focus: { + x: parseFocusValue(options["focus-x"], "x"), + y: parseFocusValue(options["focus-y"], "y"), + }, + }; + } + + return meal; +} + +function getResolvedSourcePath(imageArg) { + const resolvedPath = path.resolve(process.cwd(), imageArg); + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Source image not found: ${resolvedPath}`); + } + + if (!fs.statSync(resolvedPath).isFile()) { + throw new Error(`Source image is not a file: ${resolvedPath}`); + } + + return resolvedPath; +} + +async function writeFullImage(sourcePath, destinationPath) { + await sharp(sourcePath) + .rotate() + .jpeg({ quality: FULL_IMAGE_QUALITY, mozjpeg: true }) + .toFile(destinationPath); +} + +async function rollback({ + createdFullPath, + createdThumbPath, + previousIndex, + previousManifest, + previousMeals, +}) { + if (previousMeals !== undefined) { + fs.writeFileSync(mealsPath, previousMeals); + } + + if (previousIndex !== undefined) { + fs.writeFileSync(indexPath, previousIndex); + } + + if (previousManifest === null) { + if (fs.existsSync(manifestPath)) { + await fs.promises.unlink(manifestPath); + } + } else if (previousManifest !== undefined) { + fs.writeFileSync(manifestPath, previousManifest); + } + + if (createdThumbPath && fs.existsSync(createdThumbPath)) { + await fs.promises.unlink(createdThumbPath); + } + + if (createdFullPath && fs.existsSync(createdFullPath)) { + await fs.promises.unlink(createdFullPath); + } +} + +async function ingestMeal(options) { + const sourcePath = getResolvedSourcePath(options.image); + const meals = loadMeals(); + const nextId = getNextMealId(meals); + const meal = buildMealFromOptions(nextId, options); + + const fullPath = path.join(fullsDir, `${nextId}.jpg`); + const thumbPath = path.join(thumbsDir, `${nextId}.jpg`); + + if (fs.existsSync(fullPath) || fs.existsSync(thumbPath)) { + throw new Error(`Meal id ${nextId} already has generated image files`); + } + + const previousMeals = fs.readFileSync(mealsPath, "utf8"); + const previousIndex = fs.readFileSync(indexPath, "utf8"); + const previousManifest = fs.existsSync(manifestPath) + ? fs.readFileSync(manifestPath, "utf8") + : null; + + await fs.promises.mkdir(fullsDir, { recursive: true }); + + try { + await writeFullImage(sourcePath, fullPath); + saveMeals([...meals, meal]); + await buildThumbnails(); + buildPages(); + } catch (error) { + await rollback({ + createdFullPath: fullPath, + createdThumbPath: thumbPath, + previousIndex, + previousManifest, + previousMeals, + }); + throw error; + } + + return meal; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + if (options.help) { + printHelp(); + return; + } + + for (const field of ["image", "title", "description"]) { + if (!options[field]) { + throw new Error(`Missing required option "--${field}"`); + } + } + + const meal = await ingestMeal(options); + console.log(`Ingested meal ${meal.id}: ${meal.title}`); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} + +module.exports = { + ingestMeal, +}; diff --git a/scripts/lib/meals.js b/scripts/lib/meals.js index 22e7303..a2a866d 100644 --- a/scripts/lib/meals.js +++ b/scripts/lib/meals.js @@ -60,6 +60,10 @@ function validateMeals(meals) { } } + if (!/^\d+$/.test(meal.id)) { + throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`); + } + if (meal.position !== undefined && typeof meal.position !== "string") { throw new Error(`Meal ${index} has a non-string "position" value`); } @@ -82,8 +86,31 @@ function loadMeals() { return meals; } +function saveMeals(meals) { + validateMeals(meals); + fs.writeFileSync(mealsPath, `${JSON.stringify(meals, null, 2)}\n`); +} + +function getNextMealId(meals) { + if (meals.length === 0) { + return "01"; + } + + const nextNumber = + Math.max(...meals.map((meal) => Number.parseInt(meal.id, 10))) + 1; + const idWidth = Math.max( + 2, + ...meals.map((meal) => meal.id.length), + String(nextNumber).length + ); + + return String(nextNumber).padStart(idWidth, "0"); +} + module.exports = { + getNextMealId, loadMeals, mealsPath, repoRoot, + saveMeals, };