feat: category assignment in create_task / update_task (v0.4.10)

Verified via FW_DEBUG=1 + systematic param-name probing that the
correct parameter is `taskCategoryId` with value = full metaId from
get_categories (e.g. taskCategory/23431854_200).  Numeric systemCategoryId
alone causes API error; full metaId is accepted and stored.

Changes:
- create_task: add optional category_id parameter → sent as taskCategoryId
- update_task: add optional category_id parameter → sent as taskCategoryId;
  guard now accepts category_id-only updates
- get_tasks: expose category_id field in returned task objects
- get_categories: update docstring (param name now known)
- SPEC.md: document verified taskCategoryId param + clarify categories[]
  vs taskCategoryId field distinction
- scripts/find_category_param.py: discovery script used to find param name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 06:54:10 +02:00
parent 9bc6a54783
commit a76dc0fd51
6 changed files with 262 additions and 26 deletions
+197
View File
@@ -0,0 +1,197 @@
"""Discover the category assignment parameter name for taskcreate2 / taskupdate2.
Usage:
FW_DEBUG=1 python scripts/find_category_param.py
The script:
1. Fetches the first available shopping-list category (German locale).
2. For each candidate parameter name, creates a test task with that parameter set,
checks whether the category appears in the response, then deletes the task.
3. Reports which parameter name (if any) caused the category to be applied.
Prerequisites: credentials stored in OS keyring or FW_EMAIL / FW_PASSWORD set.
"""
from __future__ import annotations
import json
import os
import sys
# Allow running from repo root without installing the package.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from mcp_familywall.auth import get_credentials
from mcp_familywall.fw_client import FamilyWallClient, FamilyWallError
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
# Shopping list to use for the test (adjust if needed).
TEST_LIST_ID = "taskList/23431854_29740941"
# Category to assign — will be resolved from the first taskcategorysync entry
# for this list in German locale; override here if desired.
FORCED_CATEGORY_META_ID: str | None = None
# Parameter-name candidates not yet tested (already ruled out are commented out):
CANDIDATES: list[dict[str, str | None]] = [
# key → value format description
# Full metaId variants
{"key": "systemCategoryId", "fmt": "numeric"}, # just the number, e.g. "200"
{"key": "taskCategorySystemId", "fmt": "numeric"},
{"key": "categories", "fmt": "meta_id"}, # full taskCategory/… metaId
{"key": "categoryIds", "fmt": "meta_id"},
{"key": "taskCategoryName", "fmt": "name"}, # category name as string
{"key": "categoryName", "fmt": "name"},
{"key": "taskCategoryId", "fmt": "meta_id"}, # already tried but try numeric too
# Numeric variants of already-tried names
{"key": "taskCategoryId", "fmt": "numeric"},
{"key": "categoryId", "fmt": "numeric"},
{"key": "category", "fmt": "numeric"},
{"key": "categoryMetaId", "fmt": "numeric"},
]
def _resolve_category(
client: FamilyWallClient, list_id: str
) -> tuple[str, str, str]:
"""Return (meta_id, system_category_id_str, name) for the first German category."""
data = client.call(
"accgetallfamily",
{"a01call": "taskcategorysync", "a02call": "tasksync"},
)
cats = data["a01"]["r"]["r"]["updatedCreated"]
# Also resolve list type for filtering.
list_type: str | None = None
try:
list_data = client.call("taskgettasklists", {})
for lst in list_data.get("a00", {}).get("r", {}).get("r", []) or []:
if lst.get("metaId") == list_id:
list_type = lst.get("taskListType")
break
except FamilyWallError:
pass
for cat in cats:
if cat.get("locale") != "de":
continue
if list_type and cat.get("taskListType") != list_type:
continue
meta_id: str = cat["metaId"]
sys_id: str = str(cat.get("systemCategoryId", ""))
name: str = cat.get("name", "")
print(f" Using category: metaId={meta_id!r}, systemCategoryId={sys_id!r}, name={name!r}")
return meta_id, sys_id, name
raise RuntimeError("No German category found for list; adjust TEST_LIST_ID.")
def _get_task_categories(task_obj: dict) -> list[str]:
"""Extract category names from a task API object."""
cats = task_obj.get("categories") or []
return [c.get("name", "") for c in cats if isinstance(c, dict)]
def _delete_task(client: FamilyWallClient, task_id: str) -> None:
try:
client.call("metadelete", {"id": task_id})
except FamilyWallError as exc:
print(f" [warn] Could not delete task {task_id}: {exc}")
def main() -> None:
email, password = get_credentials()
with FamilyWallClient() as client:
client.login(email, password)
print(f"\nResolving category for list {TEST_LIST_ID!r}")
if FORCED_CATEGORY_META_ID:
# Can't resolve name/numeric from meta_id easily; just use the meta_id.
cat_meta = FORCED_CATEGORY_META_ID
cat_numeric = cat_meta.split("_")[-1] if "_" in cat_meta else cat_meta
cat_name = cat_meta
else:
cat_meta, cat_numeric, cat_name = _resolve_category(client, TEST_LIST_ID)
value_map = {
"meta_id": cat_meta,
"numeric": cat_numeric,
"name": cat_name,
}
print(f"\nTesting {len(CANDIDATES)} candidate parameter names …\n")
results: list[tuple[str, str, bool]] = []
seen: set[tuple[str, str]] = set() # avoid duplicate tests
for cand in CANDIDATES:
key: str = cand["key"] # type: ignore[assignment]
fmt: str = cand["fmt"] # type: ignore[assignment]
value: str = value_map[fmt]
pair = (key, value)
if pair in seen:
continue
seen.add(pair)
params: dict = {
"taskListId": TEST_LIST_ID,
"text": f"[TEST] cat-param-probe {key}={fmt}",
key: value,
}
print(f" Testing {key!r} = {value!r}", end=" ", flush=True)
try:
data = client.call("taskcreate2", params)
except FamilyWallError as exc:
print(f"API ERROR: {exc}")
results.append((key, value, False))
continue
task_obj = data.get("a00", {}).get("r", {}).get("r", {})
task_id: str = task_obj.get("metaId", "")
applied_cats = _get_task_categories(task_obj)
# A non-default category was applied when the name matches our target.
success = any(cat_name in c or c == cat_name for c in applied_cats)
if success:
print(f"SUCCESS -> categories={applied_cats}")
else:
print(f"no effect -> categories={applied_cats}")
results.append((key, value, success))
if task_id:
_delete_task(client, task_id)
client.logout()
# -----------------------------------------------------------------------
# Summary
# -----------------------------------------------------------------------
print("\n" + "=" * 60)
print("RESULTS")
print("=" * 60)
hits = [(k, v) for k, v, ok in results if ok]
misses = [(k, v) for k, v, ok in results if not ok]
if hits:
print(f"\nWORKING parameter(s) found ({len(hits)}):")
for k, v in hits:
print(f" {k!r} = {v!r}")
else:
print("\nNo working parameter found.")
print(f"\nDid NOT work ({len(misses)}):")
for k, v in misses:
print(f" {k!r} = {v!r}")
print()
if __name__ == "__main__":
main()