From eb7c8495521e92447e6c7886da7ce5ff1f4a0ef0 Mon Sep 17 00:00:00 2001 From: bdoerfchen Date: Mon, 21 Jul 2025 00:10:15 +0200 Subject: [PATCH] feat: add universities and restaurants --- .vscode/launch.json | 3 +- cmd/config.go | 7 +- cmd/resources.go | 41 +++++++- cmd/root.go | 1 + cmd/verbs.go | 16 +++- core/handler/meals/handler.go | 20 ++-- core/handler/params.go | 6 ++ .../universities/restaurant-handler.go | 93 +++++++++++++++++++ core/handler/universities/shared.go | 31 +++++++ core/handler/universities/uni-handler.go | 64 +++++++++++++ core/interfaces/handler.go | 2 +- core/interfaces/resource.go | 2 +- core/output/table.go | 2 +- model/external/stwbremen/restaurant.go | 35 +++++++ model/external/stwbremen/types.go | 53 +++++++++++ model/resources/meals.go | 5 +- model/resources/resources.go | 5 +- model/resources/restaurant.go | 21 +++++ model/resources/university.go | 19 ++++ util/slices.go | 15 +++ 20 files changed, 408 insertions(+), 33 deletions(-) create mode 100644 core/handler/params.go create mode 100644 core/handler/universities/restaurant-handler.go create mode 100644 core/handler/universities/shared.go create mode 100644 core/handler/universities/uni-handler.go create mode 100644 model/external/stwbremen/restaurant.go create mode 100644 model/external/stwbremen/types.go create mode 100644 model/resources/restaurant.go create mode 100644 model/resources/university.go diff --git a/.vscode/launch.json b/.vscode/launch.json index ff6a5b6..85a6002 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,8 +11,7 @@ "mode": "debug", "program": "${workspaceFolder}/main.go", "args": [ - "get", "dishes", - "-o", "yaml", + "get", "meals", ] } ] diff --git a/cmd/config.go b/cmd/config.go index 560a052..d957b7e 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -1,7 +1,8 @@ package cmd type AppConfig struct { - OutputVerbose bool - OutputFormatter string - PrintConfig bool + OutputVerbose bool + OutputFormatter string + OutputOrderReverse bool + PrintConfig bool } diff --git a/cmd/resources.go b/cmd/resources.go index c21a7dc..96818b4 100644 --- a/cmd/resources.go +++ b/cmd/resources.go @@ -2,8 +2,11 @@ package cmd import ( "context" + "slices" + "strings" "git.bissendorf.co/bissendorf/unifood/m/v2/core/handler/meals" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/handler/universities" "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/stwhbclient" @@ -15,28 +18,56 @@ import ( var availableResources = []interfaces.ResourceCommand[any]{ { Name: "resources", - Aliases: []string{"resource", "r"}, - Description: "A meta resource representing all other object kinds of this CLI", + Aliases: []string{"resource"}, + Description: "A meta resource representing all other object kinds of this CLI.", Verbs: []interfaces.Verb{interfaces.VerbGet}, Handler: ®isteredResourcesHandler{}, }, { Name: "meals", Aliases: []string{"meal", "m"}, - Description: "A meal represents a cooked combination of ingredients that can be bought and consumed", + Description: "A meal represents a cooked combination of ingredients that can be bought and consumed.", Verbs: []interfaces.Verb{interfaces.VerbGet}, Handler: &meals.MealsHandler{ QueryClient: stwhbclient.New[[]stwbremen.Meal](), }, }, + { + Name: "universities", + Aliases: []string{"university", "unis", "uni", "u"}, + Description: "A facility that is hosting one or multiple restaurants.", + Verbs: []interfaces.Verb{interfaces.VerbGet}, + Handler: &universities.UniversityHandler{ + QueryClient: stwhbclient.New[stwbremen.RestaurantList](), + }, + }, + { + Name: "restaurants", + Aliases: []string{"restaurant", "r"}, + Description: "A place to eat meals", + Verbs: []interfaces.Verb{interfaces.VerbGet}, + Handler: &universities.RestaurantHandler{ + QueryClient: stwhbclient.New[stwbremen.RestaurantList](), + QueryClientRestaurant: stwhbclient.New[stwbremen.Restaurant](), + }, + }, } type registeredResourcesHandler struct{} -func (h *registeredResourcesHandler) Get(ctx context.Context, params params.Container) (*interfaces.ResourceList, error) { +func (h *registeredResourcesHandler) Get(ctx context.Context, name string, params params.Container) (*interfaces.ResourceList, error) { + lowerName := strings.ToLower(name) + list := util.Select(availableResources, func(i *interfaces.ResourceCommand[any]) bool { + return name == "" || i.Name == lowerName + }) + + slices.SortFunc(list, func(a, b interfaces.ResourceCommand[any]) int { + return strings.Compare(a.Name, b.Name) + }) + return &interfaces.ResourceList{ ItemKind: resources.ResourceResource, - Items: util.Transform(availableResources, func(i *interfaces.ResourceCommand[any]) interfaces.Resource { + Items: util.Transform(list, func(i *interfaces.ResourceCommand[any]) interfaces.Resource { return &resources.Resource{ ResourceName: i.Name, Aliases: i.Aliases, diff --git a/cmd/root.go b/cmd/root.go index 3c12f02..a0f1706 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -32,6 +32,7 @@ func initRootCmd() { rootCmd.PersistentFlags().BoolVarP(&appConfig.OutputVerbose, "verbose", "v", false, "Enable verbose output") rootCmd.PersistentFlags().StringVarP(&appConfig.OutputFormatter, "output", "o", "table", "Set output format") + rootCmd.PersistentFlags().BoolVar(&appConfig.OutputOrderReverse, "reverse", false, "Reverses output item order") rootCmd.PersistentFlags().BoolVar(&appConfig.PrintConfig, "print-config", false, "Enable printing the application config") logger := jlog.New(slog.LevelDebug) diff --git a/cmd/verbs.go b/cmd/verbs.go index fa2f9cd..6274eb8 100644 --- a/cmd/verbs.go +++ b/cmd/verbs.go @@ -20,24 +20,27 @@ type VerbItem struct { Name interfaces.Verb Aliases []string Description string - RunFn func(ctx context.Context, config *AppConfig, handler interfaces.ResourceHandler, params params.Container) error + RunFn func(ctx context.Context, config *AppConfig, handler interfaces.ResourceHandler, params params.Container, name string) error } var verbs = []VerbItem{ { Name: interfaces.VerbGet, Description: "Retrieve a list of resources", - RunFn: func(ctx context.Context, config *AppConfig, handler interfaces.ResourceHandler, params params.Container) error { + RunFn: func(ctx context.Context, config *AppConfig, handler interfaces.ResourceHandler, params params.Container, name string) error { h, ok := handler.(interfaces.GetHandler) if !ok { return fmt.Errorf("resource does not support GET") } // Get items - items, err := h.Get(ctx, params) + items, err := h.Get(ctx, name, params) if err != nil { return fmt.Errorf("retrieving item failed: %w", err) } + if config.OutputOrderReverse { + slices.Reverse(items.Items) + } formatterName := strings.ToLower(config.OutputFormatter) formatter, exists := output.Formatters[formatterName] @@ -94,7 +97,12 @@ func getVerbs(ctx context.Context, config *AppConfig) (commands []*cobra.Command logger.WarnContext(ctx, "Printing app config", slog.Any("config", config), slog.Any("parameters", params.ToMap())) } - err := v.RunFn(ctx, config, r.Handler, params) + var name string + if len(args) > 0 { + name = args[0] + } + + err := v.RunFn(ctx, config, r.Handler, params, name) if err != nil { logger.ErrorContext(ctx, fmt.Sprintf("%s %s failed", strings.ToUpper(string(v.Name)), r.Name), "error", err.Error()) os.Exit(1) diff --git a/core/handler/meals/handler.go b/core/handler/meals/handler.go index 3e43286..5e3018e 100644 --- a/core/handler/meals/handler.go +++ b/core/handler/meals/handler.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/handler" "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" @@ -19,20 +20,15 @@ type MealsHandler struct { QueryClient interfaces.QueryClient[[]stwbremen.Meal] } -const ( - paramDate = "date" - paramLocation = "location" -) - -func (h *MealsHandler) Get(ctx context.Context, params params.Container) (*interfaces.ResourceList, error) { +func (h *MealsHandler) Get(ctx context.Context, name string, params params.Container) (*interfaces.ResourceList, error) { // Read parameters - p, err := params.GetValue(paramDate) + p, err := params.GetValue(handler.ParamDate) if err != nil { return nil, fmt.Errorf("unable to parse date parameter: %w", err) } date := p.(time.Time) - location, err := params.GetValue(paramLocation) + location, err := params.GetValue(handler.ParamLocation) if err != nil { return nil, fmt.Errorf("unable to parse location parameter: %w", err) } @@ -72,16 +68,16 @@ func (h *MealsHandler) Get(ctx context.Context, params params.Container) (*inter func (h *MealsHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { return []params.Registration{ { - Name: paramDate, + Name: handler.ParamDate, ShortHand: "d", - Description: "Menu date", + Description: "Meal date", DefaultFunc: func() string { return time.Now().Format(time.DateOnly) }, ParseFunc: func(value string) (any, error) { return time.Parse(time.DateOnly, value) }, }, { - Name: paramLocation, + Name: handler.ParamLocation, ShortHand: "l", - Description: "Define the restaurant", + Description: "Select a restaurant", DefaultFunc: func() string { return "330" }, ParseFunc: params.ParseString, }, diff --git a/core/handler/params.go b/core/handler/params.go new file mode 100644 index 0000000..04618ea --- /dev/null +++ b/core/handler/params.go @@ -0,0 +1,6 @@ +package handler + +const ( + ParamDate string = "date" + ParamLocation string = "location" +) diff --git a/core/handler/universities/restaurant-handler.go b/core/handler/universities/restaurant-handler.go new file mode 100644 index 0000000..7fb9518 --- /dev/null +++ b/core/handler/universities/restaurant-handler.go @@ -0,0 +1,93 @@ +package universities + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/resources" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" +) + +type RestaurantHandler struct { + QueryClient interfaces.QueryClient[stwbremen.RestaurantList] + QueryClientRestaurant interfaces.QueryClient[stwbremen.Restaurant] +} + +func (h *RestaurantHandler) Get(ctx context.Context, name string, params params.Container) (*interfaces.ResourceList, error) { + var list []string = []string{name} + + // If no name provided, get list of all available restaurants + if name == "" { + fetchedNames, err := getRestaurantList(ctx, h.QueryClient) + if err != nil { + return nil, err + } + + list = fetchedNames + } + + // Get restaurant information in parallel + var wg sync.WaitGroup + var mutex sync.Mutex + restaurants := make([]resources.Restaurant, 0, len(list)) + for _, name := range list { + wg.Add(1) + go func() { + defer wg.Done() + + restaurant, err := h.getRestaurantInfo(ctx, name) + + // Append restaurant in critical section + if err == nil { + mutex.Lock() + restaurants = append(restaurants, *restaurant) + mutex.Unlock() + } + }() + } + wg.Wait() + + // Sort by name + slices.SortFunc(restaurants, func(a, b resources.Restaurant) int { return strings.Compare(a.Name, b.Name) }) + + // Return + return &interfaces.ResourceList{ + ItemKind: resources.ResourceRestaurant, + Items: util.Transform(restaurants, func(i *resources.Restaurant) interfaces.Resource { return i }), + }, nil + +} + +func (h *RestaurantHandler) getRestaurantInfo(ctx context.Context, name string) (*resources.Restaurant, error) { + r, err := h.QueryClientRestaurant.Get(ctx, + fmt.Sprintf("page('essen-und-trinken/%s')", name), + `{"title": "page.title","id": "page.content.mensalocationid","image": "page.files.first().url","address": "page.content.address","openingTimes": {"query": "page.openingTimes.toStructure()","select": {"weekday": "structureItem.day","openingTime": "structureItem.open","closingTime": "structureItem.close"}},"offseasonOpeningTimes": {"query": "page.offseasonOpeningTimes.toStructure()","select": {"weekday": "structureItem.day","openingTime": "structureItem.open","closingTime": "structureItem.close"}},"offseasonStart": true,"offseasonEnd": true,"changedTimes": {"query": "page.changedTimes.toStructure()","select": {"startDate": "structureItem.startChangedDate","endDate": "structureItem.endChangedDate","openingTime": "structureItem.openChangedTime","closingTime": "structureItem.closedChangedTime"}}}`, + false, + ) + if err != nil { + return nil, fmt.Errorf("failed to load restaurant %s: %w", name, err) + } + + if r.Title == "" { + return nil, fmt.Errorf("restaurant not found: %s", name) + } + + return &resources.Restaurant{ + Name: name, + Title: r.Title, + ID: r.ID, + Address: r.Address, + Image: r.Image, + OpeningHours: r.OpeningHours, + }, nil +} + +func (h *RestaurantHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { + return []params.Registration{} +} diff --git a/core/handler/universities/shared.go b/core/handler/universities/shared.go new file mode 100644 index 0000000..d6e5be0 --- /dev/null +++ b/core/handler/universities/shared.go @@ -0,0 +1,31 @@ +package universities + +import ( + "context" + "fmt" + "strings" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" +) + +func getRestaurantList(ctx context.Context, client interfaces.QueryClient[stwbremen.RestaurantList]) ([]string, error) { + list, err := client.Get(ctx, + `page('essen-und-trinken')`, + `{"items": "page.children.children"}`, + false, + ) + if err != nil { + return nil, fmt.Errorf("failed to load university list: %w", err) + } + + return util.Transform(list.Items, func(i *string) string { + parts := strings.SplitN(*i, "/", 2) + if len(parts) < 2 { + return parts[0] + } + + return parts[1] + }), nil +} diff --git a/core/handler/universities/uni-handler.go b/core/handler/universities/uni-handler.go new file mode 100644 index 0000000..62c2226 --- /dev/null +++ b/core/handler/universities/uni-handler.go @@ -0,0 +1,64 @@ +package universities + +import ( + "context" + "strings" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/resources" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" +) + +type UniversityHandler struct { + QueryClient interfaces.QueryClient[stwbremen.RestaurantList] +} + +func (h *UniversityHandler) Get(ctx context.Context, name string, params params.Container) (*interfaces.ResourceList, error) { + // Get list of restaurants + list, err := getRestaurantList(ctx, h.QueryClient) + if err != nil { + return nil, err + } + + // Group restaurants into universities + unis := util.Group(list, func(i *string) (string, string) { + parts := strings.SplitN(*i, "/", 2) + if len(parts) <= 1 { + return *i, *i + } + + return parts[0], *i + }) + + // If name is provided remove all other unis + if name != "" { + uni, exists := unis[name] + unis = make(map[string][]string) + + if exists { + unis[name] = uni + } + } + + // Map into resource + uniItems := make([]resources.University, 0, len(unis)) + for uni, restaurants := range unis { + uniItems = append(uniItems, resources.University{ + Name: uni, + Restaurants: restaurants, + }) + } + + // Return + return &interfaces.ResourceList{ + ItemKind: resources.ResourceUniversity, + Items: util.Transform(uniItems, func(i *resources.University) interfaces.Resource { return i }), + }, nil + +} + +func (h *UniversityHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { + return []params.Registration{} +} diff --git a/core/interfaces/handler.go b/core/interfaces/handler.go index 1f61fde..8431526 100644 --- a/core/interfaces/handler.go +++ b/core/interfaces/handler.go @@ -19,7 +19,7 @@ type ResourceHandler interface { } type GetHandler interface { - Get(ctx context.Context, params params.Container) (*ResourceList, error) + Get(ctx context.Context, name string, params params.Container) (*ResourceList, error) } type Verb string diff --git a/core/interfaces/resource.go b/core/interfaces/resource.go index 6072d57..97f3ea5 100644 --- a/core/interfaces/resource.go +++ b/core/interfaces/resource.go @@ -3,7 +3,7 @@ package interfaces type Resource interface { Kind() string - Name() string + ItemName() string } type ResourceList struct { diff --git a/core/output/table.go b/core/output/table.go index 83289d1..b8740da 100644 --- a/core/output/table.go +++ b/core/output/table.go @@ -55,7 +55,7 @@ func (f *TableFormatter) Format(list *interfaces.ResourceList) (io.Reader, error // Write rows for _, item := range list.Items { - rowOutput := []any{item.Name()} + rowOutput := []any{item.ItemName()} itemFormatter, ok := item.(interfaces.TableOutput) if ok { diff --git a/model/external/stwbremen/restaurant.go b/model/external/stwbremen/restaurant.go new file mode 100644 index 0000000..148a4b5 --- /dev/null +++ b/model/external/stwbremen/restaurant.go @@ -0,0 +1,35 @@ +package stwbremen + +type RestaurantList struct { + Items []string `json:"items"` +} + +type Restaurant struct { + Title string `json:"title"` + ID string `json:"id"` + Image string `json:"image"` + Address string `json:"address"` + + OpeningHours +} + +type OpeningHours struct { + OpeningTimes []OpeningTime `json:"openingTimes"` + OffseasonOpeningTimes []OpeningTime `json:"offseasonOpeningTimes"` + OffseasonStart DateOnly `json:"offseasonStart"` + OffseasonEnd DateOnly `json:"offseasonEnd"` + ChangedTimes []ChangedTime `json:"changedTimes"` +} + +type OpeningTime struct { + Weekday string `json:"weekday"` + OpeningTime TimeOnly `json:"openingTime"` + ClosingTime TimeOnly `json:"closingTime"` +} + +type ChangedTime struct { + StartDate DateOnly `json:"startDate"` + EndDate DateOnly `json:"endDate"` + OpeningTime TimeOnly `json:"openingTime"` + ClosingTime TimeOnly `json:"closingTime"` +} diff --git a/model/external/stwbremen/types.go b/model/external/stwbremen/types.go new file mode 100644 index 0000000..11205ec --- /dev/null +++ b/model/external/stwbremen/types.go @@ -0,0 +1,53 @@ +package stwbremen + +import ( + "encoding/json" + "time" +) + +type TimeOnly struct { + time.Time +} + +// UnmarshalJSON parses a JSON string in HH:MM:SS format into a TimeOnly +func (t *TimeOnly) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + parsed, err := time.Parse(time.TimeOnly, s) + if err != nil { + return err + } + t.Time = parsed + return nil +} + +// MarshalJSON converts the TimeOnly to a JSON string in HH:MM:SS format +func (t TimeOnly) MarshalJSON() ([]byte, error) { + return json.Marshal(t.Format(time.TimeOnly)) +} + +type DateOnly struct { + time.Time +} + +// UnmarshalJSON parses a JSON string in YYYY-MM-DD format into a DateOnly +func (d *DateOnly) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + parsed, err := time.Parse(time.DateOnly, s) + if err != nil { + d.Time = time.Time{} + } else { + d.Time = parsed + } + return nil +} + +// MarshalJSON converts the DateOnly to a JSON string in YYYY-MM-DD format +func (d DateOnly) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Format(time.DateOnly)) +} diff --git a/model/resources/meals.go b/model/resources/meals.go index 4e02c3d..a17cc00 100644 --- a/model/resources/meals.go +++ b/model/resources/meals.go @@ -55,9 +55,10 @@ type ingredient struct { Additionals []string } -func (d *Meal) Kind() string { return ResourceMeal } -func (d *Meal) Name() string { return d.Title } +func (d *Meal) Kind() string { return ResourceMeal } +func (d *Meal) ItemName() string { return d.Title } +// Table output func (d *Meal) ColumnNames() []string { return []string{"Location", "Date", "Counter", "Price"} } func (d *Meal) Columns() []any { return []any{d.Location, d.Date.Format(time.DateOnly), d.Counter, d.Prices["Studierende"]} diff --git a/model/resources/resources.go b/model/resources/resources.go index a915266..f35040a 100644 --- a/model/resources/resources.go +++ b/model/resources/resources.go @@ -10,9 +10,10 @@ type Resource struct { Description string } -func (r *Resource) Kind() string { return ResourceResource } -func (r *Resource) Name() string { return r.ResourceName } +func (r *Resource) Kind() string { return ResourceResource } +func (r *Resource) ItemName() string { return r.ResourceName } +// Table output func (r *Resource) ColumnNames() []string { return []string{"Aliases", "Description"} } func (r *Resource) Columns() []any { return []any{strings.Join(r.Aliases, ", "), r.Description} diff --git a/model/resources/restaurant.go b/model/resources/restaurant.go new file mode 100644 index 0000000..c82a878 --- /dev/null +++ b/model/resources/restaurant.go @@ -0,0 +1,21 @@ +package resources + +const ResourceRestaurant = "restaurant" + +type Restaurant struct { + Name string + Title string + ID string + Address string + Image string + OpeningHours any +} + +func (r *Restaurant) Kind() string { return ResourceRestaurant } +func (r *Restaurant) ItemName() string { return r.Name } + +// Table output +func (r *Restaurant) ColumnNames() []string { return []string{"ID", "Title"} } +func (r *Restaurant) Columns() []any { + return []any{r.ID, r.Title} +} diff --git a/model/resources/university.go b/model/resources/university.go new file mode 100644 index 0000000..f5f50fc --- /dev/null +++ b/model/resources/university.go @@ -0,0 +1,19 @@ +package resources + +import "strings" + +const ResourceUniversity = "university" + +type University struct { + Name string + Restaurants []string +} + +func (u *University) Kind() string { return ResourceUniversity } +func (u *University) ItemName() string { return u.Name } + +// Table output +func (u *University) ColumnNames() []string { return []string{"Restaurants"} } +func (u *University) Columns() []any { + return []any{strings.Join(u.Restaurants, "\r\n")} +} diff --git a/util/slices.go b/util/slices.go index 4221f34..02dcbc8 100644 --- a/util/slices.go +++ b/util/slices.go @@ -31,3 +31,18 @@ func Map[T any, Tkey comparable, Tval any](s []T, transformFn func(i *T) (Tkey, return } + +func Group[T any, Tkey comparable, Tval any](s []T, groupFn func(i *T) (groupKey Tkey, value Tval)) (out map[Tkey][]Tval) { + out = make(map[Tkey][]Tval) + for _, i := range s { + key, value := groupFn(&i) + list, exists := out[key] + if !exists { + list = make([]Tval, 0, 2) + } + + out[key] = append(list, value) + } + + return +} -- 2.49.0