package web import ( "encoding/json" "errors" "net/http" "strconv" "strings" "time" "gardomatic.kleiax.de/lib/client" ) type speciesForm struct { Global bool `form:"global"` CommonName string `form:"common_name"` Cultivar string `form:"cultivar"` BotanicalName string `form:"botanical_name"` CategoryID int `form:"category_id"` SunExposure string `form:"sun_exposure"` SoilCondition string `form:"soil_condition"` SoilReaction string `form:"soil_reaction"` WinterProtection string `form:"winter_protection"` SpacingCM int `form:"spacing_cm"` HeightCMFrom int `form:"height_cm_from"` HeightCMTo int `form:"height_cm_to"` WidthCMFrom int `form:"width_cm_from"` WidthCMTo int `form:"width_cm_to"` Notes string `form:"notes"` ImageData string `form:"image_data"` ImageID int `form:"image_id"` Attributes string `form:"attributes"` TemplateSpeciesID int `form:"template_species_id"` Keywords string `form:"keywords"` SowMonthFrom int `form:"sow_month_from"` SowDayFrom int `form:"sow_day_from"` SowMonthTo int `form:"sow_month_to"` SowDayTo int `form:"sow_day_to"` PlantingMonthFrom int `form:"planting_month_from"` PlantingDayFrom int `form:"planting_day_from"` PlantingMonthTo int `form:"planting_month_to"` PlantingDayTo int `form:"planting_day_to"` HarvestMonthFrom int `form:"harvest_month_from"` HarvestDayFrom int `form:"harvest_day_from"` HarvestMonthTo int `form:"harvest_month_to"` HarvestDayTo int `form:"harvest_day_to"` SowDuration int `form:"sow_duration"` SowDurationUnit string `form:"sow_duration_unit"` PlantingDuration int `form:"planting_duration"` PlantingDurationUnit string `form:"planting_duration_unit"` HarvestDuration int `form:"harvest_duration"` HarvestDurationUnit string `form:"harvest_duration_unit"` Errors map[string]string } func (app *application) speciesList(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 } species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } data := app.newTemplateData(r) data.Filters = collectionFilters(r, "q", "origin", "sort") data.Garden, data.Species = &garden, filterAndSortSpecies(species, data.Filters) data.Species, data.Pagination = paginateCollection(r, data.Species) app.render(w, http.StatusOK, "species.tmpl", data) } func (app *application) speciesCreate(w http.ResponseWriter, r *http.Request) { form := speciesForm{SowDayFrom: 1, PlantingDayFrom: 1, HarvestDayFrom: 1, SowDuration: 1, PlantingDuration: 1, HarvestDuration: 1, SowDurationUnit: "week", PlantingDurationUnit: "week", HarvestDurationUnit: "week", Errors: map[string]string{}} if templateID, _ := strconv.Atoi(r.URL.Query().Get("template_id")); templateID > 0 { gardenID, err := app.readPathID(r, "gardenID") if err == nil { if source, _, loadErr := client.FromContext(r.Context()).Species(r.Context(), gardenID, templateID); loadErr == nil { form = speciesFormFromSpecies(source) form.CommonName = "" form.Global = false form.TemplateSpeciesID = source.ID } else { app.handleAPIError(w, r, loadErr) return } } } app.renderSpeciesForm(w, r, form, http.StatusOK, 0) } func (app *application) speciesEdit(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } speciesID, err := app.readPathID(r, "speciesID") if err != nil { app.notFound(w) return } value, _, err := client.FromContext(r.Context()).Species(r.Context(), gardenID, speciesID) if err != nil { app.handleAPIError(w, r, err) return } if value.GardenID != nil && *value.GardenID != gardenID { app.notFound(w) return } form := speciesFormFromSpecies(value) app.renderSpeciesForm(w, r, form, http.StatusOK, speciesID) } func speciesFormFromSpecies(value client.Species) speciesForm { form := speciesForm{Global: value.GardenID == nil, CommonName: value.CommonName, Cultivar: value.Cultivar, BotanicalName: value.BotanicalName, Notes: value.Notes, ImageData: value.ImageData, Attributes: string(value.Attributes), Keywords: strings.Join(value.Tags, ", "), Errors: map[string]string{}} if value.ImageID != nil { form.ImageID = *value.ImageID } if value.SunExposure != nil { form.SunExposure = *value.SunExposure } if value.SoilCondition != nil { form.SoilCondition = *value.SoilCondition } if value.SoilReaction != nil { form.SoilReaction = *value.SoilReaction } if value.WinterProtection != nil { form.WinterProtection = *value.WinterProtection } copyOptionalInt(value.SpacingCM, &form.SpacingCM) copyOptionalInt(value.HeightCMFrom, &form.HeightCMFrom) copyOptionalInt(value.HeightCMTo, &form.HeightCMTo) if value.HeightCMTo == nil { copyOptionalInt(value.HeightCM, &form.HeightCMTo) } copyOptionalInt(value.WidthCMFrom, &form.WidthCMFrom) copyOptionalInt(value.WidthCMTo, &form.WidthCMTo) copyOptionalInt(value.CategoryID, &form.CategoryID) copyOptionalInt(value.SowMonthFrom, &form.SowMonthFrom) copyOptionalInt(value.SowDayFrom, &form.SowDayFrom) copyOptionalInt(value.SowMonthTo, &form.SowMonthTo) copyOptionalInt(value.SowDayTo, &form.SowDayTo) copyOptionalInt(value.PlantingMonthFrom, &form.PlantingMonthFrom) copyOptionalInt(value.PlantingDayFrom, &form.PlantingDayFrom) copyOptionalInt(value.PlantingMonthTo, &form.PlantingMonthTo) copyOptionalInt(value.PlantingDayTo, &form.PlantingDayTo) copyOptionalInt(value.HarvestMonthFrom, &form.HarvestMonthFrom) copyOptionalInt(value.HarvestDayFrom, &form.HarvestDayFrom) copyOptionalInt(value.HarvestMonthTo, &form.HarvestMonthTo) copyOptionalInt(value.HarvestDayTo, &form.HarvestDayTo) form.SowDuration, form.SowDurationUnit = rangeDuration(value.SowMonthFrom, value.SowDayFrom, value.SowMonthTo, value.SowDayTo) form.PlantingDuration, form.PlantingDurationUnit = rangeDuration(value.PlantingMonthFrom, value.PlantingDayFrom, value.PlantingMonthTo, value.PlantingDayTo) form.HarvestDuration, form.HarvestDurationUnit = rangeDuration(value.HarvestMonthFrom, value.HarvestDayFrom, value.HarvestMonthTo, value.HarvestDayTo) return form } func (app *application) speciesSave(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } speciesID := 0 if id, readErr := app.readPathID(r, "speciesID"); readErr == nil { speciesID = id } var form speciesForm if err := app.decodePostForm(r, &form); err != nil { app.clientError(w, http.StatusBadRequest) return } form.CommonName, form.Cultivar, form.BotanicalName, form.Notes = strings.TrimSpace(form.CommonName), strings.TrimSpace(form.Cultivar), strings.TrimSpace(form.BotanicalName), strings.TrimSpace(form.Notes) form.Errors = map[string]string{} if form.CommonName == "" { form.Errors["common_name"] = "Ein Name ist erforderlich." } input := speciesInput(form) if len(form.Errors) == 0 { apiClient := client.FromContext(r.Context()) var saveErr error savedSpeciesID := speciesID if speciesID > 0 { _, _, saveErr = apiClient.UpdateSpecies(r.Context(), gardenID, speciesID, input) } else { var created client.Species created, _, saveErr = apiClient.CreateSpecies(r.Context(), gardenID, input) savedSpeciesID = created.ID if saveErr == nil && form.TemplateSpeciesID > 0 { saveErr = app.copySpeciesTaskTemplates(r, apiClient, gardenID, form.TemplateSpeciesID, created.ID) if saveErr != nil { _, _ = apiClient.DeleteSpecies(r.Context(), gardenID, created.ID) } } } if saveErr == nil { if r.FormValue("continue") == "care" { http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, savedSpeciesID), "step", "care"), http.StatusSeeOther) return } http.Redirect(w, r, webPath("species", 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.renderSpeciesForm(w, r, form, http.StatusUnprocessableEntity, speciesID) } func speciesInput(form speciesForm) client.SpeciesInput { input := client.SpeciesInput{Global: form.Global, CommonName: &form.CommonName, Cultivar: &form.Cultivar, BotanicalName: &form.BotanicalName, Notes: &form.Notes, ImageData: &form.ImageData, Tags: parseKeywords(form.Keywords)} if form.ImageID > 0 { imageID := form.ImageID input.ImageID = &imageID } input.SunExposure, input.SoilCondition, input.SoilReaction, input.WinterProtection = &form.SunExposure, &form.SoilCondition, &form.SoilReaction, &form.WinterProtection if form.SpacingCM > 0 { input.SpacingCM = &form.SpacingCM } input.HeightCMFrom, input.HeightCMTo = optionalPositive(form.HeightCMFrom), optionalPositive(form.HeightCMTo) input.WidthCMFrom, input.WidthCMTo = optionalPositive(form.WidthCMFrom), optionalPositive(form.WidthCMTo) input.ClearHeightCMFrom, input.ClearHeightCMTo = form.HeightCMFrom == 0, form.HeightCMTo == 0 input.ClearWidthCMFrom, input.ClearWidthCMTo = form.WidthCMFrom == 0, form.WidthCMTo == 0 if json.Valid([]byte(form.Attributes)) { input.Attributes = json.RawMessage(form.Attributes) } if form.CategoryID > 0 { input.CategoryID = &form.CategoryID } else { input.ClearCategoryID = true } form.SowMonthTo, form.SowDayTo = rangeEnd(form.SowMonthFrom, form.SowDayFrom, form.SowDuration, form.SowDurationUnit) form.PlantingMonthTo, form.PlantingDayTo = rangeEnd(form.PlantingMonthFrom, form.PlantingDayFrom, form.PlantingDuration, form.PlantingDurationUnit) form.HarvestMonthTo, form.HarvestDayTo = rangeEnd(form.HarvestMonthFrom, form.HarvestDayFrom, form.HarvestDuration, form.HarvestDurationUnit) input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = optionalPositive(form.SowMonthFrom), optionalPositive(form.SowDayFrom), optionalPositive(form.SowMonthTo), optionalPositive(form.SowDayTo) input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = optionalPositive(form.PlantingMonthFrom), optionalPositive(form.PlantingDayFrom), optionalPositive(form.PlantingMonthTo), optionalPositive(form.PlantingDayTo) input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = optionalPositive(form.HarvestMonthFrom), optionalPositive(form.HarvestDayFrom), optionalPositive(form.HarvestMonthTo), optionalPositive(form.HarvestDayTo) input.ClearSowRange = form.SowMonthFrom == 0 && form.SowMonthTo == 0 input.ClearPlantingRange = form.PlantingMonthFrom == 0 && form.PlantingMonthTo == 0 input.ClearHarvestRange = form.HarvestMonthFrom == 0 && form.HarvestMonthTo == 0 input.ClearSowDayFrom, input.ClearSowDayTo = form.SowDayFrom == 0, form.SowDayTo == 0 input.ClearPlantingDayFrom, input.ClearPlantingDayTo = form.PlantingDayFrom == 0, form.PlantingDayTo == 0 input.ClearHarvestDayFrom, input.ClearHarvestDayTo = form.HarvestDayFrom == 0, form.HarvestDayTo == 0 return input } func optionalPositive(value int) *int { if value > 0 { return &value } return nil } func rangeEnd(month, day, duration int, unit string) (int, int) { if month < 1 { return 0, 0 } if day < 1 { day = 1 } start := time.Date(2024, time.Month(month), day, 0, 0, 0, 0, time.UTC) switch unit { case "month": start = start.AddDate(0, duration, 0) case "week": start = start.AddDate(0, 0, 7*duration) default: start = start.AddDate(0, 0, duration) } return int(start.Month()), start.Day() } func rangeDuration(fromMonth, fromDay, toMonth, toDay *int) (int, string) { if fromMonth == nil || toMonth == nil { return 1, "week" } fd, td := 1, 1 if fromDay != nil { fd = *fromDay } if toDay != nil { td = *toDay } from := time.Date(2024, time.Month(*fromMonth), fd, 0, 0, 0, 0, time.UTC) to := time.Date(2024, time.Month(*toMonth), td, 0, 0, 0, 0, time.UTC) if to.Before(from) { to = to.AddDate(1, 0, 0) } days := int(to.Sub(from).Hours() / 24) if days%7 == 0 && days > 0 { return days / 7, "week" } return days, "day" } func (app *application) copySpeciesTaskTemplates(r *http.Request, apiClient *client.Client, gardenID, sourceID, targetID int) error { templates, _, err := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, sourceID) if err != nil { return err } for _, item := range templates { if item.Origin != "" && item.Origin != "manual" { continue } title, description, triggerType := item.Title, item.Description, item.TriggerType triggerOffset, triggerOffsetUnit := item.TriggerOffset, item.TriggerOffsetUnit duration, durationUnit := item.Duration, item.DurationUnit recurrence, recurrenceInterval, priority, active := item.Recurrence, item.RecurrenceInterval, item.Priority, item.Active input := client.SpeciesTaskTemplateInput{ Title: &title, Description: &description, TriggerType: &triggerType, MonthFrom: item.MonthFrom, DayFrom: item.DayFrom, MonthTo: item.MonthTo, DayTo: item.DayTo, OffsetDaysFrom: item.OffsetDaysFrom, OffsetDaysTo: item.OffsetDaysTo, IntervalDays: item.IntervalDays, TriggerOffset: &triggerOffset, TriggerOffsetUnit: &triggerOffsetUnit, Duration: &duration, DurationUnit: &durationUnit, Recurrence: &recurrence, RecurrenceInterval: &recurrenceInterval, Priority: &priority, Active: &active, } if _, _, err = apiClient.CreateSpeciesTaskTemplate(r.Context(), gardenID, targetID, input); err != nil { return err } } return nil } func (app *application) speciesDelete(w http.ResponseWriter, r *http.Request) { gardenID, err := app.readPathID(r, "gardenID") if err != nil { app.notFound(w) return } speciesID, err := app.readPathID(r, "speciesID") if err != nil { app.notFound(w) return } if _, err := client.FromContext(r.Context()).DeleteSpecies(r.Context(), gardenID, speciesID); err != nil { app.handleAPIError(w, r, err) return } http.Redirect(w, r, webPath("species", gardenID), http.StatusSeeOther) } func (app *application) renderSpeciesForm(w http.ResponseWriter, r *http.Request, form speciesForm, status, speciesID 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 } data := app.newTemplateData(r) data.Garden, data.Form, data.SpeciesID, data.SpeciesGlobal = &garden, form, speciesID, form.Global data.WizardStep = r.URL.Query().Get("step") if speciesID > 0 && data.WizardStep == "" { data.WizardStep = "all" } else if data.WizardStep != "general" && data.WizardStep != "care" && data.WizardStep != "tasks" { data.WizardStep = "general" } data.Images, err = app.loadImageLibrary(r, gardenID) if err != nil { app.handleAPIError(w, r, err) return } data.TagSuggestions = app.loadTagSuggestions(r, gardenID) categories, _, err := apiClient.SpeciesCategories(r.Context()) if err != nil { app.handleAPIError(w, r, err) return } data.SpeciesCategories = categories if speciesID == 0 { data.Species, _, err = apiClient.SpeciesForGarden(r.Context(), gardenID) if err != nil { app.handleAPIError(w, r, err) return } } if speciesID > 0 { templates, _, err := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, speciesID) if err != nil { app.handleAPIError(w, r, err) return } data.TaskTemplates = map[int][]client.SpeciesTaskTemplate{speciesID: templates} data.CareInstructions, _, err = apiClient.CareInstructions(r.Context(), gardenID, speciesID) if err != nil { app.handleAPIError(w, r, err) return } } app.render(w, status, "species_form.tmpl", data) }