diff --git a/README.md b/README.md index 1f73d5e..ef68e07 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ cached session, so it also overrides a stale cached timezone. | Tool | Description | |------|-------------| | `add_food_entry` | Log a food serving to the diary | -| `remove_food_entry` | Remove one or more diary entries | +| `remove_food_entry` | Remove one or more diary entries: foods, exercises, notes or biometrics | | `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) | diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..cb03bd5 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -1452,28 +1452,33 @@ def get_diary(self, day: date | None = None) -> dict: # ------------------------------------------------------------------ def delete_entries(self, entry_ids: list[str], day: date | None = None) -> dict: - """Remove diary entries by their serving IDs. + """Remove diary entries by their IDs. - Fetches the diary for the given day, matches entries by servingId, - and sends the full serving objects to the v3 DELETE endpoint. + Fetches the diary for the given day, matches entries by their ID -- + servingId for foods, exerciseId for exercises, noteId for notes, + biometricId for biometrics -- and sends them to the v3 DELETE + endpoint in the shape the app uses (see _v3_diary_entry). Uses: DELETE /api/v3/user/{userId}/diary-entries Args: - entry_ids: List of serving IDs to delete (as strings). + entry_ids: List of entry IDs to delete (as strings). day: The day the entries belong to. Defaults to today. Returns dict with removed IDs and count. """ - # Fetch the diary to get full serving objects (required by v3 API) + # Fetch the diary to get full entry objects (required by v3 API) diary_data = self.get_diary(day) diary_entries = diary_data.get("diary", []) id_set = {str(eid) for eid in entry_ids} to_delete = [] + removed_ids = [] for entry in diary_entries: - if str(entry.get("servingId")) in id_set: - to_delete.append(entry) + entry_id = _diary_entry_id(entry) + if entry_id is not None and str(entry_id) in id_set: + to_delete.append(_v3_diary_entry(entry)) + removed_ids.append(str(entry_id)) if not to_delete: logger.warning( @@ -1489,7 +1494,6 @@ def delete_entries(self, entry_ids: list[str], day: date | None = None) -> dict: ) if resp.status_code == 204: - removed_ids = [str(e["servingId"]) for e in to_delete] logger.info( "Deleted %d entries for %s: %s", len(removed_ids), @@ -1991,6 +1995,39 @@ def _translation_id(food: dict) -> int: return 0 +# The id key get_diary uses for each diary entry type. +_DIARY_ENTRY_ID_KEYS = { + "Serving": "servingId", + "Exercise": "exerciseId", + "Note": "noteId", + "Biometric": "biometricId", +} + + +def _diary_entry_id(entry: dict) -> int | None: + """The id of a get_diary entry, whatever its type.""" + key = _DIARY_ENTRY_ID_KEYS.get(entry.get("type", "Serving"), "servingId") + return entry.get(key) + + +def _v3_diary_entry(entry: dict) -> dict: + """Reshape a get_diary entry into what the v3 delete accepts. + + Servings and exercises go back as they are. Notes and biometrics are + keyed by a plain `id` there (with noteId/biometricId the server answers + 204 but deletes nothing, or 400). The v3 endpoint also wants `meta` as a + JSON string, not the object get_diary returns, or it answers 400 "Not + able to deserialize data provided". Both verified against the live API + and matching what the app sends. + """ + out = dict(entry) + if out.get("type") in ("Note", "Biometric"): + out["id"] = out.pop(_DIARY_ENTRY_ID_KEYS[out["type"]]) + if isinstance(out.get("meta"), dict): + out["meta"] = json.dumps(out["meta"], separators=(",", ":")) + return out + + def _meal_group_for_hour(hour: int) -> int: """Map hour of day to a Cronometer diary meal group. diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..feb31cc 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -324,12 +324,14 @@ def remove_food_entry( entry_ids: list[str], date: str | None = None, ) -> str: - """Remove one or more food entries from the Cronometer diary. + """Remove one or more entries from the Cronometer diary. - Use get_food_log to find entry IDs. + Works for every entry type in get_food_log's diary: foods (servingId), + exercises (exerciseId), notes (noteId) and biometrics such as a weight + (biometricId). Args: - entry_ids: List of serving/entry IDs to remove. + entry_ids: List of entry IDs to remove. date: Date the entries belong to as YYYY-MM-DD (defaults to today). """ try: diff --git a/tests/test_diary_delete.py b/tests/test_diary_delete.py new file mode 100644 index 0000000..0d530c2 --- /dev/null +++ b/tests/test_diary_delete.py @@ -0,0 +1,120 @@ +"""delete_entries for every diary entry type. + +Foods, exercises, notes and biometrics all go to the same v3 DELETE +/diary-entries, but notes and biometrics must be keyed by `id` there and +`meta` must be a JSON string. +""" + +from __future__ import annotations + +from datetime import date + +from test_client import make_cold_client + +DAY = date(1900, 1, 2) +SERVING_ID = 5312202908 + + +def serving() -> dict: + """A get_diary food entry shaped like the real one.""" + return { + "servingId": SERVING_ID, + "type": "Serving", + "foodId": 5442, + "measureId": 14990, + "translationId": 0, + "grams": 100, + "day": "1900-01-02", + "time": "11:23:50", + "offset": 0, + "userId": 42, + "order": (1 << 16) | 1, + } + + +def diary(*entries) -> dict: + return {"diary": list(entries), "summary": {}, "messages": []} + + +def _capture_v3_delete(client) -> list[dict]: + sent: list[dict] = [] + + class NoContent: + status_code = 204 + text = "" + + def fake_request(method, url, json=None, headers=None): + sent.append({"method": method, "url": url, "json": json}) + return NoContent() + + client._http.request = fake_request # type: ignore[method-assign] + return sent + + +EXERCISE = { + "type": "Exercise", + "exerciseId": 881413372, + "activityId": 1342, + "activitySpecId": 0, + "minutes": 30, + "weight": 176.4, + "calories": -123, + "calorieOverride": True, + "meta": {}, + "userId": 42, + "day": "1900-01-02", + "order": 65538, +} +NOTE = { + "type": "Note", + "noteId": 42080649, + "text": "a note", + "meta": {}, + "userId": 42, + "day": "1900-01-02", + "order": 65537, +} +BIOMETRIC = { + "type": "Biometric", + "biometricId": 1894305060, + "metricId": 1, + "unitId": 1, + "amount": 99.9, + "samplesVersion": 0, + "meta": {}, + "userId": 42, + "day": "1900-01-02", + "order": 1, +} + + +def test_delete_entries_matches_every_entry_type_by_its_id(tmp_path): + client, _ = make_cold_client( + tmp_path, [diary(serving(), EXERCISE, NOTE, BIOMETRIC)] + ) + sent = _capture_v3_delete(client) + + result = client.delete_entries( + [str(SERVING_ID), "881413372", "42080649", "1894305060"], DAY + ) + + assert result["count"] == 4 + assert sent[0]["method"] == "DELETE" + assert sent[0]["url"].endswith("/diary-entries") + serving_out, exercise_out, note_out, bio_out = sent[0]["json"]["diaryEntries"] + assert serving_out == serving() + assert exercise_out["exerciseId"] == 881413372 + # v3 wants notes and biometrics keyed by `id`, and meta as a JSON string. + assert note_out["id"] == 42080649 and "noteId" not in note_out + assert bio_out["id"] == 1894305060 and "biometricId" not in bio_out + assert {e["meta"] for e in (exercise_out, note_out, bio_out)} == {"{}"} + + +def test_delete_entries_leaves_unrequested_entries_alone(tmp_path): + client, _ = make_cold_client(tmp_path, [diary(serving(), EXERCISE, NOTE)]) + sent = _capture_v3_delete(client) + + result = client.delete_entries(["42080649"], DAY) + + assert result == {"removed": ["42080649"], "count": 1} + assert [e["type"] for e in sent[0]["json"]["diaryEntries"]] == ["Note"]