const fs = require("fs"); const path = require("path"); const sharp = require("sharp"); const { buildPages } = require("./build"); const { buildThumbnails } = require("./generate-thumbnails"); const { eloPath } = require("./lib/elo"); 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 rankingsPath = path.join(repoRoot, "rankings.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); } function readOptionalFile(filePath) { return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null; } async function restoreOptionalFile(filePath, previousContents) { if (previousContents === null) { if (fs.existsSync(filePath)) { await fs.promises.unlink(filePath); } return; } if (previousContents !== undefined) { fs.writeFileSync(filePath, previousContents); } } async function rollback({ createdFullPath, createdThumbPath, previousElo, previousIndex, previousManifest, previousMeals, previousRankings, }) { if (previousMeals !== undefined) { fs.writeFileSync(mealsPath, previousMeals); } await restoreOptionalFile(eloPath, previousElo); if (previousIndex !== undefined) { fs.writeFileSync(indexPath, previousIndex); } await restoreOptionalFile(rankingsPath, previousRankings); await restoreOptionalFile(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 previousElo = readOptionalFile(eloPath); const previousIndex = fs.readFileSync(indexPath, "utf8"); const previousRankings = readOptionalFile(rankingsPath); const previousManifest = readOptionalFile(manifestPath); 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, previousElo, previousIndex, previousManifest, previousMeals, previousRankings, }); 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, };