78881e1b07
Extract CreateRecurringTask into engine package for reuse by both CLI and API. Add POST /tasks/parse endpoint for CLI-style input parsing. Remove FK constraint on change_log to preserve history after task deletion. Update web frontend to filter completed tasks from view and add mock mode support for development. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
530 lines
13 KiB
Go
530 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
jsonResponse(w, http.StatusOK, task)
|
|
}
|