Skip to content

Commit 9150a17

Browse files
authored
Merge pull request #913 from CodeWithCJ/dev
implement localized food name mapping for OpenFoodFacts
2 parents 215c9f6 + ac56923 commit 9150a17

7 files changed

Lines changed: 148 additions & 24 deletions

File tree

SparkyFitnessFrontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "sparkyfitnessfrontend",
33
"private": true,
4-
"version": "0.16.5.1",
4+
"version": "0.16.5.2",
55
"homepage": "https://github.com/CodeWithCJ/SparkyFitness",
66
"type": "module",
77
"scripts": {

SparkyFitnessFrontend/src/components/Onboarding/OnBoarding.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,12 @@ const OnBoarding = ({ onOnboardingComplete }: OnBoardingProps) => {
2525
}
2626

2727
return (
28-
weightData &&
29-
heightData && (
30-
<OnBoardingForm
31-
onOnboardingComplete={onOnboardingComplete}
32-
profileData={profileData}
33-
weightData={weightData}
34-
heightData={heightData}
35-
/>
36-
)
28+
<OnBoardingForm
29+
onOnboardingComplete={onOnboardingComplete}
30+
profileData={profileData ?? undefined}
31+
weightData={weightData ?? undefined}
32+
heightData={heightData ?? undefined}
33+
/>
3734
);
3835
};
3936

