diff --git a/apps/api/src/open-food-facts.test.ts b/apps/api/src/open-food-facts.test.ts new file mode 100644 index 0000000..c669b9b --- /dev/null +++ b/apps/api/src/open-food-facts.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { lookupOpenFoodFacts, parseOpenFoodFactsProduct } from "./open-food-facts.js"; + +test("parses per-100g values and converts salt/sodium to mg", () => { + const result = parseOpenFoodFactsProduct("7290000000001", { + product_name: " Cottage ", + quantity: "250 g", + nutriments: { "energy-kcal_100g": 98, proteins_100g: 11.5, carbohydrates_100g: 3.4, fat_100g: 4, salt_100g: 1.25 }, + }); + assert.deepEqual(result, { + barcode: "7290000000001", + name: "Cottage", + basis: "solid", + calories: 98, + protein: 11.5, + carbohydrates: 3.4, + fat: 4, + fiber: 0, + sugars: 0, + sodium: 500, + }); +}); + +test("falls back to kJ and detects liquids", () => { + const result = parseOpenFoodFactsProduct("7290000000002", { + product_name_he: "חלב", + quantity: "1 l", + nutriments: { energy_100g: 251.2, sodium_100g: 0.05 }, + }); + assert.equal(result?.calories, 60.04); + assert.equal(result?.basis, "liquid"); + assert.equal(result?.sodium, 50); +}); + +test("returns null without a name or calories", () => { + assert.equal(parseOpenFoodFactsProduct("1", { product_name: "", nutriments: { "energy-kcal_100g": 5 } }), null); + assert.equal(parseOpenFoodFactsProduct("1", { product_name: "X", nutriments: {} }), null); + assert.equal(parseOpenFoodFactsProduct("1", null), null); +}); + +test("lookup is best effort", async () => { + const ok = (async () => new Response(JSON.stringify({ status: 1, product: { product_name: "A", nutriments: { "energy-kcal_100g": 10 } } }))) as typeof fetch; + assert.equal((await lookupOpenFoodFacts("7290000000003", ok))?.name, "A"); + const boom = (async () => { throw new Error("offline"); }) as typeof fetch; + assert.equal(await lookupOpenFoodFacts("7290000000003", boom), null); + assert.equal(await lookupOpenFoodFacts("abc", ok), null); +}); diff --git a/apps/api/src/open-food-facts.ts b/apps/api/src/open-food-facts.ts new file mode 100644 index 0000000..7a1f33f --- /dev/null +++ b/apps/api/src/open-food-facts.ts @@ -0,0 +1,82 @@ +/** Barcode lookup in Open Food Facts, used when a barcode is not in our own catalog. */ + +export type FoodSuggestion = { + barcode: string; + name: string; + basis: "solid" | "liquid"; + calories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber: number; + sugars: number; + sodium: number; +}; + +const FIELDS = "product_name,product_name_he,product_name_en,quantity,nutriments"; +const TIMEOUT_MS = 4000; + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +function amount(nutriments: Record, key: string): number | null { + const value = nutriments[key]; + const parsed = typeof value === "string" ? Number(value) : value; + return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +/** + * Turns an Open Food Facts product into a suggestion for a new food (values per 100 g / 100 ml). + * Returns null when the product has no name or no calories: nothing useful to prefill. + * Sodium is stored in mg; Open Food Facts reports grams, or salt (sodium = salt / 2.5). + */ +export function parseOpenFoodFactsProduct(barcode: string, product: unknown): FoodSuggestion | null { + if (!product || typeof product !== "object") return null; + const p = product as Record; + const nutriments = (p.nutriments && typeof p.nutriments === "object" ? p.nutriments : {}) as Record; + const text = (key: string) => (typeof p[key] === "string" ? (p[key] as string).trim() : ""); + const name = (text("product_name") || text("product_name_he") || text("product_name_en")).slice(0, 120); + let calories = amount(nutriments, "energy-kcal_100g"); + if (calories == null) { + const kj = amount(nutriments, "energy_100g"); + calories = kj == null ? null : kj / 4.184; + } + if (!name || calories == null || calories > 10000) return null; + const sodiumGrams = amount(nutriments, "sodium_100g") ?? (() => { + const salt = amount(nutriments, "salt_100g"); + return salt == null ? null : salt / 2.5; + })(); + const quantity = text("quantity").toLowerCase(); + return { + barcode, + name, + basis: /\d\s*(ml|cl|l)\b|מ"ל|מל/.test(quantity) ? "liquid" : "solid", + calories: round(calories), + protein: round(amount(nutriments, "proteins_100g") ?? 0), + carbohydrates: round(amount(nutriments, "carbohydrates_100g") ?? 0), + fat: round(amount(nutriments, "fat_100g") ?? 0), + fiber: round(amount(nutriments, "fiber_100g") ?? 0), + sugars: round(amount(nutriments, "sugars_100g") ?? 0), + sodium: round((sodiumGrams ?? 0) * 1000), + }; +} + +/** Best effort: any network or format problem means "not found", never an error for the user. */ +export async function lookupOpenFoodFacts( + barcode: string, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!/^\d{6,14}$/.test(barcode)) return null; + try { + const response = await fetchImpl( + `https://world.openfoodfacts.org/api/v2/product/${barcode}.json?fields=${FIELDS}`, + { headers: { "User-Agent": "DropIt/1.0" }, signal: AbortSignal.timeout(TIMEOUT_MS) }, + ); + if (!response.ok) return null; + const body = (await response.json()) as { status?: number; product?: unknown }; + return body.status === 1 ? parseOpenFoodFactsProduct(barcode, body.product) : null; + } catch { + return null; + } +} diff --git a/apps/api/src/routes/foods.ts b/apps/api/src/routes/foods.ts index 8999241..bdad085 100644 --- a/apps/api/src/routes/foods.ts +++ b/apps/api/src/routes/foods.ts @@ -3,6 +3,7 @@ import { currentUser, requireUser } from "../auth.js"; import { prisma } from "../db.js"; import { resolveBarcode } from "../domain.js"; import { asyncRoute, HttpError } from "../http.js"; +import { lookupOpenFoodFacts } from "../open-food-facts.js"; import { CATALOG_PAGE_SIZE, catalogWhere, pickRecentFoods, pickSearchResults } from "../food-search.js"; import { num, presentFood } from "../present.js"; import { requirePermission } from "../rbac.js"; @@ -112,10 +113,13 @@ foodsRouter.get( const published = rows.filter((food) => food.published && food.ownerId !== user.id); const verified = published.find((food) => food.verifiedAt != null) ?? null; const decision = resolveBarcode({ own, verified, published }); + // Not in our catalog: offer Open Food Facts values to prefill a new food. Nothing is saved here. + const suggestion = decision.status === "none" ? await lookupOpenFoodFacts(barcode) : null; res.json({ - status: decision.status, + status: suggestion ? "external" : decision.status, food: decision.food ? presentFood(decision.food, user.id) : null, foods: decision.foods.map((food) => presentFood(food, user.id)), + suggestion, }); }), ); diff --git a/apps/mobile/lib/copy.dart b/apps/mobile/lib/copy.dart index ff04523..fef3191 100644 --- a/apps/mobile/lib/copy.dart +++ b/apps/mobile/lib/copy.dart @@ -237,6 +237,10 @@ class Copy { String get catalogLoadMore => pick('Load more', 'טען עוד'); String get catalogPrivate => pick('Private', 'פרטי'); String get chooseFood => pick('Choose a published food', 'בחירת מזון שפורסם'); + String get onlineValues => pick( + 'Values from Open Food Facts (per 100 g / 100 ml) – please check them before saving.', + 'הערכים מ-Open Food Facts (ל-100 גרם / 100 מ״ל) – נא לבדוק אותם לפני השמירה.', + ); String get noFood => pick( 'This barcode is not in the database yet.', 'הברקוד הזה עדיין לא במאגר.', diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index dc62a8a..fb12fa2 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -1366,11 +1366,18 @@ class _TodayScreenState extends State { if (!mounted) return; Map? food = (result['food'] as Map?) ?.cast(); - if (result['status'] == 'none') { + if (result['status'] == 'none' || result['status'] == 'external') { + final suggestion = (result['suggestion'] as Map?) + ?.cast(); food = await Navigator.push>( context, MaterialPageRoute( - builder: (_) => FoodScreen(api: widget.api, t: t, barcode: barcode), + builder: (_) => FoodScreen( + api: widget.api, + t: t, + barcode: barcode, + suggestion: suggestion, + ), ), ); } else if (result['status'] == 'list') { @@ -4034,10 +4041,14 @@ class FoodScreen extends StatefulWidget { required this.t, this.barcode, this.initialName, + this.suggestion, }); final Api api; final Copy t; + /// Open Food Facts values for the barcode, to prefill (and flag for review). + final Map? suggestion; + /// Known when the food was scanned; otherwise the user types it. final String? barcode; final String? initialName; @@ -4047,18 +4058,29 @@ class FoodScreen extends StatefulWidget { } class _FoodScreenState extends State { - late final name = TextEditingController(text: widget.initialName ?? ''); + late final name = TextEditingController( + text: (widget.suggestion?['name'] as String?) ?? widget.initialName ?? '', + ); late final barcode = TextEditingController(text: widget.barcode ?? ''); - String basis = 'solid'; - final calories = TextEditingController(); - final protein = TextEditingController(); - final carbohydrates = TextEditingController(); - final fat = TextEditingController(); - final fiber = TextEditingController(); - final sugars = TextEditingController(); - final sodium = TextEditingController(); + late String basis = widget.suggestion?['basis'] == 'liquid' ? 'liquid' : 'solid'; + late final calories = _prefilled('calories'); + late final protein = _prefilled('protein'); + late final carbohydrates = _prefilled('carbohydrates'); + late final fat = _prefilled('fat'); + late final fiber = _prefilled('fiber'); + late final sugars = _prefilled('sugars'); + late final sodium = _prefilled('sodium'); String error = ''; - final scanned = {}; + late final scanned = { + if (widget.suggestion != null) + for (final c in [calories, protein, carbohydrates, fat, fiber, sugars, sodium]) + if (c.text.isNotEmpty) c, + }; + + TextEditingController _prefilled(String key) { + final value = widget.suggestion?[key]; + return TextEditingController(text: value is num ? _plainNumber(value) : ''); + } bool scannedFromServing = false; String scannedText = ''; NutritionScan? lastScan; @@ -4122,6 +4144,11 @@ class _FoodScreenState extends State { label: Text(t.scanLabel), ), ), + if (widget.suggestion != null) + Text( + t.onlineValues, + style: TextStyle(color: context.apple.labelSecondary), + ), if (scannedText.isNotEmpty) Align( alignment: AlignmentDirectional.centerStart, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 288aa3f..a061bd4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,6 +13,7 @@ import { type FastingState, type FastNotification, type Food, + type FoodSuggestion, type ReminderOffset, MEALS, type MealCategory, @@ -611,7 +612,7 @@ function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; o const [query, setQuery] = useState(""); const [results, setResults] = useState(null); const [recent, setRecent] = useState([]); - const [lookup, setLookup] = useState<{ status: string; food: Food | null; foods: Food[] } | null>(null); + const [lookup, setLookup] = useState<{ status: string; food: Food | null; foods: Food[]; suggestion?: FoodSuggestion | null } | null>(null); const [picked, setPicked] = useState(null); const [amount, setAmount] = useState("100"); const [error, setError] = useState(""); @@ -720,7 +721,7 @@ function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; o return; } try { - const result = await api<{ status: string; food: Food | null; foods: Food[] }>(`/api/foods/barcode/${encodeURIComponent(trimmedQuery)}`); + const result = await api<{ status: string; food: Food | null; foods: Food[]; suggestion?: FoodSuggestion | null }>(`/api/foods/barcode/${encodeURIComponent(trimmedQuery)}`); setLookup(result); if (result.food) pick(result.food); } catch (err) { @@ -746,6 +747,12 @@ function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; o } } + function suggestionLink(meal: MealCategory, suggestion: FoodSuggestion) { + const search = new URLSearchParams({ meal, source: "online" }); + for (const [key, value] of Object.entries(suggestion)) search.set(key, String(value)); + return `/foods/new?${search}`; + } + function newFoodLink(meal: MealCategory) { const search = new URLSearchParams({ meal }); if (barcodeQuery) search.set("barcode", trimmedQuery); @@ -906,6 +913,12 @@ function Today({ canVerifyFoods, onFastingChange }: { canVerifyFoods: boolean; o {lookup?.status === "none" && (

{t.noFood} {t.createFood}

)} + {lookup?.status === "external" && lookup.suggestion && ( +

+ {t.foundOnline}: {lookup.suggestion.name}{" "} + {t.reviewAndSave} +

+ )} {lookup?.status === "list" && (

{t.chooseFood}

@@ -1870,21 +1883,22 @@ function FoodForm() { return (

{t.createFood}

+ {!id && params.get("source") === "online" &&

{t.onlineValues}

} {!id && } - +
{(["protein", "carbohydrates", "fat", "fiber", "sugars", "sodium"] as const).map((field) => ( - + ))}
{error &&

{error}

} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index dcbd2f5..63f0f67 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -79,6 +79,19 @@ export type Nutrients = { sodium: number; }; +export type FoodSuggestion = { + barcode: string; + name: string; + basis: "solid" | "liquid"; + calories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber: number; + sugars: number; + sodium: number; +}; + export type Food = Nutrients & { id: string; barcode: string | null; diff --git a/apps/web/src/i18n.tsx b/apps/web/src/i18n.tsx index 3cfe682..71a83ec 100644 --- a/apps/web/src/i18n.tsx +++ b/apps/web/src/i18n.tsx @@ -167,6 +167,9 @@ const en = { catalogFilter: "Verification", personal: "Your food", chooseFood: "Choose a published food", + foundOnline: "Found online", + reviewAndSave: "Review and save", + onlineValues: "Values from Open Food Facts (per 100 g / 100 ml). Please check them before saving.", noFood: "This barcode is not in the database yet.", emptyDay: "No foods logged today.", emptyMeal: "Nothing logged yet.", @@ -411,6 +414,9 @@ const he: typeof en = { catalogFilter: "אימות", personal: "המזון שלך", chooseFood: "בחירת מזון שפורסם", + foundOnline: "נמצא ברשת", + reviewAndSave: "בדיקה ושמירה", + onlineValues: "הערכים מ-Open Food Facts (ל-100 גרם / 100 מ״ל). נא לבדוק אותם לפני השמירה.", noFood: "הברקוד הזה עדיין לא במאגר.", emptyDay: "עדיין לא נרשם מזון היום.", emptyMeal: "עדיין לא נרשם.", diff --git a/docs/ideas.md b/docs/ideas.md index 566bf33..6414015 100644 --- a/docs/ideas.md +++ b/docs/ideas.md @@ -7,7 +7,7 @@ Ideas for improving Drop It, to revisit. Roughly in priority order. Real failures become parser tests (like the Pringles one) and a test set builds up over time. Liquid / per-100 ml labels still fail. -2. **Auto-fill foods from a barcode database** +2. **Auto-fill foods from a barcode database** (done: API falls back to Open Food Facts, clients prefill the form) Look the barcode up in Open Food Facts (good Israeli coverage) before asking for a label photo. The label scanner becomes the fallback. Also seeds the catalog.