fix(lists): circle scope support for get_lists, create_list, delete_list (v0.7.1)
- get_lists(scope): API scope parameter now used server-side; accepts circle
metaId ("family/XXXX") or circle name; returns circle_id field per list
- create_list(circle_id): new optional param; passes as API scope param
- delete_list: derives circle from list metaId and passes scope for
secondary-circle lists
- Added _circle_id_from_list_id() helper (taskList/FAMNUM_LISTNUM -> family/FAMNUM)
- SPEC.md: documented scope param for taskgettasklists, taskcreatelist, taskdeletelist
- Verified: familyId/circleId/id params ignored by API, only scope works
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,26 @@ def get_circles():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _circle_id_from_list_id(list_id: str) -> str | None:
|
||||
"""Derive the circle metaId from a list metaId.
|
||||
|
||||
The list metaId format is ``taskList/<family_num>_<list_num>``.
|
||||
This function returns ``"family/<family_num>"``.
|
||||
|
||||
Args:
|
||||
list_id: List metaId (e.g. ``"taskList/23431854_29759623"``).
|
||||
|
||||
Returns:
|
||||
Circle metaId (e.g. ``"family/23431854"``), or ``None`` when the
|
||||
format cannot be parsed.
|
||||
"""
|
||||
bare = list_id.removeprefix("taskList/")
|
||||
parts = bare.split("_", 1)
|
||||
if len(parts) == 2 and parts[0].isdigit():
|
||||
return f"family/{parts[0]}"
|
||||
return None
|
||||
|
||||
|
||||
def _famlistfamily() -> list[dict[str, Any]]:
|
||||
"""Login, call famlistfamily, logout and return the raw circle list.
|
||||
|
||||
@@ -252,17 +272,64 @@ def get_members(circle_id: str | None = None) -> str:
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_lists(scope: str | None = None):
|
||||
"""Return task lists as JSON, optionally filtered by circle name (scope)."""
|
||||
def get_lists(scope: str | None = None) -> str:
|
||||
"""Return task lists as JSON, optionally filtered to a specific circle.
|
||||
|
||||
Each list object includes a ``circle_id`` field with the owning circle's
|
||||
metaId (e.g. ``"family/23431854"``).
|
||||
|
||||
Args:
|
||||
scope: Optional circle filter. Accepts either:
|
||||
|
||||
- A circle metaId (e.g. ``"family/23447378"``) — passed directly
|
||||
to the API.
|
||||
- A circle display name (e.g. ``"Test Kreis 2"``) — resolved to
|
||||
the matching circle metaId via ``get_circles`` first.
|
||||
|
||||
When ``None`` (default) the primary circle's lists are returned.
|
||||
|
||||
Returns:
|
||||
JSON list of list objects with keys id, name, type, open, total,
|
||||
emoji, color, circle_id.
|
||||
"""
|
||||
try:
|
||||
email, password = get_credentials()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
|
||||
# Build the scope param for taskgettasklists.
|
||||
# When scope is provided as a circle name (not a metaId), we need to
|
||||
# resolve it via famlistfamily first — done in the same session.
|
||||
api_scope: str | None = None
|
||||
if scope:
|
||||
if scope.startswith("family/"):
|
||||
api_scope = scope
|
||||
else:
|
||||
# Treat as circle name — look up the metaId.
|
||||
try:
|
||||
circles = _famlistfamily()
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {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]
|
||||
return json.dumps(
|
||||
{
|
||||
"error": f"Circle not found: {scope!r}",
|
||||
"available_circles": circle_names,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
api_scope = matched["metaId"]
|
||||
|
||||
try:
|
||||
with FamilyWallClient() as client:
|
||||
client.login(email, password)
|
||||
data = client.call("taskgettasklists", {})
|
||||
params: dict[str, Any] = {}
|
||||
if api_scope:
|
||||
params["scope"] = api_scope
|
||||
data = client.call("taskgettasklists", params)
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: {exc}"
|
||||
@@ -281,7 +348,6 @@ def get_lists(scope: str | None = None):
|
||||
pass
|
||||
|
||||
if raw_lists is None:
|
||||
# Response structure not yet verified — return raw JSON for inspection.
|
||||
return json.dumps(
|
||||
{"warning": "Unexpected taskgettasklists response structure", "raw": data},
|
||||
ensure_ascii=False,
|
||||
@@ -290,7 +356,6 @@ def get_lists(scope: str | None = None):
|
||||
|
||||
result = []
|
||||
for item in raw_lists:
|
||||
# TODO: apply scope filtering once the circle field is identified.
|
||||
# emoji: API returns "" when unset — normalise to None for a clean JSON null.
|
||||
# color: API omits the key entirely when unset — .get() returns None directly.
|
||||
raw_emoji: str = item.get("emoji", "")
|
||||
@@ -303,6 +368,7 @@ def get_lists(scope: str | None = None):
|
||||
"total": item.get("totalTaskNumber"),
|
||||
"emoji": raw_emoji if raw_emoji else None,
|
||||
"color": item.get("color") or None,
|
||||
"circle_id": item.get("familyId"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -912,6 +978,7 @@ def create_list(
|
||||
shared_to_all: bool = True,
|
||||
color: str | None = None,
|
||||
emoji: str | None = None,
|
||||
circle_id: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new task list in Family Wall.
|
||||
|
||||
@@ -924,9 +991,15 @@ def create_list(
|
||||
circle members. When ``False`` it is private to the creator.
|
||||
color: Optional background colour as a hex string (e.g. ``"#4784EC"``).
|
||||
emoji: Optional Unicode emoji to use as the list icon (e.g. ``"🛒"``).
|
||||
circle_id: Optional circle metaId to create the list in
|
||||
(e.g. ``"family/23447378"``). When ``None`` (default) the list
|
||||
is created in the primary circle. Use ``get_circles`` to
|
||||
retrieve available circle IDs.
|
||||
|
||||
Returns:
|
||||
JSON with the new list object on success, or an error message.
|
||||
Includes ``circle_id`` field showing which circle the list was
|
||||
created in.
|
||||
"""
|
||||
if list_type not in ("SHOPPING_LIST", "TODOS"):
|
||||
return "Error: list_type must be 'SHOPPING_LIST' or 'TODOS'."
|
||||
@@ -942,6 +1015,9 @@ def create_list(
|
||||
params["color"] = color
|
||||
if emoji:
|
||||
params["emoji"] = emoji
|
||||
if circle_id:
|
||||
# The API uses the 'scope' parameter to specify the target circle.
|
||||
params["scope"] = circle_id
|
||||
|
||||
try:
|
||||
data = _authenticated_call("taskcreatelist", params)
|
||||
@@ -968,6 +1044,7 @@ def create_list(
|
||||
"shared_to_all": list_obj.get("sharedToAll") == "true",
|
||||
"emoji": raw_emoji if raw_emoji else None,
|
||||
"color": list_obj.get("color") or None,
|
||||
"circle_id": list_obj.get("familyId") or circle_id,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
@@ -1003,13 +1080,21 @@ def delete_list(list_id: str) -> str:
|
||||
except RuntimeError as exc:
|
||||
return f"Error: {exc}"
|
||||
|
||||
# Derive the owning circle from the list metaId so that secondary-circle
|
||||
# lists can be queried and deleted with the correct scope parameter.
|
||||
# Format: taskList/<family_num>_<list_num> → family/<family_num>
|
||||
circle_scope = _circle_id_from_list_id(list_id)
|
||||
|
||||
list_obj: dict[str, Any] | None = None
|
||||
try:
|
||||
with FamilyWallClient() as client:
|
||||
client.login(email, password)
|
||||
|
||||
# Fetch lists and verify the target can be deleted.
|
||||
raw = client.call("taskgettasklists", {})
|
||||
# Fetch lists scoped to the correct circle and verify deletion rights.
|
||||
get_params: dict[str, Any] = {}
|
||||
if circle_scope:
|
||||
get_params["scope"] = circle_scope
|
||||
raw = client.call("taskgettasklists", get_params)
|
||||
try:
|
||||
raw_lists: list[dict[str, Any]] = raw["a00"]["r"]["r"]
|
||||
if not isinstance(raw_lists, list):
|
||||
@@ -1037,8 +1122,11 @@ def delete_list(list_id: str) -> str:
|
||||
)
|
||||
|
||||
# Verified — delete in the same session.
|
||||
# taskdeletelist uses 'id' (same pattern as metadelete / taskcategorydelete).
|
||||
client.call("taskdeletelist", {"id": list_id})
|
||||
# For secondary circles the 'scope' parameter is required.
|
||||
del_params: dict[str, Any] = {"id": list_id}
|
||||
if circle_scope:
|
||||
del_params["scope"] = circle_scope
|
||||
client.call("taskdeletelist", del_params)
|
||||
client.logout()
|
||||
except FamilyWallError as exc:
|
||||
return f"Error: Family Wall API error: {exc}"
|
||||
|
||||
Reference in New Issue
Block a user