package web import ( "errors" "net/http" "strconv" "strings" "gardomatic.kleiax.de/lib/client" ) type locationForm struct { CSRFToken string `form:"csrf_token"` ParentID int `form:"parent_id"` Name string `form:"name"` Description string `form:"description"` ImageData string `form:"image_data"` ImageID int `form:"image_id"` Kind string `form:"kind"` AreaSQM string `form:"area_sqm"` SunExposure string `form:"sun_exposure"` SoilCondition string `form:"soil_condition"` SoilReaction string `form:"soil_reaction"` ReturnTo string `form:"return_to"` Errors map[string]string } func (app *application) locations(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 } locations, _, err := apiClient.Locations(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } data := app.newTemplateData(r) data.Filters = collectionFilters(r, "q", "kind", "sort") locations = filterAndSortLocations(locations, data.Filters) locationTree := buildLocationTree(locations) locationTree, data.Pagination = paginateCollection(r, locationTree) data.Garden, data.Locations, data.LocationTree = &garden, flattenLocationTree(locationTree), locationTree app.render(w, http.StatusOK, "locations.tmpl", data) } func (app *application) locationCreate(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } form := locationForm{ReturnTo: safeLocationReturn(r.URL.Query().Get("return_to"), gardenID), Errors: make(map[string]string)} app.renderLocationForm(w, r, gardenID, form, http.StatusOK, r.Header.Get("HX-Request") == "true") } func (app *application) locationCreatePost(w http.ResponseWriter, r *http.Request) { app.locationSave(w, r) } func (app *application) locationEdit(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } locationID, err := app.readPathID(r, "locationID") if err != nil { app.notFound(w) return } location, _, err := client.FromContext(r.Context()).Location(r.Context(), gardenID, locationID) if err != nil { app.handleAPIError(w, r, err) return } form := locationForm{Name: location.Name, Description: location.Description, ImageData: location.ImageData, Kind: location.Kind, ReturnTo: webPath("locations", gardenID), Errors: map[string]string{}} if location.ImageID != nil { form.ImageID = *location.ImageID } if location.ParentID != nil { form.ParentID = *location.ParentID } if location.AreaSQM != nil { form.AreaSQM = strconv.FormatFloat(*location.AreaSQM, 'f', -1, 64) } if location.SunExposure != nil { form.SunExposure = *location.SunExposure } if location.SoilCondition != nil { form.SoilCondition = *location.SoilCondition } if location.SoilReaction != nil { form.SoilReaction = *location.SoilReaction } app.renderLocationForm(w, r, gardenID, form, http.StatusOK, false, locationID) } func (app *application) locationEditPost(w http.ResponseWriter, r *http.Request) { app.locationSave(w, r) } func (app *application) locationSave(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } locationID := 0 if id, readErr := app.readPathID(r, "locationID"); readErr == nil { locationID = id } var form locationForm if err := app.decodePostForm(r, &form); err != nil { app.clientError(w, http.StatusBadRequest) return } form.Name, form.Description, form.Kind = strings.TrimSpace(form.Name), strings.TrimSpace(form.Description), strings.TrimSpace(form.Kind) form.Errors = make(map[string]string) if form.Name == "" { form.Errors["name"] = "Ein Name ist erforderlich." } var area *float64 if strings.TrimSpace(form.AreaSQM) != "" { value, parseErr := strconv.ParseFloat(strings.ReplaceAll(form.AreaSQM, ",", "."), 64) if parseErr != nil || value < 0 { form.Errors["area_sqm"] = "Die Fläche muss eine nichtnegative Zahl sein." } else { area = &value } } if len(form.Errors) == 0 { name, description, kind := form.Name, form.Description, form.Kind imageData := form.ImageData input := client.LocationInput{Name: &name, Description: &description, ImageData: &imageData, Kind: &kind, AreaSQM: area} if form.ImageID > 0 { imageID := form.ImageID input.ImageID = &imageID } if form.ParentID > 0 { parentID := form.ParentID input.ParentID = &parentID } else { input.ClearParentID = true } if strings.TrimSpace(form.SunExposure) != "" { exposure := strings.TrimSpace(form.SunExposure) input.SunExposure = &exposure } input.SoilCondition = &form.SoilCondition input.SoilReaction = &form.SoilReaction var location client.Location var createErr error if locationID > 0 { location, _, createErr = client.FromContext(r.Context()).UpdateLocation(r.Context(), gardenID, locationID, input) } else { location, _, createErr = client.FromContext(r.Context()).CreateLocation(r.Context(), gardenID, input) } if createErr == nil { if r.Header.Get("HX-Request") == "true" && locationID == 0 { w.Header().Set("HX-Trigger", "locationCreated") data := app.newTemplateData(r) data.Garden = &client.Garden{ID: gardenID} data.Locations = []client.Location{location} app.renderTemplate(w, http.StatusCreated, "location_form.tmpl", "location_option", data) return } if locationID > 0 { http.Redirect(w, r, webPath("locations", gardenID), http.StatusSeeOther) return } returnTo := safeLocationReturn(form.ReturnTo, gardenID) if returnTo == webPath("plant.new", gardenID) { returnTo += "?location_id=" + strconv.Itoa(location.ID) } http.Redirect(w, r, returnTo, http.StatusSeeOther) return } var apiError *client.APIError if errors.As(createErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { form.Errors = apiError.Validation } else { app.handleAPIError(w, r, createErr) return } } inline := r.Header.Get("HX-Request") == "true" status := http.StatusUnprocessableEntity if inline { // htmx does not swap 422 responses by default. Retarget the validated // dialog fragment while retaining 422 for the normal HTML form. w.Header().Set("HX-Retarget", "#location-dialog-host") w.Header().Set("HX-Reswap", "innerHTML") status = http.StatusOK } app.renderLocationForm(w, r, gardenID, form, status, inline, locationID) } func (app *application) locationDelete(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } locationID, err := app.readPathID(r, "locationID") if err != nil { app.notFound(w) return } if _, err := client.FromContext(r.Context()).DeleteLocation(r.Context(), gardenID, locationID); err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("locations", gardenID), http.StatusSeeOther) } func (app *application) renderLocationForm(w http.ResponseWriter, r *http.Request, gardenID int, form locationForm, status int, inline bool, locationIDs ...int) { apiClient := client.FromContext(r.Context()) garden, _, err := apiClient.Garden(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 } data := app.newTemplateData(r) data.Garden, data.Locations, data.Form = &garden, locations, form data.Images, err = app.loadImageLibrary(r, gardenID) if err != nil { app.handleAPIError(w, r, err) return } if len(locationIDs) > 0 && locationIDs[0] > 0 { locationID := locationIDs[0] data.LocationID = locationID for _, location := range locations { if location.ID == locationID { data.LocationCreatedBy = location.CreatedBy break } } plants, _, plantsErr := apiClient.Plants(r.Context(), gardenID) if plantsErr != nil { app.handleAPIError(w, r, plantsErr) return } for _, plant := range plants { assignments, _, assignmentErr := apiClient.PlantLocations(r.Context(), gardenID, plant.ID) if assignmentErr != nil { app.handleAPIError(w, r, assignmentErr) return } for _, assignment := range assignments { if assignment.LocationID != locationID { continue } item := locationPlant{Plant: plant, Assignment: assignment} if assignment.RemovedAt == nil && !isHistoricalPlant(plant) { data.LocationPlants = append(data.LocationPlants, item) } if isHistoricalPlant(plant) { year := locationPlantHistoryYear(plant, assignment, data.CurrentYear) for i := range data.LocationHistory { if data.LocationHistory[i].Year == year { data.LocationHistory[i].Plants = append(data.LocationHistory[i].Plants, item) year = 0 break } } if year != 0 { for i := 0; i < len(data.LocationHistory); i++ { if data.LocationHistory[i].Year < year { data.LocationHistory = append(data.LocationHistory, locationPlantHistory{}) copy(data.LocationHistory[i+1:], data.LocationHistory[i:]) data.LocationHistory[i] = locationPlantHistory{Year: year, Plants: []locationPlant{item}} year = 0 break } } if year != 0 { data.LocationHistory = append(data.LocationHistory, locationPlantHistory{Year: year, Plants: []locationPlant{item}}) } } } } } for year := data.CurrentYear; year >= data.CurrentYear-2; year-- { found := false for _, history := range data.LocationHistory { if history.Year == year { found = true break } } if !found { data.LocationHistory = append(data.LocationHistory, locationPlantHistory{Year: year}) } } // Keep the fixed three-year window in descending order. for i := 0; i < len(data.LocationHistory); i++ { for j := i + 1; j < len(data.LocationHistory); j++ { if data.LocationHistory[j].Year > data.LocationHistory[i].Year { data.LocationHistory[i], data.LocationHistory[j] = data.LocationHistory[j], data.LocationHistory[i] } } } } if inline { app.renderTemplate(w, status, "location_form.tmpl", "location_dialog", data) } else { app.render(w, status, "location_form.tmpl", data) } } func isHistoricalPlant(plant client.Plant) bool { return plant.Status == "dead" || plant.Status == "removed" } func locationPlantHistoryYear(plant client.Plant, assignment client.PlantLocation, currentYear int) int { date := plant.RemovedAt if date == nil { date = assignment.RemovedAt } if date == nil && !plant.UpdatedAt.IsZero() { date = &plant.UpdatedAt } if date == nil && !plant.CreatedAt.IsZero() { date = &plant.CreatedAt } if date == nil { return currentYear } if date.Year() < currentYear-2 || date.Year() > currentYear { return 0 } return date.Year() } func safeLocationReturn(value string, gardenID int) string { plantForm := webPath("plant.new", gardenID) if value == plantForm { return value } return webPath("locations", gardenID) }