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
|
- `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.
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
console.error(error.message);
|
main().catch((error) => {
|
||||||
process.exitCode = 1;
|
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") {
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user