Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
53 changes: 45 additions & 8 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
120 changes: 120 additions & 0 deletions tests/test_diary_delete.py
Original file line number Diff line number Diff line change
@@ -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"]