226 lines
5.4 KiB
JavaScript
226 lines
5.4 KiB
JavaScript
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,
|
|
};
|