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

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,
};