Files
gems/opal-task/internal/api/handlers/tasks.go
T
joakim b02c40f716 feat: improve CLI output with relative dates, rich feedback, and recurring task info
Add relative date formatting (today, tomorrow, in 3d, etc.) for list and
detail views. Add structured feedback helpers for add/complete/delete
operations showing display IDs and parsed modifiers. Change Complete() to
return spawned recurring instance so callers can display recurrence info.
Add AppendTask to working set for immediate display ID assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:44:56 +01:00

549 lines
13 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// Response helpers (avoiding import cycle)
func jsonResponse(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": status >= 200 && status < 300,
"data": data,
})
}
func errorResponse(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": message,
})
}
// ListTasks returns tasks based on filter query parameters or a named report
func ListTasks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
// Check for report mode
if reportName := query.Get("report"); reportName != "" {
report, err := engine.GetReport(reportName)
if err != nil {
errorResponse(w, http.StatusBadRequest, err.Error())
return
}
tasks, err := report.Execute(nil)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
// Report sorts may already populate urgency, but ensure it for all paths
engine.PopulateUrgency(tasks...)
jsonResponse(w, http.StatusOK, map[string]interface{}{
"report": reportName,
"tasks": tasks,
"count": len(tasks),
})
return
}
// Build filter from query params
filter := engine.NewFilter()
// Status filter
if status := query.Get("status"); status != "" {
filter.Attributes["status"] = status
}
// Project filter
if project := query.Get("project"); project != "" {
filter.Attributes["project"] = project
}
// Priority filter
if priority := query.Get("priority"); priority != "" {
filter.Attributes["priority"] = priority
}
// Tag filters
if tags := query["tag"]; len(tags) > 0 {
filter.IncludeTags = tags
}
// Exclude tag filters
if excludeTags := query["exclude_tag"]; len(excludeTags) > 0 {
filter.ExcludeTags = excludeTags
}
// Get tasks
tasks, err := engine.GetTasks(filter)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
engine.PopulateUrgency(tasks...)
jsonResponse(w, http.StatusOK, tasks)
}
// CreateTaskRequest represents the request body for creating a task
type CreateTaskRequest struct {
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Project *string `json:"project,omitempty"`
Priority *string `json:"priority,omitempty"`
Due *int64 `json:"due,omitempty"`
Scheduled *int64 `json:"scheduled,omitempty"`
Wait *int64 `json:"wait,omitempty"`
Until *int64 `json:"until,omitempty"`
Recurrence *string `json:"recurrence,omitempty"`
}
// CreateTask creates a new task
func CreateTask(w http.ResponseWriter, r *http.Request) {
var req CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Description == "" {
errorResponse(w, http.StatusBadRequest, "description is required")
return
}
// Build modifier from request
mod := engine.NewModifier()
mod.AddTags = req.Tags
if req.Project != nil {
mod.SetAttributes["project"] = req.Project
}
if req.Priority != nil {
mod.SetAttributes["priority"] = req.Priority
}
if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due)
mod.SetAttributes["due"] = &dueStr
}
if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.SetAttributes["scheduled"] = &scheduledStr
}
if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait)
mod.SetAttributes["wait"] = &waitStr
}
if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until)
mod.SetAttributes["until"] = &untilStr
}
if req.Recurrence != nil {
mod.SetAttributes["recurrence"] = req.Recurrence
}
// Create task
task, err := engine.CreateTaskWithModifier(req.Description, mod)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusCreated, task)
}
// GetTask returns a single task by UUID
func GetTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task)
}
// UpdateTaskRequest represents the request body for updating a task
type UpdateTaskRequest struct {
Description *string `json:"description,omitempty"`
Status *string `json:"status,omitempty"`
Priority *string `json:"priority,omitempty"`
Project *string `json:"project,omitempty"`
Due *int64 `json:"due,omitempty"`
Scheduled *int64 `json:"scheduled,omitempty"`
Wait *int64 `json:"wait,omitempty"`
Until *int64 `json:"until,omitempty"`
Start *int64 `json:"start,omitempty"`
Recurrence *string `json:"recurrence,omitempty"`
}
// UpdateTask updates an existing task
func UpdateTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
var req UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
// Build modifier
mod := engine.NewModifier()
if req.Description != nil {
mod.SetAttributes["description"] = req.Description
}
if req.Status != nil {
mod.SetAttributes["status"] = req.Status
}
if req.Priority != nil {
mod.SetAttributes["priority"] = req.Priority
}
if req.Project != nil {
mod.SetAttributes["project"] = req.Project
}
if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due)
mod.SetAttributes["due"] = &dueStr
}
if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.SetAttributes["scheduled"] = &scheduledStr
}
if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait)
mod.SetAttributes["wait"] = &waitStr
}
if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until)
mod.SetAttributes["until"] = &untilStr
}
if req.Start != nil {
t := time.Unix(*req.Start, 0)
task.Start = &t
}
if req.Recurrence != nil {
mod.SetAttributes["recurrence"] = req.Recurrence
}
// Apply modifier
if err := mod.Apply(task); err != nil {
errorResponse(w, http.StatusBadRequest, err.Error())
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task)
}
// DeleteTask deletes a task
func DeleteTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
// Check for permanent delete flag
permanent := r.URL.Query().Get("permanent") == "true"
if err := task.Delete(permanent); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"message": "task deleted"})
}
// CompleteTask marks a task as completed
func CompleteTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
if _, err := task.Complete(); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task)
}
// StartTask sets the start time for a task
func StartTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
if err := task.StartTask(); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task)
}
// StopTask clears the start time for a task
func StopTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
if err := task.StopTask(); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task)
}
// GetTaskTags returns all tags for a task
func GetTaskTags(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
jsonResponse(w, http.StatusOK, task.Tags)
}
// AddTaskTagRequest represents the request to add a tag
type AddTaskTagRequest struct {
Tag string `json:"tag"`
}
// AddTaskTag adds a tag to a task
func AddTaskTag(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
var req AddTaskTagRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Tag == "" {
errorResponse(w, http.StatusBadRequest, "tag is required")
return
}
if err := task.AddTag(req.Tag); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task)
}
// ParseTaskRequest represents the request body for parsing a CLI-style task input
type ParseTaskRequest struct {
Input string `json:"input"`
}
// ParseTask accepts a raw CLI-style input string, parses it, and creates a task
func ParseTask(w http.ResponseWriter, r *http.Request) {
var req ParseTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
input := strings.TrimSpace(req.Input)
if input == "" {
errorResponse(w, http.StatusBadRequest, "input is required")
return
}
// Split input on whitespace
args := strings.Fields(input)
// Classify args: words with +/- prefix or containing : are modifiers
var descParts []string
var modifierArgs []string
for _, arg := range args {
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") || strings.Contains(arg, ":") {
modifierArgs = append(modifierArgs, arg)
} else {
descParts = append(descParts, arg)
}
}
if len(descParts) == 0 {
errorResponse(w, http.StatusBadRequest, "description is required")
return
}
description := strings.Join(descParts, " ")
// Parse modifiers
var mod *engine.Modifier
if len(modifierArgs) > 0 {
var err error
mod, err = engine.ParseModifier(modifierArgs)
if err != nil {
errorResponse(w, http.StatusBadRequest, fmt.Sprintf("failed to parse modifiers: %v", err))
return
}
}
// Check for recurring task
if mod != nil && mod.SetAttributes["recur"] != nil {
instance, err := engine.CreateRecurringTask(description, mod)
if err != nil {
errorResponse(w, http.StatusBadRequest, err.Error())
return
}
engine.PopulateUrgency(instance)
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": instance})
return
}
// Create regular task
task, err := engine.CreateTaskWithModifier(description, mod)
if err != nil {
errorResponse(w, http.StatusBadRequest, err.Error())
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusCreated, map[string]interface{}{"task": task})
}
// RemoveTaskTag removes a tag from a task
func RemoveTaskTag(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
tag := chi.URLParam(r, "tag")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
if err := task.RemoveTag(tag); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
engine.PopulateUrgency(task)
jsonResponse(w, http.StatusOK, task)
}