b02c40f716
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>
549 lines
13 KiB
Go
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)
|
|
}
|