package web import ( "errors" "net/http" "strconv" "strings" "time" "gardomatic.kleiax.de/lib/client" ) type plantForm struct { CSRFToken string `form:"csrf_token"` Name string `form:"name"` SpeciesID int `form:"species_id"` LocationIDs []int `form:"location_id"` Quantities []int `form:"quantity"` AssignmentIDs []int `form:"assignment_id"` Assignments []plantAssignmentForm `form:"-"` TaskIDs []int `form:"plant_task_id"` TaskRowKeys []string `form:"plant_task_row_key"` TaskTitles []string `form:"plant_task_title"` TaskDescriptions []string `form:"plant_task_description"` TaskDueAtStarts []string `form:"plant_task_due_at_start"` TaskDueAtEnds []string `form:"plant_task_due_at_end"` TaskPriorities []int `form:"plant_task_priority"` TaskPlantIDs []int `form:"plant_task_plant_id"` TaskLocationIDs []int `form:"plant_task_location_id"` TaskRecurrences []string `form:"plant_task_recurrence"` TaskRecurrenceIntervals []int `form:"plant_task_recurrence_interval"` TaskKeywords []string `form:"plant_task_keywords"` TaskStatuses []string `form:"plant_task_status_on_completion"` TaskActives []bool `form:"plant_task_active"` TaskDeletes []bool `form:"plant_task_delete"` Tasks []plantTaskForm `form:"-"` Notes string `form:"notes"` ImageData string `form:"image_data"` ImageID int `form:"image_id"` AcquiredAt string `form:"acquired_at"` Status string `form:"status"` Keywords string `form:"keywords"` Errors map[string]string } type plantTaskForm struct { ID, Priority, PlantID, LocationID, RecurrenceInterval int RowKey string Title, Description, Recurrence, Keywords, PlantStatusOnCompletion string PendingPlantName string DueAtStart, DueAtEnd string Active, Delete bool Errors map[string]string } type plantAssignmentForm struct { AssignmentID int LocationID int Quantity int } func (form *plantForm) buildAssignments() { form.Assignments = make([]plantAssignmentForm, len(form.LocationIDs)) for i, locationID := range form.LocationIDs { form.Assignments[i].LocationID = locationID if i < len(form.Quantities) { form.Assignments[i].Quantity = form.Quantities[i] } if i < len(form.AssignmentIDs) { form.Assignments[i].AssignmentID = form.AssignmentIDs[i] } } } func (form *plantForm) buildTasks() { form.Tasks = make([]plantTaskForm, len(form.TaskTitles)) for i := range form.TaskTitles { form.Tasks[i].Title = strings.TrimSpace(form.TaskTitles[i]) if i < len(form.TaskIDs) { form.Tasks[i].ID = form.TaskIDs[i] } if i < len(form.TaskRowKeys) { form.Tasks[i].RowKey = form.TaskRowKeys[i] } if i < len(form.TaskDescriptions) { form.Tasks[i].Description = strings.TrimSpace(form.TaskDescriptions[i]) } if i < len(form.TaskDueAtStarts) { form.Tasks[i].DueAtStart = form.TaskDueAtStarts[i] } if i < len(form.TaskDueAtEnds) { form.Tasks[i].DueAtEnd = form.TaskDueAtEnds[i] } if i < len(form.TaskPriorities) { form.Tasks[i].Priority = form.TaskPriorities[i] } if i < len(form.TaskPlantIDs) { form.Tasks[i].PlantID = form.TaskPlantIDs[i] } if i < len(form.TaskLocationIDs) { form.Tasks[i].LocationID = form.TaskLocationIDs[i] } if i < len(form.TaskRecurrences) { form.Tasks[i].Recurrence = form.TaskRecurrences[i] } if i < len(form.TaskRecurrenceIntervals) { form.Tasks[i].RecurrenceInterval = form.TaskRecurrenceIntervals[i] } if i < len(form.TaskKeywords) { form.Tasks[i].Keywords = form.TaskKeywords[i] } if i < len(form.TaskStatuses) { form.Tasks[i].PlantStatusOnCompletion = form.TaskStatuses[i] } if i < len(form.TaskActives) { form.Tasks[i].Active = form.TaskActives[i] } if i < len(form.TaskDeletes) { form.Tasks[i].Delete = form.TaskDeletes[i] } if form.Tasks[i].RowKey == "" { form.Tasks[i].RowKey = "task-" + strconv.Itoa(form.Tasks[i].ID) + "-" + strconv.Itoa(i) } } } func (app *application) plants(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 } plants, _, err := apiClient.Plants(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } data := app.newTemplateData(r) data.Garden = &garden data.Plants = plants data.Species = species locations, _, err := apiClient.Locations(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } data.Locations = locations data.LocationNames = make(map[int]string, len(locations)) for _, location := range locations { data.LocationNames[location.ID] = location.Name } data.PlantLocations = make(map[int][]client.PlantLocation, len(plants)) for _, plant := range plants { assignments, _, assignmentErr := apiClient.PlantLocations(r.Context(), gardenID, plant.ID) if assignmentErr != nil { app.handleAPIError(w, r, assignmentErr) return } data.PlantLocations[plant.ID] = assignments } data.Filters = collectionFilters(r, "q", "status", "species", "location", "sort") data.Plants = filterAndSortPlants(plants, data.PlantLocations, data.Filters) data.Plants, data.Pagination = paginateCollection(r, data.Plants) app.render(w, http.StatusOK, "plant.tmpl", data) } func (app *application) plantCreate(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } locationID, _ := strconv.Atoi(r.URL.Query().Get("location_id")) app.renderPlantForm(w, r, gardenID, plantForm{Status: "alive", Assignments: []plantAssignmentForm{{LocationID: locationID, Quantity: 1}}, Errors: make(map[string]string)}, http.StatusOK) } func (app *application) plantCreatePost(w http.ResponseWriter, r *http.Request) { app.plantSave(w, r) } func (app *application) plantEdit(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } plantID, err := app.readPathID(r, "plantID") if err != nil { app.notFound(w) return } apiClient := client.FromContext(r.Context()) plant, _, err := apiClient.Plant(r.Context(), gardenID, plantID) if err != nil { app.handleAPIError(w, r, err) return } form := plantForm{Name: plant.Name, Notes: plant.Notes, ImageData: plant.ImageData, Status: plant.Status, Keywords: strings.Join(plant.Tags, ", "), Errors: map[string]string{}} if plant.ImageID != nil { form.ImageID = *plant.ImageID } if plant.SpeciesID != nil { form.SpeciesID = *plant.SpeciesID } if plant.AcquiredAt != nil { form.AcquiredAt = plant.AcquiredAt.Format("2006-01-02") } assignments, _, err := apiClient.PlantLocations(r.Context(), gardenID, plantID) if err != nil { app.handleAPIError(w, r, err) return } for _, assignment := range assignments { form.Assignments = append(form.Assignments, plantAssignmentForm{AssignmentID: assignment.ID, LocationID: assignment.LocationID, Quantity: assignment.Quantity}) } if len(form.Assignments) == 0 { form.Assignments = []plantAssignmentForm{{Quantity: 1}} } app.renderPlantForm(w, r, gardenID, form, http.StatusOK, plantID) } func (app *application) plantEditPost(w http.ResponseWriter, r *http.Request) { app.plantSave(w, r) } func (app *application) plantSave(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } plantID := 0 if id, readErr := app.readPathID(r, "plantID"); readErr == nil { plantID = id } var form plantForm if err := app.decodePostForm(r, &form); err != nil { app.clientError(w, http.StatusBadRequest) return } form.buildAssignments() form.buildTasks() form.Name = strings.TrimSpace(form.Name) form.Notes = strings.TrimSpace(form.Notes) form.Errors = make(map[string]string) if form.Name == "" { form.Errors["name"] = "Ein Name ist erforderlich." } if form.Status == "" { form.Status = "alive" } for i, assignment := range form.Assignments { if assignment.LocationID > 0 && assignment.Quantity < 1 { form.Errors["quantity_"+strconv.Itoa(i)] = "Die Anzahl muss mindestens 1 sein." } } for i, task := range form.Tasks { if task.Delete { continue } if task.Title == "" { form.Errors["plant_task_title_"+strconv.Itoa(i)] = "Ein Titel ist erforderlich." } start, _ := parseTaskTime(task.DueAtStart, "plant_task_due_at_start_"+strconv.Itoa(i), form.Errors) end, _ := parseTaskTime(task.DueAtEnd, "plant_task_due_at_end_"+strconv.Itoa(i), form.Errors) if start != nil && end != nil && start.After(*end) { form.Errors["plant_task_due_at_end_"+strconv.Itoa(i)] = "Das Ende darf nicht vor dem Beginn liegen." } } var acquiredAt *time.Time if form.AcquiredAt != "" { parsed, parseErr := time.Parse("2006-01-02", form.AcquiredAt) if parseErr != nil { form.Errors["acquired_at"] = "Das Datum ist ungültig." } else { acquiredAt = &parsed } } if len(form.Errors) == 0 { name, notes, status := form.Name, form.Notes, form.Status imageData := form.ImageData input := client.PlantInput{Name: &name, Notes: ¬es, ImageData: &imageData, Status: &status, AcquiredAt: acquiredAt, ClearAcquiredAt: acquiredAt == nil, Tags: parseKeywords(form.Keywords)} if form.ImageID > 0 { imageID := form.ImageID input.ImageID = &imageID } if form.SpeciesID > 0 { speciesID := form.SpeciesID input.SpeciesID = &speciesID } else { input.ClearSpeciesID = true } apiClient := client.FromContext(r.Context()) var plant client.Plant if plantID > 0 { plant, _, err = apiClient.UpdatePlant(r.Context(), gardenID, plantID, input) } else { plant, _, err = apiClient.CreatePlant(r.Context(), gardenID, input) } if err == nil { for _, assignment := range form.Assignments { if assignment.LocationID == 0 { if assignment.AssignmentID > 0 { _, err = apiClient.DeletePlantLocation(r.Context(), gardenID, plant.ID, assignment.AssignmentID) } } else { locationID, quantity := assignment.LocationID, assignment.Quantity assignmentInput := client.PlantLocationInput{LocationID: &locationID, Quantity: &quantity, PlantedAt: acquiredAt, ClearPlantedAt: acquiredAt == nil} if assignment.AssignmentID > 0 { _, _, err = apiClient.UpdatePlantLocation(r.Context(), gardenID, plant.ID, assignment.AssignmentID, assignmentInput) } else { _, _, err = apiClient.CreatePlantLocation(r.Context(), gardenID, plant.ID, assignmentInput) } } if err != nil { break } } if err == nil { err = app.savePlantTasks(r, gardenID, plant.ID, form.Tasks) } if err == nil && form.SpeciesID > 0 { err = app.savePlantTemplateStates(r, gardenID, plant.ID, form.SpeciesID) } if err != nil && plantID == 0 { _, _ = apiClient.DeletePlant(r.Context(), gardenID, plant.ID) } } if err == nil { http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther) return } var apiError *client.APIError if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { form.Errors = apiError.Validation } else { app.handleAPIError(w, r, err) return } } app.renderPlantForm(w, r, gardenID, form, http.StatusUnprocessableEntity, plantID) } func (app *application) plantStatusPost(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } plantID, err := app.readPathID(r, "plantID") if err != nil { app.notFound(w) return } if err = r.ParseForm(); err != nil { app.clientError(w, http.StatusBadRequest) return } status := r.PostForm.Get("status") if status != "alive" && status != "dead" && status != "removed" && status != "infested" && status != "harvested" && status != "dormant" { app.clientError(w, http.StatusUnprocessableEntity) return } if _, _, err = client.FromContext(r.Context()).UpdatePlant(r.Context(), gardenID, plantID, client.PlantInput{Status: &status}); err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther) } func (app *application) plantDelete(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } plantID, err := app.readPathID(r, "plantID") if err != nil { app.notFound(w) return } if _, err := client.FromContext(r.Context()).DeletePlant(r.Context(), gardenID, plantID); err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther) } func (app *application) plantTemplateOptOutPost(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } plantID, err := app.readPathID(r, "plantID") if err != nil { app.notFound(w) return } templateID, err := app.readPathID(r, "templateID") if err != nil { app.notFound(w) return } if err = r.ParseForm(); err != nil { app.clientError(w, http.StatusBadRequest) return } optedOut := r.PostForm.Get("opted_out") != "false" if _, err = client.FromContext(r.Context()).SetTaskTemplateOptOut(r.Context(), gardenID, plantID, templateID, optedOut); err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("plant.edit", gardenID, plantID), http.StatusSeeOther) } func (app *application) renderPlantForm(w http.ResponseWriter, r *http.Request, gardenID int, form plantForm, status int, plantIDs ...int) { apiClient := client.FromContext(r.Context()) garden, _, err := apiClient.Garden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } data := app.newTemplateData(r) data.Garden = &garden data.Species = species priorities, _, err := apiClient.TaskPriorities(r.Context()) if err != nil { app.handleAPIError(w, r, err) return } data.TaskPriorities = priorities locations, _, err := apiClient.Locations(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } data.Locations = locations images, err := app.loadImageLibrary(r, gardenID) if err != nil { app.handleAPIError(w, r, err) return } data.Images = images data.Form = form data.SpeciesID = form.SpeciesID data.TaskTemplates = map[int][]client.SpeciesTaskTemplate{} data.TemplateOptOuts = map[int]bool{} if len(plantIDs) > 0 { data.PlantID = plantIDs[0] plant, _, plantErr := apiClient.Plant(r.Context(), gardenID, data.PlantID) if plantErr != nil { app.handleAPIError(w, r, plantErr) return } data.PlantCreatedBy = plant.CreatedBy if form.Tasks == nil { tasks, _, taskErr := apiClient.Tasks(r.Context(), gardenID) var apiError *client.APIError if taskErr != nil && !(errors.As(taskErr, &apiError) && apiError.StatusCode == http.StatusNotFound) { app.handleAPIError(w, r, taskErr) return } for _, task := range tasks { if task.PlantID == nil || *task.PlantID != data.PlantID || task.TemplateID != nil { continue } active := task.Active == nil || *task.Active item := plantTaskForm{ID: task.ID, PlantID: data.PlantID, RowKey: "task-" + strconv.Itoa(task.ID), Title: task.Title, Description: task.Description, Priority: task.Priority, Active: active, Recurrence: task.Recurrence, RecurrenceInterval: task.RecurrenceInterval, Keywords: strings.Join(task.Tags, ", ")} if task.LocationID != nil { item.LocationID = *task.LocationID } if task.PlantStatusOnCompletion != nil { item.PlantStatusOnCompletion = *task.PlantStatusOnCompletion } if task.DueAtStart != nil { item.DueAtStart = task.DueAtStart.Local().Format("2006-01-02") } if task.DueAtEnd != nil { item.DueAtEnd = task.DueAtEnd.Local().Format("2006-01-02") } form.Tasks = append(form.Tasks, item) } data.Form = form } if form.SpeciesID > 0 { ids, _, optErr := apiClient.TaskTemplateOptOuts(r.Context(), gardenID, data.PlantID) if optErr != nil { app.handleAPIError(w, r, optErr) return } for _, id := range ids { data.TemplateOptOuts[id] = true } } } if form.SpeciesID > 0 { templates, _, templateErr := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, form.SpeciesID) if templateErr != nil { app.handleAPIError(w, r, templateErr) return } data.TaskTemplates[form.SpeciesID] = templates for _, item := range templates { if values, present := r.PostForm["plant_template_active_"+strconv.Itoa(item.ID)]; present { data.TemplateOptOuts[item.ID] = len(values) == 0 || values[len(values)-1] != "true" } } } app.render(w, status, "plant_form.tmpl", data) }