7 Commits

Author SHA1 Message Date
cdd3216f46 feat: add completion for output flag 2025-07-24 23:37:42 +02:00
5205ddf094 feat: output available output formatters in help page 2025-07-24 21:20:02 +02:00
79c6a1ea8a feat: add name formatter 2025-07-24 21:19:40 +02:00
723aae48ce feat: version command (#6)
Version v0.1

Reviewed-on: #6
Co-authored-by: bdoerfchen <git@bissendorf.co>
Co-committed-by: bdoerfchen <git@bissendorf.co>
2025-07-20 23:13:11 +00:00
90a5125c66 feat: improve meals with better search and ids (#5)
Features:
- Meals now have an id instead of a title only
- It is now possible to get all meals without date or restaurant filter
- Sorting meals output

Reviewed-on: #5
Co-authored-by: bdoerfchen <git@bissendorf.co>
Co-committed-by: bdoerfchen <git@bissendorf.co>
2025-07-20 23:00:07 +00:00
4b7866da03 feat: add universities and restaurants (#4)
Features:
- Allow to search for individual resources by their name
- Add new resources and their handler: Universities, Restaurants
- Added new parameter to reverse output order

Reviewed-on: #4
Co-authored-by: bdoerfchen <git@bissendorf.co>
Co-committed-by: bdoerfchen <git@bissendorf.co>
2025-07-20 22:13:03 +00:00
e395b0b4ca fix: rename dish to meal (#3)
Reviewed-on: #3
Co-authored-by: bdoerfchen <git@bissendorf.co>
Co-committed-by: bdoerfchen <git@bissendorf.co>
2025-07-20 18:01:26 +00:00
25 changed files with 533 additions and 70 deletions

3
.vscode/launch.json vendored
View File

@ -11,8 +11,7 @@
"mode": "debug", "mode": "debug",
"program": "${workspaceFolder}/main.go", "program": "${workspaceFolder}/main.go",
"args": [ "args": [
"get", "dishes", "get", "meals",
"-o", "yaml",
] ]
} }
] ]

View File

@ -1,7 +1,8 @@
package cmd package cmd
type AppConfig struct { type AppConfig struct {
OutputVerbose bool OutputVerbose bool
OutputFormatter string OutputFormatter string
PrintConfig bool OutputOrderReverse bool
PrintConfig bool
} }

View File

