add: meal ingestion CLI for images and metadata
All checks were successful
Deploy on push / deploy (push) Has been skipped

This commit is contained in:
2026-03-22 20:09:23 -07:00
parent 21c3a0c4b2
commit 8f9a7eda2f
6 changed files with 308 additions and 12 deletions

View File

@@ -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 - `data/meals.json`: source of truth for gallery entries
- `scripts/build.js`: renders static pages from templates and data - `scripts/build.js`: renders static pages from templates and data
- `scripts/generate-thumbnails.js`: regenerates thumbnails from the full-size images - `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 - `package.json`: minimal Node build entrypoint
## Content Workflow ## 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. 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: If you only need to regenerate thumbnails, run:
```sh ```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 ## Planned Features
1. Automatic image ingestion, potentially with a stronger data model if the static workflow becomes too limiting. 1. An Elo-style ranking page that shows two food images at a time and updates rankings automatically based on the selected winner.
2. 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.
3. General cleanup and history cleanup once the bigger structural changes are in place.

View File

@@ -3,6 +3,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "npm run build:thumbs && npm run build:pages", "build": "npm run build:thumbs && npm run build:pages",
"ingest": "node scripts/ingest-meal.js",
"build:pages": "node scripts/build.js", "build:pages": "node scripts/build.js",
"build:thumbs": "node scripts/generate-thumbnails.js", "build:thumbs": "node scripts/generate-thumbnails.js",
"build:thumbs:force": "node scripts/generate-thumbnails.js --force" "build:thumbs:force": "node scripts/generate-thumbnails.js --force"

View File

@@ -63,4 +63,15 @@ function main() {
writeFile(indexOutputPath, buildIndex()); writeFile(indexOutputPath, buildIndex());
} }
main(); function buildPages() {
main();
}
if (require.main === module) {
buildPages();
}
module.exports = {
buildPages,
buildIndex,
};

View File

@@ -182,6 +182,18 @@ function writeManifest(manifest) {
async function main() { async function main() {
const options = parseArgs(process.argv.slice(2)); 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 meals = loadMeals();
const manifest = loadManifest(); const manifest = loadManifest();
const nextManifest = {}; const nextManifest = {};
@@ -194,7 +206,7 @@ async function main() {
let skipped = 0; let skipped = 0;
for (const meal of meals) { 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; nextManifest[getManifestKey(meal.id)] = result.manifestEntry;
if (result.changed) { if (result.changed) {
@@ -206,12 +218,21 @@ async function main() {
writeManifest(nextManifest); writeManifest(nextManifest);
console.log( return {
`Thumbnail build complete: ${generated} generated, ${skipped} skipped, ${removed} removed` generated,
); removed,
skipped,
total: meals.length,
};
} }
main().catch((error) => { if (require.main === module) {
main().catch((error) => {
console.error(error.message); console.error(error.message);
process.exitCode = 1; process.exitCode = 1;
}); });
}
module.exports = {
buildThumbnails,
};

225
scripts/ingest-meal.js Normal file
View File

@@ -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 <path> --title <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,
};

View File

@@ -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") { if (meal.position !== undefined && typeof meal.position !== "string") {
throw new Error(`Meal ${index} has a non-string "position" value`); throw new Error(`Meal ${index} has a non-string "position" value`);
} }
@@ -82,8 +86,31 @@ function loadMeals() {
return meals; 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 = { module.exports = {
getNextMealId,
loadMeals, loadMeals,
mealsPath, mealsPath,
repoRoot, repoRoot,
saveMeals,
}; };