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 } // 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) }