Files
gems/opal-task/internal/api/handlers/tasks.go
T
joakim 393b7a144a fix: use Modifier.Set() to maintain AttributeOrder invariant in API handlers
API handlers were populating SetAttributes directly without appending to
AttributeOrder. Since Apply() only iterates AttributeOrder, all updates
via PUT/POST were silently dropped — causing edits to revert and tasks
to disappear on reload.

Adds Modifier.Set() helper, safety net in Apply()/ApplyToNew(), adds
description and status to applyNonDateAttribute, and fixes the
recurrence->recur key mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:42:57 +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.Set("project", req.Project)
}
if req.Priority != nil {
mod.Set("priority", req.Priority)
}
if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due)
mod.Set("due", &dueStr)
}
if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.Set("scheduled", &scheduledStr)
}
if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait)
mod.Set("wait", &waitStr)
}
if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until)
mod.Set("until", &untilStr)
}
if req.Recurrence != nil {
mod.Set("recur", 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.Set("description", req.Description)
}
if req.Status != nil {
mod.Set("status", req.Status)
}
if req.Priority != nil {
mod.Set("priority", req.Priority)
}
if req.Project != nil {
mod.Set("project", req.Project)
}
if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due)
mod.Set("due", &dueStr)
}
if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.Set("scheduled", &scheduledStr)
}
if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait)
mod.Set("wait", &waitStr)
}
if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until)
mod.Set("until", &untilStr)
}
if req.Start != nil {
t := time.Unix(*req.Start, 0)
task.Start = &t
}
if req.Recurrence != nil {
mod.Set("recur", 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)
}