diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py index a483d5c..4392d6e 100644 --- a/src/fabledassistant/routes/tasks.py +++ b/src/fabledassistant/routes/tasks.py @@ -1,4 +1,5 @@ import asyncio +from datetime import date from quart import Blueprint, jsonify, request @@ -14,9 +15,32 @@ from fabledassistant.services.notes import ( list_notes, update_note, ) +from fabledassistant.services.recurrence import calculate_next_due, validate_recurrence_rule tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks") +_UNSET = object() + + +def _parse_recurrence_rule(data: dict) -> tuple[object, tuple | None]: + """Extract and validate recurrence_rule from request data. + + Returns (rule_or_UNSET, error_response_or_None): + _UNSET → key not present, do nothing + None → key present and null, clear the rule + dict → key present and valid, set the rule + """ + if "recurrence_rule" not in data: + return _UNSET, None + rule = data["recurrence_rule"] + if rule is None: + return None, None + try: + validate_recurrence_rule(rule) + except ValueError as exc: + return _UNSET, (jsonify({"error": str(exc)}), 400) + return rule, None + @tasks_bp.route("", methods=["GET"]) @login_required @@ -24,8 +48,8 @@ async def list_tasks_route(): uid = get_current_user_id() q = request.args.get("q") tag = request.args.getlist("tag") - status = request.args.get("status") - priority = request.args.get("priority") + status = request.args.getlist("status") or None + priority = request.args.getlist("priority") or None sort = request.args.get("sort", "updated_at") order = request.args.get("order", "desc") limit, offset = parse_pagination() @@ -80,6 +104,10 @@ async def create_task_route(): except ValueError: return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400 + recurrence_rule, recurrence_err = _parse_recurrence_rule(data) + if recurrence_err: + return recurrence_err + project_id = data.get("project_id") if project_id is None and data.get("project"): from fabledassistant.services.projects import get_project_by_title as _gpbt @@ -98,6 +126,7 @@ async def create_task_route(): project_id=project_id, milestone_id=data.get("milestone_id"), parent_id=data.get("parent_id"), + recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None, ) text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "") if text: @@ -163,6 +192,12 @@ async def update_task_route(task_id: int): if key in data: fields[key] = data[key] + recurrence_rule, recurrence_err = _parse_recurrence_rule(data) + if recurrence_err: + return recurrence_err + if recurrence_rule is not _UNSET: + fields["recurrence_rule"] = recurrence_rule + task = await update_note(uid, task_id, **fields) if task is None: return not_found("Task") @@ -192,6 +227,27 @@ async def patch_task_status(task_id: int): return jsonify(task.to_dict()) +@tasks_bp.route("//recurrence-preview", methods=["GET"]) +@login_required +async def recurrence_preview_route(task_id: int): + uid = get_current_user_id() + result = await get_note_for_user(uid, task_id) + if result is None: + return not_found("Task") + task, _ = result + if not task.recurrence_rule: + return jsonify({"error": "Task has no recurrence rule"}), 400 + + count = min(request.args.get("count", 5, type=int), 10) + base = task.due_date or date.today() + dates = [] + current = base + for _ in range(count): + current = calculate_next_due(task.recurrence_rule, current) + dates.append(current.isoformat()) + return jsonify({"dates": dates}) + + @tasks_bp.route("/", methods=["DELETE"]) @login_required async def delete_task_route(task_id: int):