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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 80 additions & 0 deletions src/cronometer_api_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
51 changes: 51 additions & 0 deletions src/cronometer_api_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
113 changes: 113 additions & 0 deletions tests/test_notes.py
Original file line number Diff line number Diff line change
@@ -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 == []
2 changes: 2 additions & 0 deletions tests/test_server_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
EXPECTED_TOOLS = {
"add_custom_food",
"add_food_entry",
"add_note",
"add_recipe",
"copy_day",
"delete_custom_food",
Expand All @@ -31,6 +32,7 @@
"remove_food_entry",
"search_foods",
"update_custom_food",
"update_note",
}


Expand Down