diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5567bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +unifood +__bin* +__debug* \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..85a6002 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug app", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/main.go", + "args": [ + "get", "meals", + ] + } + ] +} \ No newline at end of file diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..d957b7e --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,8 @@ +package cmd + +type AppConfig struct { + OutputVerbose bool + OutputFormatter string + OutputOrderReverse bool + PrintConfig bool +} diff --git a/cmd/resources.go b/cmd/resources.go new file mode 100644 index 0000000..96818b4 --- /dev/null +++ b/cmd/resources.go @@ -0,0 +1,82 @@ +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" + "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" +) + +var availableResources = []interfaces.ResourceCommand[any]{ + { + Name: "resources", + 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.", + 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, 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(list, func(i *interfaces.ResourceCommand[any]) interfaces.Resource { + return &resources.Resource{ + ResourceName: i.Name, + Aliases: i.Aliases, + Description: i.Description, + } + }), + }, nil +} + +func (h *registeredResourcesHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { + return []params.Registration{} +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..1ff55d1 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "context" + "fmt" + "log/slog" + "os" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/jlog" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "unifood", + Short: "Unifood is a CLI for retrieving restaurant information", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + }, +} + +func Execute() { + initRootCmd() + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func initRootCmd() { + var appConfig AppConfig + + 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) + ctx := jlog.ContextWith(context.Background(), logger) + + rootCmd.AddCommand(&versionCmd) + rootCmd.AddCommand(getVerbs(ctx, &appConfig)...) +} diff --git a/cmd/verbs.go b/cmd/verbs.go new file mode 100644 index 0000000..4a24b24 --- /dev/null +++ b/cmd/verbs.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "slices" + "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/core/output" + "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/jlog" + "github.com/spf13/cobra" +) + +type VerbItem struct { + Name interfaces.Verb + Aliases []string + Description string + 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, 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, 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] + if !exists { + return fmt.Errorf("could not find output formatter '%s'", formatterName) + } + + // Format and output + formatted, err := formatter.Format(items) + if err != nil { + return fmt.Errorf("failed to format output: %w", err) + } + _, err = io.Copy(os.Stdout, formatted) + if err != nil { + return fmt.Errorf("failed to write output: %w", err) + } + + return nil + }, + }, +} + +func getVerbs(ctx context.Context, config *AppConfig) (commands []*cobra.Command) { + for _, v := range verbs { + verbCommand := &cobra.Command{ + Use: strings.ToLower(string(v.Name)), + Aliases: v.Aliases, + Short: v.Description, + } + + // Add all resources that can handle this verb + for _, r := range availableResources { + if !slices.Contains(r.Verbs, v.Name) { + continue + } + + var params params.Container + + resourceCommand := &cobra.Command{ + Use: r.Name, + Aliases: r.Aliases, + Short: r.Description, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Configure log + var logLevel = slog.LevelWarn + if config.OutputVerbose { + logLevel = slog.LevelDebug + } + logger := jlog.New(logLevel) + ctx := jlog.ContextWith(context.Background(), logger) + + // Print config + if config.PrintConfig { + logger.WarnContext(ctx, "Printing app config", slog.Any("config", config), slog.Any("parameters", params.ToMap())) + } + + 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) + } + }, + } + + // Register parameters + for _, param := range r.Handler.GetParametersForVerb(v.Name) { + resourceCommand.Flags().StringVarP( + params.Register(param.Name, param.ParseFunc), + param.Name, + param.ShortHand, + param.DefaultFunc(), + param.Description, + ) + } + + verbCommand.AddCommand(resourceCommand) + } + + commands = append(commands, verbCommand) + } + + return +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..92f9ddf --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "fmt" + + "git.bissendorf.co/bissendorf/unifood/m/v2/util" + "github.com/spf13/cobra" +) + +var versionCmd = cobra.Command{ + Use: "version", + Aliases: []string{"v"}, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(util.AppVersion) + }, +} diff --git a/core/handler/meals/handler.go b/core/handler/meals/handler.go new file mode 100644 index 0000000..57b28b3 --- /dev/null +++ b/core/handler/meals/handler.go @@ -0,0 +1,108 @@ +package meals + +import ( + "context" + "fmt" + "slices" + "strings" + "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" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/resources" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" +) + +type MealsHandler struct { + interfaces.ResourceHandler + interfaces.GetHandler + + QueryClient interfaces.QueryClient[[]stwbremen.Meal] +} + +func (h *MealsHandler) Get(ctx context.Context, name string, params params.Container) (*interfaces.ResourceList, error) { + // Read parameters + date, err := params.GetValue(handler.ParamDate) + if err != nil { + return nil, fmt.Errorf("unable to parse date parameter: %w", err) + } + + restaurant, err := params.GetValue(handler.ParamRestaurant) + if err != nil { + return nil, fmt.Errorf("unable to parse restaurant parameter: %w", err) + } + + // Build query + query := "page('meals').children.filterBy('printonly', 0)" + if date != "" { + parsed, err := time.Parse(time.DateOnly, date.(string)) + if err != nil { + return nil, fmt.Errorf("unable to parse date parameter: %w", err) + } + query += fmt.Sprintf(".filterBy('date', '%s')", parsed.Format(time.DateOnly)) + } + if restaurant != "" { + query += fmt.Sprintf(".filterBy('location', '%s')", restaurant.(string)) + } + if name != "" { + query += fmt.Sprintf(".filterBy('id', 'meals/%s')", name) + } + + // Run query + meals, err := h.QueryClient.Get(ctx, + query, + `{"title":true,"id":"page.id","ingredients":"page.ingredients.toObject","prices":"page.prices.toObject","location":true,"counter":true,"date":true,"mealadds":true,"mark":true,"frei3":true,"printonly":true,"kombicategory":true,"categories":"page.categories.split"}`, + false, + ) + if err != nil { + return nil, fmt.Errorf("querying menu failed: %w", err) + } + + // Transform to meal resource + items := util.Transform(*meals, func(i *stwbremen.Meal) *resources.Meal { + d, err := resources.MealFromDTO(*i) + if err != nil { + return &resources.Meal{} + } + + return d + }) + + // Sort by date, restaurant and title - need to apply stable sorts in reverse order to achieve this + slices.SortFunc(items, func(a, b *resources.Meal) int { + return strings.Compare(a.Title, b.Title) + }) + slices.SortStableFunc(items, func(a, b *resources.Meal) int { + return strings.Compare(a.RestaurantID, b.RestaurantID) + }) + slices.SortStableFunc(items, func(a, b *resources.Meal) int { + return a.Date.Compare(b.Date) + }) + + // Return + return &interfaces.ResourceList{ + ItemKind: resources.ResourceMeal, + Items: util.Transform(items, func(i **resources.Meal) interfaces.Resource { return *i }), + }, nil +} + +func (h *MealsHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { + return []params.Registration{ + { + Name: handler.ParamDate, + ShortHand: "d", + Description: "Meal date", + DefaultFunc: func() string { return "" }, + ParseFunc: params.ParseString, + }, + { + Name: handler.ParamRestaurant, + ShortHand: "r", + Description: "Select a restaurant", + DefaultFunc: func() string { return "" }, + ParseFunc: params.ParseString, + }, + } +} diff --git a/core/handler/params.go b/core/handler/params.go new file mode 100644 index 0000000..04ec9b0 --- /dev/null +++ b/core/handler/params.go @@ -0,0 +1,6 @@ +package handler + +const ( + ParamDate string = "date" + ParamRestaurant string = "restaurant" +) 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 new file mode 100644 index 0000000..8431526 --- /dev/null +++ b/core/interfaces/handler.go @@ -0,0 +1,29 @@ +package interfaces + +import ( + "context" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" +) + +type ResourceCommand[T any] struct { + Name string + Aliases []string + Description string + Verbs []Verb + Handler ResourceHandler +} + +type ResourceHandler interface { + GetParametersForVerb(verb Verb) []params.Registration +} + +type GetHandler interface { + Get(ctx context.Context, name string, params params.Container) (*ResourceList, error) +} + +type Verb string + +const ( + VerbGet Verb = "get" +) diff --git a/core/interfaces/http.go b/core/interfaces/http.go new file mode 100644 index 0000000..eef4bc1 --- /dev/null +++ b/core/interfaces/http.go @@ -0,0 +1,7 @@ +package interfaces + +import "context" + +type QueryClient[T any] interface { + Get(ctx context.Context, queryStatement string, selectStatement string, allowCache bool) (*T, error) +} diff --git a/core/interfaces/output.go b/core/interfaces/output.go new file mode 100644 index 0000000..fc9448f --- /dev/null +++ b/core/interfaces/output.go @@ -0,0 +1,14 @@ +package interfaces + +import ( + "io" +) + +type Formatter interface { + Format(object *ResourceList) (io.Reader, error) +} + +type TableOutput interface { + ColumnNames() []string + Columns() []any +} diff --git a/core/interfaces/params/params.go b/core/interfaces/params/params.go new file mode 100644 index 0000000..f2f7fbb --- /dev/null +++ b/core/interfaces/params/params.go @@ -0,0 +1,69 @@ +package params + +import "errors" + +var ( + ErrParamNotFound = errors.New("parameter not found") +) + +type Registration struct { + Name string + ShortHand string + Description string + DefaultFunc func() string + ParseFunc ParseFn +} + +type ParseFn func(value string) (any, error) + +var ParseString ParseFn = func(value string) (any, error) { return value, nil } + +type Container struct { + params map[string]Value +} + +type Value struct { + value *string + parseFn ParseFn +} + +func (c *Container) Register(key string, parseFn ParseFn) *string { + if c.params == nil { + c.params = make(map[string]Value) + } + + item, exists := c.params[key] + if exists { + return item.value + } + + var str string + value := Value{ + parseFn: parseFn, + value: &str, + } + c.params[key] = value + return value.value +} + +func (c *Container) GetValue(key string) (any, error) { + item, exists := c.params[key] + if !exists { + return nil, ErrParamNotFound + } + + return item.parseFn(*item.value) +} + +// Returns a map of all parameters. Parameters that produce errors during parsing are ignored. +func (c *Container) ToMap() (out map[string]any) { + out = make(map[string]any) + for key := range c.params { + v, err := c.GetValue(key) + if err == nil { + out[key] = v + } + } + + return +} diff --git a/core/interfaces/resource.go b/core/interfaces/resource.go new file mode 100644 index 0000000..97f3ea5 --- /dev/null +++ b/core/interfaces/resource.go @@ -0,0 +1,12 @@ +package interfaces + +type Resource interface { + Kind() string + + ItemName() string +} + +type ResourceList struct { + ItemKind string + Items []Resource +} diff --git a/core/output/formatter.go b/core/output/formatter.go new file mode 100644 index 0000000..f5f0583 --- /dev/null +++ b/core/output/formatter.go @@ -0,0 +1,15 @@ +package output + +import ( + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" +) + +var Formatters = map[string]interfaces.Formatter{ + "json": &JsonFormatter{}, + "yaml": &YamlFormatter{}, + "go": &GoFormatter{}, + "table": &TableFormatter{}, + "csv": &TableFormatter{HideSummary: true, RenderFormat: tableFormatCSV}, + "html": &TableFormatter{HideSummary: true, RenderFormat: tableFormatHTML}, + "markdown": &TableFormatter{HideSummary: true, RenderFormat: tableFormatMarkdown}, +} diff --git a/core/output/go.go b/core/output/go.go new file mode 100644 index 0000000..fc476d6 --- /dev/null +++ b/core/output/go.go @@ -0,0 +1,15 @@ +package output + +import ( + "fmt" + "io" + "strings" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" +) + +type GoFormatter struct{} + +func (f *GoFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) { + return strings.NewReader(fmt.Sprintf("%#v", list)), nil +} diff --git a/core/output/json.go b/core/output/json.go new file mode 100644 index 0000000..3b2884f --- /dev/null +++ b/core/output/json.go @@ -0,0 +1,17 @@ +package output + +import ( + "bytes" + "encoding/json" + "io" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" +) + +type JsonFormatter struct{} + +func (f *JsonFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) { + var buffer = make([]byte, 0, 1024) + outputBuffer := bytes.NewBuffer(buffer) + return outputBuffer, json.NewEncoder(outputBuffer).Encode(list) +} diff --git a/core/output/table.go b/core/output/table.go new file mode 100644 index 0000000..b8740da --- /dev/null +++ b/core/output/table.go @@ -0,0 +1,81 @@ +package output + +import ( + "bytes" + "fmt" + "io" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" + "github.com/jedib0t/go-pretty/table" +) + +type tableRenderFormat string + +const ( + tableFormatNormal tableRenderFormat = "table" + tableFormatCSV tableRenderFormat = "csv" + tableFormatHTML tableRenderFormat = "html" + tableFormatMarkdown tableRenderFormat = "markdown" +) + +type TableFormatter struct { + HideSummary bool + RenderFormat tableRenderFormat +} + +func (f *TableFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) { + + var buffer = make([]byte, 0, 1024) + outputBuffer := bytes.NewBuffer(buffer) + + if !f.HideSummary { + outputBuffer.WriteString( + fmt.Sprintf("Resource: %s\r\nCount: %v\r\n\r\n", list.ItemKind, len(list.Items)), + ) + } + + if len(list.Items) <= 0 { + return outputBuffer, nil + } + + // Setup table + t := table.NewWriter() + t.SetOutputMirror(outputBuffer) + t.SetStyle(table.StyleLight) + + // Write header + tableFormat, ok := list.Items[0].(interfaces.TableOutput) + headerRow := []any{"Name"} + if ok { + columnHeaders := util.Transform(tableFormat.ColumnNames(), func(i *string) any { return *i }) + headerRow = append(headerRow, columnHeaders...) + } + t.AppendHeader(headerRow) + + // Write rows + for _, item := range list.Items { + rowOutput := []any{item.ItemName()} + + itemFormatter, ok := item.(interfaces.TableOutput) + if ok { + rowOutput = append(rowOutput, itemFormatter.Columns()...) + } + + t.AppendRow(rowOutput) + } + + // Render + switch f.RenderFormat { + case tableFormatCSV: + t.RenderCSV() + case tableFormatHTML: + t.RenderHTML() + case tableFormatMarkdown: + t.RenderMarkdown() + default: + t.Render() + } + + return outputBuffer, nil +} diff --git a/core/output/yaml.go b/core/output/yaml.go new file mode 100644 index 0000000..551d2d8 --- /dev/null +++ b/core/output/yaml.go @@ -0,0 +1,20 @@ +package output + +import ( + "bytes" + "io" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "github.com/goccy/go-yaml" +) + +type YamlFormatter struct{} + +func (f *YamlFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) { + buffer, err := yaml.Marshal(list) + if err != nil { + return nil, err + } + + return bytes.NewBuffer(buffer), nil +} diff --git a/core/services/jlog/context.go b/core/services/jlog/context.go new file mode 100644 index 0000000..96038b5 --- /dev/null +++ b/core/services/jlog/context.go @@ -0,0 +1,23 @@ +package jlog + +import ( + "context" + "log/slog" +) + +type ctxKey string + +const ctxkeyLogger ctxKey = "jlog:logger" + +func FromContext(ctx context.Context) *slog.Logger { + logger, exists := ctx.Value(ctxkeyLogger).(*slog.Logger) + if !exists { + return New(slog.LevelInfo) + } + + return logger +} + +func ContextWith(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, ctxkeyLogger, logger) +} diff --git a/core/services/jlog/logger.go b/core/services/jlog/logger.go new file mode 100644 index 0000000..e2534ef --- /dev/null +++ b/core/services/jlog/logger.go @@ -0,0 +1,24 @@ +package jlog + +import ( + "io" + "log/slog" + "math/rand" + "os" + "strconv" +) + +func New(level slog.Level) *slog.Logger { + return NewFor(level, os.Stderr) +} + +func NewFor(level slog.Level, output io.Writer) *slog.Logger { + return slog.New(slog.NewJSONHandler(output, &slog.HandlerOptions{ + // AddSource: true, + Level: level, + })).With(slog.String("traceid", newTraceID())) +} + +func newTraceID() string { + return strconv.Itoa(rand.Intn(1000000)) +} diff --git a/core/services/stwhbclient/client.go b/core/services/stwhbclient/client.go new file mode 100644 index 0000000..89b4386 --- /dev/null +++ b/core/services/stwhbclient/client.go @@ -0,0 +1,66 @@ +package stwhbclient + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces" + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" +) + +type stwHBClientFactory struct{} + +func NewFactory() *stwHBClientFactory { return &stwHBClientFactory{} } + +func Create[T any]() *stwHBClient[T] { + return New[T]() +} + +type stwHBClient[T any] struct { + interfaces.QueryClient[T] + + client *http.Client + baseUrl string +} + +func New[T any]() *stwHBClient[T] { + return &stwHBClient[T]{ + client: http.DefaultClient, + baseUrl: "https://content.stw-bremen.de", + } +} + +func (c *stwHBClient[T]) Get(ctx context.Context, queryStatement string, selectStatement string, allowCache bool) (*T, error) { + // Setup url + url := c.baseUrl + "/api/kql" + if !allowCache { + url = url + "nocache" + } + + // Create request from query + fullQuery := fmt.Sprintf(`{"query": "%s", "select": %s}`, queryStatement, selectStatement) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(fullQuery)) + if err != nil { + return nil, fmt.Errorf("unable to create request: %w", err) + } + + // Perform request + response, err := c.client.Do(req) + if err != nil || response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("performing request failed: %w", err) + } + defer response.Body.Close() + + // Read response + var result stwbremen.Result[T] + err = json.NewDecoder(response.Body).Decode(&result) + if err != nil { + return nil, fmt.Errorf("unable to parse result: %w", err) + } + + return &result.Result, nil + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..37c1538 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module git.bissendorf.co/bissendorf/unifood/m/v2 + +go 1.24.0 + +require ( + github.com/goccy/go-yaml v1.18.0 + github.com/jedib0t/go-pretty v4.3.0+incompatible + github.com/spf13/cobra v1.9.1 +) + +require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.7 // indirect + github.com/stretchr/testify v1.10.0 // indirect + go.mongodb.org/mongo-driver v1.14.0 // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4dd9ed4 --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= +github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= +go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..0b346da --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "git.bissendorf.co/bissendorf/unifood/m/v2/cmd" + +func main() { + cmd.Execute() +} diff --git a/model/external/stwbremen/meal.go b/model/external/stwbremen/meal.go new file mode 100644 index 0000000..10ac0e4 --- /dev/null +++ b/model/external/stwbremen/meal.go @@ -0,0 +1,22 @@ +package stwbremen + +type Meal struct { + Title string `json:"title"` + ID string `json:"id"` + Ingredients []Ingredient `json:"ingredients"` + Prices []Price `json:"prices"` + Location string `json:"location"` + Date string `json:"date"` // YYYY-MM-DD + Counter string `json:"counter"` + Tags string `json:"mealadds"` +} + +type Ingredient struct { + Label string `json:"label"` + Additionals []string `json:"additionals"` +} + +type Price struct { + Label string `json:"label"` + Price string `json:"price"` +} 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/result.go b/model/external/stwbremen/result.go new file mode 100644 index 0000000..df990c1 --- /dev/null +++ b/model/external/stwbremen/result.go @@ -0,0 +1,7 @@ +package stwbremen + +type Result[T any] struct { + Code uint16 `json:"code"` + Status string `json:"status"` + Result T `json:"result"` +} 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 new file mode 100644 index 0000000..68bc469 --- /dev/null +++ b/model/resources/meals.go @@ -0,0 +1,71 @@ +package resources + +import ( + "fmt" + "strconv" + "strings" + "time" + + "git.bissendorf.co/bissendorf/unifood/m/v2/model/external/stwbremen" + "git.bissendorf.co/bissendorf/unifood/m/v2/util" +) + +func MealFromDTO(meal stwbremen.Meal) (*Meal, error) { + date, err := time.Parse(time.DateOnly, meal.Date) + if err != nil { + return nil, fmt.Errorf("unable to parse meal date: %w", err) + } + + id, _ := strings.CutPrefix(meal.ID, "meals/") + + return &Meal{ + Title: meal.Title, + RestaurantID: meal.Location, + ID: id, + Date: date, + Tags: strings.Split(strings.Replace(meal.Tags, " ", "", -1), ","), + Counter: meal.Counter, + Prices: util.Map(meal.Prices, func(i *stwbremen.Price) (string, float32) { + p, err := strconv.ParseFloat(strings.Trim(i.Price, " "), 32) + if err != nil { + p = 0 + } + return i.Label, float32(p) + }), + Ingredients: util.Select(util.Transform(meal.Ingredients, func(i *stwbremen.Ingredient) ingredient { + return ingredient{ + Name: i.Label, + Additionals: i.Additionals, + } + }), func(i *ingredient) bool { return i.Name != "" }), + }, nil +} + +const ResourceMeal = "meal" + +type Meal struct { + Title string + ID string + RestaurantID string + Ingredients []ingredient + Prices map[string]float32 + Date time.Time + Counter string + Tags []string +} + +type ingredient struct { + Name string + Additionals []string +} + +func (d *Meal) Kind() string { return ResourceMeal } +func (d *Meal) ItemName() string { return d.ID } + +// Table output +func (d *Meal) ColumnNames() []string { + return []string{"Date", "Restaurant", "Title", "Counter", "Price"} +} +func (d *Meal) Columns() []any { + return []any{d.Date.Format(time.DateOnly), d.RestaurantID, d.Title, d.Counter, d.Prices["Studierende"]} +} diff --git a/model/resources/resources.go b/model/resources/resources.go new file mode 100644 index 0000000..f35040a --- /dev/null +++ b/model/resources/resources.go @@ -0,0 +1,20 @@ +package resources + +import "strings" + +const ResourceResource = "resource" + +type Resource struct { + ResourceName string + Aliases []string + Description string +} + +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 new file mode 100644 index 0000000..02dcbc8 --- /dev/null +++ b/util/slices.go @@ -0,0 +1,48 @@ +package util + +func Transform[Tin any, Tout any](s []Tin, transformFn func(i *Tin) Tout) (out []Tout) { + out = make([]Tout, 0) + + for _, item := range s { + out = append(out, transformFn(&item)) + } + + return +} + +func Select[T any](s []T, selectFn func(i *T) bool) (out []T) { + out = make([]T, 0) + + for _, item := range s { + if selectFn(&item) { + out = append(out, item) + } + } + + return +} + +func Map[T any, Tkey comparable, Tval any](s []T, transformFn func(i *T) (Tkey, Tval)) (out map[Tkey]Tval) { + out = make(map[Tkey]Tval) + for _, i := range s { + key, val := transformFn(&i) + out[key] = val + } + + 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 +} diff --git a/util/version.go b/util/version.go new file mode 100644 index 0000000..ef48573 --- /dev/null +++ b/util/version.go @@ -0,0 +1,3 @@ +package util + +const AppVersion = "v0.1"