Files

491 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Sync sweets images: ftp-images/bilder-bp/sweets/{dish}/bild/* → public/images/dishes
* - Walk nested FTP folders (one folder per dish)
* - Fuzzy-match to sweets items in static-menu-data.ts
* - Resize/crop uniformly (800×600 cover), JPEG ≤ 100 KB
*
* Usage:
* node scripts/sync-sweets-images.mjs
* node scripts/sync-sweets-images.mjs --dry-run
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync, spawnSync } from 'child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, '..');
const SOURCE_DIR = path.join(ROOT, 'ftp-images/bilder-bp/sweets');
const OUTPUT_DIR = path.join(ROOT, 'public/images/dishes');
const MENU_DATA_PATH = path.join(ROOT, 'infrastructure/menu/static-menu-data.ts');
const REPORT_PATH = path.join(__dirname, 'sweets-sync-report.json');
const MAX_BYTES = 100 * 1024;
const TARGET_WIDTH = 800;
const TARGET_HEIGHT = 600;
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.tif', '.tiff', '.heic']);
const DRY_RUN = process.argv.includes('--dry-run');
/** FTP subfolder name (normalized) → menu dish id */
const FOLDER_TO_DISH = {
'badam-barfi': 'badam-barfi',
'besan-patisa': 'baisan-patisa',
'baisan-patisa': 'baisan-patisa',
'cham-cham': 'cham-cham',
'chochlate-barfi': 'chocolate-barfi',
'chocolate-barfi': 'chocolate-barfi',
'coconut-barfi': 'coconut-barfi',
'cream-gulab-jaman': 'cream-gulab-jaman',
'cream-gulab-jamun': 'cream-gulab-jaman',
'gajar-barfi': 'gajar-barfi',
'gajar-halwa': 'gajar-halwa',
'gulab-jaman': 'gulab-jaman',
'gulab-jamun': 'gulab-jaman',
'habshi-halwa': 'habshi-halwa',
'jalebi': 'jalebi',
'laddu': 'laddu',
'ladoo': 'laddu',
'lambay-gulab-jaman': 'lambay-gulab-jaman',
'lambay-gulab-jamun': 'lambay-gulab-jaman',
'milk-cake-akhrot': 'milk-cake-akhrot',
'milk-cake-khajoor': 'milk-cake-khajoor',
'milk-cake-plain': 'milk-cake-plain',
'namak-paray': 'namakpare',
'namakpare': 'namakpare',
'namak-pare': 'namakpare',
'paira': 'paira',
'patisa': 'patisa',
'pink-barfi': 'pink-barfi',
'pistacho-barfi': 'pistachio-barfi',
'pistachio-barfi': 'pistachio-barfi',
'plain-barfi': 'plain-barfi',
'qalakand': 'qalakand',
'kalakand': 'qalakand',
'ras-gulay': 'ras-gulay',
'rasgulla': 'ras-gulay',
'ras-malai': 'rasmalai',
'rasmalai': 'rasmalai',
'shakar-paray': 'shakar-paray',
'shakar-pare': 'shakar-paray',
'baisan-barfi': 'baisan-barfi',
'besan-barfi': 'baisan-barfi',
'basen-barfi': 'baisan-barfi',
'round-gulab-jaman': 'round-gulab-jaman',
'round-gulab-jamun': 'round-gulab-jaman',
'shahi-tukra': 'shahi-tukra',
'kulfi': 'kulfi',
};
/** Folders that are not sweets — skip entirely */
const SKIP_FOLDERS = new Set(['samosa-aloo', 'samosa-keema', 'samosa-chaat']);
let sharp = null;
try {
sharp = (await import('sharp')).default;
} catch {
sharp = null;
}
function normalize(value) {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
function normalizeSourceStem(raw) {
let stem = normalize(raw);
stem = stem
.replace(/-poster$/i, '')
.replace(/-mithai$/i, '')
.replace(/-new$/i, '')
.replace(/-copy\d*$/i, '')
.replace(/-final$/i, '')
.replace(/-img\d*$/i, '')
.replace(/-\d+$/i, '');
return stem;
}
function tokenize(value) {
return normalize(value).split('-').filter((t) => t.length > 1);
}
function levenshtein(a, b) {
const rows = a.length + 1;
const cols = b.length + 1;
const matrix = Array.from({ length: rows }, () => Array(cols).fill(0));
for (let i = 0; i < rows; i++) matrix[i][0] = i;
for (let j = 0; j < cols; j++) matrix[0][j] = j;
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
}
}
return matrix[a.length][b.length];
}
function similarity(a, b) {
if (!a || !b) return 0;
if (a === b) return 1;
const maxLen = Math.max(a.length, b.length);
return maxLen ? 1 - levenshtein(a, b) / maxLen : 0;
}
function parseSweetsDishes(menuContent) {
const sweetsIdx = menuContent.indexOf('id: "sweets"');
if (sweetsIdx < 0) throw new Error('Sweets category not found in static-menu-data.ts');
const itemsStart = menuContent.indexOf('items: [', sweetsIdx);
if (itemsStart < 0) throw new Error('Sweets items array not found');
let depth = 0;
let itemsEnd = -1;
for (let i = itemsStart + 'items: '.length; i < menuContent.length; i++) {
const ch = menuContent[i];
if (ch === '[') depth++;
else if (ch === ']') {
depth--;
if (depth === 0) {
itemsEnd = i;
break;
}
}
}
if (itemsEnd < 0) throw new Error('Could not parse sweets items array');
const itemsBlock = menuContent.slice(itemsStart, itemsEnd + 1);
const dishes = [];
for (const match of itemsBlock.matchAll(/\{\s*id:\s*"([^"]+)"\s*,\s*name:\s*"([^"]+)"/g)) {
const id = match[1];
const name = match[2];
const itemChunk = itemsBlock.slice(match.index, match.index + 800);
const image = itemChunk.match(/\bimage:\s*"([^"]+)"/)?.[1] ?? `${id}.jpg`;
dishes.push({ id, name, image, names: [name] });
}
if (!dishes.length) throw new Error('No sweets dishes found in static-menu-data.ts');
return dishes;
}
function isCameraDump(filename) {
return /^img[_-]/i.test(path.parse(filename).name);
}
function extPriority(ext) {
const e = ext.toLowerCase();
if (e === '.jpg') return 0;
if (e === '.jpeg') return 1;
if (e === '.webp') return 2;
if (e === '.png') return 3;
return 4;
}
/** Pick the best image file inside a dish folder */
function pickBestImage(files, folderStem) {
const candidates = files
.filter((f) => IMAGE_EXTENSIONS.has(path.extname(f).toLowerCase()))
.map((file) => {
const rawStem = path.parse(file).name;
const stem = normalizeSourceStem(rawStem);
const folderNorm = normalizeSourceStem(folderStem);
let score = 0;
if (FOLDER_TO_DISH[folderNorm] && stem === folderNorm) score = 1;
else if (stem === folderNorm) score = 0.95;
else if (stem.includes(folderNorm) || folderNorm.includes(stem)) score = 0.85;
else score = similarity(stem, folderNorm);
if (isCameraDump(file)) score -= 0.35;
score -= extPriority(path.extname(file)) * 0.02;
return { file, stem, score };
})
.sort((a, b) => b.score - a.score);
return candidates[0] ?? null;
}
function listSourceFolders() {
if (!fs.existsSync(SOURCE_DIR)) {
throw new Error(`Source directory not found: ${SOURCE_DIR}`);
}
const sources = [];
for (const entry of fs.readdirSync(SOURCE_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const folder = entry.name;
const folderNorm = normalize(folder);
if (SKIP_FOLDERS.has(folderNorm)) continue;
const searchDirs = ['bild', 'bilder']
.map((sub) => path.join(SOURCE_DIR, folder, sub))
.filter((d) => fs.existsSync(d));
let allFiles = [];
for (const dir of searchDirs) {
allFiles.push(
...fs.readdirSync(dir).map((f) => ({
file: f,
fullPath: path.join(dir, f),
fromDir: dir,
})),
);
}
if (!allFiles.length) continue;
const fileNames = allFiles.map((f) => f.file);
const best = pickBestImage(fileNames, folder);
if (!best) continue;
const chosen = allFiles.find((f) => f.file === best.file);
sources.push({
folder,
folderNorm: normalizeSourceStem(folder),
file: best.file,
fullPath: chosen.fullPath,
stem: best.stem,
pickScore: Number(best.score.toFixed(3)),
});
}
return sources;
}
function outputFileForDish(dish) {
if (dish.image) {
const base = path.basename(dish.image);
if (base) return base.replace(/\.(png|webp|jpeg)$/i, '.jpg');
}
return `${dish.id}.jpg`;
}
function resolveDishForFolder(source) {
const folderNorm = source.folderNorm;
if (FOLDER_TO_DISH[folderNorm]) {
return { dishId: FOLDER_TO_DISH[folderNorm], score: 1, method: 'folder-map' };
}
return { dishId: null, score: 0, method: 'unmapped' };
}
function matchFoldersToDishes(sources, dishes) {
const dishById = new Map(dishes.map((d) => [d.id, d]));
const matches = [];
const unmatchedFolders = [];
const matchedDishIds = new Set();
for (const source of sources) {
const resolved = resolveDishForFolder(source);
let dish = resolved.dishId ? dishById.get(resolved.dishId) : null;
if (!dish) {
let best = null;
for (const d of dishes) {
if (matchedDishIds.has(d.id)) continue;
const score = Math.max(
similarity(source.folderNorm, normalize(d.id)),
similarity(source.folderNorm, normalizeSourceStem(path.parse(d.image).name)),
similarity(source.stem, normalize(d.id)),
);
if (!best || score > best.score) best = { dish: d, score };
}
if (best && best.score >= 0.55) {
dish = best.dish;
resolved.score = best.score;
resolved.method = 'fuzzy';
}
}
if (dish) {
matchedDishIds.add(dish.id);
const outputFile = outputFileForDish(dish);
matches.push({
dishId: dish.id,
dishNames: dish.names,
currentImage: dish.image,
sourceFolder: source.folder,
sourceFile: source.file,
sourcePath: source.fullPath,
score: resolved.score,
matchMethod: resolved.method,
outputFile,
outputPath: `/images/dishes/${outputFile}`,
});
} else {
unmatchedFolders.push(source);
}
}
const unmatchedDishes = dishes.filter((d) => !matchedDishIds.has(d.id));
return { matches, unmatchedDishes, unmatchedFolders };
}
async function optimizeWithSharp(inputPath, outputPath) {
let quality = 84;
let width = TARGET_WIDTH;
let height = TARGET_HEIGHT;
let buffer = null;
while (quality >= 32) {
while (width >= 480) {
buffer = await sharp(inputPath)
.rotate()
.resize(width, height, { fit: 'cover', position: 'centre' })
.modulate({ brightness: 1.02, saturation: 1.05 })
.sharpen({ sigma: 0.6 })
.jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:2:0' })
.toBuffer();
if (buffer.length <= MAX_BYTES) {
if (!DRY_RUN) fs.writeFileSync(outputPath, buffer);
return { bytes: buffer.length, width, height, quality, method: 'sharp' };
}
width -= 80;
height -= 60;
}
quality -= 6;
width = TARGET_WIDTH;
height = TARGET_HEIGHT;
}
if (!DRY_RUN) fs.writeFileSync(outputPath, buffer);
return {
bytes: buffer.length,
width,
height,
quality,
method: 'sharp',
warning: buffer.length > MAX_BYTES ? 'exceeds-100kb' : undefined,
};
}
function optimizeWithSips(inputPath, outputPath) {
const temp = `${outputPath}.tmp.jpg`;
fs.copyFileSync(inputPath, temp);
for (const [w, h] of [
[TARGET_WIDTH, TARGET_HEIGHT],
[640, 480],
[520, 390],
]) {
execSync(
`sips -s format jpeg -s formatOptions 65 --resampleHeightWidth ${h} ${w} "${temp}" --out "${outputPath}"`,
{ stdio: 'pipe' },
);
const bytes = fs.statSync(outputPath).size;
if (bytes <= MAX_BYTES) {
fs.unlinkSync(temp);
return { bytes, width: w, height: h, quality: 65, method: 'sips' };
}
}
const bytes = fs.statSync(outputPath).size;
fs.unlinkSync(temp);
return {
bytes,
width: 520,
height: 390,
quality: 65,
method: 'sips',
warning: bytes > MAX_BYTES ? 'exceeds-100kb' : undefined,
};
}
async function optimizeImage(inputPath, outputPath) {
if (DRY_RUN) {
return { bytes: fs.statSync(inputPath).size, width: null, height: null, quality: null, method: 'dry-run' };
}
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
if (sharp) return optimizeWithSharp(inputPath, outputPath);
if (process.platform === 'darwin' && spawnSync('which', ['sips']).status === 0) {
return optimizeWithSips(inputPath, outputPath);
}
throw new Error('Install sharp: npm install sharp');
}
async function main() {
console.log('Shahi Kitchen — sweets image sync\n');
console.log(`Source: ${SOURCE_DIR}`);
console.log(`Output: ${OUTPUT_DIR}`);
console.log(`Optimizer: ${sharp ? 'sharp' : 'sips'}`);
if (DRY_RUN) console.log('Mode: DRY RUN\n');
const menuContent = fs.readFileSync(MENU_DATA_PATH, 'utf8');
const dishes = parseSweetsDishes(menuContent);
const sources = listSourceFolders();
const { matches, unmatchedDishes, unmatchedFolders } = matchFoldersToDishes(sources, dishes);
console.log(`Folders: ${sources.length} Sweets dishes: ${dishes.length} Matched: ${matches.length}\n`);
const results = [];
for (const match of matches) {
const dest = path.join(OUTPUT_DIR, match.outputFile);
const opt = await optimizeImage(match.sourcePath, dest);
results.push({ ...match, ...opt });
const kb = (opt.bytes / 1024).toFixed(1);
const warn = opt.warning ? ' ⚠ over 100KB' : '';
console.log(`${match.dishNames[0] || match.dishId}`);
console.log(` ${match.sourceFolder}/bild/${match.sourceFile}${match.outputFile} (${kb} KB)${warn}`);
}
if (unmatchedDishes.length) {
console.log('\n⚠ No FTP image for these sweets (keeping existing):');
for (const d of unmatchedDishes) console.log(` - ${d.id}: ${d.names.join(' / ')}`);
}
if (unmatchedFolders.length) {
console.log('\nUnused FTP folders:');
for (const f of unmatchedFolders) console.log(` - ${f.folder}/ (${f.file})`);
}
const overLimit = results.filter((r) => r.warning);
if (overLimit.length) {
console.log(`\n${overLimit.length} image(s) still exceed 100 KB after optimization`);
}
const report = {
generatedAt: new Date().toISOString(),
dryRun: DRY_RUN,
summary: {
sourceFolders: sources.length,
sweetsDishes: dishes.length,
matched: matches.length,
unmatchedDishes: unmatchedDishes.map((d) => ({ id: d.id, names: d.names, image: d.image })),
unmatchedFolders: unmatchedFolders.map((f) => ({ folder: f.folder, file: f.file })),
},
mappings: results.map((r) => ({
dish: r.dishNames[0] || r.dishId,
dishId: r.dishId,
sourceFolder: r.sourceFolder,
sourceFile: r.sourceFile,
outputFile: r.outputFile,
imagePath: r.outputPath,
kb: Number((r.bytes / 1024).toFixed(2)),
score: r.score,
matchMethod: r.matchMethod,
warning: r.warning ?? null,
})),
};
if (!DRY_RUN) {
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2));
console.log(`\nReport: ${REPORT_PATH}`);
}
if (unmatchedDishes.length) process.exitCode = 0;
}
main().catch((err) => {
console.error('Failed:', err.message);
process.exit(1);
});