import asyncio from datetime import date from quart import Blueprint, jsonify, request from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.models.note import TaskPriority, TaskStatus from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination from fabledassistant.services.access import can_write_note from fabledassistant.services.embeddings import upsert_note_embedding from fabledassistant.services.notes import ( create_note, delete_note, get_note, get_note_for_user, 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 async def list_tasks_route(): uid = get_current_user_id() q = request.args.get("q") tag = request.args.getlist("tag") 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() project_id = request.args.get("project_id", type=int) no_project = request.args.get("no_project", "").lower() == "true" due_before = parse_iso_date(request.args.get("due_before"), "due_before") if isinstance(due_before, tuple): return due_before due_after = parse_iso_date(request.args.get("due_after"), "due_after") if isinstance(due_after, tuple): return due_after tasks, total = await list_notes( uid, q=q, tags=tag or None, is_task=True, status=status, priority=priority, due_before=due_before, due_after=due_after, project_id=project_id, no_project=no_project, sort=sort, order=order, limit=limit, offset=offset, ) return jsonify({"tasks": [t.to_dict() for t in tasks], "total": total}) @tasks_bp.route("", methods=["POST"]) @login_required async def create_task_route(): uid = get_current_user_id() data = await request.get_json() # Description (user-stated goal) and body (machine-maintained summary) are # separate fields under the task-as-durable-record design. Don't fold one # into the other. body = data.get("body", "") description = data.get("description") tags = data.get("tags", []) due_date = parse_iso_date(data.get("due_date"), "due_date") if isinstance(due_date, tuple): return due_date try: status = TaskStatus(data["status"]).value if "status" in data else TaskStatus.todo.value except ValueError: return jsonify({"error": f"Invalid status: {data['status']}"}), 400 try: priority = TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value 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 proj = await _gpbt(uid, data["project"]) if proj: project_id = proj.id task = await create_note( uid, title=data.get("title", ""), body=body, description=description, status=status, priority=priority, due_date=due_date, tags=tags, 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: asyncio.create_task(upsert_note_embedding(task.id, uid, text)) return jsonify(task.to_dict()), 201 @tasks_bp.route("/", methods=["GET"]) @login_required async def get_task_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, permission = result data = task.to_dict() data["permission"] = permission if task.parent_id: parent = await get_note(uid, task.parent_id) data["parent_title"] = parent.title if parent else None return jsonify(data) @tasks_bp.route("/", methods=["PUT", "PATCH"]) @login_required async def update_task_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_note, _ = result if not await can_write_note(uid, task_id): return jsonify({"error": "Permission denied"}), 403 data = await request.get_json() fields = {} for key in ("title",): if key in data: fields[key] = data[key] if "status" in data: try: fields["status"] = TaskStatus(data["status"]).value except ValueError: return jsonify({"error": f"Invalid status: {data['status']}"}), 400 if "priority" in data: try: fields["priority"] = TaskPriority(data["priority"]).value except ValueError: return jsonify({"error": f"Invalid priority: {data['priority']}"}), 400 # Body and description are distinct fields under the task-as-durable-record # design. Don't alias one to the other. if "body" in data: fields["body"] = data["body"] if "description" in data: fields["description"] = data["description"] if "due_date" in data: if data["due_date"]: result = parse_iso_date(data["due_date"], "due_date") if isinstance(result, tuple): return result fields["due_date"] = result else: fields["due_date"] = None if "tags" in data: fields["tags"] = data["tags"] for key in ("project_id", "milestone_id", "parent_id"): 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(task_note.user_id, task_id, **fields) if task is None: return not_found("Task") text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "") if text: asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text)) return jsonify(task.to_dict()) @tasks_bp.route("//status", methods=["PATCH"]) @login_required async def patch_task_status(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_note, _ = result if not await can_write_note(uid, task_id): return jsonify({"error": "Permission denied"}), 403 data = await request.get_json() status_val = data.get("status") if not status_val: return jsonify({"error": "status is required"}), 400 try: TaskStatus(status_val) except ValueError: return jsonify({"error": f"Invalid status: {status_val}"}), 400 task = await update_note(task_note.user_id, task_id, status=status_val) if task is None: return not_found("Task") 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): uid = get_current_user_id() result = await get_note_for_user(uid, task_id) if result is None: return not_found("Task") task_note, _ = result if not await can_write_note(uid, task_id): return jsonify({"error": "Permission denied"}), 403 deleted = await delete_note(task_note.user_id, task_id) if not deleted: return not_found("Task") return "", 204 @tasks_bp.route("//consolidate", methods=["POST"]) @login_required async def consolidate_task_route(task_id: int): """Manually trigger a consolidation pass for a task. Bypasses the auto_consolidate_tasks setting (the user is asking explicitly). Returns the task's updated state including the freshly- written body and consolidated_at timestamp. """ uid = get_current_user_id() if not await can_write_note(uid, task_id): return jsonify({"error": "Permission denied"}), 403 from fabledassistant.services.consolidation import consolidate_task await consolidate_task(uid, task_id) note = await get_note(uid, task_id) if note is None: return not_found("Task") return jsonify(note.to_dict())