diff --git a/README.md b/README.md index 1f73d5e..644d072 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ cached session, so it also overrides a stale cached timezone. | `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 | | `copy_day` | Copy all entries from the previous day | | `mark_day_complete` | Mark a diary day as complete or incomplete | +| `add_note` | Add a note to a diary day | +| `update_note` | Replace the text of a diary note | ### Targets & Tracking diff --git a/src/cronometer_api_mcp/client.py b/src/cronometer_api_mcp/client.py index 89e4575..5ae5c40 100644 --- a/src/cronometer_api_mcp/client.py +++ b/src/cronometer_api_mcp/client.py @@ -1427,6 +1427,86 @@ def add_serving( ) return data + # ------------------------------------------------------------------ + # Diary: notes + # ------------------------------------------------------------------ + + def add_note( + self, text: str, day: date | None = None, diary_group: int = 0 + ) -> dict: + """Add a note to a diary day. + + Args: + text: The note text. + day: Date to add it to. Defaults to today. + diary_group: Meal group the note sits in, as for add_serving: + 0 = auto (by time of day), 1 = Breakfast, 2 = Lunch, + 3 = Dinner, 4 = Snacks. + + Returns the stored note: {"noteId", "text", "day", "order", ...}. + """ + if diary_group == 0: + diary_group = _meal_group_for_hour(self.now().hour) + return self._save_note(0, text, self._format_day(day), (diary_group << 16) | 1) + + def update_note(self, note_id: int, text: str, day: date | None = None) -> dict: + """Replace the text of an existing diary note, keeping its place. + + Returns the stored note. + """ + entries = self.get_diary(day).get("diary", []) + note = next( + ( + e + for e in entries + if e.get("type") == "Note" and str(e.get("noteId")) == str(note_id) + ), + None, + ) + if note is None: + raise CronometerError( + f"No note {note_id} in the diary for {self._format_day(day)}" + ) + return self._save_note(note["noteId"], text, note["day"], note["order"]) + + def _save_note(self, note_id: int, text: str, day_str: str, order: int) -> dict: + """Create (note_id 0) or overwrite a note via /photoupload. + + The app writes notes through its photo upload endpoint: a multipart + form, authenticated by the session token in a `sessionKey` field + rather than the JSON auth block. deleteExistingPhoto is what the app + sends for a text-only note. + """ + if not text.strip(): + raise ValueError("note text must not be empty") + self._ensure_auth() + fields = { + "action": "diaryImage", + "sessionKey": self._token, + "noteId": str(note_id), + "day": day_str, + "order": str(order), + "deleteExistingPhoto": "true", + "noteText": text, + } + form = httpx.Request( + "POST", "/", files={k: (None, v) for k, v in fields.items()} + ) + resp = self._http.post( + "/photoupload", + content=form.read(), + headers={"content-type": form.headers["content-type"]}, + ) + resp.raise_for_status() + try: + data = resp.json() + except ValueError: + raise CronometerError(f"Failed to save note: {resp.text[:300]}") from None + if not isinstance(data, dict) or "noteId" not in data: + raise CronometerError(f"Failed to save note: {data}") + logger.info("Saved note %s on %s", data["noteId"], day_str) + return data + # ------------------------------------------------------------------ # Diary: get diary entries # ------------------------------------------------------------------ diff --git a/src/cronometer_api_mcp/server.py b/src/cronometer_api_mcp/server.py index af0b74f..a593542 100644 --- a/src/cronometer_api_mcp/server.py +++ b/src/cronometer_api_mcp/server.py @@ -347,6 +347,57 @@ def remove_food_entry( return _err(e) +# ------------------------------------------------------------------ +# Diary: notes +# ------------------------------------------------------------------ + + +@mcp.tool(annotations=_WRITE) +def add_note(text: str, date: str | None = None, diary_group: str = "auto") -> str: + """Add a note to a diary day. + + Notes show up in get_food_log's diary as entries of type "Note". Remove + one with remove_food_entry and its noteId. + + Args: + text: The note text. + date: Date as YYYY-MM-DD (defaults to today). + diary_group: Meal slot the note sits in -- one of "auto", + "breakfast", "lunch", "dinner", "snacks" (default "auto"). + """ + try: + groups = {"auto": 0, "breakfast": 1, "lunch": 2, "dinner": 3, "snacks": 4} + group_int = groups.get(diary_group.strip().lower()) + if group_int is None: + return _err( + ValueError( + f"Invalid diary_group '{diary_group}'. " + "Must be one of: auto, breakfast, lunch, dinner, snacks." + ) + ) + client = _get_client() + note = client.add_note(text, _parse_date(date), group_int) + return _ok({"note": note}) + except Exception as e: + return _err(e) + + +@mcp.tool(annotations=_WRITE_IDEMPOTENT) +def update_note(note_id: int, text: str, date: str | None = None) -> str: + """Replace the text of a diary note. + + Args: + note_id: noteId of the note, from get_food_log. + text: The new note text. + date: Date the note is on as YYYY-MM-DD (defaults to today). + """ + try: + client = _get_client() + return _ok({"note": client.update_note(note_id, text, _parse_date(date))}) + except Exception as e: + return _err(e) + + # ------------------------------------------------------------------ # Diary: management # ------------------------------------------------------------------ diff --git a/tests/test_notes.py b/tests/test_notes.py new file mode 100644 index 0000000..7e89f75 --- /dev/null +++ b/tests/test_notes.py @@ -0,0 +1,113 @@ +"""add_note / update_note. + +The app writes diary notes through /photoupload as a multipart form, with +the session token in a `sessionKey` field instead of the JSON auth block. +""" + +from __future__ import annotations + +from datetime import date + +import pytest +from test_client import FakeResp, make_cold_client + +from cronometer_api_mcp.client import CronometerError + +DAY = date(1900, 1, 2) +NOTE_ID = 42080649 + + +def capture_forms(client, body: dict | None = None) -> list[dict]: + """Route POST /photoupload to a fake that records the parsed form.""" + forms: list[dict] = [] + real_post = client._http.post + + def fake_post(endpoint, json=None, content=None, headers=None): + if endpoint != "/photoupload": + return real_post(endpoint, json=json) + boundary = headers["content-type"].split("boundary=")[1].encode() + fields = {} + for part in content.split(b"--" + boundary): + if b'name="' not in part: + continue + head, _, value = part.partition(b"\r\n\r\n") + name = head.split(b'name="')[1].split(b'"')[0].decode() + fields[name] = value.rstrip(b"\r\n").decode() + forms.append(fields) + stored = body or { + "noteId": int(fields["noteId"]) or NOTE_ID, + "text": fields["noteText"], + "day": fields["day"], + "order": int(fields["order"]), + "meta": {}, + } + return FakeResp(stored) + + client._http.post = fake_post # type: ignore[method-assign] + return forms + + +def test_add_note_posts_multipart_form_with_session_key(tmp_path): + client, _ = make_cold_client(tmp_path, [{}]) + forms = capture_forms(client) + + note = client.add_note("ate late", DAY, diary_group=3) + + assert forms == [ + { + "action": "diaryImage", + "sessionKey": "FRESH_TOKEN_1", + "noteId": "0", + "day": "1900-1-2", + "order": str((3 << 16) | 1), + "deleteExistingPhoto": "true", + "noteText": "ate late", + } + ] + assert note["noteId"] == NOTE_ID + + +def test_update_note_keeps_day_and_order(tmp_path): + stored = { + "type": "Note", + "noteId": NOTE_ID, + "text": "old", + "day": "1900-01-02", + "order": (2 << 16) | 4, + "meta": {}, + } + client, _ = make_cold_client(tmp_path, [{"diary": [stored]}]) + forms = capture_forms(client) + + client.update_note(NOTE_ID, "new", DAY) + + assert forms[0]["noteId"] == str(NOTE_ID) + assert forms[0]["noteText"] == "new" + assert forms[0]["day"] == "1900-01-02" + assert forms[0]["order"] == str((2 << 16) | 4) + + +def test_update_note_unknown_id_raises(tmp_path): + client, _ = make_cold_client(tmp_path, [{"diary": []}]) + forms = capture_forms(client) + + with pytest.raises(CronometerError, match="No note 1"): + client.update_note(1, "x", DAY) + assert forms == [] + + +def test_save_note_raises_on_unexpected_response(tmp_path): + client, _ = make_cold_client(tmp_path, [{}]) + capture_forms(client, body={"result": "FAIL"}) + + with pytest.raises(CronometerError, match="Failed to save note"): + client.add_note("x", DAY, diary_group=1) + + +def test_add_note_rejects_empty_text(tmp_path): + client, _ = make_cold_client(tmp_path, [{}]) + forms = capture_forms(client) + + with pytest.raises(ValueError, match="empty"): + client.add_note(" ", DAY, diary_group=1) + assert forms == [] diff --git a/tests/test_server_import.py b/tests/test_server_import.py index 917f190..c6c4fdb 100644 --- a/tests/test_server_import.py +++ b/tests/test_server_import.py @@ -14,6 +14,7 @@ EXPECTED_TOOLS = { "add_custom_food", "add_food_entry", + "add_note", "add_recipe", "copy_day", "delete_custom_food", @@ -31,6 +32,7 @@ "remove_food_entry", "search_foods", "update_custom_food", + "update_note", }