Replace entire repo content with correct code from /root/shahikitchen-website/

This commit is contained in:
root
2026-06-29 16:22:09 +00:00
parent 57cc67d2d3
commit 50e3a34895
723 changed files with 20055 additions and 26101 deletions
+491
View File
@@ -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);
});