476 lines
14 KiB
Go
476 lines
14 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gardomatic.kleiax.de/lib/client"
|
|
)
|
|
|
|
func (app *application) gardenDashboard(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
|
|
}
|
|
journalEntries, _, err := apiClient.JournalEntries(r.Context(), gardenID)
|
|
if err != nil {
|
|
app.handleAPIError(w, r, err)
|
|
return
|
|
}
|
|
pinboardEntries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard)
|
|
if err != nil {
|
|
app.handleAPIError(w, r, err)
|
|
return
|
|
}
|
|
images, _, err := apiClient.Images(r.Context(), gardenID, "", "")
|
|
if err != nil {
|
|
var apiError *client.APIError
|
|
if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusNotFound {
|
|
app.handleAPIError(w, r, err)
|
|
return
|
|
}
|
|
}
|
|
horizon := time.Now().AddDate(0, 0, 30)
|
|
upcoming := make([]client.Task, 0)
|
|
for _, task := range tasks {
|
|
if task.CompletedAt != nil {
|
|
continue
|
|
}
|
|
due := task.DueAtEnd
|
|
if due == nil {
|
|
due = task.DueAtStart
|
|
}
|
|
if due != nil && !due.After(horizon) {
|
|
upcoming = append(upcoming, task)
|
|
}
|
|
}
|
|
sort.SliceStable(upcoming, func(i, j int) bool { return taskSortTime(upcoming[i]).Before(taskSortTime(upcoming[j])) })
|
|
if len(upcoming) > 8 {
|
|
upcoming = upcoming[:8]
|
|
}
|
|
data := app.newTemplateData(r)
|
|
data.Garden = &garden
|
|
data.Tasks = upcoming
|
|
data.Plants = plants
|
|
data.Locations = locations
|
|
data.JournalEntries = journalEntries
|
|
data.PinboardEntries = pinboardEntries
|
|
data.PhotoCount = len(images)
|
|
app.render(w, http.StatusOK, "dashboard.tmpl", data)
|
|
}
|
|
|
|
func taskSortTime(task client.Task) time.Time {
|
|
if task.DueAtEnd != nil {
|
|
return *task.DueAtEnd
|
|
}
|
|
if task.DueAtStart != nil {
|
|
return *task.DueAtStart
|
|
}
|
|
return time.Unix(1<<62, 0)
|
|
}
|
|
|
|
func (app *application) taskCalendar(w http.ResponseWriter, r *http.Request) {
|
|
gardenID, err := app.readPathID(r, "gardenID")
|
|
if err != nil {
|
|
app.notFound(w)
|
|
return
|
|
}
|
|
view := r.URL.Query().Get("view")
|
|
if view != "month" {
|
|
view = "week"
|
|
}
|
|
anchor := time.Now()
|
|
if value := r.URL.Query().Get("date"); value != "" {
|
|
if parsed, parseErr := time.Parse("2006-01-02", value); parseErr == nil {
|
|
anchor = parsed
|
|
}
|
|
}
|
|
start, end := calendarRange(anchor, view)
|
|
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
|
|
}
|
|
days := make([]calendarDay, 0)
|
|
for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
|
|
entry := calendarDay{Date: day}
|
|
for _, task := range tasks {
|
|
if task.CompletedAt == nil && taskOverlapsDay(task, day) {
|
|
entry.Tasks = append(entry.Tasks, task)
|
|
}
|
|
}
|
|
days = append(days, entry)
|
|
}
|
|
step := 7
|
|
if view == "month" {
|
|
step = 1
|
|
}
|
|
previous := start.AddDate(0, 0, -step)
|
|
next := end
|
|
if view == "month" {
|
|
previous = start.AddDate(0, -1, 0)
|
|
next = start.AddDate(0, 1, 0)
|
|
}
|
|
data := app.newTemplateData(r)
|
|
data.Garden = &garden
|
|
data.CalendarDays = days
|
|
data.CalendarStart = start
|
|
data.CalendarEnd = end.Add(-time.Nanosecond)
|
|
data.CalendarView = view
|
|
data.PreviousDate = previous.Format("2006-01-02")
|
|
data.NextDate = next.Format("2006-01-02")
|
|
app.render(w, http.StatusOK, "task_calendar.tmpl", data)
|
|
}
|
|
|
|
func calendarRange(anchor time.Time, view string) (time.Time, time.Time) {
|
|
y, m, d := anchor.Date()
|
|
loc := anchor.Location()
|
|
start := time.Date(y, m, d, 0, 0, 0, 0, loc)
|
|
if view == "month" {
|
|
start = time.Date(y, m, 1, 0, 0, 0, 0, loc)
|
|
return start, start.AddDate(0, 1, 0)
|
|
}
|
|
offset := (int(start.Weekday()) + 6) % 7
|
|
start = start.AddDate(0, 0, -offset)
|
|
return start, start.AddDate(0, 0, 7)
|
|
}
|
|
|
|
func taskOverlapsDay(task client.Task, day time.Time) bool {
|
|
dayEnd := day.AddDate(0, 0, 1)
|
|
start, end := task.DueAtStart, task.DueAtEnd
|
|
if start == nil {
|
|
start = end
|
|
}
|
|
if end == nil {
|
|
end = start
|
|
}
|
|
if start == nil {
|
|
return false
|
|
}
|
|
return start.Before(dayEnd) && !end.Before(day)
|
|
}
|
|
|
|
func (app *application) gardenSearch(w http.ResponseWriter, r *http.Request) {
|
|
gardenID, err := app.readPathID(r, "gardenID")
|
|
if err != nil {
|
|
app.notFound(w)
|
|
return
|
|
}
|
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
mode := r.URL.Query().Get("mode")
|
|
month, _ := strconv.Atoi(r.URL.Query().Get("month"))
|
|
if month < 1 || month > 12 {
|
|
month = 0
|
|
}
|
|
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
|
if year < 1 {
|
|
year = 0
|
|
}
|
|
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
|
|
}
|
|
result := searchResults{Query: query, Mode: mode, Month: month, MonthName: germanMonthName(month), Year: year, Years: taskYears(tasks)}
|
|
if query != "" || month > 0 || year > 0 {
|
|
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
|
|
}
|
|
journal, _, err := apiClient.JournalEntries(r.Context(), gardenID)
|
|
if err != nil {
|
|
app.handleAPIError(w, r, err)
|
|
return
|
|
}
|
|
pinboard, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard)
|
|
if err != nil {
|
|
app.handleAPIError(w, r, err)
|
|
return
|
|
}
|
|
tags, _, err := apiClient.GardenTags(r.Context(), gardenID)
|
|
if err != nil {
|
|
app.handleAPIError(w, r, err)
|
|
return
|
|
}
|
|
if mode != "month" {
|
|
if queryMonth := parseGermanMonth(query); queryMonth > 0 {
|
|
month = queryMonth
|
|
}
|
|
}
|
|
matchedTags := map[string]string{}
|
|
if mode != "month" {
|
|
for _, tag := range tags {
|
|
if containsTerms(query, tag) {
|
|
matchedTags[strings.ToLower(tag)] = tag
|
|
}
|
|
}
|
|
}
|
|
addMonthTags := func(values []string) {
|
|
if mode == "month" {
|
|
for _, tag := range values {
|
|
matchedTags[strings.ToLower(tag)] = tag
|
|
}
|
|
}
|
|
}
|
|
for _, item := range tasks {
|
|
textMatch := query == "" || containsTerms(query, item.Title, item.Description, strings.Join(item.Tags, " "))
|
|
monthMatch := month == 0 || taskInMonth(item, month)
|
|
yearMatch := year == 0 || taskInYear(item, year)
|
|
if textMatch && monthMatch && yearMatch {
|
|
addMonthTags(item.Tags)
|
|
result.Tasks = append(result.Tasks, taskSearchCard(gardenID, item))
|
|
result.All = append(result.All, result.Tasks[len(result.Tasks)-1])
|
|
}
|
|
}
|
|
for _, item := range plants {
|
|
if (mode == "month" && plantInMonth(item, month)) || (mode != "month" && containsTerms(query, item.Name, item.Notes, string(item.Attributes), strings.Join(item.Tags, " "))) {
|
|
addMonthTags(item.Tags)
|
|
result.Plants = append(result.Plants, plantSearchCard(gardenID, item))
|
|
result.All = append(result.All, result.Plants[len(result.Plants)-1])
|
|
}
|
|
}
|
|
for _, item := range species {
|
|
if (mode == "month" && speciesInMonth(item, month)) || (mode != "month" && (containsTerms(query, item.CommonName, item.Cultivar, item.BotanicalName, item.Category, item.Notes, string(item.Attributes), strings.Join(item.Tags, " ")) || (month > 0 && speciesInMonth(item, month)))) {
|
|
addMonthTags(item.Tags)
|
|
result.Species = append(result.Species, speciesSearchCard(gardenID, item))
|
|
result.All = append(result.All, result.Species[len(result.Species)-1])
|
|
}
|
|
}
|
|
for _, item := range journal {
|
|
if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, strings.Join(item.Tags, " "))) {
|
|
addMonthTags(item.Tags)
|
|
result.Journal = append(result.Journal, gardenEntrySearchCard(gardenID, item))
|
|
result.All = append(result.All, result.Journal[len(result.Journal)-1])
|
|
}
|
|
}
|
|
for _, item := range pinboard {
|
|
if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, item.Body, strings.Join(item.Tags, " "))) {
|
|
addMonthTags(item.Tags)
|
|
result.Pinboard = append(result.Pinboard, gardenEntrySearchCard(gardenID, item))
|
|
result.All = append(result.All, result.Pinboard[len(result.Pinboard)-1])
|
|
}
|
|
}
|
|
seenTagUsages := map[string]bool{}
|
|
addTagUsages := func(card searchCard, values []string) {
|
|
for _, tag := range values {
|
|
if _, ok := matchedTags[strings.ToLower(tag)]; !ok {
|
|
continue
|
|
}
|
|
if seenTagUsages[card.URL] {
|
|
continue
|
|
}
|
|
seenTagUsages[card.URL] = true
|
|
usage := tagUsageCard(card, matchedTags[strings.ToLower(tag)])
|
|
result.Tags = append(result.Tags, usage)
|
|
allSeen := false
|
|
for _, existing := range result.All {
|
|
if existing.URL == usage.URL {
|
|
allSeen = true
|
|
break
|
|
}
|
|
}
|
|
if !allSeen {
|
|
result.All = append(result.All, usage)
|
|
}
|
|
}
|
|
}
|
|
for _, item := range tasks {
|
|
addTagUsages(taskSearchCard(gardenID, item), item.Tags)
|
|
}
|
|
for _, item := range plants {
|
|
addTagUsages(plantSearchCard(gardenID, item), item.Tags)
|
|
}
|
|
for _, item := range species {
|
|
addTagUsages(speciesSearchCard(gardenID, item), item.Tags)
|
|
}
|
|
for _, item := range journal {
|
|
addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags)
|
|
}
|
|
for _, item := range pinboard {
|
|
addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags)
|
|
}
|
|
}
|
|
data := app.newTemplateData(r)
|
|
data.Garden = &garden
|
|
data.Search = result
|
|
app.render(w, http.StatusOK, "search.tmpl", data)
|
|
}
|
|
|
|
func taskSearchCard(gardenID int, item client.Task) searchCard {
|
|
return searchCard{item.Title, item.Description, taskDue(item), "Aufgabe", webPath("task.edit", gardenID, item.ID)}
|
|
}
|
|
func plantSearchCard(gardenID int, item client.Plant) searchCard {
|
|
return searchCard{item.Name, item.Notes, plantStatusName(item.Status), "Im Garten", webPath("plant.edit", gardenID, item.ID)}
|
|
}
|
|
func speciesSearchCard(gardenID int, item client.Species) searchCard {
|
|
title := item.CommonName
|
|
if item.Cultivar != "" {
|
|
title += " · " + item.Cultivar
|
|
}
|
|
return searchCard{title, item.BotanicalName, speciesSeason(item), "Pflanze", webPath("species.edit", gardenID, item.ID)}
|
|
}
|
|
func gardenEntrySearchCard(gardenID int, item client.JournalEntry) searchCard {
|
|
path, label := "journal", "Tagebuch"
|
|
if item.EntryType == client.JournalEntryTypePinboard {
|
|
path, label = "pinboard", "Pinnwand"
|
|
}
|
|
return searchCard{item.Title, item.AuthorName, item.CreatedAt.Local().Format("02.01.2006 · 15:04 Uhr"), label, webPath("garden."+path, gardenID) + fmt.Sprintf("#entry-%d", item.ID)}
|
|
}
|
|
func tagUsageCard(card searchCard, tag string) searchCard {
|
|
card.Meta = "#" + tag + " · " + card.Meta
|
|
return card
|
|
}
|
|
func speciesSeason(item client.Species) string {
|
|
if item.HarvestMonthFrom != nil {
|
|
return fmt.Sprintf("Erntezeit ab %s", germanMonthName(*item.HarvestMonthFrom))
|
|
}
|
|
return item.Category
|
|
}
|
|
func plantInMonth(item client.Plant, month int) bool {
|
|
return (item.AcquiredAt != nil && int(item.AcquiredAt.Month()) == month) || int(item.CreatedAt.Month()) == month
|
|
}
|
|
func germanMonthName(month int) string {
|
|
names := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
|
|
if month >= 1 && month <= 12 {
|
|
return names[month]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func containsTerms(query string, values ...string) bool {
|
|
query = strings.ToLower(strings.TrimSpace(query))
|
|
for _, term := range strings.Fields(query) {
|
|
term = strings.TrimPrefix(term, "#")
|
|
found := false
|
|
for _, value := range values {
|
|
if strings.Contains(strings.ToLower(value), term) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
return query != ""
|
|
}
|
|
|
|
func parseKeywords(value string) []string {
|
|
parts := strings.Split(value, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if part = strings.TrimSpace(part); part != "" {
|
|
result = append(result, part)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
func parseGermanMonth(value string) int {
|
|
names := []string{"januar", "februar", "märz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember"}
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
for i, name := range names {
|
|
if value == name || value == strings.TrimSuffix(name, "uar") {
|
|
return i + 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
func taskInMonth(task client.Task, month int) bool {
|
|
return (task.DueAtStart != nil && int(task.DueAtStart.Month()) == month) ||
|
|
(task.DueAtEnd != nil && int(task.DueAtEnd.Month()) == month)
|
|
}
|
|
|
|
func taskInYear(task client.Task, year int) bool {
|
|
return (task.DueAtStart != nil && task.DueAtStart.Year() == year) ||
|
|
(task.DueAtEnd != nil && task.DueAtEnd.Year() == year)
|
|
}
|
|
|
|
func taskYears(tasks []client.Task) []int {
|
|
minYear, maxYear := 0, 0
|
|
setYear := func(year int) {
|
|
if minYear == 0 || year < minYear {
|
|
minYear = year
|
|
}
|
|
if year > maxYear {
|
|
maxYear = year
|
|
}
|
|
}
|
|
for _, task := range tasks {
|
|
if task.DueAtStart != nil {
|
|
setYear(task.DueAtStart.Year())
|
|
}
|
|
if task.DueAtEnd != nil {
|
|
setYear(task.DueAtEnd.Year())
|
|
}
|
|
}
|
|
if minYear == 0 {
|
|
return nil
|
|
}
|
|
result := make([]int, 0, maxYear-minYear+1)
|
|
for year := minYear; year <= maxYear; year++ {
|
|
result = append(result, year)
|
|
}
|
|
return result
|
|
}
|
|
func speciesInMonth(item client.Species, month int) bool {
|
|
return monthInRange(month, item.SowMonthFrom, item.SowMonthTo) || monthInRange(month, item.PlantingMonthFrom, item.PlantingMonthTo) || monthInRange(month, item.HarvestMonthFrom, item.HarvestMonthTo)
|
|
}
|
|
func monthInRange(month int, from, to *int) bool {
|
|
if from == nil || to == nil {
|
|
return false
|
|
}
|
|
return monthRangeMatches(month, *from, *to)
|
|
}
|
|
|
|
func monthRangeMatches(month, from, to int) bool {
|
|
if from <= to {
|
|
return month >= from && month <= to
|
|
}
|
|
return month >= from || month <= to
|
|
}
|