diff --git a/README.md b/README.md index 8a51991..1f73d5e 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,8 @@ cached session, so it also overrides a stale cached timezone. |------|-------------| | `add_food_entry` | Log a food serving to the diary | | `remove_food_entry` | Remove one or more diary entries | -| `add_custom_food` | Create a custom food with specified nutrition | -| `update_custom_food` | Edit a custom food in place: name, nutrition, or serving size | +| `add_custom_food` | Create a custom food with specified nutrition and optional barcodes | +| `update_custom_food` | Edit a custom food in place: name, nutrition, serving size, or barcodes | | `delete_custom_food` | Retire a custom food so it leaves search and the Custom Foods list (existing diary entries are kept) | | `add_recipe` | Create a recipe from existing foods referenced by ID and gram weight | | `import_recipe` | Create a recipe from a free-text ingredient list; Cronometer matches each line to a database food and converts the amount to grams | diff --git a/src/cronometer_api_mcp/barcode.py b/src/cronometer_api_mcp/barcode.py new file mode 100644 index 0000000..7894ed8 --- /dev/null +++ b/src/cronometer_api_mcp/barcode.py @@ -0,0 +1,60 @@ +"""Product barcode (EAN, UPC and GTIN) validation for custom foods.""" + +import re + + +def normalize_barcodes(barcodes: list[str]) -> list[str]: + """Validate product barcodes and return them as bare digit strings. + + Accepts the GTIN family the app's scanner reads -- EAN-13, UPC-A (12), + EAN-8 or UPC-E (8) and GTIN-14 -- including the spaces or dashes the + digits are printed with under the bars ("0 12345 67890 5"). The check + digit is verified, so a digit misread off a label is rejected instead of + saved as a code that never scans. Duplicates are dropped. Bare digit + strings are what get_food reports for branded foods (["602652270284"]). + """ + normalized: list[str] = [] + for raw in barcodes: + code = re.sub(r"[\s-]", "", str(raw)) + if not (code.isascii() and code.isdigit()) or len(code) not in (8, 12, 13, 14): + raise ValueError( + f"barcode {raw!r} must be 8, 12, 13 or 14 digits (EAN, UPC or GTIN)" + ) + if not _has_valid_check_digit(code): + raise ValueError( + f"barcode {raw!r} has the wrong check digit (last digit); " + f"re-check the digits" + ) + if code not in normalized: + normalized.append(code) + return normalized + + +def _gtin_check_digit(body: str) -> int: + """GS1 check digit for `body`: weights 3, 1, 3, ... from the right.""" + total = sum(int(d) * (1 if i % 2 else 3) for i, d in enumerate(reversed(body))) + return -total % 10 + + +def _has_valid_check_digit(code: str) -> bool: + if _gtin_check_digit(code[:-1]) == int(code[-1]): + return True + # An 8-digit code that isn't a valid EAN-8 can still be UPC-E, whose check + # digit is the one of the 12-digit UPC-A it abbreviates. + return ( + len(code) == 8 + and code[0] in "01" + and _gtin_check_digit(_expand_upc_e(code)) == int(code[-1]) + ) + + +def _expand_upc_e(code: str) -> str: + """The UPC-A digits (check digit excluded) a UPC-E code abbreviates.""" + system, d = code[0], code[1:7] + if d[5] in "012": + return system + d[0:2] + d[5] + "0000" + d[2:5] + if d[5] == "3": + return system + d[0:3] + "00000" + d[3:5] + if d[5] == "4": + return system + d[0:4] + "00000" + d[4] + return system + d[0:5] + "0000" + d[5] diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index d645527..0a4a76e 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -27,6 +27,8 @@ import httpx +from .barcode import normalize_barcodes + logger = logging.getLogger(__name__) BASE_URL = "https://mobile.cronometer.com" @@ -659,6 +661,7 @@ def create_custom_food( extra_nutrients: dict[int, float] | None = None, serving_name: str = "1 serving", serving_grams: float = 100.0, + barcodes: list[str] | None = None, ) -> dict: """Create a custom food in Cronometer. @@ -676,9 +679,14 @@ def create_custom_food( Must not reuse an ID already written by the named macro args above; doing so raises ValueError rather than silently duplicating or shadowing an entry. + barcodes: Product barcodes (EAN/UPC/GTIN) to attach; scanning one + in the app finds this food. Spaces and dashes are stripped and + the check digit verified; a bad code raises ValueError. Returns {"food_id": int, "measure_id": int | None}. """ + barcodes = normalize_barcodes(barcodes or []) + # Cronometer stores nutrients per 100g -- normalize from per-serving. scale = 100.0 / serving_grams if serving_grams > 0 else 1.0 @@ -741,6 +749,7 @@ def create_custom_food( "nutrients": nutrients, "properties": {}, "foodTags": [], + "barcodes": barcodes, }, "config": {"call_version": 1}, } @@ -831,6 +840,7 @@ def update_custom_food( serving_name: str | None = None, serving_grams: float | None = None, serving_quantity: float | None = None, + barcodes: list[str] | None = None, ) -> dict: """Edit a user-created custom food in place. @@ -851,6 +861,9 @@ def update_custom_food( reason. A quantity set without serving_grams keeps the total weight, so the per-unit weight changes. + barcodes replaces the food's whole barcode list ([] clears it) and is + validated like create_custom_food's. + Returns {"food_id": int, "name": str}. """ if extra_nutrients: @@ -882,9 +895,15 @@ def update_custom_food( if v is not None and not (math.isfinite(v) and v > 0): raise ValueError(f"{label} must be a positive number, got {v!r}") + if barcodes is not None: + barcodes = normalize_barcodes(barcodes) + with self._food_lock(food_id): food = self._get_custom_food(food_id) + if barcodes is not None: + food["barcodes"] = barcodes + if name is not None: old_name = food.get("name") food["name"] = name diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index 22630b6..af0b74f 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -563,6 +563,7 @@ def get_food_details(food_id: int) -> str: "name": data.get("name"), "default_measure_id": data.get("defaultMeasureId"), "measures": measures, + "barcodes": data.get("barcodes", []), "nutrients": data.get("nutrients", []), } ) @@ -589,6 +590,7 @@ def add_custom_food( extra_nutrients: dict[int, float] | None = None, serving_name: str = "1 serving", serving_grams: float = 100.0, + barcodes: list[str] | None = None, ) -> str: """Create a custom food in Cronometer with specified nutrition. @@ -613,6 +615,12 @@ def add_custom_food( named macro args already cover. serving_name: Name for the serving size (default "1 serving"). serving_grams: Weight of one serving in grams (default 100). + barcodes: Product barcodes (EAN-13, UPC-A, EAN-8, UPC-E or GTIN-14), + e.g. the digits printed under the bars on the package; spaces and + dashes are ignored. Scanning one of them in the Cronometer app + then finds this food (search_foods does not match barcodes). The + check digit is verified, so a misread digit is rejected rather + than saved. """ try: client = _get_client() @@ -629,6 +637,7 @@ def add_custom_food( extra_nutrients=extra_nutrients, serving_name=serving_name, serving_grams=serving_grams, + barcodes=barcodes, ) # Fetch back to get the server-assigned measure_id @@ -663,6 +672,7 @@ def update_custom_food( serving_name: str | None = None, serving_grams: float | None = None, serving_quantity: float | None = None, + barcodes: list[str] | None = None, ) -> str: """Edit an existing custom food (one you created) in place. @@ -703,6 +713,9 @@ def update_custom_food( gives "3 cookies = 30 g". To keep the per-unit weight, also pass serving_grams (45 here). Check the result with get_food_details, which lists each measure's quantity. + barcodes: Replaces the food's barcodes (see add_custom_food for the + accepted formats); pass [] to remove them all. To add one, pass + the current barcodes from get_food_details plus the new one. """ try: client = _get_client() @@ -721,6 +734,7 @@ def update_custom_food( serving_name=serving_name, serving_grams=serving_grams, serving_quantity=serving_quantity, + barcodes=barcodes, ) return _ok({"food_id": result["food_id"], "name": result["name"]}) except Exception as e: diff --git a/tests/test_barcode.py b/tests/test_barcode.py new file mode 100644 index 0000000..41fc159 --- /dev/null +++ b/tests/test_barcode.py @@ -0,0 +1,68 @@ +"""Tests for barcode validation of custom foods. + +normalize_barcodes accepts the GTIN family (EAN-13, UPC-A, EAN-8, UPC-E, +GTIN-14), strips the spaces and dashes codes are printed with, and verifies +the GS1 check digit so a misread digit is rejected rather than saved. +""" + +from __future__ import annotations + +import pytest + +from cronometer_api_mcp.barcode import normalize_barcodes + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("4006381333931", "4006381333931"), # EAN-13 + ("602652270284", "602652270284"), # UPC-A (from a real get_food) + ("0 49000 05010 3", "049000050103"), # UPC-A as printed on a label + ("9638-5074", "96385074"), # EAN-8 + # UPC-E, one per expansion rule (the sixth digit picks it). The first + # three aren't valid EAN-8s, so only the UPC-E check accepts them; + # under rule 5-9 the two check digits always coincide. + ("04252614", "04252614"), # -> UPC-A 042100005264 + ("01234531", "01234531"), # -> UPC-A 012300000451 + ("01234543", "01234543"), # -> UPC-A 012340000053 + ("01234565", "01234565"), # -> UPC-A 012345000065 + ("10012345678902", "10012345678902"), # GTIN-14 + ], +) +def test_normalize_barcodes_accepts_gtin_family(raw, expected): + assert normalize_barcodes([raw]) == [expected] + + +@pytest.mark.parametrize( + "raw", + [ + "4006381333932", # EAN-13, last digit off by one + "602652270285", # UPC-A, wrong check digit + "04252615", # neither a valid EAN-8 nor a valid UPC-E + "24252618", # UPC-E check digit, but UPC-E only has number systems 0, 1 + "01234566", # rule 5-9: wrong check digit as EAN-8 and as UPC-E + ], +) +def test_normalize_barcodes_rejects_wrong_check_digit(raw): + with pytest.raises(ValueError, match="check digit"): + normalize_barcodes([raw]) + + +@pytest.mark.parametrize( + "raw", + [ + "", + "12345", # no GTIN has 5 digits + "400638133393a", + "4006381333931", # full-width digits: isdigit() but not ASCII + ], +) +def test_normalize_barcodes_rejects_malformed(raw): + with pytest.raises(ValueError, match="must be 8, 12, 13 or 14 digits"): + normalize_barcodes([raw]) + + +def test_normalize_barcodes_drops_duplicates(): + assert normalize_barcodes(["4006381333931", "4006 3813 3393 1"]) == [ + "4006381333931" + ] diff --git a/tests/test_client.py b/tests/test_client.py index f7deff8..ae129bd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -908,6 +908,44 @@ def test_create_custom_food_extra_nutrients_overlap_raises(tmp_path): ) +# --------------------------------------------------------------------------- +# create_custom_food: barcodes +# --------------------------------------------------------------------------- + + +def _create_test_food(client, **kwargs): + return client.create_custom_food( + "Test Food", calories=200, protein_g=10, fat_g=5, carbs_g=20, **kwargs + ) + + +def test_create_custom_food_sends_normalized_barcodes(tmp_path): + """Barcodes go out as the food's `barcodes` list of bare digit strings, + the shape get_food reports for branded foods.""" + client, state = make_cold_client(tmp_path, [{"result": "SUCCESS", "id": 555}]) + + _create_test_food(client, barcodes=["0 49000 05010 3", "4006381333931"]) + + data = state["payloads"][0]["data"] + assert data["barcodes"] == ["049000050103", "4006381333931"] + + +def test_create_custom_food_without_barcodes_sends_empty_list(tmp_path): + client, state = make_cold_client(tmp_path, [{"result": "SUCCESS", "id": 555}]) + + _create_test_food(client) + + assert state["payloads"][0]["data"]["barcodes"] == [] + + +def test_create_custom_food_bad_barcode_raises_before_request(tmp_path): + client, state = make_cold_client(tmp_path, [{"result": "SUCCESS", "id": 555}]) + + with pytest.raises(ValueError, match="check digit"): + _create_test_food(client, barcodes=["4006381333932"]) + assert state["payloads"] == [] + + def test_enrich_diary_no_servings_skips_lookup(tmp_path): client = _enrich_client(tmp_path) calls = {"n": 0} diff --git a/tests/test_custom_food_edit.py b/tests/test_custom_food_edit.py index 1a3eff2..1dfcc65 100644 --- a/tests/test_custom_food_edit.py +++ b/tests/test_custom_food_edit.py @@ -358,6 +358,42 @@ def test_update_custom_food_extra_nutrients(tmp_path): client2.update_custom_food(FOOD_ID, extra_nutrients={204: 1.0}) +def test_update_custom_food_replaces_barcodes(tmp_path): + """barcodes replaces the whole list, normalized; nothing else changes.""" + client, state = make_cold_client(tmp_path, [custom_food(barcodes=["96385074"]), OK]) + + client.update_custom_food(FOOD_ID, barcodes=["0 49000 05010 3"]) + + data = state["payloads"][1]["data"] + assert data["barcodes"] == ["049000050103"] + assert data["nutrients"] == custom_food()["nutrients"] + assert data["measures"] == custom_food()["measures"] + + +def test_update_custom_food_empty_barcodes_clears_them(tmp_path): + client, state = make_cold_client(tmp_path, [custom_food(barcodes=["96385074"]), OK]) + + client.update_custom_food(FOOD_ID, barcodes=[]) + + assert state["payloads"][1]["data"]["barcodes"] == [] + + +def test_update_custom_food_keeps_barcodes_when_not_passed(tmp_path): + client, state = make_cold_client(tmp_path, [custom_food(barcodes=["96385074"]), OK]) + + client.update_custom_food(FOOD_ID, name="Renamed") + + assert state["payloads"][1]["data"]["barcodes"] == ["96385074"] + + +def test_update_custom_food_rejects_bad_barcode_before_fetch(tmp_path): + client, state = make_cold_client(tmp_path, []) + + with pytest.raises(ValueError, match="check digit"): + client.update_custom_food(FOOD_ID, barcodes=["4006381333932"]) + assert state["payloads"] == [] + + def test_update_custom_food_refuses_database_foods(tmp_path): """Only user-created foods may be edited; a database food is rejected before anything is sent to add_food."""