#!/usr/bin/env node /** * Generates demo/catalog.json + demo/images/*.jpg for DEMO_MODE. * Downloads real photos from LoremFlickr matched to category / product keywords * (Picsum fallback). Variant siblings share a base photo and get a light tint. * * Usage: node scripts/generate-demo-catalog.mjs */ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, '..'); const DEMO_DIR = path.join(ROOT, 'demo'); const IMAGES_DIR = path.join(DEMO_DIR, 'images'); const IMAGE_SIZE = 480; const JPEG_QUALITY = 65; const DOWNLOAD_CONCURRENCY = 8; const TARGET_PRODUCTS = 1100; const COLORS = ['Red', 'Blue', 'Green', 'Black', 'White', 'Grey', 'Navy', 'Beige']; const MATERIALS = ['Oak', 'Pine', 'Steel', 'Aluminium', 'Cotton', 'Leather', 'Plastic', 'Bamboo']; const PACKAGE_SIZES = ['250g', '500g', '1kg', '2kg', '5kg', '10pcs', '20pcs', '50pcs']; const TINTS = { Red: { r: 220, g: 60, b: 60 }, Blue: { r: 50, g: 90, b: 200 }, Green: { r: 40, g: 150, b: 70 }, Black: { r: 30, g: 30, b: 30 }, White: { r: 230, g: 230, b: 230 }, Grey: { r: 120, g: 120, b: 120 }, Navy: { r: 20, g: 40, b: 90 }, Beige: { r: 210, g: 190, b: 150 }, }; /** Flickr-friendly tags for catalog subjects (category / product type names). */ const KEYWORD_ALIASES = { 'Home & Living': 'livingroom,interior,home', Furniture: 'furniture,home', Seating: 'seating,chair', Armchairs: 'armchair,chair', Sofas: 'sofa,couch', Stools: 'stool,chair', Tables: 'table,furniture', 'Coffee Tables': 'coffee,table', 'Side Tables': 'side,table', 'Dining Tables': 'dining,table', Storage: 'storage,cabinet', Shelves: 'shelf,bookshelf', Cabinets: 'cabinet,cupboard', Decor: 'decor,interior', Lighting: 'lamp,lighting', 'Floor Lamps': 'floor,lamp', 'Table Lamps': 'table,lamp', Pendants: 'pendant,lamp', Textiles: 'textile,fabric', Cushions: 'cushion,pillow', Throws: 'blanket,throw', Rugs: 'rug,carpet', WallArt: 'wallart,painting', Prints: 'poster,print', Mirrors: 'mirror,reflection', Office: 'office,workspace', Desks: 'desk,office', Standing: 'standing,desk', 'Electric Desks': 'standing,desk', 'Manual Desks': 'desk,office', Sitting: 'desk,office', 'Compact Desks': 'desk,workspace', 'Executive Desks': 'desk,office', Chairs: 'office,chair', Ergonomic: 'ergonomic,chair', 'Mesh Chairs': 'office,chair', 'Leather Chairs': 'leather,chair', Guest: 'guest,chair', 'Stacking Chairs': 'stacking,chair', 'Visitor Chairs': 'office,chair', Supplies: 'office,supplies', Paper: 'paper,stationery', 'A4 Paper': 'paper,stack', Notebooks: 'notebook,journal', Writing: 'pen,writing', Pens: 'pen,fountain', Markers: 'marker,pen', Organizers: 'desk,organizer', Trays: 'tray,desk', 'File Boxes': 'archive,box', Outdoor: 'outdoor,garden', Garden: 'garden,outdoors', Tools: 'garden,tools', 'Hand Tools': 'hand,tools', 'Power Tools': 'power,tools', Planters: 'planter,pot', 'Ceramic Pots': 'ceramic,pot', 'Hanging Baskets': 'hanging,basket', Benches: 'bench,park', Loungers: 'lounger,sunbed', Sports: 'sports,fitness', Fitness: 'fitness,gym', Weights: 'dumbbell,weights', Mats: 'yoga,mat', Bands: 'resistance,band', Recreation: 'recreation,sport', Balls: 'ball,sport', Rackets: 'tennis,racket', Kitchen: 'kitchen,cooking', Cookware: 'cookware,kitchen', Pots: 'cooking,pot', 'Sauce Pans': 'saucepan,pot', 'Stock Pots': 'stockpot,pot', Pans: 'frying,pan', 'Frying Pans': 'frying,pan', Woks: 'wok,pan', Bakeware: 'bakeware,baking', 'Baking Trays': 'baking,tray', 'Cake Tins': 'cake,tin', Tableware: 'tableware,dishes', Plates: 'plate,dish', 'Dinner Plates': 'dinner,plate', 'Side Plates': 'plate,dish', Drinkware: 'drinkware,cup', Mugs: 'mug,coffee', Glasses: 'glass,drink', Cutlery: 'cutlery,silverware', 'Fork Sets': 'fork,cutlery', 'Knife Sets': 'knife,cutlery', Food: 'food,grocery', DryGoods: 'grocery,food', Pasta: 'pasta,noodles', Rice: 'rice,grain', Beans: 'beans,legume', Beverages: 'beverage,drink', Coffee: 'coffee,beans', Tea: 'tea,cup', Juice: 'juice,orange', Snacks: 'snack,food', Nuts: 'nuts,almond', Bars: 'granola,bar', }; const PRODUCT_STEMS = [ 'Classic', 'Premium', 'Essential', 'Urban', 'Nordic', 'Studio', 'Heritage', 'Compact', 'Pro', 'Lite', 'Max', 'Basic', ]; const STEM_RE = new RegExp(`^(${PRODUCT_STEMS.join('|')})\\s+`, 'i'); /** Department → groups → subgroups → leaves (3–4 levels). */ const TREE = { 'Home & Living': { Furniture: { Seating: ['Armchairs', 'Sofas', 'Stools'], Tables: ['Coffee Tables', 'Side Tables', 'Dining Tables'], Storage: ['Shelves', 'Cabinets'], }, Decor: { Lighting: ['Floor Lamps', 'Table Lamps', 'Pendants'], Textiles: ['Cushions', 'Throws', 'Rugs'], WallArt: ['Prints', 'Mirrors'], }, }, Office: { Desks: { Standing: ['Electric Desks', 'Manual Desks'], Sitting: ['Compact Desks', 'Executive Desks'], }, Chairs: { Ergonomic: ['Mesh Chairs', 'Leather Chairs'], Guest: ['Stacking Chairs', 'Visitor Chairs'], }, Supplies: { Paper: ['A4 Paper', 'Notebooks'], Writing: ['Pens', 'Markers'], Organizers: ['Trays', 'File Boxes'], }, }, Outdoor: { Garden: { Tools: ['Hand Tools', 'Power Tools'], Planters: ['Ceramic Pots', 'Hanging Baskets'], Furniture: ['Benches', 'Loungers'], }, Sports: { Fitness: ['Weights', 'Mats', 'Bands'], Recreation: ['Balls', 'Rackets'], }, }, Kitchen: { Cookware: { Pots: ['Sauce Pans', 'Stock Pots'], Pans: ['Frying Pans', 'Woks'], Bakeware: ['Baking Trays', 'Cake Tins'], }, Tableware: { Plates: ['Dinner Plates', 'Side Plates'], Drinkware: ['Mugs', 'Glasses'], Cutlery: ['Fork Sets', 'Knife Sets'], }, Food: { DryGoods: ['Pasta', 'Rice', 'Beans'], Beverages: ['Coffee', 'Tea', 'Juice'], Snacks: ['Nuts', 'Bars'], }, }, }; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function hashBuffer(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); } function padSku(n) { return `DEMO-${String(n).padStart(5, '0')}`; } function barcodeFor(n) { return `200${String(n).padStart(10, '0')}`; } function formatDateTime(date) { const pad = (n) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } function grossPrice(net, taxRate) { return (Number(net) * (1 + Number(taxRate) / 100)).toFixed(2); } function pick(arr, i) { return arr[i % arr.length]; } function variantCombos(axes) { const keys = Object.keys(axes); if (keys.length === 0) { return [{}]; } let combos = [{}]; for (const key of keys) { const next = []; for (const base of combos) { for (const value of axes[key]) { next.push({ ...base, [key]: value }); } } combos = next; } return combos; } function formatVariants(combo) { return Object.entries(combo) .map(([k, v]) => `${k}: ${v}`) .join(' | '); } async function mapPool(items, concurrency, fn) { const results = new Array(items.length); let index = 0; async function worker() { while (index < items.length) { const i = index++; results[i] = await fn(items[i], i); } } await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); return results; } function subjectFromProductName(name) { return String(name || '') .replace(/\s*\([^)]*\)\s*$/, '') .replace(STEM_RE, '') .replace(/\s+Featured\s+\d+$/i, '') .replace(/\s+Item$/i, '') .replace(/\s+Starter Kit\s+\d+$/i, '') .replace(/\s+Kit$/i, '') .trim(); } function keywordsForSubject(subject) { if (!subject) { return 'product'; } if (KEYWORD_ALIASES[subject]) { return KEYWORD_ALIASES[subject]; } const cleaned = subjectFromProductName(subject); if (KEYWORD_ALIASES[cleaned]) { return KEYWORD_ALIASES[cleaned]; } const tags = cleaned .replace(/&/g, ' ') .split(/[\s/_-]+/) .map((w) => w.toLowerCase().replace(/[^a-z0-9]/g, '')) .filter((w) => w.length > 2 && !['the', 'and', 'set', 'sets'].includes(w)) .slice(0, 3); return tags.length > 0 ? tags.join(',') : 'product'; } function lockFromSeed(seed) { const hex = crypto.createHash('sha1').update(String(seed)).digest('hex').slice(0, 8); return Number.parseInt(hex, 16) % 1_000_000_000 || 1; } async function fetchBuffer(url, retries = 4) { for (let attempt = 0; attempt < retries; attempt++) { try { const res = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'jtlsrv-demo-catalog/1.0' }, }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const buf = Buffer.from(await res.arrayBuffer()); if (buf.length < 1000) { throw new Error('image too small'); } await sharp(buf).metadata(); return buf; } catch (err) { if (attempt === retries - 1) { throw err; } await sleep(250 * (attempt + 1)); } } throw new Error('unreachable'); } const rawImageCache = new Map(); async function fetchMatchingImage(keywords, lock) { const cacheKey = `${keywords}|${lock}`; if (rawImageCache.has(cacheKey)) { return rawImageCache.get(cacheKey); } const pathTags = String(keywords) .split(',') .map((tag) => encodeURIComponent(tag.trim())) .filter(Boolean) .join(','); try { const flickrUrl = `https://loremflickr.com/${IMAGE_SIZE}/${IMAGE_SIZE}/${pathTags}?lock=${lock}`; const buf = await fetchBuffer(flickrUrl); rawImageCache.set(cacheKey, buf); return buf; } catch (err) { console.warn(` loremflickr miss (${keywords}): ${err.message}; falling back to picsum`); } const picsumUrl = `https://picsum.photos/seed/${encodeURIComponent(`${keywords}-${lock}`)}/${IMAGE_SIZE}/${IMAGE_SIZE}.jpg`; const buf = await fetchBuffer(picsumUrl); rawImageCache.set(cacheKey, buf); return buf; } async function processImage(raw, { tintKey = null, label = null } = {}) { let pipeline = sharp(raw).resize(IMAGE_SIZE, IMAGE_SIZE, { fit: 'cover' }); if (tintKey && TINTS[tintKey]) { const { r, g, b } = TINTS[tintKey]; const overlay = await sharp({ create: { width: IMAGE_SIZE, height: IMAGE_SIZE, channels: 4, background: { r, g, b, alpha: 0.28 }, }, }) .png() .toBuffer(); pipeline = sharp(await pipeline.toBuffer()).composite([{ input: overlay, blend: 'over' }]); } if (label) { const safe = String(label) .replace(/&/g, '&') .replace(//g, '>') .slice(0, 40); const svg = Buffer.from(` ${safe} `); pipeline = sharp(await pipeline.toBuffer()).composite([{ input: svg, blend: 'over' }]); } return pipeline.jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer(); } async function saveImage(jpegBuffer) { const hash = hashBuffer(jpegBuffer); const filePath = path.join(IMAGES_DIR, `${hash}.jpg`); if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, jpegBuffer); } return hash; } function buildCategoryTree() { const categories = []; let nextId = 1; let sort = 0; let lastChanged = 1; function add(name, pid, depth) { const id = nextId++; categories.push({ _id: String(id), name, pid: pid === null ? '0' : String(pid), sort: String(++sort), lastChanged: String(lastChanged++), imghash: null, imgsrc: null, discounts: [], depth, }); return id; } for (const [dept, groups] of Object.entries(TREE)) { const deptId = add(dept, null, 1); for (const [group, subgroups] of Object.entries(groups)) { const groupId = add(group, deptId, 2); for (const [subgroup, leaves] of Object.entries(subgroups)) { const subgroupId = add(subgroup, groupId, 3); for (const leaf of leaves) { add(leaf, subgroupId, 4); } } } } return categories; } function chooseAxes(leafName, familyIndex) { const foodish = /Pasta|Rice|Beans|Coffee|Tea|Juice|Nuts|Bars|Paper|Notebooks/.test(leafName); const furnitureish = /Chair|Sofa|Table|Desk|Shelf|Cabinet|Bench|Lounger|Lamp|Armchair|Stool/.test(leafName); const mode = familyIndex % 5; if (foodish || mode === 0) { return { Size: PACKAGE_SIZES.slice(0, 4 + (familyIndex % 3)) }; } if (furnitureish || mode === 1) { return { Color: COLORS.slice(0, 3 + (familyIndex % 3)), Material: MATERIALS.slice(0, 2 + (familyIndex % 2)), }; } if (mode === 2) { return { Color: COLORS.slice(0, 4 + (familyIndex % 3)) }; } if (mode === 3) { return { Material: MATERIALS.slice(0, 3 + (familyIndex % 3)) }; } return { Color: COLORS.slice(0, 2 + (familyIndex % 2)), Size: PACKAGE_SIZES.slice(0, 2 + (familyIndex % 2)), }; } const MIN_PRODUCTS_PER_CATEGORY = 5; function buildProducts(categories, customerGroupIds) { const products = []; const composites = []; let nextId = 1; let lastChanged = 1; let familyIndex = 0; const createdAt = formatDateTime(new Date('2024-01-15T10:00:00Z')); const taxRate = 19; const leafCategories = categories.filter((c) => c.depth === 4); function pricesFor(net) { const base = grossPrice(net, taxRate); const wholesaleNet = Number(net) * 0.85; return customerGroupIds.map((customerGroupId, i) => ({ customerGroupId: String(customerGroupId), customerId: '0', price: i === 0 ? base : grossPrice(wholesaleNet, taxRate), quantity: '0', })); } function pushProduct(partial) { const id = nextId++; const net = partial.netPrice ?? 9.99 + (id % 80) * 1.25; const product = { _id: String(id), sku: padSku(id), barcode: barcodeFor(id), name: partial.name, tax_rate: String(taxRate), price: grossPrice(net, taxRate), created_at: createdAt, lastChanged: String(lastChanged++), categories_id: partial.categoryId, categories: [{ categoryId: partial.categoryId }], prices: pricesFor(net), is_parent: partial.is_parent ?? '0', parent: partial.parent ?? '0', variants: partial.variants ?? '', isCompositeProduct: partial.isCompositeProduct ?? '0', attributes: [], imghash: null, imgsrc: null, _tintKey: partial.tintKey ?? null, _label: partial.label ?? null, _seed: partial.seed, }; products.push(product); return product; } function countByCategory() { const counts = new Map(); for (const product of products) { const id = product.categories_id; counts.set(id, (counts.get(id) || 0) + 1); } return counts; } // Variant families on leaf categories (majority of catalog) for (const leaf of leafCategories) { const familiesPerLeaf = 2 + (Number(leaf._id) % 3); // 2–4 families for (let f = 0; f < familiesPerLeaf; f++) { familyIndex++; const stem = pick(PRODUCT_STEMS, familyIndex); const baseName = `${stem} ${leaf.name}`; const axes = chooseAxes(leaf.name, familyIndex); const combos = variantCombos(axes).slice(0, 8); const parent = pushProduct({ name: baseName, categoryId: leaf._id, is_parent: '1', parent: '0', variants: '', seed: `parent-${leaf._id}-${f}`, netPrice: 15 + (familyIndex % 40), }); for (let c = 0; c < combos.length; c++) { const combo = combos[c]; const tintKey = combo.Color || null; pushProduct({ name: `${baseName} (${formatVariants(combo)})`, categoryId: leaf._id, is_parent: '0', parent: parent._id, variants: formatVariants(combo), tintKey, label: formatVariants(combo), seed: `var-${leaf._id}-${f}-${c}`, netPrice: 15 + (familyIndex % 40) + c * 0.5, }); } if (products.length >= TARGET_PRODUCTS) { break; } } if (products.length >= TARGET_PRODUCTS) { break; } } // Dedicated simple kit products for BOM (not variant parents/children) const kitLeaf = leafCategories[0]; for (let i = 0; i < 8; i++) { const kit = pushProduct({ name: `${pick(PRODUCT_STEMS, i + 7)} Starter Kit ${i + 1}`, categoryId: kitLeaf._id, isCompositeProduct: '1', seed: `kit-${i}`, netPrice: 49 + i * 5, }); const components = products .filter((p) => p.is_parent === '0' && p.parent !== '0' && p._id !== kit._id) .slice(i * 3, i * 3 + 3); for (const comp of components) { composites.push({ productId: kit._id, productIdComponent: comp._id, quantity: (1 + (i % 3)).toFixed(2), lastChanged: kit.lastChanged, }); } } // Fill remaining with simple products across leaves let simpleIndex = 0; while (products.length < TARGET_PRODUCTS) { const leaf = leafCategories[simpleIndex % leafCategories.length]; const stem = pick(PRODUCT_STEMS, simpleIndex + 3); pushProduct({ name: `${stem} ${leaf.name} Item`, categoryId: leaf._id, seed: `simple-${leaf._id}-${simpleIndex}`, netPrice: 4.5 + (simpleIndex % 50), }); simpleIndex++; } // Every category (including top-level / intermediate) gets ≥5 products let ensureIndex = 0; for (const category of categories) { const counts = countByCategory(); const have = counts.get(category._id) || 0; for (let i = have; i < MIN_PRODUCTS_PER_CATEGORY; i++) { const stem = pick(PRODUCT_STEMS, ensureIndex + i); pushProduct({ name: `${stem} ${category.name} Featured ${i + 1}`, categoryId: category._id, seed: `ensure-${category._id}-${i}`, netPrice: 8 + ((ensureIndex + i) % 40), }); } ensureIndex++; } return { products, composites }; } async function main() { console.log('Generating demo catalog…'); fs.mkdirSync(IMAGES_DIR, { recursive: true }); // Clear previous images for (const file of fs.readdirSync(IMAGES_DIR)) { fs.unlinkSync(path.join(IMAGES_DIR, file)); } const categories = buildCategoryTree(); const leafCategories = categories.filter((c) => c.depth === 4); console.log(`Categories: ${categories.length} (leaves: ${leafCategories.length})`); const customerGroups = [ { customerGroupId: '1', name: 'Standard', standard: '1', discountPercent: '0.00', lastChanged: '1', }, { customerGroupId: '2', name: 'Wholesale', standard: '0', discountPercent: '10.00', lastChanged: '2', }, ]; const { products, composites } = buildProducts( categories, customerGroups.map((g) => Number(g.customerGroupId)) ); const perCategory = new Map(); for (const product of products) { perCategory.set(product.categories_id, (perCategory.get(product.categories_id) || 0) + 1); } const minPerCategory = Math.min(...categories.map((c) => perCategory.get(c._id) || 0)); console.log( `Products: ${products.length} (composites links: ${composites.length}, min per category: ${minPerCategory})` ); const categoryById = new Map(categories.map((c) => [c._id, c])); const imageJobs = [ ...categories.map((c) => ({ kind: 'category', ref: c, keywords: keywordsForSubject(c.name), lock: lockFromSeed(`cat-${c._id}`), tintKey: null, label: c.name, })), ...products.map((p) => { const category = categoryById.get(p.categories_id); const subject = p.isCompositeProduct === '1' ? 'gift basket' : category?.name || subjectFromProductName(p.name); const lockSeed = p.parent !== '0' ? `parent-${p.parent}` : `product-${p._id}`; return { kind: 'product', ref: p, keywords: keywordsForSubject(subject), lock: lockFromSeed(lockSeed), tintKey: p._tintKey, label: p.is_parent === '1' ? p.name : p._label, }; }), ]; console.log(`Downloading / processing ${imageJobs.length} images (keyword-matched via LoremFlickr)…`); let done = 0; await mapPool(imageJobs, DOWNLOAD_CONCURRENCY, async (job) => { const raw = await fetchMatchingImage(job.keywords, job.lock); const jpeg = await processImage(raw, { tintKey: job.tintKey, label: job.label }); const hash = await saveImage(jpeg); job.ref.imghash = hash; job.ref.imgsrc = hash; done++; if (done % 50 === 0 || done === imageJobs.length) { console.log(` images ${done}/${imageJobs.length} (unique fetches: ${rawImageCache.size})`); } }); // Strip generator-only fields for (const p of products) { delete p._tintKey; delete p._label; delete p._seed; } for (const c of categories) { delete c.depth; } const deletedEntities = [ { entityId: '999001', entityType: '1', lastChanged: '1', }, ]; const catalog = { version: 1, generatedAt: new Date().toISOString(), customerGroups, categories, products, composites, deletedEntities, maxOrderId: 0, }; const catalogPath = path.join(DEMO_DIR, 'catalog.json'); fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2)); const imageCount = fs.readdirSync(IMAGES_DIR).length; console.log(`Wrote ${catalogPath}`); console.log(`Images on disk: ${imageCount}`); console.log('Done.'); } main().catch((err) => { console.error(err); process.exit(1); });