@ -2,8 +2,11 @@ package cmd
import ( import (
"context" "context"
"slices"
"strings"
"git.bissendorf.co/bissendorf/unifood/m/v2/core/handler/meals" "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"
"git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" "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/core/services/stwhbclient"
@ -15,28 +18,56 @@ import (
var availableResources = []interfaces.ResourceCommand[any]{ var availableResources = []interfaces.ResourceCommand[any]{
{ {
Name: "resources", Name: "resources",
Aliases: []string{"resource", "r"}, Aliases: []string{"resource"},
Description: "A meta resource representing all other object kinds of this CLI", Description: "A meta resource representing all other object kinds of this CLI.",
Verbs: []interfaces.Verb{interfaces.VerbGet}, Verbs: []interfaces.Verb{interfaces.VerbGet},
Handler: &registeredResourcesHandler{}, Handler: &registeredResourcesHandler{},
}, },
{ {
Name: "meals", Name: "meals",
Aliases: []string{"meal", "m"}, 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}, Verbs: []interfaces.Verb{interfaces.VerbGet},
Handler: &meals.MealsHandler{ Handler: &meals.MealsHandler{
QueryClient: stwhbclient.New[[]stwbremen.Meal](), 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{} 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{ return &interfaces.ResourceList{
ItemKind: resources.ResourceResource, 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{ return &resources.Resource{
ResourceName: i.Name, ResourceName: i.Name,
Aliases: i.Aliases, Aliases: i.Aliases,

View File

@ -4,8 +4,12 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"os" "os"
"slices"
"strings"
"git.bissendorf.co/bissendorf/unifood/m/v2/core/output"
"git.bissendorf.co/bissendorf/unifood/m/v2/core/services/jlog" "git.bissendorf.co/bissendorf/unifood/m/v2/core/services/jlog"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -15,6 +19,7 @@ var rootCmd = &cobra.Command{
Short: "Unifood is a CLI for retrieving restaurant information", Short: "Unifood is a CLI for retrieving restaurant information",
Long: ``, Long: ``,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
_ = cmd.Help()
}, },
} }
@ -30,12 +35,23 @@ func Execute() {
func initRootCmd() { func initRootCmd() {
var appConfig AppConfig var appConfig AppConfig
// Compile list of available formatters
formatters := slices.AppendSeq([]string{}, maps.Keys(output.Formatters))
formattersList := fmt.Sprintf("(available: %s)", strings.Join(formatters, ", "))
// Add persistent flags
rootCmd.PersistentFlags().BoolVarP(&appConfig.OutputVerbose, "verbose", "v", false, "Enable verbose output") rootCmd.PersistentFlags().BoolVarP(&appConfig.OutputVerbose, "verbose", "v", false, "Enable verbose output")
rootCmd.PersistentFlags().StringVarP(&appConfig.OutputFormatter, "output", "o", "table", "Set output format") rootCmd.PersistentFlags().StringVarP(&appConfig.OutputFormatter, "output", "o", "table", "Set output format "+formattersList)
rootCmd.PersistentFlags().BoolVar(&appConfig.OutputOrderReverse, "reverse", false, "Reverses output item order")
rootCmd.PersistentFlags().BoolVar(&appConfig.PrintConfig, "print-config", false, "Enable printing the application config") rootCmd.PersistentFlags().BoolVar(&appConfig.PrintConfig, "print-config", false, "Enable printing the application config")
// Add flag completions
rootCmd.RegisterFlagCompletionFunc("output", cobra.FixedCompletions(formatters, cobra.ShellCompDirectiveNoFileComp))
// Create logger and add child commands
logger := jlog.New(slog.LevelDebug) logger := jlog.New(slog.LevelDebug)
ctx := jlog.ContextWith(context.Background(), logger) ctx := jlog.ContextWith(context.Background(), logger)
rootCmd.AddCommand(&versionCmd)
rootCmd.AddCommand(getVerbs(ctx, &appConfig)...) rootCmd.AddCommand(getVerbs(ctx, &appConfig)...)
} }

View File

@ -20,24 +20,27 @@ type VerbItem struct {
Name interfaces.Verb Name interfaces.Verb
Aliases []string Aliases []string
Description 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{ var verbs = []VerbItem{
{ {
Name: interfaces.VerbGet, Name: interfaces.VerbGet,
Description: "Retrieve a list of resources", 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) h, ok := handler.(interfaces.GetHandler)
if !ok { if !ok {
return fmt.Errorf("resource does not support GET") return fmt.Errorf("resource does not support GET")
} }
// Get items // Get items
items, err := h.Get(ctx, params) items, err := h.Get(ctx, name, params)
if err != nil { if err != nil {
return fmt.Errorf("retrieving item failed: %w", err) return fmt.Errorf("retrieving item failed: %w", err)
} }
if config.OutputOrderReverse {
slices.Reverse(items.Items)
}
formatterName := strings.ToLower(config.OutputFormatter) formatterName := strings.ToLower(config.OutputFormatter)
formatter, exists := output.Formatters[formatterName] formatter, exists := output.Formatters[formatterName]
@ -80,6 +83,7 @@ func getVerbs(ctx context.Context, config *AppConfig) (commands []*cobra.Command
Use: r.Name, Use: r.Name,
Aliases: r.Aliases, Aliases: r.Aliases,
Short: r.Description, Short: r.Description,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Configure log // Configure log
var logLevel = slog.LevelWarn var logLevel = slog.LevelWarn
@ -94,7 +98,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())) 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 { if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("%s %s failed", strings.ToUpper(string(v.Name)), r.Name), "error", err.Error()) logger.ErrorContext(ctx, fmt.Sprintf("%s %s failed", strings.ToUpper(string(v.Name)), r.Name), "error", err.Error())
os.Exit(1) os.Exit(1)
@ -112,6 +121,7 @@ func getVerbs(ctx context.Context, config *AppConfig) (commands []*cobra.Command
param.Description, param.Description,
) )
} }
verbCommand.AddCommand(resourceCommand) verbCommand.AddCommand(resourceCommand)
} }

16
cmd/version.go Normal file
View File

@ -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)
},
}

View File

@ -3,8 +3,11 @@ package meals
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"strings"
"time" "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"
"git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces/params" "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/external/stwbremen"
@ -19,70 +22,86 @@ type MealsHandler struct {
QueryClient interfaces.QueryClient[[]stwbremen.Meal] QueryClient interfaces.QueryClient[[]stwbremen.Meal]
} }
const ( func (h *MealsHandler) Get(ctx context.Context, name string, params params.Container) (*interfaces.ResourceList, error) {
paramDate = "date"
paramLocation = "location"
)
func (h *MealsHandler) Get(ctx context.Context, params params.Container) (*interfaces.ResourceList, error) {
// Read parameters // Read parameters
p, err := params.GetValue(paramDate) date, err := params.GetValue(handler.ParamDate)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse date parameter: %w", err) return nil, fmt.Errorf("unable to parse date parameter: %w", err)
} }
date := p.(time.Time)
location, err := params.GetValue(paramLocation) restaurant, err := params.GetValue(handler.ParamRestaurant)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to parse location parameter: %w", err) return nil, fmt.Errorf("unable to parse restaurant parameter: %w", err)
} }
// Build query // Build query
query := fmt.Sprintf( query := "page('meals').children.filterBy('printonly', 0)"
`page('meals').children.filterBy('location', '%s').filterBy('date', '%s').filterBy('printonly', 0)`, if date != "" {
location, parsed, err := time.Parse(time.DateOnly, date.(string))
date.Format(time.DateOnly), 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 // Run query
meals, err := h.QueryClient.Get(ctx, meals, err := h.QueryClient.Get(ctx,
query, query,
`{"title":true,"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"}`, `{"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, false,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("querying menu failed: %w", err) 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
return &interfaces.ResourceList{ return &interfaces.ResourceList{
ItemKind: resources.ResourceMeal, ItemKind: resources.ResourceMeal,
Items: util.Transform(*meals, func(i *stwbremen.Meal) interfaces.Resource { Items: util.Transform(items, func(i **resources.Meal) interfaces.Resource { return *i }),
d, err := resources.MealFromDTO(*i)
if err != nil {
return &resources.Meal{}
}
return d
}),
}, nil }, nil
} }
func (h *MealsHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration { func (h *MealsHandler) GetParametersForVerb(verb interfaces.Verb) []params.Registration {
return []params.Registration{ return []params.Registration{
{ {
Name: paramDate, Name: handler.ParamDate,
ShortHand: "d", ShortHand: "d",
Description: "Menu date", Description: "Meal date",
DefaultFunc: func() string { return time.Now().Format(time.DateOnly) }, DefaultFunc: func() string { return "" },
ParseFunc: func(value string) (any, error) { return time.Parse(time.DateOnly, value) }, ParseFunc: params.ParseString,
}, },
{ {
Name: paramLocation, Name: handler.ParamRestaurant,
ShortHand: "l", ShortHand: "r",
Description: "Define the restaurant", Description: "Select a restaurant",
DefaultFunc: func() string { return "330" }, DefaultFunc: func() string { return "" },
ParseFunc: params.ParseString, ParseFunc: params.ParseString,
}, },
} }

6
core/handler/params.go Normal file
View File

@ -0,0 +1,6 @@
package handler
const (
ParamDate string = "date"
ParamRestaurant string = "restaurant"
)

View File

@ -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{}
}

View File

@ -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
}

View File

@ -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{}
}

View File

@ -19,7 +19,7 @@ type ResourceHandler interface {
} }
type GetHandler 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 type Verb string

View File

@ -3,7 +3,7 @@ package interfaces
type Resource interface { type Resource interface {
Kind() string Kind() string
Name() string ItemName() string
} }
type ResourceList struct { type ResourceList struct {

View File

@ -12,4 +12,5 @@ var Formatters = map[string]interfaces.Formatter{
"csv": &TableFormatter{HideSummary: true, RenderFormat: tableFormatCSV}, "csv": &TableFormatter{HideSummary: true, RenderFormat: tableFormatCSV},
"html": &TableFormatter{HideSummary: true, RenderFormat: tableFormatHTML}, "html": &TableFormatter{HideSummary: true, RenderFormat: tableFormatHTML},
"markdown": &TableFormatter{HideSummary: true, RenderFormat: tableFormatMarkdown}, "markdown": &TableFormatter{HideSummary: true, RenderFormat: tableFormatMarkdown},
"name": &NameFormatter{},
} }

21
core/output/name.go Normal file
View File

@ -0,0 +1,21 @@
package output
import (
"bytes"
"io"
"git.bissendorf.co/bissendorf/unifood/m/v2/core/interfaces"
)
type NameFormatter struct{}
func (f *NameFormatter) Format(list *interfaces.ResourceList) (io.Reader, error) {
var buffer = make([]byte, 0, 1024)
outputBuffer := bytes.NewBuffer(buffer)
for _, item := range list.Items {
outputBuffer.WriteString(item.ItemName() + "\r\n")
}
return outputBuffer, nil
}

View File

@ -55,7 +55,7 @@ func (f *TableFormatter) Format(list *interfaces.ResourceList) (io.Reader, error
// Write rows // Write rows
for _, item := range list.Items { for _, item := range list.Items {
rowOutput := []any{item.Name()} rowOutput := []any{item.ItemName()}
itemFormatter, ok := item.(interfaces.TableOutput) itemFormatter, ok := item.(interfaces.TableOutput)
if ok { if ok {

View File

@ -2,6 +2,7 @@ package stwbremen
type Meal struct { type Meal struct {
Title string `json:"title"` Title string `json:"title"`
ID string `json:"id"`
Ingredients []Ingredient `json:"ingredients"` Ingredients []Ingredient `json:"ingredients"`
Prices []Price `json:"prices"` Prices []Price `json:"prices"`
Location string `json:"location"` Location string `json:"location"`

35
model/external/stwbremen/restaurant.go vendored Normal file
View File

@ -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"`
}

53
model/external/stwbremen/types.go vendored Normal file
View File

@ -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))
}

View File

@ -16,12 +16,15 @@ func MealFromDTO(meal stwbremen.Meal) (*Meal, error) {
return nil, fmt.Errorf("unable to parse meal date: %w", err) return nil, fmt.Errorf("unable to parse meal date: %w", err)
} }
id, _ := strings.CutPrefix(meal.ID, "meals/")
return &Meal{ return &Meal{
Title: meal.Title, Title: meal.Title,
Location: meal.Location, RestaurantID: meal.Location,
Date: date, ID: id,
Tags: strings.Split(strings.Replace(meal.Tags, " ", "", -1), ","), Date: date,
Counter: meal.Counter, Tags: strings.Split(strings.Replace(meal.Tags, " ", "", -1), ","),
Counter: meal.Counter,
Prices: util.Map(meal.Prices, func(i *stwbremen.Price) (string, float32) { Prices: util.Map(meal.Prices, func(i *stwbremen.Price) (string, float32) {
p, err := strconv.ParseFloat(strings.Trim(i.Price, " "), 32) p, err := strconv.ParseFloat(strings.Trim(i.Price, " "), 32)
if err != nil { if err != nil {
@ -41,13 +44,14 @@ func MealFromDTO(meal stwbremen.Meal) (*Meal, error) {
const ResourceMeal = "meal" const ResourceMeal = "meal"
type Meal struct { type Meal struct {
Title string Title string
Location string ID string
Ingredients []ingredient RestaurantID string
Prices map[string]float32 Ingredients []ingredient
Date time.Time Prices map[string]float32
Counter string Date time.Time
Tags []string Counter string
Tags []string
} }
type ingredient struct { type ingredient struct {
@ -55,10 +59,13 @@ type ingredient struct {
Additionals []string Additionals []string
} }
func (d *Meal) Kind() string { return ResourceMeal } func (d *Meal) Kind() string { return ResourceMeal }
func (d *Meal) Name() string { return d.Title } func (d *Meal) ItemName() string { return d.ID }
func (d *Meal) ColumnNames() []string { return []string{"Location", "Date", "Counter", "Price"} } // Table output
func (d *Meal) Columns() []any { func (d *Meal) ColumnNames() []string {
return []any{d.Location, d.Date.Format(time.DateOnly), d.Counter, d.Prices["Studierende"]} 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"]}
} }

View File

@ -10,9 +10,10 @@ type Resource struct {
Description string Description string
} }
func (r *Resource) Kind() string { return ResourceResource } func (r *Resource) Kind() string { return ResourceResource }
func (r *Resource) Name() string { return r.ResourceName } func (r *Resource) ItemName() string { return r.ResourceName }
// Table output
func (r *Resource) ColumnNames() []string { return []string{"Aliases", "Description"} } func (r *Resource) ColumnNames() []string { return []string{"Aliases", "Description"} }
func (r *Resource) Columns() []any { func (r *Resource) Columns() []any {
return []any{strings.Join(r.Aliases, ", "), r.Description} return []any{strings.Join(r.Aliases, ", "), r.Description}

View File

@ -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}
}

View File

@ -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")}
}

View File

@ -31,3 +31,18 @@ func Map[T any, Tkey comparable, Tval any](s []T, transformFn func(i *T) (Tkey,
return 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
}

3
util/version.go Normal file
View File

@ -0,0 +1,3 @@
package util
const AppVersion = "v0.1"