package web import ( "errors" "net/http" "strconv" "strings" "time" "gardomatic.kleiax.de/lib/client" ) type taskForm struct { CSRFToken string `form:"csrf_token"` PlantID int `form:"plant_id"` LocationID int `form:"location_id"` Title string `form:"title"` Description string `form:"description"` DueAtStart string `form:"due_at_start"` DueAtEnd string `form:"due_at_end"` Recurrence string `form:"recurrence"` RecurrenceInterval int `form:"recurrence_interval"` Priority int `form:"priority"` Keywords string `form:"keywords"` PlantStatusOnCompletion string `form:"status_on_completion"` Errors map[string]string } func (app *application) tasks(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } apiClient := client.FromContext(r.Context()) garden, _, err := apiClient.Garden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } tasks, _, err := apiClient.Tasks(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } plants, _, err := apiClient.Plants(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } locations, _, err := apiClient.Locations(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } priorities, _, err := apiClient.TaskPriorities(r.Context()) if err != nil { app.handleAPIError(w, r, err) return } taskYears := taskYears(tasks) filters := collectionFilters(r, "status", "plant", "location", "priority", "month", "year", "sort") if !r.URL.Query().Has("status") { filters["status"] = "open" } tasks = filterTasks(tasks, filters) sortTasks(tasks, filters["sort"]) tasks, pagination := paginateCollection(r, tasks) data := app.newTemplateData(r) data.Garden, data.Tasks, data.Plants, data.Locations, data.TaskPriorities, data.TaskYears = &garden, tasks, plants, locations, priorities, taskYears data.Filters, data.Pagination = filters, pagination app.render(w, http.StatusOK, "tasks.tmpl", data) } func filterTasks(tasks []client.Task, filters map[string]string) []client.Task { result := make([]client.Task, 0, len(tasks)) plantID, _ := strconv.Atoi(filters["plant"]) locationID, _ := strconv.Atoi(filters["location"]) priority, _ := strconv.Atoi(filters["priority"]) month, _ := strconv.Atoi(filters["month"]) year, _ := strconv.Atoi(filters["year"]) for _, task := range tasks { if task.Active != nil && !*task.Active { continue } if filters["status"] == "open" && task.CompletedAt != nil { continue } if filters["status"] == "done" && task.CompletedAt == nil { continue } if plantID > 0 && (task.PlantID == nil || *task.PlantID != plantID) { continue } if locationID > 0 && (task.LocationID == nil || *task.LocationID != locationID) { continue } if filters["priority"] != "" && task.Priority != priority { continue } if month > 0 && !taskInMonth(task, month) { continue } if year > 0 && !taskInYear(task, year) { continue } result = append(result, task) } return result } func (app *application) taskCreate(w http.ResponseWriter, r *http.Request) { app.renderTaskForm(w, r, taskForm{RecurrenceInterval: 1, Errors: map[string]string{}}, http.StatusOK, 0) } func (app *application) taskEdit(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } taskID, err := app.readPathID(r, "taskID") if err != nil { app.notFound(w) return } task, _, err := client.FromContext(r.Context()).Task(r.Context(), gardenID, taskID) if err != nil { app.handleAPIError(w, r, err) return } form := taskForm{Title: task.Title, Description: task.Description, Recurrence: task.Recurrence, RecurrenceInterval: task.RecurrenceInterval, Priority: task.Priority, Keywords: strings.Join(task.Tags, ", "), Errors: map[string]string{}} if form.RecurrenceInterval < 1 { form.RecurrenceInterval = 1 } if task.PlantID != nil { form.PlantID = *task.PlantID } if task.LocationID != nil { form.LocationID = *task.LocationID } if task.PlantStatusOnCompletion != nil { form.PlantStatusOnCompletion = *task.PlantStatusOnCompletion } if task.DueAtStart != nil { form.DueAtStart = task.DueAtStart.Local().Format("2006-01-02") } if task.DueAtEnd != nil { form.DueAtEnd = task.DueAtEnd.Local().Format("2006-01-02") } app.renderTaskForm(w, r, form, http.StatusOK, taskID) } func (app *application) taskSave(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } taskID := 0 if id, readErr := app.readPathID(r, "taskID"); readErr == nil { taskID = id } var form taskForm if err := app.decodePostForm(r, &form); err != nil { app.clientError(w, http.StatusBadRequest) return } form.Title, form.Description = strings.TrimSpace(form.Title), strings.TrimSpace(form.Description) form.Errors = map[string]string{} if form.Title == "" { form.Errors["title"] = "Ein Titel ist erforderlich." } input := client.TaskInput{Title: &form.Title, Description: &form.Description, Priority: &form.Priority, Tags: parseKeywords(form.Keywords)} if form.PlantID > 0 { input.PlantID = &form.PlantID } else { input.ClearPlantID = true } if form.LocationID > 0 { input.LocationID = &form.LocationID } else { input.ClearLocationID = true } input.DueAtStart, input.ClearDueAtStart = parseTaskTime(form.DueAtStart, "due_at_start", form.Errors) input.DueAtEnd, input.ClearDueAtEnd = parseTaskTime(form.DueAtEnd, "due_at_end", form.Errors) if input.DueAtStart != nil && input.DueAtEnd != nil && input.DueAtStart.After(*input.DueAtEnd) { form.Errors["due_at_end"] = "Das Ende darf nicht vor dem Beginn liegen." } if form.Recurrence != "" && form.Recurrence != "daily" && form.Recurrence != "weekly" && form.Recurrence != "monthly" && form.Recurrence != "yearly" { form.Errors["recurrence"] = "Bitte eine gültige Wiederholung auswählen." } if form.Recurrence != "" && input.DueAtStart == nil && input.DueAtEnd == nil { form.Errors["recurrence"] = "Für eine Wiederholung ist ein Fälligkeitsdatum erforderlich." } if form.Recurrence != "" && form.RecurrenceInterval < 1 { form.Errors["recurrence_interval"] = "Der Faktor muss mindestens 1 sein." } input.Recurrence = &form.Recurrence input.RecurrenceInterval = &form.RecurrenceInterval input.PlantStatusOnCompletion = &form.PlantStatusOnCompletion if form.PlantStatusOnCompletion != "" && form.PlantID < 1 { form.Errors["plant_status_on_completion"] = "Bitte zuerst eine Pflanze auswählen." } if len(form.Errors) == 0 { apiClient := client.FromContext(r.Context()) var saveErr error if taskID > 0 { _, _, saveErr = apiClient.UpdateTask(r.Context(), gardenID, taskID, input) } else { _, _, saveErr = apiClient.CreateTask(r.Context(), gardenID, input) } if saveErr == nil { http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther) return } var apiError *client.APIError if errors.As(saveErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { form.Errors = apiError.Validation } else { app.handleAPIError(w, r, saveErr) return } } app.renderTaskForm(w, r, form, http.StatusUnprocessableEntity, taskID) } func parseTaskTime(value, field string, validation map[string]string) (*time.Time, bool) { if strings.TrimSpace(value) == "" { return nil, true } parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) if err != nil { // Accept legacy clients that still submit a datetime; current forms are day-only. parsed, err = time.ParseInLocation("2006-01-02T15:04", value, time.Local) } if err != nil { validation[field] = "Bitte ein vollständiges Datum angeben." return nil, false } return &parsed, false } func (app *application) taskComplete(w http.ResponseWriter, r *http.Request) { app.setTaskCompleted(w, r, true) } func (app *application) taskReopen(w http.ResponseWriter, r *http.Request) { app.setTaskCompleted(w, r, false) } func (app *application) setTaskCompleted(w http.ResponseWriter, r *http.Request, completed bool) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } taskID, err := app.readPathID(r, "taskID") if err != nil { app.notFound(w) return } if _, _, err := client.FromContext(r.Context()).UpdateTask(r.Context(), gardenID, taskID, client.TaskInput{Completed: &completed}); err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther) } func (app *application) taskDelete(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } taskID, err := app.readPathID(r, "taskID") if err != nil { app.notFound(w) return } if _, err := client.FromContext(r.Context()).DeleteTask(r.Context(), gardenID, taskID); err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("tasks", gardenID), http.StatusSeeOther) } func (app *application) renderTaskForm(w http.ResponseWriter, r *http.Request, form taskForm, status, taskID int) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } apiClient := client.FromContext(r.Context()) garden, _, err := apiClient.Garden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } plants, _, err := apiClient.Plants(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } locations, _, err := apiClient.Locations(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } priorities, _, err := apiClient.TaskPriorities(r.Context()) if err != nil { app.handleAPIError(w, r, err) return } data := app.newTemplateData(r) data.Garden, data.Plants, data.Locations, data.TaskPriorities, data.Form, data.TaskID = &garden, plants, locations, priorities, form, taskID if taskID > 0 { task, _, taskErr := apiClient.Task(r.Context(), gardenID, taskID) if taskErr != nil { app.handleAPIError(w, r, taskErr) return } data.TaskCreatedBy = task.CreatedBy } data.TagSuggestions = app.loadTagSuggestions(r, gardenID) app.render(w, status, "task_form.tmpl", data) }