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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user