add: meal ingestion CLI for images and metadata
All checks were successful
Deploy on push / deploy (push) Has been skipped
All checks were successful
Deploy on push / deploy (push) Has been skipped
This commit is contained in:
17
README.md
17
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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -63,4 +63,15 @@ function main() {
|
||||
writeFile(indexOutputPath, buildIndex());
|
||||
}
|
||||
|
||||
main();
|
||||
function buildPages() {
|
||||
main();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
buildPages();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildPages,
|
||||
buildIndex,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
225
scripts/ingest-meal.js
Normal file
225
scripts/ingest-meal.js
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user