166 lines
6.2 KiB
JavaScript
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
|
|
};
|