feat(tasks): support reminder write via dot-notation (v1.2.0)

- Add reminder_unit, reminder_value to create_task
- Add reminder_unit, reminder_value, clear_reminder to update_task
- Wire format verified: reminder.reminderUnit / .reminderValue /
  .reminderType (SNOOZE=active, NONE=clear) / .localId (optional)
- Valid units: MINUTE, HOUR, DAY (WEEK rejected by enum decoder)
- Clear requires full inactive block; partial updates reject with
  "task reminder invalid"
- Flat top-level keys, JSON string and bracket notation are silently
  ignored by the server — confirmed via isolated fuzz per variant
- Update SPEC.md, CLAUDE.md, README.md, CHANGELOG.md
- Remove outdated "not supported" warning in update_task docstring
This commit is contained in:
2026-04-17 22:41:16 +02:00
parent d6d8d40305
commit 4c1e4e2c23
6 changed files with 147 additions and 22 deletions
+89 -7
View File
@@ -807,6 +807,59 @@ def get_activities(limit: int = 20) -> str:
# ---------------------------------------------------------------------------
# Reminder units accepted by the Family Wall API (verified 2026-04-17 via
# taskupdate2 fuzz: WEEK is rejected, MINUTE/HOUR/DAY succeed).
_VALID_REMINDER_UNITS = ("MINUTE", "HOUR", "DAY")
def _build_reminder_params(
reminder_unit: str | None,
reminder_value: int | None,
clear_reminder: bool,
) -> tuple[dict[str, Any], str | None]:
"""Return (params, error_message). Params are the dot-notation reminder.* fields.
Encoding note: taskupdate2 only accepts reminders via dot-notation
(``reminder.reminderUnit``, ``reminder.reminderValue``, ``reminder.reminderType``).
Flat top-level keys, JSON strings, and bracket notation are silently ignored
by the server — verified 2026-04-17.
Clearing requires sending the full "inactive" block
(reminderType=NONE, reminderValue=0, reminderUnit=MINUTE, localId=0) —
partial updates return ``"task reminder invalid"``.
"""
if clear_reminder and (reminder_unit is not None or reminder_value is not None):
return {}, "'clear_reminder' cannot be combined with 'reminder_unit' or 'reminder_value'."
if clear_reminder:
return {
"reminder.reminderUnit": "MINUTE",
"reminder.reminderValue": "0",
"reminder.reminderType": "NONE",
"reminder.localId": "0",
}, None
if reminder_unit is None and reminder_value is None:
return {}, None
if reminder_unit is None or reminder_value is None:
return {}, "'reminder_unit' and 'reminder_value' must be provided together."
if reminder_unit not in _VALID_REMINDER_UNITS:
allowed = ", ".join(_VALID_REMINDER_UNITS)
return {}, f"Invalid reminder_unit {reminder_unit!r}. Allowed: {allowed}."
if reminder_value < 0:
return {}, "'reminder_value' must be non-negative."
return {
"reminder.reminderUnit": reminder_unit,
"reminder.reminderValue": str(reminder_value),
"reminder.reminderType": "SNOOZE",
"reminder.localId": "0",
}, None
def _authenticated_call(endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
"""Login, call *endpoint* with *params*, logout, and return the response body.
@@ -847,6 +900,8 @@ def create_task(
category_id: str | None = None,
due_date: str | None = None,
assignee_ids: list[str] | None = None,
reminder_unit: str | None = None,
reminder_value: int | None = None,
) -> str:
"""Create a new task in the given list.
@@ -878,10 +933,20 @@ def create_task(
due_date: Optional due date in ISO 8601 format (e.g. ``"2026-04-30T18:00:00"``).
assignee_ids: Optional list of member IDs from get_members to assign the task
(e.g. ``["23431898"]``). Empty list assigns to nobody.
reminder_unit: Reminder time unit — ``"MINUTE"``, ``"HOUR"``, or ``"DAY"``.
Must be provided together with *reminder_value*. Requires Family Wall Premium.
reminder_value: Reminder offset before the due date (e.g. ``30`` for
"30 minutes before"). Must be provided together with *reminder_unit*.
Returns:
JSON with the new task's metaId on success, or an error message.
"""
reminder_params, reminder_error = _build_reminder_params(
reminder_unit, reminder_value, clear_reminder=False
)
if reminder_error:
return _err(reminder_error)
params: dict[str, Any] = {"taskListId": list_id, "text": text}
if description:
params["description"] = description
@@ -891,6 +956,7 @@ def create_task(
params["dueDate"] = due_date
if assignee_ids is not None:
params["assignee"] = assignee_ids if assignee_ids else ""
params.update(reminder_params)
try:
data = _authenticated_call("taskcreate2", params)
@@ -930,17 +996,15 @@ def update_task(
recurrency_interval: int | None = None,
rrule: str | None = None,
clear_recurrency: bool = False,
reminder_unit: str | None = None,
reminder_value: int | None = None,
clear_reminder: bool = False,
) -> str:
"""Update an existing task's fields.
IMPORTANT: Ask the user for confirmation before calling this tool.
At least one parameter besides *task_id* must be provided.
NOTE: Setting or clearing reminders is **not supported** via this tool.
The Family Wall API does not accept reminder writes through ``taskupdate2``
— reminder updates are handled exclusively by the mobile app's Service
Worker and cannot be replicated via direct API calls.
Args:
task_id: Task metaId from get_tasks.
text: New title text (omit to leave unchanged).
@@ -969,6 +1033,13 @@ def update_task(
Overrides simple recurrency fields when set.
clear_recurrency: Set to ``True`` to remove the recurrence rule from the task.
Cannot be used together with *recurrency*.
reminder_unit: Reminder time unit — ``"MINUTE"``, ``"HOUR"``, or ``"DAY"``.
Must be provided together with *reminder_value*. Requires Family Wall
Premium. Cannot be used together with *clear_reminder*.
reminder_value: Reminder offset before the due date (e.g. ``30`` for
"30 minutes before"). Must be provided together with *reminder_unit*.
clear_reminder: Set to ``True`` to remove the reminder from the task.
Cannot be used together with *reminder_unit* or *reminder_value*.
Returns:
JSON success indicator or an error message.
@@ -978,6 +1049,12 @@ def update_task(
if clear_recurrency and recurrency is not None:
return _err("'clear_recurrency' and 'recurrency' cannot be used together.")
reminder_params, reminder_error = _build_reminder_params(
reminder_unit, reminder_value, clear_reminder
)
if reminder_error:
return _err(reminder_error)
if (
text is None
and description is None
@@ -988,11 +1065,13 @@ def update_task(
and list_id is None
and recurrency is None
and not clear_recurrency
and not reminder_params
):
return _err(
"At least one of 'text', 'description', 'category_id', 'due_date',"
" 'clear_due_date', 'assignee_ids', 'list_id', 'recurrency', or"
" 'clear_recurrency' must be provided."
" 'clear_due_date', 'assignee_ids', 'list_id', 'recurrency',"
" 'clear_recurrency', 'reminder_unit'/'reminder_value', or"
" 'clear_reminder' must be provided."
)
params: dict[str, Any] = {"metaId": task_id}
@@ -1022,6 +1101,9 @@ def update_task(
if rrule is not None:
params["rrule"] = rrule
# Reminder fields use dot-notation `reminder.*` (verified: flat keys ignored).
params.update(reminder_params)
try:
_authenticated_call("taskupdate2", params)
except RuntimeError as exc: