Files

166 lines
6.2 KiB
JavaScript

/**
* 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
};