chore: finalize v1.0.0 — cleanup, unified errors, date validation
- Move integration tests to tests/; fix .gitignore to scope root-only
- Remove tracked debug artefacts (probe2_stderr/stdout.txt)
- __init__.py: version via importlib.metadata; export create_server in __all__
- server.py: unified JSON error format {"error":"..."} for all tools
- server.py: date validation (YYYY-MM-DD) for all meal-plan tools
- server.py: clear_list reports partial failures (failed_count, failed_ids)
- server.py: -> str annotations on get_circles, get_tasks, get_activities
- server.py: document TODO:94 as known limitation (no name in sortingIndexByTaskList)
- server.py: date validation also added to get_meal_plan
- Add LICENSE (MIT, Marcus van Elst)
- Add CHANGELOG.md (Keep a Changelog, v0.1.0–v1.0.0)
- README.md: restructured by use case; 🔒 marks write tools
- CLAUDE.md: update to v1.0.0 state; condense roadmap history
- SPEC.md: add version stamp
- pyproject.toml: version 1.0.0 (single source of truth)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+123
-90
@@ -6,6 +6,7 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
@@ -31,6 +32,18 @@ mcp = FastMCP("familywall")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _err(msg: str) -> str:
|
||||
"""Return a JSON-encoded error response."""
|
||||
return json.dumps({"error": msg}, ensure_ascii=False)
|
||||
|
||||
|
||||
def _validate_date(date: str) -> str | None:
|
||||
"""Return None when *date* is a valid ISO YYYY-MM-DD string, else an error message."""
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
|
||||
return f"Invalid date format {date!r}. Expected ISO YYYY-MM-DD (e.g. '2026-04-20')."
|
||||
return None
|
||||
|
||||
|
||||
def _accgetallfamily() -> dict[str, Any]:
|
||||
"""Login, call accgetallfamily, logout and return the response body.
|
||||
|
||||
@@ -91,7 +104,7 @@ def _extract_lists(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
result.append(
|
||||
{
|
||||
"id": list_id,
|
||||
"name": list_id, # real name unknown — TODO once field identified
|
||||
"name": list_id, # no name in sortingIndexByTaskList; get_lists has full data
|
||||
"type": None,
|
||||
"open": None,
|
||||
"total": None,
|
||||
@@ -128,12 +141,12 @@ def _extract_tasks(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_circles():
|
||||
def get_circles() -> str:
|
||||
"""Return all Family Wall circles as JSON list of {id, name}."""
|
||||
try:
|
||||
raw_circles = _famlistfamily()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
result = [{"id": c.get("metaId"), "name": c.get("name")} for c in raw_circles]
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
@@ -214,7 +227,7 @@ def get_members(circle_id: str | None = None) -> str:
|
||||
try:
|
||||
raw_circles = _famlistfamily()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
@@ -298,7 +311,7 @@ def get_lists(scope: str | None = None) -> str:
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# Build the scope param for taskgettasklists.
|
||||
# When scope is provided as a circle name (not a metaId), we need to
|
||||
@@ -312,7 +325,7 @@ def get_lists(scope: str | None = None) -> str:
|
||||
try:
|
||||
circles = _famlistfamily()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
matched = next((c for c in circles if c.get("name") == scope), None)
|
||||
if matched is None:
|
||||
circle_names = [c.get("name") for c in circles]
|
||||
@@ -331,7 +344,7 @@ def get_lists(scope: str | None = None) -> str:
|
||||
circles = _famlistfamily()
|
||||
api_scopes = [c["metaId"] for c in circles if "metaId" in c]
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
with FamilyWallClient() as client:
|
||||
@@ -357,9 +370,9 @@ def get_lists(scope: str | None = None) -> str:
|
||||
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
except Exception as exc:
|
||||
return f"Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
result = []
|
||||
for item in all_lists:
|
||||
@@ -388,12 +401,12 @@ def get_lists(scope: str | None = None) -> str:
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_tasks(list_id: str, only_open: bool = True):
|
||||
def get_tasks(list_id: str, only_open: bool = True) -> str:
|
||||
"""Return tasks for a list as JSON. list_id from get_lists. only_open=True filters completed."""
|
||||
try:
|
||||
data = _accgetallfamily()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
raw_tasks = _extract_tasks(data)
|
||||
|
||||
@@ -530,7 +543,7 @@ def get_categories(list_id: str, locale: str = "de") -> str:
|
||||
try:
|
||||
data = _accgetallfamily()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
raw_cats = data["a01"]["r"]["r"]["updatedCreated"]
|
||||
@@ -614,7 +627,7 @@ def create_category(list_id: str, name: str, icon: str | None = None) -> str:
|
||||
try:
|
||||
data = _authenticated_call("taskcategoryput", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
cat_obj = data["a00"]["r"]["r"]
|
||||
@@ -663,7 +676,7 @@ def delete_category(category_id: str) -> str:
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
cat_obj: dict[str, Any] | None = None
|
||||
try:
|
||||
@@ -683,7 +696,7 @@ def delete_category(category_id: str) -> str:
|
||||
cat_obj = next((c for c in raw_cats if c.get("metaId") == category_id), None)
|
||||
if cat_obj is None:
|
||||
client.logout()
|
||||
return f"Error: Category '{category_id}' not found."
|
||||
return _err(f"Category '{category_id}' not found.")
|
||||
|
||||
can_delete: str | None = cat_obj.get("rights", {}).get("canDelete")
|
||||
if can_delete != "true":
|
||||
@@ -705,9 +718,9 @@ def delete_category(category_id: str) -> str:
|
||||
client.call("taskcategorydelete", {"id": category_id})
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
return json.dumps(
|
||||
{"deleted": True, "id": category_id, "name": cat_obj.get("name")},
|
||||
@@ -722,12 +735,12 @@ def delete_category(category_id: str) -> str:
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_activities(limit: int = 20):
|
||||
def get_activities(limit: int = 20) -> str:
|
||||
"""Return recent Family Wall wall activities as JSON. limit controls max number of results."""
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# Load member data to resolve author IDs to display names.
|
||||
# Non-fatal: fall back to raw account IDs when member lookup fails.
|
||||
@@ -748,9 +761,9 @@ def get_activities(limit: int = 20):
|
||||
data = client.call("wallget", {"nb": str(limit)})
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
except Exception as exc:
|
||||
return f"Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
# Try known response patterns; fall back to raw JSON for verification.
|
||||
raw_activities: list[dict[str, Any]] | None = None
|
||||
@@ -881,7 +894,7 @@ def create_task(
|
||||
try:
|
||||
data = _authenticated_call("taskcreate2", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# Try to extract the new task's metaId from the response.
|
||||
try:
|
||||
@@ -939,7 +952,7 @@ def update_task(
|
||||
JSON success indicator or an error message.
|
||||
"""
|
||||
if clear_due_date and due_date is not None:
|
||||
return "Error: 'clear_due_date' and 'due_date' cannot be used together."
|
||||
return _err("'clear_due_date' and 'due_date' cannot be used together.")
|
||||
|
||||
if (
|
||||
text is None
|
||||
@@ -950,8 +963,8 @@ def update_task(
|
||||
and assignee_ids is None
|
||||
and list_id is None
|
||||
):
|
||||
return (
|
||||
"Error: At least one of 'text', 'description', 'category_id', 'due_date',"
|
||||
return _err(
|
||||
"At least one of 'text', 'description', 'category_id', 'due_date',"
|
||||
" 'clear_due_date', 'assignee_ids', or 'list_id' must be provided."
|
||||
)
|
||||
|
||||
@@ -975,7 +988,7 @@ def update_task(
|
||||
try:
|
||||
_authenticated_call("taskupdate2", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# A response without 'ex'/'un' keys is treated as success by fw_client.
|
||||
return json.dumps({"updated": True, "id": task_id}, ensure_ascii=False, indent=2)
|
||||
@@ -1005,7 +1018,7 @@ def toggle_task(task_id: str, complete: bool) -> str:
|
||||
try:
|
||||
_authenticated_call("taskmark", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
return json.dumps(
|
||||
{"toggled": True, "id": task_id, "complete": complete},
|
||||
@@ -1036,7 +1049,7 @@ def delete_task(task_id: str) -> str:
|
||||
"metadelete", {"id": task_id}
|
||||
) # verified: metadelete uses 'id', not 'metaId'
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
return json.dumps({"deleted": True, "id": task_id}, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -1066,12 +1079,12 @@ def clear_list(list_id: str, only_open: bool = False) -> str:
|
||||
or an error message.
|
||||
"""
|
||||
if not list_id.startswith("taskList/"):
|
||||
return "Error: list_id must start with 'taskList/'"
|
||||
return _err("list_id must start with 'taskList/'")
|
||||
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
with FamilyWallClient() as client:
|
||||
@@ -1090,20 +1103,30 @@ def clear_list(list_id: str, only_open: bool = False) -> str:
|
||||
and (not only_open or str(t.get("complete", "false")).lower() != "true")
|
||||
]
|
||||
|
||||
deleted_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
for task in tasks_to_delete:
|
||||
client.call("metadelete", {"id": task["metaId"]})
|
||||
meta_id: str = task["metaId"]
|
||||
try:
|
||||
client.call("metadelete", {"id": meta_id})
|
||||
deleted_ids.append(meta_id)
|
||||
except Exception:
|
||||
failed_ids.append(meta_id)
|
||||
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
return json.dumps(
|
||||
{"deleted_count": len(tasks_to_delete), "list_id": list_id},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
result: dict[str, Any] = {
|
||||
"deleted_count": len(deleted_ids),
|
||||
"list_id": list_id,
|
||||
}
|
||||
if failed_ids:
|
||||
result["failed_count"] = len(failed_ids)
|
||||
result["failed_ids"] = failed_ids
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1142,9 +1165,9 @@ def create_list(
|
||||
created in.
|
||||
"""
|
||||
if list_type not in ("SHOPPING_LIST", "TODOS", "OTHER"):
|
||||
return "Error: list_type must be 'SHOPPING_LIST', 'TODOS', or 'OTHER'."
|
||||
return _err("list_type must be 'SHOPPING_LIST', 'TODOS', or 'OTHER'.")
|
||||
if len(name) > 200:
|
||||
return "Error: name must not exceed 200 characters."
|
||||
return _err("name must not exceed 200 characters.")
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -1162,7 +1185,7 @@ def create_list(
|
||||
try:
|
||||
data = _authenticated_call("taskcreatelist", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
list_obj = data["a00"]["r"]["r"]
|
||||
@@ -1218,7 +1241,7 @@ def delete_list(list_id: str) -> str:
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# Derive the owning circle from the list metaId so that secondary-circle
|
||||
# lists can be queried and deleted with the correct scope parameter.
|
||||
@@ -1245,7 +1268,7 @@ def delete_list(list_id: str) -> str:
|
||||
list_obj = next((lst for lst in raw_lists if lst.get("metaId") == list_id), None)
|
||||
if list_obj is None:
|
||||
client.logout()
|
||||
return f"Error: List '{list_id}' not found."
|
||||
return _err(f"List '{list_id}' not found.")
|
||||
|
||||
can_delete: str | None = (list_obj.get("rights") or {}).get("canDelete")
|
||||
if can_delete != "true":
|
||||
@@ -1269,9 +1292,9 @@ def delete_list(list_id: str) -> str:
|
||||
client.call("taskdeletelist", del_params)
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
return json.dumps(
|
||||
{"deleted": True, "id": list_id, "name": list_obj.get("name")},
|
||||
@@ -1316,12 +1339,12 @@ def update_list(
|
||||
JSON with the updated list object on success, or an error message.
|
||||
"""
|
||||
if name is None and color is None and emoji is None:
|
||||
return "Error: At least one of 'name', 'color', or 'emoji' must be provided."
|
||||
return _err("At least one of 'name', 'color', or 'emoji' must be provided.")
|
||||
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# Derive the owning circle from the list metaId (same as delete_list).
|
||||
circle_scope = _circle_id_from_list_id(list_id)
|
||||
@@ -1346,7 +1369,7 @@ def update_list(
|
||||
list_obj = next((lst for lst in raw_lists if lst.get("metaId") == list_id), None)
|
||||
if list_obj is None:
|
||||
client.logout()
|
||||
return f"Error: List '{list_id}' not found."
|
||||
return _err(f"List '{list_id}' not found.")
|
||||
|
||||
can_update: str | None = (list_obj.get("rights") or {}).get("canUpdate")
|
||||
if can_update != "true":
|
||||
@@ -1375,9 +1398,9 @@ def update_list(
|
||||
resp = client.call("taskupdatelist", upd_params)
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
try:
|
||||
updated_obj: dict[str, Any] = resp["a00"]["r"]["r"]
|
||||
@@ -1434,7 +1457,7 @@ def create_circle(name: str) -> str:
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
with FamilyWallClient() as client:
|
||||
@@ -1471,9 +1494,9 @@ def create_circle(name: str) -> str:
|
||||
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
return json.dumps(
|
||||
{"created": True, "id": circle_id, "name": canonical_name},
|
||||
@@ -1505,12 +1528,12 @@ def update_circle(circle_id: str, name: str) -> str:
|
||||
JSON with ``{updated, id, name}`` on success, or an error message.
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return "Error: 'name' must not be empty."
|
||||
return _err("'name' must not be empty.")
|
||||
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
with FamilyWallClient() as client:
|
||||
@@ -1524,7 +1547,7 @@ def update_circle(circle_id: str, name: str) -> str:
|
||||
raise TypeError("a00.r.r is not a list")
|
||||
except (KeyError, TypeError):
|
||||
client.logout()
|
||||
return "Error: Unexpected famlistfamily response structure."
|
||||
return _err("Unexpected famlistfamily response structure.")
|
||||
|
||||
target = next((c for c in raw_circles if c.get("metaId") == circle_id), None)
|
||||
if target is None:
|
||||
@@ -1561,9 +1584,9 @@ def update_circle(circle_id: str, name: str) -> str:
|
||||
resp = client.call("accupdatefamily", {"scope": circle_id, "name": name})
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
# Response: a00.r.r = full circle object
|
||||
try:
|
||||
@@ -1624,7 +1647,7 @@ def add_member_to_circle(
|
||||
JSON success indicator or an error message.
|
||||
"""
|
||||
if not email or "@" not in email:
|
||||
return "Error: 'email' must be a valid e-mail address."
|
||||
return _err("'email' must be a valid e-mail address.")
|
||||
|
||||
# Derive a sensible first-name fallback from the email local part.
|
||||
if firstname is None:
|
||||
@@ -1641,7 +1664,7 @@ def add_member_to_circle(
|
||||
try:
|
||||
data = _authenticated_call("accinvite", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# On success the server returns the invitation object under a00.r.r.
|
||||
try:
|
||||
@@ -1686,7 +1709,7 @@ def delete_circle(circle_id: str) -> str:
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
circle_name: str = circle_id
|
||||
try:
|
||||
@@ -1701,7 +1724,7 @@ def delete_circle(circle_id: str) -> str:
|
||||
raise TypeError("a00.r.r is not a list")
|
||||
except (KeyError, TypeError):
|
||||
client.logout()
|
||||
return "Error: Unexpected famlistfamily response structure."
|
||||
return _err("Unexpected famlistfamily response structure.")
|
||||
|
||||
target = next((c for c in raw_circles if c.get("metaId") == circle_id), None)
|
||||
if target is None:
|
||||
@@ -1739,9 +1762,9 @@ def delete_circle(circle_id: str) -> str:
|
||||
client.call("adminwipefamily", {"scope": circle_id})
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
return json.dumps(
|
||||
{"deleted": True, "id": circle_id, "name": circle_name},
|
||||
@@ -1795,7 +1818,7 @@ def like_post(post_id: str, like: bool = True) -> str:
|
||||
try:
|
||||
data = _authenticated_call("wallmood", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# Extract moodMap from the response to confirm the like was recorded.
|
||||
try:
|
||||
@@ -1950,7 +1973,7 @@ def get_recipes() -> str:
|
||||
try:
|
||||
raw_recipes = _get_raw_recipes()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
result = [parse_recipe_summary(r) for r in raw_recipes]
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
@@ -1976,7 +1999,7 @@ def get_recipe_box() -> str:
|
||||
try:
|
||||
raw_recipes = _get_raw_recipes()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
result = [parse_recipe_summary(r) for r in raw_recipes if r.get("isRecipe") == "true"]
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
@@ -2003,11 +2026,11 @@ def get_recipe(recipe_id: str) -> str:
|
||||
try:
|
||||
raw_recipes = _get_raw_recipes()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
raw = next((r for r in raw_recipes if r.get("metaId") == recipe_id), None)
|
||||
if raw is None:
|
||||
return f"Error: Recipe '{recipe_id}' not found."
|
||||
return _err(f"Recipe '{recipe_id}' not found.")
|
||||
|
||||
return json.dumps(parse_recipe_full(raw), ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -2067,7 +2090,7 @@ def create_recipe(
|
||||
try:
|
||||
data = _authenticated_call("mprecipeput", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
recipe_obj = data["a00"]["r"]["r"]
|
||||
@@ -2110,7 +2133,7 @@ def delete_recipe(recipe_id: str) -> str:
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
recipe_obj: dict[str, Any] | None = None
|
||||
try:
|
||||
@@ -2127,7 +2150,7 @@ def delete_recipe(recipe_id: str) -> str:
|
||||
recipe_obj = next((r for r in items if r.get("metaId") == recipe_id), None)
|
||||
if recipe_obj is None:
|
||||
client.logout()
|
||||
return f"Error: Recipe '{recipe_id}' not found."
|
||||
return _err(f"Recipe '{recipe_id}' not found.")
|
||||
|
||||
can_delete: str | None = (recipe_obj.get("rights") or {}).get("canDelete")
|
||||
if can_delete != "true":
|
||||
@@ -2147,9 +2170,9 @@ def delete_recipe(recipe_id: str) -> str:
|
||||
client.call("metadelete", {"id": recipe_id})
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
return json.dumps(
|
||||
{"deleted": True, "id": recipe_id, "name": recipe_obj.get("name")},
|
||||
@@ -2230,7 +2253,7 @@ def update_recipe(
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
with FamilyWallClient() as client:
|
||||
@@ -2246,7 +2269,7 @@ def update_recipe(
|
||||
current = next((r for r in items if r.get("metaId") == recipe_id), None)
|
||||
if current is None:
|
||||
client.logout()
|
||||
return f"Error: Recipe '{recipe_id}' not found."
|
||||
return _err(f"Recipe '{recipe_id}' not found.")
|
||||
|
||||
can_update: str | None = (current.get("rights") or {}).get("canUpdate")
|
||||
if can_update != "true":
|
||||
@@ -2280,9 +2303,9 @@ def update_recipe(
|
||||
resp = client.call("mprecipeput", params)
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
return _err(f"Family Wall API error: {exc}")
|
||||
except Exception as exc:
|
||||
return f"Error: Connection error: {exc}"
|
||||
return _err(f"Connection error: {exc}")
|
||||
|
||||
try:
|
||||
recipe_obj = resp["a00"]["r"]["r"]
|
||||
@@ -2337,10 +2360,14 @@ def get_meal_plan(date_from: str, date_to: str) -> str:
|
||||
|
||||
Returns an error message string on failure.
|
||||
"""
|
||||
if date_err := _validate_date(date_from):
|
||||
return _err(date_err)
|
||||
if date_err := _validate_date(date_to):
|
||||
return _err(date_err)
|
||||
try:
|
||||
data = _authenticated_call("mplistinterval", {"from": date_from, "to": date_to})
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
payload = data["a00"]["r"]["r"]
|
||||
@@ -2437,7 +2464,9 @@ def add_recipe_to_meal_plan(
|
||||
JSON with the new dish entry on success, or an error message.
|
||||
"""
|
||||
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
||||
return "Error: meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'."
|
||||
return _err("meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'.")
|
||||
if date_err := _validate_date(date):
|
||||
return _err(date_err)
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"recipeId": recipe_id,
|
||||
@@ -2448,7 +2477,7 @@ def add_recipe_to_meal_plan(
|
||||
try:
|
||||
data = _authenticated_call("mpcreateByRecipeId", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
dish = data["a00"]["r"]["r"]
|
||||
@@ -2506,9 +2535,11 @@ def add_meal_to_meal_plan(
|
||||
JSON with the new dish entry on success, or an error message.
|
||||
"""
|
||||
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
||||
return "Error: meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'."
|
||||
return _err("meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'.")
|
||||
if not name or not name.strip():
|
||||
return "Error: 'name' must not be empty."
|
||||
return _err("'name' must not be empty.")
|
||||
if date_err := _validate_date(date):
|
||||
return _err(date_err)
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -2519,7 +2550,7 @@ def add_meal_to_meal_plan(
|
||||
try:
|
||||
data = _authenticated_call("mpcreate", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
# NOTE: mpcreate returns a00.r.r as an *array*, unlike mpcreateByRecipeId
|
||||
# which returns a plain object. Take the first (and only) element.
|
||||
@@ -2577,15 +2608,15 @@ def delete_meal_plan_entry(entry_id: str) -> str:
|
||||
JSON success indicator or an error message.
|
||||
"""
|
||||
if not entry_id.startswith(("dish/", "meal/")):
|
||||
return (
|
||||
"Error: entry_id must be a dish or meal metaId "
|
||||
return _err(
|
||||
"entry_id must be a dish or meal metaId "
|
||||
"(e.g. 'dish/16282169_20009811' or 'meal/16282169_1620659')."
|
||||
)
|
||||
|
||||
try:
|
||||
_authenticated_call("metadelete", {"id": entry_id})
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
return json.dumps({"deleted": True, "id": entry_id}, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -2620,9 +2651,11 @@ def add_meal_note(
|
||||
JSON with the new meal entry on success, or an error message.
|
||||
"""
|
||||
if meal_type not in ("BREAKFAST", "LUNCH", "SNACK", "DINNER"):
|
||||
return "Error: meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'."
|
||||
return _err("meal_type must be one of 'BREAKFAST', 'LUNCH', 'SNACK', 'DINNER'.")
|
||||
if note is None and serves is None:
|
||||
return "Error: At least one of 'note' or 'serves' must be provided."
|
||||
return _err("At least one of 'note' or 'serves' must be provided.")
|
||||
if date_err := _validate_date(date):
|
||||
return _err(date_err)
|
||||
|
||||
params: dict[str, Any] = {"date": date, "type": meal_type}
|
||||
if note is not None:
|
||||
@@ -2633,7 +2666,7 @@ def add_meal_note(
|
||||
try:
|
||||
data = _authenticated_call("mpmealput", params)
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
return _err(str(exc))
|
||||
|
||||
try:
|
||||
meal = data["a00"]["r"]["r"]
|
||||
|
||||
Reference in New Issue
Block a user