491 lines
15 KiB
JavaScript
491 lines
15 KiB
JavaScript
#!/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);
|
||
}); |