diff --git a/cmd/create.go b/cmd/create.go index a5e613c..3886387 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -1,6 +1,5 @@ /* Copyright © 2024 NAME HERE - */ package cmd @@ -32,7 +31,9 @@ func init() { // Cobra supports Persistent Flags which will work for this command // and all subcommands, e.g.: - // createCmd.PersistentFlags().String("foo", "", "A help for foo") + + createCmd.PersistentFlags().StringP("input", "i", "", "Specify an input file") + createCmd.PersistentFlags().Bool("stdin", false, "Consume a string from stdin") // Cobra supports local flags which will only run when this command // is called directly, e.g.: diff --git a/cmd/filter.go b/cmd/filter.go index 43c1d1f..71578f5 100644 --- a/cmd/filter.go +++ b/cmd/filter.go @@ -9,7 +9,13 @@ import ( "git.andreafazzi.eu/andrea/probo/cmd/filter" + "bufio" + "io" + "strings" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "github.com/spf13/cobra" ) @@ -32,7 +38,7 @@ func init() { // Cobra supports local flags which will only run when this command // is called directly, e.g.: - // filterCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + filterCmd.Flags().StringP("type", "t", "participants", "Select the type of filter (participants or quizzes)") } func createFilter(cmd *cobra.Command, args []string) { @@ -45,9 +51,51 @@ func createFilter(cmd *cobra.Command, args []string) { defer f.Close() } - if _, err := tea.NewProgram(filter.New()).Run(); err != nil { + var b strings.Builder + + stat, err := os.Stdin.Stat() + if err != nil { + panic(err) + } + + if stat.Mode()&os.ModeNamedPipe != 0 || stat.Size() != 0 { + reader := bufio.NewReader(os.Stdin) + for { + r, _, err := reader.ReadRune() + if err != nil && err == io.EOF { + break + } + _, err = b.WriteRune(r) + if err != nil { + fmt.Println("Error getting input:", err) + os.Exit(1) + } + } + } + + path, err := cmd.Flags().GetString("input") + if err != nil { + panic(err) + } + + filterType, err := cmd.Flags().GetString("type") + if err != nil { + panic(err) + } + + lipgloss.SetColorProfile(termenv.TrueColor) + + model, err := tea.NewProgram( + filter.New(path, filterType, strings.TrimSpace(b.String())), + tea.WithOutput(os.Stderr), + ).Run() + if err != nil { fmt.Println("Error running program:", err) os.Exit(1) } + result := model.(*filter.FilterModel) + if result.FilteredJson != "" { + fmt.Fprintf(os.Stdout, result.FilteredJson) + } } diff --git a/cmd/filter/filter.go b/cmd/filter/filter.go index ba6e77f..5760eb4 100644 --- a/cmd/filter/filter.go +++ b/cmd/filter/filter.go @@ -3,6 +3,8 @@ package filter import ( "encoding/json" "fmt" + "log" + "os" "strings" "git.andreafazzi.eu/andrea/probo/pkg/store/file" @@ -10,6 +12,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" + teatable "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/itchyny/gojq" @@ -28,7 +31,7 @@ type storeLoadedMsg struct { } type resultMsg struct { - result string + result []any } type errorMsg struct { @@ -39,6 +42,7 @@ type FilterModel struct { // UI textInput *textinput.Model viewport *viewport.Model + table *table.Model group *group.Model help help.Model statusBar *statusbar.Model @@ -51,18 +55,23 @@ type FilterModel struct { bindings *keyBindings // file store - store []any + store []any + result []any - // jq - lastQuery string + // json + lastQuery string + FilteredJson string + InputJson string state int + + filterType string } type keyBindings struct { group *group.Model - quit key.Binding + quit, enter key.Binding } func (k *keyBindings) ShortHelp() []key.Binding { @@ -101,26 +110,48 @@ func newBindings(g *group.Model) *keyBindings { quit: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "Quit app"), ), + enter: key.NewBinding( + key.WithKeys("enter"), key.WithHelp("enter", "Quit app and return the results"), + ), } } -func New() *FilterModel { +func New(path string, filterType string, stdin string) *FilterModel { textInput := textinput.New( textinput.WithPlaceholder("Write your jq filter here..."), ) - table := table.New() + var tabl *table.Model + + if filterType == "participants" { + tabl = table.New(table.WithRelWidths(10, 40, 40, 10)) + tabl.Model.SetColumns([]teatable.Column{ + {Title: "ID", Width: 20}, + {Title: "Lastname", Width: 10}, + {Title: "Firstname", Width: 10}, + {Title: "Token", Width: 10}, + }) + } else if filterType == "quizzes" { + tabl = table.New(table.WithRelWidths(20, 80)) + tabl.Model.SetColumns([]teatable.Column{ + {Title: "ID", Width: 20}, + {Title: "Question", Width: 10}, + }) + } else { + panic("Unknown filter type!") + } + viewport := viewport.New() help := help.New() group := group.New( - group.WithItems(textInput, viewport, table), + group.WithItems(textInput, viewport, tabl), group.WithLayout( layout.New( layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 0, 1, 0)}), layout.WithItem(textInput), - layout.WithItem(tiled.New(viewport, table)), + layout.WithItem(tiled.New(viewport, tabl)), ), ), ) @@ -139,7 +170,7 @@ func New() *FilterModel { lipgloss.NewStyle(). Bold(true). Border(lipgloss.NormalBorder(), false, false, true, false). - Render("Participants filter 👫"), + Render(filterTypeFormats[filterType]), ), ) @@ -150,15 +181,26 @@ func New() *FilterModel { layout.WithItem(statusBar), ) + if path != "" { + jq, err := os.ReadFile(path) + if err != nil { + panic(err) + } + textInput.SetValue(strings.TrimSpace(string(jq))) + } + return &FilterModel{ - textInput: textInput, - viewport: viewport, - group: group, - statusBar: statusBar, - spinner: s, - document: document, - bindings: bindings, - help: help, + textInput: textInput, + viewport: viewport, + table: tabl, + group: group, + statusBar: statusBar, + spinner: s, + document: document, + bindings: bindings, + help: help, + filterType: filterType, + InputJson: stdin, } } @@ -184,14 +226,25 @@ func (m *FilterModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch { case key.Matches(msg, m.bindings.quit): + m.FilteredJson = "" return m, tea.Quit + case key.Matches(msg, m.bindings.enter): + m.FilteredJson = strings.Join([]string{ + fmt.Sprintf("{\"%s\": %s,", m.filterType, m.FilteredJson), + strings.TrimLeft(m.InputJson, "{")}, "\n") + return m, tea.Quit } case storeLoadedMsg: - m.handleStoreLoaded(msg) + cmds = append(cmds, m.handleStoreLoaded(msg)) case resultMsg: - m.handleFiltered(msg) + cmds = append(cmds, m.handleFiltered(msg)) + m.state = FilterState + + case errorMsg: + m.handleError(msg) + m.state = ErrorState } cmds = m.handleState(msg, cmds) @@ -203,21 +256,76 @@ func (m *FilterModel) View() string { return m.document.View() } -func (m *FilterModel) handleStoreLoaded(msg tea.Msg) { - storeMsg := msg.(storeLoadedMsg) - m.store = storeMsg.store - m.state = FilterState - - jsonStore, err := m.storeToJson() - if err != nil { - panic(err) - } - - m.viewport.SetContent(jsonStore) +func (m *FilterModel) showErrorOnStatusBar(err error) { + m.statusBar.SetContent( + stateFormats[ErrorState][0], + fmt.Sprintf(stateFormats[ErrorState][1], err), + stateFormats[ErrorState][2], + ) } -func (m *FilterModel) handleFiltered(msg tea.Msg) { - m.viewport.SetContent(sanitize(msg.(resultMsg).result)) +func (m *FilterModel) handleError(msg tea.Msg) { + err := msg.(errorMsg) + + m.statusBar.SetContent( + stateFormats[ErrorState][0], + fmt.Sprintf(stateFormats[ErrorState][1], err.error), + stateFormats[ErrorState][2], + ) +} + +func (m *FilterModel) handleStoreLoaded(msg tea.Msg) tea.Cmd { + return func() tea.Msg { + storeMsg := msg.(storeLoadedMsg) + m.store = storeMsg.store + m.state = FilterState + + coloredJson, err := toColoredJson(m.store) + if err != nil { + return errorMsg{err} + } + + json, err := toJson(m.store) + if err != nil { + return errorMsg{err} + } + + m.viewport.SetContent(coloredJson) + + err = m.updateTableContent(json) + if err != nil { + return errorMsg{err} + } + + return nil + } +} + +func (m *FilterModel) handleFiltered(msg tea.Msg) tea.Cmd { + return func() tea.Msg { + m.result = msg.(resultMsg).result + + coloredJson, err := toColoredJson(m.result) + if err != nil { + return errorMsg{err} + } + + json, err := toJson(m.result) + if err != nil { + return errorMsg{err} + } + + m.FilteredJson = json + + m.viewport.SetContent(coloredJson) + + err = m.updateTableContent(json) + if err != nil { + return errorMsg{err} + } + + return nil + } } func (m *FilterModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd { @@ -229,15 +337,59 @@ func (m *FilterModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd { cmds = append(cmds, cmd, m.query(m.textInput.Value())) - m.statusBar.SetContent(formats[FilterState]...) + if m.state != ErrorState { + m.statusBar.SetContent(stateFormats[FilterState]...) + } return cmds } +func (m *FilterModel) updateTableContent(jsonData string) error { + elements := make([]map[string]string, 0) + columns := make([]teatable.Column, 0) + rows := make([]teatable.Row, 0) + + err := json.Unmarshal([]byte(jsonData), &elements) + if err != nil { + return err + } + + if len(elements) > 0 { + for title := range elements[0] { + columns = append(columns, teatable.Column{Title: title, Width: 5}) + } + } + + for _, el := range elements { + cells := make([]string, 0) + for _, cell := range el { + cells = append(cells, cell) + } + rows = append(rows, teatable.Row(cells)) + } + + percentage := 100 / len(columns) + percs := make([]int, 0) + + for i := 0; i < len(columns); i++ { + percs = append(percs, percentage) + } + + log.Println(percs) + + m.table.Model.SetColumns(columns) + m.table.Model.SetRows(rows) + + m.table.SetRelWidths(percs...) + + return nil + +} + func (m *FilterModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd { m.spinner, cmd = m.spinner.Update(msg) - m.statusBar.SetContent(fmt.Sprintf(formats[m.state][0], m.spinner.View()), formats[m.state][1], formats[m.state][2]) + m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2]) cmds = append(cmds, cmd) @@ -246,18 +398,36 @@ func (m *FilterModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) [] func (m *FilterModel) loadStore() tea.Cmd { return func() tea.Msg { - pStore, err := file.NewDefaultParticipantFileStore() - if err != nil { - return errorMsg{err} - } + var jsonStore []byte - jsonStore, err := pStore.Storer.Json() - if err != nil { - return errorMsg{err} + if m.filterType == "participants" { + pStore, err := file.NewDefaultParticipantFileStore() + if err != nil { + return errorMsg{err} + } + + jsonStore, err = pStore.Storer.Json() + if err != nil { + return errorMsg{err} + } + } else if m.filterType == "quizzes" { + qStore, err := file.NewDefaultQuizFileStore() + if err != nil { + return errorMsg{err} + } + + jsonStore, err = qStore.Storer.Json() + if err != nil { + return errorMsg{err} + } + + } else { + panic("Unknown filter type!") } v := make([]any, 0) - err = json.Unmarshal(jsonStore, &v) + + err := json.Unmarshal(jsonStore, &v) if err != nil { return errorMsg{err} } @@ -280,7 +450,7 @@ func (m *FilterModel) query(input string) tea.Cmd { query, err := gojq.Parse(input) if err != nil { - return errorMsg{err} + return errorMsg{fmt.Errorf("jq query parse error: %v", err)} } var result []string @@ -291,13 +461,12 @@ func (m *FilterModel) query(input string) tea.Cmd { if !ok { break } + if err, ok := v.(error); ok { - return errorMsg{err} + return errorMsg{fmt.Errorf("jq query run error: %v", err)} } - f := colorjson.NewFormatter() - f.Indent = 2 - b, err := f.Marshal(v) + b, err := json.MarshalIndent(v, "", " ") if err != nil { return errorMsg{err} } @@ -305,20 +474,34 @@ func (m *FilterModel) query(input string) tea.Cmd { result = append(result, string(b)) } - return resultMsg{strings.Join(result, "\n")} + v := make([]any, 0) + + err = json.Unmarshal([]byte(strings.Join(result, "\n")), &v) + if err != nil { + return errorMsg{err} + } + + return resultMsg{v} } } -func (m *FilterModel) storeToJson() (string, error) { +func toColoredJson(data []any) (string, error) { f := colorjson.NewFormatter() f.Indent = 2 - result, err := f.Marshal(m.store) + result, err := f.Marshal(data) if err != nil { return "", err } return sanitize(string(result)), nil } +func toJson(data []any) (string, error) { + result, err := json.Marshal(data) + if err != nil { + return "", err + } + return string(result), nil +} func sanitize(text string) string { // FIXME: The use of a standard '-' character causes rendering // issues within the viewport. Further investigation is diff --git a/cmd/filter/formats.go b/cmd/filter/format.go similarity index 51% rename from cmd/filter/formats.go rename to cmd/filter/format.go index bff9308..5f270d1 100644 --- a/cmd/filter/formats.go +++ b/cmd/filter/format.go @@ -1,8 +1,13 @@ package filter var ( - formats = map[int][]string{ + stateFormats = map[int][]string{ FilterState: []string{"FILTER 📖", "Write your jq command in the input box to start filtering. Press enter to return the result.", "STORE 🟢"}, LoadingStoreState: []string{"LOAD %s", "Loading the store...", "STORE 🔴"}, + ErrorState: []string{"ERROR 📖", "%v", "STORE 🟢"}, + } + filterTypeFormats = map[string]string{ + "participants": "👫👫 Participants filter 👫👫", + "quizzes": "❓❓ Quizzes filter ❓❓", } ) diff --git a/cmd/filter/states.go b/cmd/filter/state.go similarity index 84% rename from cmd/filter/states.go rename to cmd/filter/state.go index 3d3d8e3..bb21280 100644 --- a/cmd/filter/states.go +++ b/cmd/filter/state.go @@ -3,4 +3,5 @@ package filter const ( LoadingStoreState = iota FilterState + ErrorState )