Replace entire repo content with correct code from /root/shahikitchen-website/
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user