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
- `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.

View File

@@ -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"

View File

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

View File

@@ -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,
};
}
if (require.main === module) {
main().catch((error) => {
console.error(error.message);
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") {
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,
};