/** * Open Food Facts Barcode API integration * Extracts product name, brand, package quantity & unit, image, and category. * Intentionally omits macro nutrients to keep interface clean & fast. */ function parseQuantityString(quantityStr) { if (!quantityStr || typeof quantityStr !== 'string') { return { quantity: 1, unit: 'pcs' }; } const clean = quantityStr.trim().toLowerCase(); // Pattern: e.g. "250g", "250 g", "1.5 kg", "1,5kg", "500 ml", "1 l", "1.5l", "6x100g", "4 x 125 g" // Multi-pack: e.g. 6 x 100 g -> 600 g or total 6 pcs const multiMatch = clean.match(/^(\d+)\s*[xX*]\s*([\d.,]+)\s*([a-zA-Z%]+)?/); if (multiMatch) { const count = parseInt(multiMatch[1], 10); const itemQty = parseFloat(multiMatch[2].replace(',', '.')); const itemUnit = multiMatch[3] ? normalizeUnit(multiMatch[3]) : 'g'; if (!isNaN(count) && !isNaN(itemQty)) { return { quantity: Math.round(count * itemQty), unit: itemUnit, raw: quantityStr }; } } // Single quantity: e.g. "250 g", "500ml", "1kg", "0.75 l" const singleMatch = clean.match(/^([\d.,]+)\s*([a-zA-Z%]+)?/); if (singleMatch) { let num = parseFloat(singleMatch[1].replace(',', '.')); let unit = singleMatch[2] ? normalizeUnit(singleMatch[2]) : 'g'; // Normalize kg to g or l to ml if preferred, or keep as is. Grams / ml are most convenient for fine adjustments! if (unit === 'kg') { num = Math.round(num * 1000); unit = 'g'; } else if (unit === 'l' || unit === 'liter' || unit === 'litre') { num = Math.round(num * 1000); unit = 'ml'; } if (!isNaN(num) && num > 0) { return { quantity: num, unit: unit, raw: quantityStr }; } } return { quantity: 1, unit: 'pcs', raw: quantityStr }; } function normalizeUnit(u) { const unit = u.toLowerCase().trim(); if (unit === 'g' || unit === 'gr' || unit === 'gram' || unit === 'gramm' || unit === 'grams') return 'g'; if (unit === 'kg' || unit === 'kilogram' || unit === 'kilo') return 'kg'; if (unit === 'ml' || unit === 'milliliter') return 'ml'; if (unit === 'l' || unit === 'liter' || unit === 'litre') return 'l'; if (unit === 'cl' || unit === 'centiliter') return 'ml'; if (unit === 'dl' || unit === 'deciliter') return 'ml'; if (unit === 'oz') return 'g'; if (unit === 'stk' || unit === 'stck' || unit === 'stueck' || unit === 'stück' || unit === 'pcs' || unit === 'pc' || unit === 'piece' || unit === 'pieces') return 'pcs'; if (unit === 'pkg' || unit === 'pack' || unit === 'packung' || unit === 'pkt') return 'pack'; if (unit === 'fl' || unit === 'flasche' || unit === 'bottle') return 'pcs'; if (unit === 'dose' || unit === 'can') return 'pcs'; return unit || 'g'; } function inferCategory(categoriesTags = [], productName = '', categoriesStr = '') { const combined = [ ...(Array.isArray(categoriesTags) ? categoriesTags : []), productName, categoriesStr ].join(' ').toLowerCase(); if (/getränk|beverage|drink|saft|juice|cola|soda|wasser|water|bier|beer|wein|wine|milchshake|smoothie|tee|tea|energy|sirup/i.test(combined)) { return 'Beverages'; } if (/milch|käse|kaese|cheese|dairy|joghurt|yogurt|butter|quark|sahne|creme|schmand|mozzarella|parmesan|gouda|cheddar|feta|camembert|pudding/i.test(combined)) { return 'Dairy & Cheese'; } if (/fleisch|meat|wurst|sausage|schinken|ham|hähnchen|chicken|rind|beef|schwein|pork|bacon|salami|speck|fisch|fish|lachs|salmon|thunfisch|tuna|seafood|garnelen/i.test(combined)) { return 'Meat & Fish'; } if (/sauce|soße|senf|mustard|ketchup|mayo|mayonnaise|dressing|pesto|dip|essig|vinegar|öl|oil|gewürz|spice|curry|tabasco|remoulade/i.test(combined)) { return 'Sauces & Condiments'; } if (/gemüse|vegetable|obst|fruit|salat|salad|tomate|tomato|gurke|cucumber|apfel|apple|banane|banana|zwiebel|onion|karotte|carrot|kartoffel|potato|beeren|berry|zitrus|citrus|spinat|spinach/i.test(combined)) { return 'Produce'; } if (/brot|bread|toast|brötchen|bun|teig|dough|hefe|yeast|croissant|tortilla|wrap/i.test(combined)) { return 'Bakery'; } if (/snack|schokolade|chocolate|chips|keks|cookie|süß|sweet|candy|nuss|nut|eis|ice cream/i.test(combined)) { return 'Snacks & Sweets'; } if (/reste|leftover|fertiggericht|meal|pizza|suppen|soup|pasta|nudeln|noodle/i.test(combined)) { return 'Prepared & Leftovers'; } return 'General'; } async function lookupBarcode(barcode) { if (!barcode || typeof barcode !== 'string') return null; const cleanBarcode = barcode.trim(); if (!cleanBarcode) return null; try { const url = `https://world.openfoodfacts.org/api/v2/product/${encodeURIComponent(cleanBarcode)}.json?fields=product_name,product_name_de,product_name_en,brands,quantity,categories_tags,categories,image_front_small_url,image_url,image_front_url`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 6000); // 6s timeout const res = await fetch(url, { headers: { 'User-Agent': 'Fridger - Fridge Inventory Manager (https://gitea.eibl.tech/me0nline/Fridger)' }, signal: controller.signal }); clearTimeout(timeout); if (!res.ok) { return null; } const data = await res.json(); if (data.status !== 1 || !data.product) { return null; } const p = data.product; const name = p.product_name_de || p.product_name || p.product_name_en || ''; const brand = p.brands || ''; const parsedQty = parseQuantityString(p.quantity); const category = inferCategory(p.categories_tags, name, p.categories); const imageUrl = p.image_front_small_url || p.image_front_url || p.image_url || ''; return { found: true, barcode: cleanBarcode, name: name.trim() || 'Unknown Product', brand: brand.trim(), quantity: parsedQty.quantity, initial_quantity: parsedQty.quantity, unit: parsedQty.unit, category: category, image_url: imageUrl, raw_quantity: p.quantity || '' }; } catch (err) { console.warn(`Barcode lookup error for ${cleanBarcode}:`, err.message); return null; } } module.exports = { lookupBarcode, parseQuantityString, inferCategory };