SparkyFitnessServer/integrations/openfoodfacts/openFoodFactsService.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const OFF_HEADERS = {
1010

1111
const OFF_FIELDS = [
1212
"product_name",
13+
"product_name_en",
1314
"brands",
1415
"code",
1516
"serving_size",
@@ -19,7 +20,13 @@ const OFF_FIELDS = [
1920

2021
async function searchOpenFoodFacts(query, page = 1, language = "en") {
2122
try {
22-
const searchUrl = `https://world.openfoodfacts.org/cgi/search.pl?search_terms=${encodeURIComponent(query)}&search_simple=1&action=process&json=1&page_size=20&page=${page}&fields=${OFF_FIELDS.join(",")}&lc=${language}`;
23+
const fieldSet = new Set(OFF_FIELDS);
24+
if (language !== "en") {
25+
fieldSet.add(`product_name_${language}`);
26+
}
27+
const fields = [...fieldSet];
28+
29+
const searchUrl = `https://world.openfoodfacts.org/cgi/search.pl?search_terms=${encodeURIComponent(query)}&search_simple=1&action=process&json=1&page_size=20&page=${page}&fields=${fields.join(",")}&lc=${language}`;
2330
const response = await fetch(searchUrl, {
2431
method: "GET",
2532
headers: OFF_HEADERS,
@@ -56,7 +63,12 @@ async function searchOpenFoodFactsByBarcodeFields(
5663
language = "en",
5764
) {
5865
try {
59-
const fieldsParam = fields.join(",");
66+
const fieldSet = new Set(fields);
67+
if (language !== "en") {
68+
fieldSet.add(`product_name_${language}`);
69+
}
70+
const finalFields = [...fieldSet];
71+
const fieldsParam = finalFields.join(",");
6072
const searchUrl = `https://world.openfoodfacts.org/api/v2/product/${barcode}.json?fields=${fieldsParam}&lc=${language}`;
6173
const response = await fetch(searchUrl, {
6274
method: "GET",
@@ -86,7 +98,10 @@ async function searchOpenFoodFactsByBarcodeFields(
8698
}
8799
}
88100

89-
function mapOpenFoodFactsProduct(product, { autoScale = true } = {}) {
101+
function mapOpenFoodFactsProduct(
102+
product,
103+
{ autoScale = true, language = "en" } = {},
104+
) {
90105
const nutriments = product.nutriments || {};
91106
const servingSize = autoScale
92107
? (product.serving_quantity > 0 ? product.serving_quantity : 100)
@@ -138,8 +153,17 @@ function mapOpenFoodFactsProduct(product, { autoScale = true } = {}) {
138153
is_default: true,
139154
};
140155

156+
// Language fallback priority:
157+
// 1. product_name_${language}
158+
// 2. product_name_en
159+
// 3. product_name (default/original)
160+
const name =
161+
product[`product_name_${language}`] ||
162+
product.product_name_en ||
163+
product.product_name;
164+
141165
return {
142-
name: product.product_name,
166+
name,
143167
brand: product.brands?.split(",")[0]?.trim() || "",
144168
barcode: normalizeBarcode(product.code),
145169
provider_external_id: product.code,

SparkyFitnessServer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sparkyfitnessserver",
3-
"version": "0.16.5.1",
3+
"version": "0.16.5.2",
44
"main": "SparkyFitnessServer.js",
55
"scripts": {
66
"start": "nodemon SparkyFitnessServer.js",

SparkyFitnessServer/routes/v2/foodRoutes.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,12 @@ const searchHandler: RequestHandler<{ providerType: string }> = async (req, res,
188188
const autoScale = (req.query.autoScale as string ?? 'true') !== 'false';
189189
const result = await searchOpenFoodFacts(query, page, language);
190190
const products = (result.products || []).filter(
191-
(p: Record<string, unknown>) => p.product_name,
191+
(p: Record<string, any>) =>
192+
p.product_name ||
193+
p[`product_name_${language}`] ||
194+
p.product_name_en,
192195
);
193-
foods = products.map((p: Record<string, unknown>) => mapOpenFoodFactsProduct(p, { autoScale })).filter(Boolean);
196+
foods = products.map((p: Record<string, unknown>) => mapOpenFoodFactsProduct(p, { autoScale, language })).filter(Boolean);
194197
pagination = result.pagination;
195198
break;
196199
}
@@ -296,7 +299,7 @@ const detailHandler: RequestHandler<{
296299
case "openfoodfacts": {
297300
const data = await searchOpenFoodFactsByBarcodeFields(externalId, undefined, language);
298301
if (data.status === 1 && data.product) {
299-
food = mapOpenFoodFactsProduct(data.product);
302+
food = mapOpenFoodFactsProduct(data.product, { language });
300303
}
301304
break;
302305
}

SparkyFitnessServer/services/foodCoreService.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -846,12 +846,15 @@ async function lookupBarcode(barcode, userId, providerId) {
846846
return { source: "not_found", food: null };
847847
}
848848

849-
if (offData?.status === 1 && offData.product?.product_name) {
850-
return {
851-
source: "openfoodfacts",
852-
food: mapOpenFoodFactsProduct(offData.product),
853-
barcode_raw: offData.product,
854-
};
849+
if (offData?.status === 1 && offData.product) {
850+
const food = mapOpenFoodFactsProduct(offData.product, { language });
851+
if (food.name) {
852+
return {
853+
source: "openfoodfacts",
854+
food,
855+
barcode_raw: offData.product,
856+
};
857+
}
855858
}
856859

857860
return { source: "not_found", food: null };
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
const {
2+
mapOpenFoodFactsProduct,
3+
searchOpenFoodFacts,
4+
searchOpenFoodFactsByBarcodeFields,
5+
} = require("../integrations/openfoodfacts/openFoodFactsService");
6+
7+
global.fetch = jest.fn();
8+
9+
describe("OpenFoodFacts Language Handling", () => {
10+
beforeEach(() => {
11+
jest.clearAllMocks();
12+
});
13+
14+
describe("mapOpenFoodFactsProduct (Fallback Logic)", () => {
15+
const mockProduct = {
16+
code: "8076809529419",
17+
product_name: "Pâtes spaghetti au blé complet integral 500g",
18+
product_name_en: "Integrale Whole Wheat Spaghetti",
19+
product_name_fr: "Pâtes spaghetti au blé complet",
20+
brands: "Barilla",
21+
nutriments: {},
22+
};
23+
24+
it("should use language-specific name if available", () => {
25+
const result = mapOpenFoodFactsProduct(mockProduct, { language: "fr" });
26+
expect(result.name).toBe("Pâtes spaghetti au blé complet");
27+
});
28+
29+
it("should fall back to English if requested language name is missing", () => {
30+
const result = mapOpenFoodFactsProduct(mockProduct, { language: "de" });
31+
expect(result.name).toBe("Integrale Whole Wheat Spaghetti");
32+
});
33+
34+
it("should fall back to default product_name if both requested and English names are missing", () => {
35+
const productNoEn = {
36+
...mockProduct,
37+
product_name_en: undefined,
38+
product_name_fr: undefined,
39+
};
40+
const result = mapOpenFoodFactsProduct(productNoEn, { language: "fr" });
41+
expect(result.name).toBe("Pâtes spaghetti au blé complet integral 500g");
42+
});
43+
44+
it("should prioritize English even if it is the requested language", () => {
45+
const result = mapOpenFoodFactsProduct(mockProduct, { language: "en" });
46+
expect(result.name).toBe("Integrale Whole Wheat Spaghetti");
47+
});
48+
});
49+
50+
describe("API Request URL generation", () => {
51+
it("should include product_name_${language} in the fields for search", async () => {
52+
fetch.mockResolvedValue({
53+
ok: true,
54+
json: () => Promise.resolve({ products: [], count: 0 }),
55+
});
56+
57+
await searchOpenFoodFacts("spaghetti", 1, "fr");
58+
59+
expect(fetch).toHaveBeenCalledWith(
60+
expect.stringContaining("product_name_fr"),
61+
expect.any(Object)
62+
);
63+
expect(fetch).toHaveBeenCalledWith(
64+
expect.stringContaining("product_name_en"),
65+
expect.any(Object)
66+
);
67+
});
68+
69+
it("should include product_name_${language} in the fields for barcode lookup", async () => {
70+
fetch.mockResolvedValue({
71+
ok: true,
72+
json: () => Promise.resolve({ status: 1, product: {} }),
73+
});
74+
75+
await searchOpenFoodFactsByBarcodeFields("12345678", undefined, "it");
76+
77+
expect(fetch).toHaveBeenCalledWith(
78+
expect.stringContaining("product_name_it"),
79+
expect.any(Object)
80+
);
81+
});
82+
83+
it("should not duplicate product_name_en if language is en", async () => {
84+
fetch.mockResolvedValue({
85+
ok: true,
86+
json: () => Promise.resolve({ status: 1, product: {} }),
87+
});
88+
89+
await searchOpenFoodFactsByBarcodeFields("12345678", undefined, "en");
90+
91+
const url = fetch.mock.calls[0][0];
92+
const fields = new URL(url).searchParams.get("fields").split(",");
93+
const enOccurrences = fields.filter(f => f === "product_name_en").length;
94+
expect(enOccurrences).toBe(1);
95+
});
96+
});
97+
});

0 commit comments

Comments
 (0)