Files
gallery/scripts/lib/meals.js
Ryan Chou 614a3d1eff
All checks were successful
Deploy on push / deploy (push) Has been skipped
refactor: clean up gallery tooling and document the workflow
2026-03-22 20:33:29 -07:00

170 lines
4.1 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const repoRoot = path.resolve(__dirname, "..", "..");
const mealsPath = path.join(repoRoot, "data", "meals.json");
const fullsDir = path.join(repoRoot, "images", "fulls");
const thumbsDir = path.join(repoRoot, "images", "thumbs");
function isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}
function validateThumbnail(meal, index) {
if (meal.thumbnail === undefined) {
return;
}
if (
!meal.thumbnail ||
typeof meal.thumbnail !== "object" ||
Array.isArray(meal.thumbnail)
) {
throw new Error(`Meal ${index} has an invalid "thumbnail" object`);
}
if (meal.thumbnail.focus === undefined) {
return;
}
const { focus } = meal.thumbnail;
if (!focus || typeof focus !== "object" || Array.isArray(focus)) {
throw new Error(`Meal ${index} has an invalid "thumbnail.focus" object`);
}
for (const axis of ["x", "y"]) {
if (typeof focus[axis] !== "number" || !Number.isFinite(focus[axis])) {
throw new Error(
`Meal ${index} has a non-numeric thumbnail focus value for "${axis}"`
);
}
if (focus[axis] < 0 || focus[axis] > 1) {
throw new Error(
`Meal ${index} thumbnail focus "${axis}" must be between 0 and 1`
);
}
}
}
function validateMeals(meals) {
if (!Array.isArray(meals)) {
throw new Error("data/meals.json must contain an array");
}
const ids = new Set();
for (const [index, meal] of meals.entries()) {
if (!meal || typeof meal !== "object") {
throw new Error(`Meal at index ${index} must be an object`);
}
for (const field of ["id", "title", "description"]) {
if (!isNonEmptyString(meal[field])) {
throw new Error(`Meal ${index} is missing required string field "${field}"`);
}
}
if (!/^\d+$/.test(meal.id)) {
throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`);
}
if (meal.position !== undefined && !isNonEmptyString(meal.position)) {
throw new Error(`Meal ${index} has an invalid "position" value`);
}
if (ids.has(meal.id)) {
throw new Error(`Duplicate meal id "${meal.id}" found in data/meals.json`);
}
ids.add(meal.id);
validateThumbnail(meal, index);
}
}
function loadMeals() {
const meals = JSON.parse(fs.readFileSync(mealsPath, "utf8"));
validateMeals(meals);
return meals;
}
function saveMeals(meals) {
validateMeals(meals);
fs.writeFileSync(mealsPath, `${JSON.stringify(meals, null, 2)}\n`);
}
function getMealImagePaths(mealOrId) {
const mealId =
typeof mealOrId === "string" ? mealOrId : mealOrId && typeof mealOrId.id === "string" ? mealOrId.id : null;
if (!mealId) {
throw new Error("Expected a meal object or meal id string");
}
return {
fullPath: path.join(fullsDir, `${mealId}.jpg`),
thumbPath: path.join(thumbsDir, `${mealId}.jpg`),
};
}
function validateMealAssets(meals, options = {}) {
const settings = {
requireFull: true,
requireThumb: true,
...options,
};
const missingAssets = [];
for (const meal of meals) {
const { fullPath, thumbPath } = getMealImagePaths(meal);
if (settings.requireFull && !fs.existsSync(fullPath)) {
missingAssets.push(
`Meal ${meal.id} is missing full-size image: ${path.relative(repoRoot, fullPath)}`
);
}
if (settings.requireThumb && !fs.existsSync(thumbPath)) {
missingAssets.push(
`Meal ${meal.id} is missing thumbnail image: ${path.relative(repoRoot, thumbPath)}`
);
}
}
if (missingAssets.length > 0) {
throw new Error(`Missing image assets:\n${missingAssets.join("\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 = {
fullsDir,
getNextMealId,
getMealImagePaths,
loadMeals,
mealsPath,
repoRoot,
saveMeals,
thumbsDir,
validateMealAssets,
};