#!/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); });