Compare commits

...

12 commits

78 changed files with 2638 additions and 5491 deletions

View file

@ -1,3 +1,3 @@
# Probo Collector
A backend to collect quizzes in a RESTful way.
A backend to collect quizzes in a RESTful way.

8
cmd/common.go Normal file
View file

@ -0,0 +1,8 @@
package cmd
var logo = ` ____ _
| _ \ _ __ ___ | |__ ___
| |_) | '__/ _ \| '_ \ / _ \
| __/| | | (_) | |_) | (_) |
|_| |_| \___/|_.__/ \___/
`

View file

@ -1,41 +0,0 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// createCmd represents the create command
var createCmd = &cobra.Command{
Use: "create",
Short: "A brief description of your command",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("create called")
},
}
func init() {
rootCmd.AddCommand(createCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
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.:
// createCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View file

@ -8,84 +8,44 @@ import (
"os"
"git.andreafazzi.eu/andrea/probo/cmd/filter"
"bufio"
"io"
"strings"
"git.andreafazzi.eu/andrea/probo/cmd/util"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
// filterCmd represents the filter command
var filterCmd = &cobra.Command{
Use: "filter",
Short: "Create a new filter",
Long: "Create a new filter to select quizzes and participants",
Run: createFilter,
Use: "filter {participants,quizzes,responses}",
Short: "Filter the given store",
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/filter/*.tmpl"),
Run: runFilter,
}
func init() {
createCmd.AddCommand(filterCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// filterCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
filterCmd.Flags().StringP("type", "t", "participants", "Select the type of filter (participants or quizzes)")
rootCmd.AddCommand(filterCmd)
filterCmd.PersistentFlags().StringP("input", "i", "", "Specify an input file")
}
func createFilter(cmd *cobra.Command, args []string) {
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
defer f.Close()
}
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)
}
}
}
func runFilter(cmd *cobra.Command, args []string) {
var storeType string
path, err := cmd.Flags().GetString("input")
if err != nil {
panic(err)
}
filterType, err := cmd.Flags().GetString("type")
if err != nil {
panic(err)
f := util.LogToFile()
if f != nil {
defer f.Close()
}
lipgloss.SetColorProfile(termenv.TrueColor)
storeType = args[0]
model, err := tea.NewProgram(
filter.New(path, filterType, strings.TrimSpace(b.String())),
filter.New(path, storeType, util.ReadStdin()),
tea.WithOutput(os.Stderr),
).Run()
if err != nil {
@ -94,6 +54,7 @@ func createFilter(cmd *cobra.Command, args []string) {
}
result := model.(*filter.FilterModel)
if result.Result != "" {
fmt.Fprintf(os.Stdout, result.Result)
}

View file

@ -9,38 +9,27 @@ import (
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/alecthomas/chroma/quick"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/itchyny/gojq"
foam "github.com/remogatto/sugarfoam"
"github.com/remogatto/sugarfoam/components/group"
"github.com/remogatto/sugarfoam/components/header"
"github.com/remogatto/sugarfoam/components/help"
"github.com/remogatto/sugarfoam/components/statusbar"
"github.com/remogatto/sugarfoam/components/textinput"
"github.com/remogatto/sugarfoam/components/viewport"
"github.com/remogatto/sugarfoam/layout"
)
type storeLoadedMsg struct {
store []any
}
type resultMsg struct {
result []any
}
type errorMsg struct {
error error
}
type FilterModel struct {
// UI
textInput *textinput.Model
viewport *viewport.Model
group *group.Model
help help.Model
help *help.Model
statusBar *statusbar.Model
spinner spinner.Model
@ -68,54 +57,6 @@ type FilterModel struct {
filterType string
}
type keyBindings struct {
group *group.Model
quit, enter key.Binding
}
func (k *keyBindings) ShortHelp() []key.Binding {
keys := make([]key.Binding, 0)
current := k.group.Current()
switch item := current.(type) {
case *viewport.Model:
keys = append(
keys,
item.KeyMap.Up,
item.KeyMap.Down,
)
}
keys = append(
keys,
k.quit,
)
return keys
}
func (k keyBindings) FullHelp() [][]key.Binding {
return [][]key.Binding{
{
k.quit,
},
}
}
func newBindings(g *group.Model) *keyBindings {
return &keyBindings{
group: g,
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(path string, filterType string, stdin string) *FilterModel {
textInput := textinput.New(
textinput.WithPlaceholder("Write your jq filter here..."),
@ -123,8 +64,6 @@ func New(path string, filterType string, stdin string) *FilterModel {
viewport := viewport.New()
help := help.New()
group := group.New(
group.WithItems(textInput, viewport),
group.WithLayout(
@ -154,10 +93,15 @@ func New(path string, filterType string, stdin string) *FilterModel {
),
)
help := help.New(
bindings,
help.WithStyles(&foam.Styles{NoBorder: lipgloss.NewStyle().Padding(1, 1)}))
document := layout.New(
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
layout.WithItem(header),
layout.WithItem(group),
layout.WithItem(help),
layout.WithItem(statusBar),
)
@ -232,23 +176,7 @@ func (m *FilterModel) marshalJSON() {
return
}
if m.InputJson != "" {
result := make([]interface{}, 2)
err := json.Unmarshal([]byte(m.InputJson), &result[0])
if err != nil {
panic(err)
}
filtered := fmt.Sprintf("{\"%s\": %s}", m.filterType, m.FilteredJson)
err = json.Unmarshal([]byte(filtered), &result[1])
if err != nil {
panic(err)
}
resultJson, err := json.Marshal(result)
if err != nil {
panic(err)
}
m.Result = string(resultJson)
m.Result = fmt.Sprintf("{%s, \"%s\": %s}", strings.Trim(m.InputJson, "{}"), m.filterType, m.FilteredJson)
} else {
var result interface{}
@ -362,28 +290,39 @@ func (m *FilterModel) loadStore() tea.Cmd {
return func() tea.Msg {
var jsonStore []byte
if m.filterType == "participants" {
switch m.filterType {
case "participants":
pStore, err := file.NewDefaultParticipantFileStore()
if err != nil {
return errorMsg{err}
panic(err)
}
jsonStore, err = pStore.Storer.Json()
if err != nil {
return errorMsg{err}
panic(err)
}
} else if m.filterType == "quizzes" {
case "quizzes":
qStore, err := file.NewDefaultQuizFileStore()
if err != nil {
return errorMsg{err}
panic(err)
}
jsonStore, err = qStore.Storer.Json()
if err != nil {
return errorMsg{err}
panic(err)
}
} else {
case "responses":
qStore, err := file.NewDefaultResponseFileStore()
if err != nil {
panic(err)
}
jsonStore, err = qStore.Storer.Json()
if err != nil {
panic(err)
}
default:
panic("Unknown filter type!")
}

View file

@ -7,7 +7,8 @@ var (
ErrorState: []string{"ERROR 📖", "%v", "STORE 🟢"},
}
filterTypeFormats = map[string]string{
"participants": "👫👫 Participants filter 👫👫",
"quizzes": "❓❓ Quizzes filter ❓❓",
"participants": "👫 Participants filter 👫",
"quizzes": "❓ Quizzes filter ❓",
"responses": "📝 Responses filter 📝",
}
)

55
cmd/filter/keymap.go Normal file
View file

@ -0,0 +1,55 @@
package filter
import (
"github.com/charmbracelet/bubbles/key"
"github.com/remogatto/sugarfoam/components/group"
"github.com/remogatto/sugarfoam/components/viewport"
)
type keyBindings struct {
group *group.Model
quit, enter key.Binding
}
func (k *keyBindings) ShortHelp() []key.Binding {
keys := make([]key.Binding, 0)
current := k.group.Current()
switch item := current.(type) {
case *viewport.Model:
keys = append(
keys,
item.KeyMap.Up,
item.KeyMap.Down,
)
}
keys = append(
keys,
k.quit,
)
return keys
}
func (k keyBindings) FullHelp() [][]key.Binding {
return [][]key.Binding{
{
k.quit,
},
}
}
func newBindings(g *group.Model) *keyBindings {
return &keyBindings{
group: g,
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"),
),
}
}

13
cmd/filter/message.go Normal file
View file

@ -0,0 +1,13 @@
package filter
type storeLoadedMsg struct {
store []any
}
type resultMsg struct {
result []any
}
type errorMsg struct {
error error
}

38
cmd/init.go Normal file
View file

@ -0,0 +1,38 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"git.andreafazzi.eu/andrea/probo/cmd/util"
"git.andreafazzi.eu/andrea/probo/embed"
"github.com/spf13/cobra"
)
// initCmd represents the init command
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize a working directory",
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/init/*.tmpl"),
Run: runInit,
}
func init() {
rootCmd.AddCommand(initCmd)
}
func runInit(cmd *cobra.Command, args []string) {
err := embed.CopyToWorkingDirectory(embed.Data)
if err != nil {
panic(err)
}
err = embed.CopyToWorkingDirectory(embed.Templates)
if err != nil {
panic(err)
}
err = embed.CopyToWorkingDirectory(embed.Public)
if err != nil {
panic(err)
}
}

View file

@ -1,50 +0,0 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/spf13/cobra"
)
// participantsCmd represents the participants command
var participantsCmd = &cobra.Command{
Use: "participants",
Short: "A brief description of your command",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: func(cmd *cobra.Command, args []string) {
importCSV(cmd, args)
},
}
func init() {
importCmd.AddCommand(participantsCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// participantsCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// participantsCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
func importCSV(cmd *cobra.Command, args []string) {
pStore, err := file.NewDefaultParticipantFileStore()
if err != nil {
panic(err)
}
_, err = pStore.ImportCSV(args[0])
if err != nil {
panic(err)
}
}

59
cmd/rank.go Normal file
View file

@ -0,0 +1,59 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"os"
"git.andreafazzi.eu/andrea/probo/cmd/rank"
"git.andreafazzi.eu/andrea/probo/cmd/util"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
// rankCmd represents the rank command
var rankCmd = &cobra.Command{
Use: "rank",
Short: "Show a ranking from the given responses.",
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/rank/*.tmpl"),
Run: runRank,
}
func runRank(cmd *cobra.Command, args []string) {
path, err := cmd.Flags().GetString("input")
if err != nil {
panic(err)
}
f := util.LogToFile()
if f != nil {
defer f.Close()
}
lipgloss.SetColorProfile(termenv.TrueColor)
model, err := tea.NewProgram(
rank.New(path, util.ReadStdin()),
tea.WithOutput(os.Stderr),
).Run()
if err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
result := model.(*rank.RankModel)
if result.Result != "" {
fmt.Fprintf(os.Stdout, result.Result)
}
}
func init() {
rootCmd.AddCommand(rankCmd)
rootCmd.PersistentFlags().StringP("input", "i", "", "Specify an input file")
}

9
cmd/rank/format.go Normal file
View file

@ -0,0 +1,9 @@
package rank
var (
stateFormats = map[int][]string{
BrowseState: []string{"BROWSE 📖", "Total scores: %d", "🟢"},
ExecutingScriptState: []string{"EXE %s", "Executing script...", "🔴"},
ErrorState: []string{"ERROR 📖", "%v", "🔴"},
}
)

62
cmd/rank/keymap.go Normal file
View file

@ -0,0 +1,62 @@
package rank
import (
"github.com/charmbracelet/bubbles/key"
"github.com/remogatto/sugarfoam/components/group"
"github.com/remogatto/sugarfoam/components/table"
"github.com/remogatto/sugarfoam/components/viewport"
)
type keyBindings struct {
group *group.Model
quit, enter key.Binding
}
func (k *keyBindings) ShortHelp() []key.Binding {
keys := make([]key.Binding, 0)
current := k.group.Current()
switch item := current.(type) {
case *table.Model:
keys = append(
keys,
item.KeyMap.LineUp,
item.KeyMap.LineDown,
)
case *viewport.Model:
keys = append(
keys,
item.KeyMap.Up,
item.KeyMap.Down,
)
}
keys = append(
keys,
k.group.KeyMap.FocusNext,
k.group.KeyMap.FocusPrev,
k.quit,
)
return keys
}
func (k keyBindings) FullHelp() [][]key.Binding {
return [][]key.Binding{
{
k.quit,
},
}
}
func newBindings(g *group.Model) *keyBindings {
return &keyBindings{
group: g,
quit: key.NewBinding(
key.WithKeys("esc"), key.WithHelp("esc", "quit app"),
),
}
}

19
cmd/rank/message.go Normal file
View file

@ -0,0 +1,19 @@
package rank
import "git.andreafazzi.eu/andrea/probo/pkg/store/file"
type storeLoadedMsg struct {
store *file.SessionFileStore
}
type resultMsg struct {
result []any
}
type errorMsg struct {
error error
}
type scriptExecutedMsg struct {
result string
}

448
cmd/rank/rank.go Normal file
View file

@ -0,0 +1,448 @@
package rank
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"strconv"
"text/template"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
btTable "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/d5/tengo/v2"
"github.com/d5/tengo/v2/stdlib"
foam "github.com/remogatto/sugarfoam"
"github.com/remogatto/sugarfoam/components/group"
"github.com/remogatto/sugarfoam/components/header"
"github.com/remogatto/sugarfoam/components/help"
"github.com/remogatto/sugarfoam/components/statusbar"
"github.com/remogatto/sugarfoam/components/table"
"github.com/remogatto/sugarfoam/components/viewport"
"github.com/remogatto/sugarfoam/layout"
"github.com/remogatto/sugarfoam/layout/tiled"
)
var responseTmpl = `{{range $answer := .Answers}}
{{$answer.Quiz|toMarkdown $.Width}}
R: {{$answer|toLipgloss}}
{{end}}
`
type ParticipantScore struct {
Participant *models.Participant `json:"participant"`
Response *models.Response `json:"response"`
Score int `json:"score"`
}
type Rank struct {
Scores []*ParticipantScore `json:"scores"`
}
type RankModel struct {
// UI
viewport *viewport.Model
table *table.Model
group *group.Model
help *help.Model
statusBar *statusbar.Model
spinner spinner.Model
// Layout
document *layout.Layout
// Key bindings
bindings *keyBindings
// json
InputJson string
Result string
// session
rank *Rank
// response
responseTmpl *template.Template
// filter file
scriptFilePath string
// markdown
mdRenderer *glamour.TermRenderer
state int
}
func New(path string, stdin string) *RankModel {
viewport := viewport.New()
table := table.New(table.WithRelWidths(10, 20, 30, 30, 10))
table.Model.SetColumns([]btTable.Column{
{Title: "Pos", Width: 5},
{Title: "Token", Width: 10},
{Title: "Lastname", Width: 40},
{Title: "Firstname", Width: 40},
{Title: "Score", Width: 5},
})
group := group.New(
group.WithItems(table, viewport),
group.WithLayout(
layout.New(
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 1)}),
layout.WithItem(tiled.New(table, viewport)),
),
),
)
bindings := newBindings(group)
statusBar := statusbar.New(bindings)
s := spinner.New(
spinner.WithStyle(
lipgloss.NewStyle().Foreground(lipgloss.Color("265"))),
)
s.Spinner = spinner.Dot
header := header.New(
header.WithContent(
lipgloss.NewStyle().
Bold(true).
Border(lipgloss.NormalBorder(), false, false, true, false).
Render("😎 Rank 😎"),
),
)
help := help.New(
bindings,
help.WithStyles(&foam.Styles{NoBorder: lipgloss.NewStyle().Padding(1, 1)}))
document := layout.New(
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
layout.WithItem(header),
layout.WithItem(group),
layout.WithItem(help),
layout.WithItem(statusBar),
)
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dracula"),
glamour.WithWordWrap(80),
)
if err != nil {
panic(err)
}
tmpl, err := template.New("response").
Funcs(template.FuncMap{
"toMarkdown": func(width int, quiz *models.Quiz) string {
md, err := models.QuizToMarkdown(quiz)
if err != nil {
panic(err)
}
result, err := renderer.Render(md)
if err != nil {
panic(err)
}
return result
},
"toLipgloss": func(answer *models.ParticipantAnswer) string {
color := "#ff0000"
if answer.Correct {
color = "#00ff00"
}
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(color)).Render(answer.Answer.Text)
},
}).
Parse(responseTmpl)
if err != nil {
panic(err)
}
return &RankModel{
table: table,
viewport: viewport,
group: group,
statusBar: statusBar,
spinner: s,
document: document,
responseTmpl: tmpl,
bindings: bindings,
help: help,
scriptFilePath: path,
InputJson: stdin,
mdRenderer: renderer,
}
}
func (m *RankModel) Init() tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, m.group.Init(), m.executeScript(), m.spinner.Tick)
m.group.Focus()
return tea.Batch(cmds...)
}
func (m *RankModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.handleWindowSize(msg)
case tea.KeyMsg:
switch {
case key.Matches(msg, m.bindings.quit):
cmds = append(cmds, tea.Quit)
}
case scriptExecutedMsg:
m.handleScriptExecuted(msg)
case errorMsg:
m.handleError(msg)
m.state = ErrorState
}
cmds = m.handleState(msg, cmds)
return m, tea.Batch(cmds...)
}
func (m *RankModel) View() string {
return m.document.View()
}
func (m *RankModel) executeScript() tea.Cmd {
return func() tea.Msg {
if m.scriptFilePath == "" {
return nil
}
rankJson, err := json.Marshal(Rank{Scores: make([]*ParticipantScore, 0)})
if err != nil {
panic(err)
}
script, err := os.ReadFile(m.scriptFilePath)
if err != nil {
panic(err)
}
s := tengo.NewScript(script)
s.SetImports(stdlib.GetModuleMap("fmt", "json", "rand", "times"))
_ = s.Add("input", m.InputJson)
_ = s.Add("output", string(rankJson))
c, err := s.Compile()
if err != nil {
panic(err)
}
if err := c.Run(); err != nil {
panic(err)
}
return scriptExecutedMsg{fmt.Sprintf("%s", c.Get("output"))}
}
}
func (m *RankModel) showErrorOnStatusBar(err error) {
m.statusBar.SetContent(
stateFormats[ErrorState][0],
fmt.Sprintf(stateFormats[ErrorState][1], err),
stateFormats[ErrorState][2],
)
}
func (m *RankModel) updateTableContent() {
rows := make([]btTable.Row, 0)
for i, score := range m.rank.Scores {
rows = append(rows, btTable.Row{
strconv.Itoa(i + 1),
score.Participant.Token,
score.Participant.Lastname,
score.Participant.Firstname,
strconv.Itoa(score.Score),
})
}
m.table.SetRows(rows)
}
func (m *RankModel) updateViewportContent() {
if len(m.table.Rows()) == 0 {
panic(errors.New("No scores available!"))
}
currentPos := m.table.SelectedRow()[0]
pos, err := strconv.Atoi(currentPos)
if err != nil {
panic(err)
}
currentResponse := m.rank.Scores[pos-1]
var buf bytes.Buffer
var response struct {
*models.Response
Width int
} = struct {
*models.Response
Width int
}{currentResponse.Response, m.viewport.GetWidth()}
err = m.responseTmpl.Execute(&buf, response)
if err != nil {
panic(err)
}
m.viewport.SetContent(buf.String())
}
func (m *RankModel) handleWindowSize(msg tea.WindowSizeMsg) {
m.group.SetSize(msg.Width, msg.Height)
m.document.SetSize(msg.Width, msg.Height)
m.mdRenderer = m.createMDRenderer()
}
func (m *RankModel) 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 *RankModel) handleScriptExecuted(msg tea.Msg) {
rank := new(Rank)
jsonData := []byte(msg.(scriptExecutedMsg).result)
err := json.Unmarshal(jsonData, &rank)
if err != nil {
panic(err)
}
slices.SortFunc(rank.Scores,
func(a, b *ParticipantScore) int {
return cmp.Compare(b.Score, a.Score)
})
m.rank = rank
m.updateTableContent()
m.updateViewportContent()
m.state = BrowseState
}
func (m *RankModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
_, cmd := m.group.Update(msg)
if m.state == ExecutingScriptState {
return m.updateSpinner(msg, cmd, cmds)
}
if m.state == BrowseState {
m.updateViewportContent()
}
if m.state != ErrorState {
m.statusBar.SetContent(
stateFormats[BrowseState][0],
fmt.Sprintf(stateFormats[BrowseState][1], len(m.rank.Scores)),
stateFormats[BrowseState][2],
)
}
cmds = append(cmds, cmd)
return cmds
}
func (m *RankModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd {
m.spinner, cmd = m.spinner.Update(msg)
m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2])
cmds = append(cmds, cmd)
return cmds
}
func (m *RankModel) loadStore() tea.Cmd {
return func() tea.Msg {
sStore, err := file.NewDefaultSessionFileStore()
if err != nil {
panic(err)
}
return storeLoadedMsg{sStore}
}
}
func (m *RankModel) createMDRenderer() *glamour.TermRenderer {
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dracula"),
glamour.WithWordWrap(m.viewport.GetWidth()),
)
if err != nil {
panic(err)
}
return renderer
}
// func toColoredJson(data []any) (string, error) {
// result, err := json.MarshalIndent(data, "", " ")
// if err != nil {
// return "", err
// }
// coloredBytes := make([]byte, 0)
// buffer := bytes.NewBuffer(coloredBytes)
// err = quick.Highlight(buffer, string(result), "json", "terminal16m", "dracula")
// if err != nil {
// panic(err)
// }
// return sanitize(buffer.String()), nil
// }
// func sanitize(text string) string {
// // FIXME: The use of a standard '-' character causes rendering
// // issues within the viewport. Further investigation is
// // required to resolve this problem.
// return strings.Replace(text, "-", "", -1)
// }
// func desanitize(text string) string {
// // FIXME: The use of a standard '-' character causes rendering
// // issues within the viewport. Further investigation is
// // required to resolve this problem.
// return strings.Replace(text, "", "-", -1)
// }

7
cmd/rank/state.go Normal file
View file

@ -0,0 +1,7 @@
package rank
const (
ExecutingScriptState = iota
BrowseState
ErrorState
)

View file

@ -25,20 +25,24 @@ import (
"fmt"
"os"
"git.andreafazzi.eu/andrea/probo/cmd/util"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var (
cfgFile string
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "probo",
Short: "A Quiz Management System for Hackers!",
Long: `
Probo is a CLI/TUI application that allows for the quick
creation of quizzes from markdown files and their distribution
as web pages.`,
Short: "A Quiz Management System",
/*Long: `
Probo is a CLI/TUI application that allows for the quick
creation of quizzes from markdown files and their distribution
as web pages.`,*/
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/root/*.tmpl"),
// Uncomment the following line if your bare application
// has an action associated with it:

82
cmd/serve.go Normal file
View file

@ -0,0 +1,82 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"net/http"
"git.andreafazzi.eu/andrea/probo/cmd/serve"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
)
// serveCmd represents the serve command
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Launch a web server to adminster exam sessions",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: runServer,
}
func init() {
rootCmd.AddCommand(serveCmd)
}
func runServer(cmd *cobra.Command, args []string) {
sStore, err := file.NewDefaultSessionFileStore()
if err != nil {
log.Fatal("Session store loading", "err", err)
}
rStore, err := file.NewDefaultResponseFileStore()
if err != nil {
log.Fatal("Session store loading", "err", err)
}
mux := http.NewServeMux()
loginController := serve.NewController(sStore, rStore).
WithTemplates(
"templates/login/layout-login.html.tmpl",
"templates/login/login.html.tmpl",
).
WithHandlerFunc(serve.LoginHandler)
sessionsController := serve.NewController(sStore, rStore).
WithTemplates(
"templates/sessions/layout-sessions.html.tmpl",
"templates/sessions/sessions.html.tmpl",
).
WithHandlerFunc(serve.SessionsHandler)
examController := serve.NewController(sStore, rStore).
WithTemplates(
"templates/exam/layout-exam.html.tmpl",
"templates/exam/exam.html.tmpl",
).
WithHandlerFunc(serve.ExamHandler)
mux.Handle("GET /login", serve.Recover(loginController))
mux.Handle("POST /login", serve.Recover(loginController))
mux.Handle("GET /sessions", serve.Recover(sessionsController))
mux.Handle("GET /sessions/{uuid}/exams/{participantID}", serve.Recover(examController))
mux.Handle("POST /sessions/{uuid}/exams/{participantID}", serve.Recover(examController))
mux.Handle("GET /public/", http.StripPrefix("/public", http.FileServer(http.Dir("public"))))
log.Info("Listening...")
err = http.ListenAndServe(":3000", mux)
if err != nil {
panic(err)
}
}

64
cmd/serve/controller.go Normal file
View file

@ -0,0 +1,64 @@
package serve
import (
"bytes"
"html/template"
"net/http"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
)
type Controller struct {
handlerFunc http.HandlerFunc
sStore *file.SessionFileStore
rStore *file.ResponseFileStore
template *template.Template
}
func NewController(sStore *file.SessionFileStore, rStore *file.ResponseFileStore) *Controller {
return &Controller{
sStore: sStore,
rStore: rStore,
}
}
func (c *Controller) WithHandlerFunc(f func(c *Controller, w http.ResponseWriter, r *http.Request)) *Controller {
hf := func(w http.ResponseWriter, r *http.Request) {
f(c, w, r)
}
c.handlerFunc = hf
return c
}
func (c *Controller) WithTemplates(paths ...string) *Controller {
tmpl, err := template.ParseFiles(paths...)
if err != nil {
panic(err)
}
c.template = tmpl
return c
}
func (c *Controller) ExecuteTemplate(w http.ResponseWriter, data any) error {
var buf bytes.Buffer
err := c.template.Execute(&buf, data)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
buf.WriteTo(w)
return nil
}
func (c *Controller) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.handlerFunc.ServeHTTP(w, r)
}

81
cmd/serve/exam.go Normal file
View file

@ -0,0 +1,81 @@
package serve
import (
"errors"
"net/http"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"github.com/charmbracelet/log"
)
var ExamHandler = func(c *Controller, w http.ResponseWriter, r *http.Request) {
_, err := ValidateJwtCookie(r)
if err != nil {
panic(err)
}
participantID := r.PathValue("participantID")
session, err := c.sStore.Read(r.PathValue("uuid"))
if err != nil {
panic(err)
}
exam, ok := session.Exams[participantID]
if !ok {
panic(errors.New("Exam not found in the store!"))
}
examWithSession := struct {
*models.Exam
Session *models.Session
}{exam, session}
switch r.Method {
case http.MethodGet:
log.Info("Sending exam to", "participant", exam.Participant, "exam", exam)
err = c.ExecuteTemplate(w, examWithSession)
if err != nil {
panic(err)
}
case http.MethodPost:
err := r.ParseForm()
if err != nil {
panic(err)
}
answers := make([]*models.ParticipantAnswer, 0)
participant := session.Participants[participantID]
for quizID, values := range r.Form {
correct := false
quiz := session.Quizzes[quizID]
for _, answerID := range values {
if quiz.Correct.ID == answerID {
correct = true
}
answers = append(answers, &models.ParticipantAnswer{Quiz: quiz, Answer: session.Answers[answerID], Correct: correct})
}
}
response, err := c.rStore.Create(
&models.Response{
SessionTitle: session.Title,
Participant: participant,
Answers: answers,
})
if err != nil {
panic(err)
}
log.Info("Saving response", "response", response)
}
}

36
cmd/serve/jwt.go Normal file
View file

@ -0,0 +1,36 @@
package serve
import (
"fmt"
"net/http"
"time"
"github.com/golang-jwt/jwt"
)
const jwtExpiresAt = time.Hour
type Claims struct {
Token string `json:"token"`
jwt.StandardClaims
}
var (
jwtKey = []byte("my-secret")
)
func ValidateJwtCookie(r *http.Request) (*jwt.Token, error) {
cookie, err := r.Cookie("Authorize")
if err != nil {
return nil, err
}
token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtKey, nil
})
return token, err
}

76
cmd/serve/login.go Normal file
View file

@ -0,0 +1,76 @@
package serve
import (
"errors"
"net/http"
"time"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"github.com/charmbracelet/log"
"github.com/golang-jwt/jwt"
)
var LoginHandler = func(c *Controller, w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
err := r.ParseForm()
if err != nil {
panic(err)
}
pToken := r.FormValue("participantToken")
if pToken == "" {
panic(errors.New("Token not found parsing the request!"))
}
log.Info("Received", "participantToken", pToken)
var loggedParticipant *models.Participant
done := false
for _, session := range c.sStore.ReadAll() {
if done {
break
}
for _, exam := range session.Exams {
if pToken == exam.Participant.Token {
loggedParticipant = exam.Participant
done = true
}
}
}
log.Info("Participant logged in as", "participant", loggedParticipant)
if loggedParticipant == nil {
panic(errors.New("Participant not found!"))
}
claims := &Claims{
Token: pToken,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(jwtExpiresAt).Unix(),
},
}
tokenString, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtKey))
if err != nil {
panic(err)
}
http.SetCookie(w, &http.Cookie{
Name: "Authorize",
Value: tokenString,
Expires: time.Now().Add(jwtExpiresAt),
})
log.Info("Released", "jwt", tokenString)
log.Info("Redirect to", "url", "/sessions")
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
}
err := c.ExecuteTemplate(w, nil)
if err != nil {
panic(err)
}
}

22
cmd/serve/recover.go Normal file
View file

@ -0,0 +1,22 @@
package serve
import (
"net/http"
"github.com/charmbracelet/log"
)
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
err := recover()
if err != nil {
log.Error("Recovering from", "err", err)
http.Error(w, err.(error).Error(), http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

39
cmd/serve/sessions.go Normal file
View file

@ -0,0 +1,39 @@
package serve
import (
"net/http"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"github.com/golang-jwt/jwt"
)
var SessionsHandler = func(c *Controller, w http.ResponseWriter, r *http.Request) {
token, err := ValidateJwtCookie(r)
if err != nil {
panic(err)
}
claims := token.Claims.(jwt.MapClaims)
var participantSessions []struct {
ParticipantID string
*models.Session
}
for _, session := range c.sStore.ReadAll() {
for _, exam := range session.Exams {
if exam.Participant.Token == claims["token"] {
s := struct {
ParticipantID string
*models.Session
}{exam.Participant.ID, session}
participantSessions = append(participantSessions, s)
break
}
}
}
err = c.ExecuteTemplate(w, participantSessions)
if err != nil {
panic(err)
}
}

View file

@ -1,40 +1,60 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"os"
"git.andreafazzi.eu/andrea/probo/cmd/session"
"git.andreafazzi.eu/andrea/probo/cmd/util"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
// sessionCmd represents the session command
var sessionCmd = &cobra.Command{
Use: "session",
Short: "A brief description of your command",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("session called")
},
Short: "Create a new session or update a given one",
Long: "Create a new session or update a given one.",
Run: updateSession,
}
func init() {
createCmd.AddCommand(sessionCmd)
updateCmd.AddCommand(sessionCmd)
sessionCmd.Flags().StringP("script", "s", "", "Execute a tengo script to initiate a session")
}
func updateSession(cmd *cobra.Command, args []string) {
f := util.LogToFile()
if f != nil {
defer f.Close()
}
path, err := cmd.Flags().GetString("script")
if err != nil {
panic(err)
}
lipgloss.SetColorProfile(termenv.TrueColor)
model, err := tea.NewProgram(
session.New(path, util.ReadStdin()),
tea.WithOutput(os.Stderr),
).Run()
if err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
result := model.(*session.SessionModel)
if result.Result != "" {
fmt.Fprintf(os.Stdout, result.Result)
}
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// sessionCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// sessionCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

9
cmd/session/format.go Normal file
View file

@ -0,0 +1,9 @@
package session
var (
stateFormats = map[int][]string{
BrowseState: []string{"BROWSE 📖", "Total sessions in the store: %d, Exams in the current session: %d", "STORE 🟢"},
LoadingStoreState: []string{"LOAD %s", "Loading the store...", "STORE 🔴"},
ErrorState: []string{"ERROR 📖", "%v", "STORE 🟢"},
}
)

69
cmd/session/keymap.go Normal file
View file

@ -0,0 +1,69 @@
package session
import (
"github.com/charmbracelet/bubbles/key"
"github.com/remogatto/sugarfoam/components/form"
"github.com/remogatto/sugarfoam/components/group"
"github.com/remogatto/sugarfoam/components/table"
"github.com/remogatto/sugarfoam/components/viewport"
)
type keyBindings struct {
group *group.Model
quit, enter key.Binding
}
func (k *keyBindings) ShortHelp() []key.Binding {
keys := make([]key.Binding, 0)
current := k.group.Current()
switch item := current.(type) {
case *table.Model:
keys = append(
keys,
item.KeyMap.LineUp,
item.KeyMap.LineDown,
)
case *viewport.Model:
keys = append(
keys,
item.KeyMap.Up,
item.KeyMap.Down,
)
case *form.Model:
keys = append(
keys,
item.KeyBinds()...,
)
}
keys = append(
keys,
k.group.KeyMap.FocusNext,
k.group.KeyMap.FocusPrev,
k.quit,
)
return keys
}
func (k keyBindings) FullHelp() [][]key.Binding {
return [][]key.Binding{
{
k.quit,
},
}
}
func newBindings(g *group.Model) *keyBindings {
return &keyBindings{
group: g,
quit: key.NewBinding(
key.WithKeys("esc"), key.WithHelp("esc", "quit app"),
),
}
}

19
cmd/session/message.go Normal file
View file

@ -0,0 +1,19 @@
package session
import "git.andreafazzi.eu/andrea/probo/pkg/store/file"
type storeLoadedMsg struct {
store *file.SessionFileStore
}
type resultMsg struct {
result []any
}
type errorMsg struct {
error error
}
type scriptExecutedMsg struct {
result string
}

452
cmd/session/session.go Normal file
View file

@ -0,0 +1,452 @@
package session
import (
"encoding/json"
"errors"
"fmt"
"os"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
btTable "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/d5/tengo/v2"
"github.com/d5/tengo/v2/stdlib"
foam "github.com/remogatto/sugarfoam"
"github.com/remogatto/sugarfoam/components/form"
"github.com/remogatto/sugarfoam/components/group"
"github.com/remogatto/sugarfoam/components/header"
"github.com/remogatto/sugarfoam/components/help"
"github.com/remogatto/sugarfoam/components/statusbar"
"github.com/remogatto/sugarfoam/components/table"
"github.com/remogatto/sugarfoam/components/viewport"
"github.com/remogatto/sugarfoam/layout"
"github.com/remogatto/sugarfoam/layout/tiled"
)
var mockSession = `
{"participants":[{"Attributes":{"class":"Math 101A"},"created_at":"2024-04-03T09:22:11.201142494+02:00","firstname":"THOMAS","id":"1024758d-0dba-4bec-aaab-339e87a0649b","lastname":"MANN","token":"427628","updated_at":"2024-05-27T13:48:50.096492274+02:00"},{"Attributes":{"class":"Math 101A"},"created_at":"2024-04-04T10:23:12.203143495+02:00","firstname":"JAMES","id":"f3c5a7bd-6fda-4bce-bbbb-333e88a0650c","lastname":"BROWN","token":"428629","updated_at":"2024-05-27T13:48:50.075089555+02:00"},{"Attributes":{"class":"Math 101A"},"created_at":"2024-04-05T11:24:13.204144496+02:00","firstname":"LUCY","id":"g4d6c8cd-7eda-4cce-cccc-444e89a0661d","lastname":"DOE","token":"429630","updated_at":"2024-05-27T13:48:50.079134348+02:00"}], "quizzes": [{"CorrectPos":0,"answers":[{"created_at":"2024-05-27T13:55:26.448679335+02:00","id":"6771592e-4e2c-45f1-b14f-91f943a5616b","text":"3 + 1 = 1 + 3","updated_at":"2024-05-27T13:55:26.448679428+02:00"},{"created_at":"2024-05-27T13:55:26.448681412+02:00","id":"bad7db2a-f5be-4f22-879a-fd783bb1c0a9","text":"3 - 1 = 1 - 3","updated_at":"2024-05-27T13:55:26.448681456+02:00"},{"created_at":"2024-05-27T13:55:26.448683251+02:00","id":"8ae29e51-65a7-4618-8074-58ee90c4fb83","text":"3 / 2 = 2 / 3","updated_at":"2024-05-27T13:55:26.448683289+02:00"},{"created_at":"2024-05-27T13:55:26.448685048+02:00","id":"7d2a9491-338d-4274-9e62-3d2641788777","text":"-3 + 1 = -1 + 3","updated_at":"2024-05-27T13:55:26.448685085+02:00"}],"correct":{"created_at":"2024-05-27T13:55:26.448679335+02:00","id":"6771592e-4e2c-45f1-b14f-91f943a5616b","text":"3 + 1 = 1 + 3","updated_at":"2024-05-27T13:55:26.448679428+02:00"},"created_at":"2024-05-27T13:49:24.170080837+02:00","hash":"","id":"6a21d3fa-d63c-4acf-9238-551692ed74c4","question":{"created_at":"2024-05-27T13:55:26.448673273+02:00","id":"bf19c1f1-6896-4ca2-b7dc-1657adcce4cd","text":"In #mathematics, for the commutative property of addition, we have\nthat:","updated_at":"2024-05-27T13:55:26.448673633+02:00"},"tags":["#mathematics"],"type":0,"updated_at":"2024-05-27T13:55:26.448701931+02:00"},{"CorrectPos":0,"answers":[{"created_at":"2024-05-27T13:55:26.449222989+02:00","id":"37f741c2-12de-4463-b6c0-42b8b5249e7a","text":"3 * (4 + 5) = 3 * 4 + 3 * 5","updated_at":"2024-05-27T13:55:26.449223079+02:00"},{"created_at":"2024-05-27T13:55:26.44922686+02:00","id":"c60b1b81-5e16-4401-831c-d9fffba1d1eb","text":"6 * (2 - 3) = 6 * 2 + 6 * 3","updated_at":"2024-05-27T13:55:26.449226924+02:00"},{"created_at":"2024-05-27T13:55:26.449230299+02:00","id":"fb0ba30c-1e4e-41a1-9630-7f18aa24a6db","text":"7 * (8 + 2) = 7 * 8 - 7 * 2","updated_at":"2024-05-27T13:55:26.449230351+02:00"},{"created_at":"2024-05-27T13:55:26.449233993+02:00","id":"0cfd6fbb-7aac-4d35-aa17-3e62f5297c68","text":"2 * (1 + 2) = (- 2) * 1 - 4","updated_at":"2024-05-27T13:55:26.449234055+02:00"}],"correct":{"created_at":"2024-05-27T13:55:26.449222989+02:00","id":"37f741c2-12de-4463-b6c0-42b8b5249e7a","text":"3 * (4 + 5) = 3 * 4 + 3 * 5","updated_at":"2024-05-27T13:55:26.449223079+02:00"},"created_at":"2024-05-27T13:49:24.173550821+02:00","hash":"","id":"40aa188a-46c6-4f26-9a1e-4d5a028ba367","question":{"created_at":"2024-05-27T13:55:26.449216807+02:00","id":"723acb0a-5c60-4062-8615-fd3494220b4d","text":"In #mathematics, the distributive property allows us to multiply a sum\nor difference by a single number. Which statement correctly applies\nthis property?","updated_at":"2024-05-27T13:55:26.449216918+02:00"},"tags":["#mathematics"],"type":0,"updated_at":"2024-05-27T13:55:26.449281612+02:00"},{"CorrectPos":0,"answers":[{"created_at":"2024-05-27T13:55:26.449440615+02:00","id":"11be2252-45f0-44c1-a9f0-19dcdcd45b68","text":"1","updated_at":"2024-05-27T13:55:26.449440713+02:00"},{"created_at":"2024-05-27T13:55:26.449452222+02:00","id":"971fbf3b-27b1-4b67-9636-ac681056ce62","text":"0","updated_at":"2024-05-27T13:55:26.449452291+02:00"},{"created_at":"2024-05-27T13:55:26.449455664+02:00","id":"d35104b1-37f9-4312-aa21-a470650cb4dc","text":"2","updated_at":"2024-05-27T13:55:26.44945573+02:00"},{"created_at":"2024-05-27T13:55:26.449459322+02:00","id":"220077d2-1994-40ed-8d28-f39d2ebb8dcf","text":"3","updated_at":"2024-05-27T13:55:26.449459397+02:00"}],"correct":{"created_at":"2024-05-27T13:55:26.449440615+02:00","id":"11be2252-45f0-44c1-a9f0-19dcdcd45b68","text":"1","updated_at":"2024-05-27T13:55:26.449440713+02:00"},"created_at":"2024-05-27T13:49:24.175865713+02:00","hash":"","id":"f9fab318-3373-4729-a80f-b681460824e3","question":{"created_at":"2024-05-27T13:55:26.449432971+02:00","id":"af7d973a-a83c-4a18-ac36-92ab6442ef07","text":"In #mathematics, certain numbers have special properties. Identify the\nidentity element for multiplication among the options given:","updated_at":"2024-05-27T13:55:26.449433136+02:00"},"tags":["#mathematics"],"type":0,"updated_at":"2024-05-27T13:55:26.449476666+02:00"}]}`
type SessionModel struct {
// UI
form *form.Model
viewport *viewport.Model
table *table.Model
group *group.Model
help *help.Model
statusBar *statusbar.Model
spinner spinner.Model
// Layout
document *layout.Layout
// Key bindings
bindings *keyBindings
// store
store *file.SessionFileStore
lenStore int
result []any
// json
InputJson string
Result string
// session
session *models.Session
// markdown
mdRenderer *glamour.TermRenderer
// filter file
scriptFilePath string
state int
}
func New(path string, stdin string) *SessionModel {
form := form.New(
form.WithGroups(huh.NewGroup(
huh.NewInput().
Key("sessionTitle").
Title("Session title").
Description("Enter the title of the session").
Validate(func(str string) error {
if str == "" {
return errors.New("You must set a session name!")
}
return nil
}),
)))
formBinding := huh.NewDefaultKeyMap()
formBinding.Input.Next = key.NewBinding(key.WithKeys("down"), key.WithHelp("down", "next"))
formBinding.Input.Prev = key.NewBinding(key.WithKeys("up"), key.WithHelp("up", "prev"))
formBinding.Confirm.Next = key.NewBinding(key.WithKeys("down"), key.WithHelp("down", "next"))
formBinding.Confirm.Prev = key.NewBinding(key.WithKeys("up"), key.WithHelp("up", "prev"))
form.WithShowHelp(false).WithTheme(huh.ThemeDracula()).WithKeyMap(formBinding)
viewport := viewport.New()
table := table.New(table.WithRelWidths(20, 10, 25, 25, 20))
table.Model.SetColumns([]btTable.Column{
{Title: "UUID", Width: 20},
{Title: "Token", Width: 20},
{Title: "Lastname", Width: 20},
{Title: "Firstname", Width: 20},
{Title: "Class", Width: 20},
})
group := group.New(
group.WithItems(form, table, viewport),
group.WithLayout(
layout.New(
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 1)}),
layout.WithItem(form),
layout.WithItem(tiled.New(table, viewport)),
),
),
)
bindings := newBindings(group)
statusBar := statusbar.New(bindings)
s := spinner.New(
spinner.WithStyle(
lipgloss.NewStyle().Foreground(lipgloss.Color("265"))),
)
s.Spinner = spinner.Dot
header := header.New(
header.WithContent(
lipgloss.NewStyle().
Bold(true).
Border(lipgloss.NormalBorder(), false, false, true, false).
Render("✨ Create session ✨"),
),
)
help := help.New(
bindings,
help.WithStyles(&foam.Styles{NoBorder: lipgloss.NewStyle().Padding(1, 1)}))
document := layout.New(
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
layout.WithItem(header),
layout.WithItem(group),
layout.WithItem(help),
layout.WithItem(statusBar),
)
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dracula"),
glamour.WithWordWrap(80),
)
if err != nil {
panic(err)
}
return &SessionModel{
form: form,
table: table,
viewport: viewport,
group: group,
statusBar: statusBar,
spinner: s,
document: document,
mdRenderer: renderer,
bindings: bindings,
help: help,
scriptFilePath: path,
InputJson: stdin,
}
}
func (m *SessionModel) Init() tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, m.group.Init(), m.loadStore(), m.spinner.Tick)
m.group.Focus()
return tea.Batch(cmds...)
}
func (m *SessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.handleWindowSize(msg)
case tea.KeyMsg:
switch {
case key.Matches(msg, m.bindings.quit):
cmds = append(cmds, tea.Quit)
}
case storeLoadedMsg:
cmds = append(cmds, m.handleStoreLoaded(msg))
case scriptExecutedMsg:
m.handleScriptExecuted(msg)
case errorMsg:
m.handleError(msg)
m.state = ErrorState
}
cmds = m.handleState(msg, cmds)
return m, tea.Batch(cmds...)
}
func (m *SessionModel) View() string {
return m.document.View()
}
func (m *SessionModel) marshalJSON() (string, error) {
session, err := m.createSession()
if err != nil {
return "", err
}
resultJson, err := json.Marshal(session)
if err != nil {
return "", err
}
return string(resultJson), nil
}
func (m *SessionModel) executeScript() tea.Cmd {
return func() tea.Msg {
if m.scriptFilePath == "" {
return nil
}
sessionJson, err := json.Marshal(models.Session{Exams: map[string]*models.Exam{}})
if err != nil {
panic(err)
}
script, err := os.ReadFile(m.scriptFilePath)
if err != nil {
panic(err)
}
s := tengo.NewScript(script)
s.SetImports(stdlib.GetModuleMap("fmt", "json", "rand", "times"))
_ = s.Add("input", m.InputJson)
_ = s.Add("output", string(sessionJson))
c, err := s.Compile()
if err != nil {
panic(err)
}
if err := c.Run(); err != nil {
panic(err)
}
return scriptExecutedMsg{fmt.Sprintf("%s", c.Get("output"))}
}
}
func (m *SessionModel) createSession() (*models.Session, error) {
m.session.Title = m.form.GetString("sessionTitle")
session, err := m.store.Storer.Create(m.session)
if err != nil {
return nil, err
}
return session, err
}
func (m *SessionModel) showErrorOnStatusBar(err error) {
m.statusBar.SetContent(
stateFormats[ErrorState][0],
fmt.Sprintf(stateFormats[ErrorState][1], err),
stateFormats[ErrorState][2],
)
}
func (m *SessionModel) updateTableContent(session *models.Session) {
rows := make([]btTable.Row, 0)
for _, exam := range session.Exams {
rows = append(rows, btTable.Row{
exam.Participant.ID,
exam.Participant.Token,
exam.Participant.Lastname,
exam.Participant.Firstname,
exam.Participant.Attributes.Get("class"),
})
}
m.table.SetRows(rows)
}
func (m *SessionModel) updateViewportContent(session *models.Session) {
if len(m.table.Rows()) == 0 {
panic(errors.New("Session have not exams"))
}
currentUUID := m.table.SelectedRow()[0]
currentExam := session.Exams[currentUUID]
if currentExam == nil {
panic("Current token is not associate to any exam!")
}
// md, err := currentExam.ToMarkdown()
// if err != nil {
// m.showErrorOnStatusBar(err)
// }
// data, err := currentExam.Marshal()
// if err != nil {
// panic(err)
// }
// result, err := m.mdRenderer.Render(md)
// if err != nil {
// m.showErrorOnStatusBar(err)
// }
// m.viewport.SetContent(result)
// m.viewport.SetContent(string(data))
m.viewport.SetContent(mockSession)
}
func (m *SessionModel) createMDRenderer() *glamour.TermRenderer {
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dracula"),
glamour.WithWordWrap(m.viewport.GetWidth()),
)
if err != nil {
panic(err)
}
return renderer
}
func (m *SessionModel) handleWindowSize(msg tea.WindowSizeMsg) {
m.group.SetSize(msg.Width, msg.Height)
m.document.SetSize(msg.Width, msg.Height)
m.mdRenderer = m.createMDRenderer()
}
func (m *SessionModel) 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 *SessionModel) handleScriptExecuted(msg tea.Msg) {
session := new(models.Session)
jsonData := []byte(msg.(scriptExecutedMsg).result)
err := json.Unmarshal(jsonData, &session)
if err != nil {
panic(err)
}
m.session = session
m.updateTableContent(session)
m.updateViewportContent(session)
m.state = BrowseState
}
func (m *SessionModel) handleStoreLoaded(msg tea.Msg) tea.Cmd {
storeMsg := msg.(storeLoadedMsg)
m.store = storeMsg.store
m.lenStore = len(m.store.ReadAll())
return m.executeScript()
}
func (m *SessionModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
_, cmd := m.group.Update(msg)
if m.state == LoadingStoreState {
return m.updateSpinner(msg, cmd, cmds)
}
if m.form.State == huh.StateCompleted {
var err error
m.Result, err = m.marshalJSON()
if err != nil {
panic(err)
}
cmds = append(cmds, tea.Quit)
}
if len(m.form.Errors()) > 0 {
m.state = ErrorState
for _, err := range m.form.Errors() {
m.showErrorOnStatusBar(err)
}
} else {
m.state = BrowseState
}
if m.state == BrowseState {
m.updateViewportContent(m.session)
}
if m.state != ErrorState {
m.statusBar.SetContent(
stateFormats[BrowseState][0],
fmt.Sprintf(stateFormats[BrowseState][1], m.lenStore, len(m.session.Exams)),
stateFormats[BrowseState][2],
)
}
cmds = append(cmds, cmd)
return cmds
}
func (m *SessionModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd {
m.spinner, cmd = m.spinner.Update(msg)
m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2])
cmds = append(cmds, cmd)
return cmds
}
func (m *SessionModel) loadStore() tea.Cmd {
return func() tea.Msg {
sStore, err := file.NewDefaultSessionFileStore()
if err != nil {
panic(err)
}
return storeLoadedMsg{sStore}
}
}

7
cmd/session/state.go Normal file
View file

@ -0,0 +1,7 @@
package session
const (
LoadingStoreState = iota
BrowseState
ErrorState
)

29
cmd/update.go Normal file
View file

@ -0,0 +1,29 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// updateCmd represents the update command
var updateCmd = &cobra.Command{
Use: "update",
Short: "Create or update an entity",
Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("update called")
},
}
func init() {
rootCmd.AddCommand(updateCmd)
}

74
cmd/util/util.go Normal file
View file

@ -0,0 +1,74 @@
package util
import (
"bufio"
"bytes"
"fmt"
"html/template"
"io"
"os"
"strings"
"git.andreafazzi.eu/andrea/probo/embed"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
)
func RenderMarkdownTemplates(paths ...string) string {
tmpl, err := template.ParseFS(embed.CLI, paths...)
if err != nil {
panic(err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, nil)
if err != nil {
panic(err)
}
result, err := glamour.Render(buf.String(), "dark")
if err != nil {
panic(err)
}
return result
}
func LogToFile() *os.File {
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("probo-debug.log", "[DEBUG]")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
return f
}
return nil
}
func ReadStdin() string {
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)
}
}
}
return strings.TrimSpace(b.String())
}

View file

@ -0,0 +1,33 @@
{{define "description"}}
# Filters
**Filters can be used to make selections among stores.**
Filters allow you to narrow down selections across various stores. By
using filters, you can select participants, quizzes, and
responses. The command triggers a Text User Interface (TUI) that runs
a `jq` filter, displaying the outcome in real-time. After you're content
with the filtered JSON, pressing ⏎ will present the result on
stdout, enabling you to further process it by piping it forward.
## Examples
1. Apply a filter to participants:
```
probo filter participants
```
2. Apply a filter to quizzes using the `jq` filter in `tags.jq` file:
```
probo filter quizzes -i data/filters/tags.jq
```
3. Apply a filter to participants, then pass the output through
another filter. The final result is saved in a JSON file:
```
probo filter participants | probo filter quizzes > data/json/selection.json
```
{{end}}

View file

@ -0,0 +1,27 @@
{{define "description"}}
# Init
**Init initializes the current working directory.**
The `init` command creates, within the current directory, a file and
folder structure prepared for immediate use of `probo`. The filesystem
structure is as follows:
```
├── filters
├── json
├── participants
├── quizzes
├── responses
├── sessions
└── templates
```
## Examples
1. Initialize the current directory:
```
probo init
```
{{end}}

2
embed/cli/layout.tmpl Normal file
View file

@ -0,0 +1,2 @@
{{template "logo" .}}
{{template "description" .}}

10
embed/cli/logo.tmpl Normal file
View file

@ -0,0 +1,10 @@
{{define "logo"}}
```
____ _
| _ \ _ __ ___ | |__ ___
| |_) | '__/ _ \| '_ \ / _ \
| __/| | | (_) | |_) | (_) |
|_| |_| \___/|_.__/ \___/____
|_____|
```
{{end}}

View file

@ -0,0 +1,21 @@
{{define "description"}}
# Rank
**The `rank` command generates a ranking of results based on participant answers.**
With the `rank` command, it is possible to generate a ranking of
results based on the answers provided by participants. The command
accepts on standard input either a JSON ready for display or the
result of a filter applied to the answers that needs to be processed
through a `tengo` script to conform to the JSON schema.
## Example
Filtra le risposte date dai partecipanti al test "Math Test" e produce
una classifica dei punteggi assegnando +1 punto per ogni risposta
esatta fornita.
```
probo filter responses -i data/filters/math_test.jq | probo rank -s data/scripts/score.tengo
```
{{end}}

View file

@ -0,0 +1,50 @@
{{define "description"}}
# Probo
`probo` is a quiz management system designed for command line
enthusiasts. `probo` aims to highlight themes such as privacy,
interoperability, accessibility, decentralization, and quick usage
speed.
`probo` organizes information in plain text files and folders so that
data can be easily revised through version control systems.
`probo` contains a built-in web server that allows quizzes to be
administered to participants and their answers received.
`probo` is a self-contained application that can be distributed through
a simple executable containing all necessary assets for its operation.
# Quickstart
1. Initialize the working directory. The command will build a scaffold
containing example participants, quizzes, and filters
```
probo init
```
2. Filter participants and quizzes and create an exam
session.
```
probo filter participant -i data/filters/9th_grade.jq \
| probo filter quizzes -i data/filters/difficult_easy.jq \
| probo create session --name="My First Session" > data/sessions/my_session.json
```
3. Run the web server to allow participants to respond.
```
probo serve
```
4. Share the *qrcode* generated from the previous step with the
participants and wait for them to finish responding.
5. Explore the results.
```
probo filter responses -f '.' | probo rank
```
{{end}}

71
embed/embed.go Normal file
View file

@ -0,0 +1,71 @@
package embed
import (
"embed"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/charmbracelet/log"
)
var (
//go:embed cli/*
CLI embed.FS
//go:embed templates/*
Templates embed.FS
//go:embed public/*
Public embed.FS
//go:embed data/*
Data embed.FS
)
func CopyToWorkingDirectory(data embed.FS) error {
currentDir, err := os.Getwd()
if err != nil {
return err
}
if err := fs.WalkDir(data, ".", func(path string, info fs.DirEntry, err error) error {
if err != nil {
log.Info(err)
return err
}
fullDstPath := filepath.Join(currentDir, path)
if info.IsDir() {
log.Info("Creating folder", "path", path)
if err := os.MkdirAll(fullDstPath, 0755); err != nil {
return err
}
} else {
srcFile, err := data.Open(path)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(fullDstPath)
if err != nil {
return err
}
defer dstFile.Close()
log.Info("Copying file", "path", path)
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,40 @@
.container {
display: grid;
width: 300px;
margin: 0 auto;
padding: 20px;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f0f0;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.container label {
display: block;
margin-bottom: 5px;
}
.container input[type="text"],
.container input[type="password"] {
width: 100%;
padding: 10px;
margin-bottom: 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
.container button {
width: 100%;
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.container button:hover {
background-color: #0056b3;
}

View file

@ -0,0 +1,23 @@
{{ define "content" }}
<div class="container">
{{range $quiz := .Quizzes }}
<div class="row">
<div class="col">
<div class="card my-2">
<div class="card-body">
<h5 class="card-title">{{$quiz.Question}}</h5>
<h6 class="card-subtitle mb-2 text-body-secondary">Una sola scelta possibile</h6>
{{range $answer := $quiz.Answers}}
<input type="radio"
id="{{$quiz.ID}}_{{$answer.ID}}" name="{{$quiz.ID}}"
value="{{$answer.ID}}">
<label
for="{{$quiz.ID}}_{{$answer.ID}}">{{$answer.Text}}</label><br>
{{end}}
</div>
</div>
</div>
</div>
{{ end }}
</div>
{{ end }}

View file

@ -0,0 +1,37 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Test di {{.Participant}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container">
<a class="navbar-brand" href="#">
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="24" fill="currentColor" class="bi bi-book d-inline-block align-text-top" viewBox="0 0 16 16">
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783"/>
</svg>
Probo_
</a>
<span class="navbar-text">
{{.Session.Title}}
</span>
<div class="text-end">
<input type="submit" value="Salva" class="btn btn-primary me-2" form="submit-exam-form"/>
</div>
</div>
</nav>
<form action="/sessions/{{.Session.ID}}/exams/{{.Participant.ID}}" method="POST" id="submit-exam-form"/>
{{template "content" .}}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Probo login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
{{template "content" .}}
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top fixed-bottom">
<div class="col-md-4 d-flex align-items-center">
<a href="/" class="mb-3 me-2 mb-md-0 text-body-secondary text-decoration-none lh-1">
<svg class="bi" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
</a>
<span class="mb-3 mb-md-0 text-body-secondary">© 2024 Andrea Fazzi</span>
</div>
<ul class="nav col-md-4 justify-content-end list-unstyled d-flex">
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#twitter"></use></svg></a></li>
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#instagram"></use></svg></a></li>
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#facebook"></use></svg></a></li>
</ul>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,25 @@
{{ define "content" }}
<div class="container col-xl-10 col-xxl-8 px-4 py-5">
<div class="row align-items-center g-lg-5 py-5">
<div class="col-lg-7 text-center text-lg-start">
<h1 class="display-4 fw-bold lh-1 text-body-emphasis mb-3">🎓 Probo_</h1>
<p class="col-lg-10 fs-4">
Una piattaforma per la creazione, la gestione e la
somministrazione di test <em>the hacker way</em>. Inserisci il
tuo <strong>token</strong> personale per iniziare.
</p>
</div>
<div class="col-md-10 mx-auto col-lg-5">
<form method="post" action="/login" class="p-4 p-md-5 border rounded-3 bg-body-tertiary">
<div class="form-floating mb-3">
<input type="password" class="form-control" name="participantToken" id="participantToken" placeholder="Token">
<label for="participantToken">Inserisci il token...</label>
</div>
<button class="w-100 btn btn-lg btn-primary" type="submit">Inizia</button>
<hr class="my-4">
<small class="text-body-secondary">Fai un bel respiro e non aver paura: non è un test su di te ma su quello che sai!</small>
</form>
</div>
</div>
</div>
{{ end }}

View file

@ -0,0 +1,55 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Probo login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<header class="mb-4 p-3 text-bg-dark border-bottom">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none">
<span class="fs-4 mx-2">Probo_</span>
</a>
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="#" class="nav-link px-2 text-secondary">I tuoi test</a></li>
</ul>
<form class="col-12 col-lg-auto mb-3 mb-lg-0 me-lg-3" role="search">
<input type="search" class="form-control form-control-dark text-bg-dark" placeholder="Cerca..." aria-label="Search">
</form>
<div class="text-end">
<button type="button" class="btn btn-outline-light me-2">Cerca...</button>
<button type="button" class="btn btn-warning">Esci</button>
</div>
</div>
</div>
</header>
{{template "content" .}}
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
<div class="col-md-4 d-flex align-items-center">
<a href="/" class="mb-3 me-2 mb-md-0 text-body-secondary text-decoration-none lh-1">
<svg class="bi" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
</a>
<span class="mb-3 mb-md-0 text-body-secondary">© 2024 Andrea Fazzi</span>
</div>
<ul class="nav col-md-4 justify-content-end list-unstyled d-flex">
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#twitter"></use></svg></a></li>
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#instagram"></use></svg></a></li>
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#facebook"></use></svg></a></li>
</ul>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,18 @@
{{ define "content" }}
<div class="container">
{{range $session := . }}
<div class="row">
<div class="col">
<div class="card my-2">
<div class="card-body">
<h5 class="card-title">{{$session.Title}}</h5>
<h6 class="card-subtitle mb-2 text-body-secondary">{{$session.CreatedAt}}</h6>
<p class="card-text">{{$session.Description}}</p>
<a href="/sessions/{{$session.ID}}/exams/{{$session.ParticipantID}}" class="btn btn-primary">Inizia!</a>
</div>
</div>
</div>
</div>
{{ end }}
</div>
{{ end }}

31
go.mod
View file

@ -1,48 +1,56 @@
module git.andreafazzi.eu/andrea/probo
go 1.21.6
go 1.22.2
require (
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2
github.com/alecthomas/chroma v0.10.0
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/glamour v0.6.0
github.com/charmbracelet/huh v0.3.0
github.com/charmbracelet/lipgloss v0.10.0
github.com/charmbracelet/log v0.4.0
github.com/charmbracelet/x/ansi v0.1.2
github.com/d5/tengo/v2 v2.17.0
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.14
github.com/lmittmann/tint v1.0.4
github.com/muesli/termenv v0.15.2
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8
github.com/remogatto/sugarfoam v0.0.0-20240324175639-28e6bae1b225
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
gopkg.in/yaml.v2 v2.4.0
gorm.io/gorm v1.25.6
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/containerd/console v1.0.4 // indirect
github.com/fatih/color v1.14.1 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/microcosm-cc/bluemonday v1.0.26 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
@ -53,9 +61,12 @@ require (
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yuin/goldmark v1.5.2 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/term v0.18.0 // indirect

73
go.sum
View file

@ -1,67 +1,92 @@
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181 h1:ntdtXC9+kcgQYvqa6nyLZLniCEUOhWQknLlz38JpDpM=
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181/go.mod h1:Zlzkn8WOd6QS7RC1BXAY1iw1VLq+xT70UZ1vkEtnrvo=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE=
github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA=
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/d5/tengo/v2 v2.17.0 h1:BWUN9NoJzw48jZKiYDXDIF3QrIVZRm1uV1gTzeZ2lqM=
github.com/d5/tengo/v2 v2.17.0/go.mod h1:XRGjEs5I9jYIKTxly6HCF8oiiilk5E/RYXOZ5b0DZC8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a h1:RYfmiM0zluBJOiPDJseKLEN4BapJ42uSi9SZBQ2YyiA=
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc=
github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=
github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@ -70,8 +95,11 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@ -80,8 +108,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 h1:nRDwTcxV9B3elxMt+1xINX0bwaPdpouqp5fbynexY8U=
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8/go.mod h1:jOEnp79oIHy5cvQSHeLcgVJk1GHOOHJHQWps/d1N5Yo=
github.com/remogatto/sugarfoam v0.0.0-20240324175639-28e6bae1b225 h1:LYFmm/8fYZQNhGBC8bHHZbRkfLeA0W1d25nfuPnRG8U=
github.com/remogatto/sugarfoam v0.0.0-20240324175639-28e6bae1b225/go.mod h1:MkNrg58aCSx3bijbdHD+E02TmJ6TfGgOim78puJjBOU=
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af h1:rSEwVRdJxMq4RK2kI1LEVhM5J3yg3pcvlRYy1vjn7mQ=
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af/go.mod h1:WeyW6WPrlPDwa48kDIytaLxXKyRjOwLp4BEd2tEGY1Y=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@ -109,29 +137,44 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -142,5 +185,3 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.6 h1:V92+vVda1wEISSOMtodHVRcUIOPYa2tgQtyF+DfFx+A=
gorm.io/gorm v1.25.6/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=

View file

@ -1,4 +1,6 @@
go 1.21.6
go 1.22.2
toolchain go1.22.3
use (
.

View file

@ -5,13 +5,16 @@ cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LR
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI=
cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@ -41,11 +44,16 @@ github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADym
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/remogatto/imgcat v0.0.0-20240318115229-ee6a34ad38fe/go.mod h1:e6G+BhMs87z7k9UKiGmV8tLWguKaNic9zlb3N+yC5Vc=
github.com/sagikazarmark/crypt v0.17.0/go.mod h1:SMtHTvdmsZMuY/bpZoqokSoChIrcJ/epOxZN58PbZDg=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/srwiley/oksvg v0.0.0-20220128195007-1f435e4c2b44/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220128185129-2efea2b9ea41/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/etcd/api/v3 v3.5.10/go.mod h1:TidfmT4Uycad3NM/o25fG3J07odo4GBB9hoxaodFCtI=
go.etcd.io/etcd/client/pkg/v3 v3.5.10/go.mod h1:DYivfIviIuQ8+/lCq4vcxuseg2P2XbHygkKwFo9fc8U=
@ -53,29 +61,57 @@ go.etcd.io/etcd/client/v2 v2.305.10/go.mod h1:m3CKZi69HzilhVqtPDcjhSGp+kA1OmbNn0
go.etcd.io/etcd/client/v3 v3.5.10/go.mod h1:RVeBnDz2PUEZqTpgqwAtUd8nAPf5kjyFyND7P1VkOKc=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=

View file

@ -21,7 +21,9 @@ THE SOFTWARE.
*/
package main
import "git.andreafazzi.eu/andrea/probo/cmd"
import (
"git.andreafazzi.eu/andrea/probo/cmd"
)
func main() {
cmd.Execute()

121
misc/logseq/.gitignore vendored
View file

@ -1,121 +0,0 @@
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
*~

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Andrea Fazzi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,21 +0,0 @@
Copyright (c) 2022 Andrea Fazzi
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,4 +0,0 @@
# What's that?
A very basic boilerplate useful to start devoloping a
[Logseq](https://logseq.com/) plugin.

View file

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-hierarchy" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="gray" fill="gray" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="12" cy="5" r="2" />
<circle cx="5" cy="19" r="2" />
<circle cx="19" cy="19" r="2" />
<path d="M6.5 17.5l5.5 -4.5l5.5 4.5" />
<line x1="12" y1="7" x2="12" y2="13" />
</svg>

Before

Width:  |  Height:  |  Size: 471 B

File diff suppressed because it is too large Load diff

View file

@ -1,29 +0,0 @@
{
"logseq": {
"id": "logseq-probo-plugin",
"title": "logseq-probo-plugin",
"icon": "./icon.svg"
},
"name": "logseq-probo-plugin",
"version": "1.2.0",
"description": "",
"main": "dist/index.html",
"targets": {
"main": false
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "parcel build --no-source-maps src/index.html --public-url ./"
},
"keywords": [],
"author": "Andrea Fazzi",
"license": "MIT",
"dependencies": {
"@logseq/libs": "^0.0.1-alpha.35",
"js-base64": "^3.7.2"
},
"devDependencies": {
"buffer": "^6.0.3",
"parcel": "^2.2.0"
}
}

View file

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="index.ts" type="module"></script>
</body>
</html>

View file

@ -1,121 +0,0 @@
import "@logseq/libs";
import { BlockEntity } from "@logseq/libs/dist/LSPlugin.user";
const endpoint = 'http://localhost:3000/quizzes';
const uniqueIdentifier = () =>
Math.random()
.toString(36)
.replace(/[^a-z]+/g, "");
const sanitizeBlockContent = (text: string) => text.replace(/((?<=::).*|.*::)/g, "").replace(/{.*}/, "").trim()
async function fetchQuizzes() {
const { status: status, content: quizzes } = await fetch(endpoint).then(res => res.json())
const ret = quizzes || []
return ret.map((quiz, i) => {
return `${i + 1}. ${quiz.Question.Text}`
})
}
const render = (id, slot, status: ("modified" | "saved" | "error")) => {
logseq.provideUI({
key: `${id}`,
slot,
reset: true,
template: `
${status === 'saved' ? '<button data-on-click="createOrUpdateQuiz" class="renderBtn">Save</button><span>saved</span>' : '<button data-on-click="createOrUpdateQuiz" class="renderBtn">Save</button>'}
`,
});
}
const main = () => {
console.log("logseq-probo-plugin LOADED!");
logseq.Editor.registerSlashCommand("Get All Quizzes", async () => {
const currBlock = await logseq.Editor.getCurrentBlock();
let blocks = await fetchQuizzes()
blocks = blocks.map((it: BlockEntity) => ({ content: it }))
await logseq.Editor.insertBatchBlock(currBlock.uuid, blocks, {
sibling: false
})
});
logseq.Editor.registerSlashCommand("Create a new Probo quiz", async () => {
await logseq.Editor.insertAtEditingCursor(
`{{renderer :probo_${uniqueIdentifier()}}}`
);
});
logseq.App.onMacroRendererSlotted(async ({ slot, payload }) => {
const [type] = payload.arguments;
if (!type.startsWith(":probo")) return
const id = type.split("_")[1]?.trim();
const proboId = `probo_${id}`;
let status: ("modified" | "saved" | "error")
logseq.provideModel({
async createOrUpdateQuiz() {
const parentBlock = await logseq.Editor.getBlock(payload.uuid, { includeChildren: true });
const answers = parentBlock.children.map((answer: BlockEntity, i: number) => {
return { text: answer.content, correct: (i == 0) ? true : false }
})
const quiz = {
question: { text: sanitizeBlockContent(parentBlock.content) },
answers: answers
}
if (parentBlock.properties.proboQuizUuid) {
const res = await fetch(endpoint + `/update/${parentBlock.properties.proboQuizUuid}`, { method: 'PUT', body: JSON.stringify(quiz) })
const data = await res.json();
await logseq.Editor.upsertBlockProperty(parentBlock.uuid, `probo-quiz-uuid`, data.content.ID)
render(proboId, slot, "saved")
} else {
const res = await fetch(endpoint + '/create', { method: 'POST', body: JSON.stringify(quiz) })
const data = await res.json();
await logseq.Editor.upsertBlockProperty(parentBlock.uuid, `probo-quiz-uuid`, data.content.ID)
render(proboId, slot, "saved")
}
}
});
logseq.provideStyle(`
.renderBtn {
border: 1px solid white;
border-radius: 8px;
padding: 5px;
margin-right: 5px;
font-size: 80%;
background-color: black;
color: white;
}
.renderBtn:hover {
background-color: white;
color: black;
}
`);
logseq.provideUI({
key: `${proboId}`,
slot,
reset: true,
template: `<button data-on-click="createOrUpdateQuiz" class="renderBtn">Save</button>`,
});
});
};
logseq.ready(main).catch(console.error);

View file

@ -10,13 +10,12 @@ import (
type Exam struct {
Meta
SessionID string
Participant *Participant
Quizzes []*Quiz
Participant *Participant `json:"participant"`
Quizzes []*Quiz `json:"quizzes"`
}
func (e *Exam) String() string {
return fmt.Sprintf("Exam ID %v with %v quizzes.", e.ID, len(e.Quizzes))
return fmt.Sprintf("%v's exam with %v quizzes.", e.Participant, len(e.Quizzes))
}
func (e *Exam) GetHash() string {
@ -35,3 +34,17 @@ func (e *Exam) Marshal() ([]byte, error) {
func (e *Exam) Unmarshal(data []byte) error {
return json.Unmarshal(data, e)
}
func (e *Exam) ToMarkdown() (string, error) {
result := ""
for _, quiz := range e.Quizzes {
quizMD, err := QuizToMarkdown(quiz)
if err != nil {
return "", err
}
result += fmt.Sprintf(quizMD)
result += "\n"
}
return strings.TrimRight(fmt.Sprintf("# %s %s \n %s", e.Participant.Lastname, e.Participant.Firstname, result), "\n"), nil
}

View file

@ -41,6 +41,7 @@ Question text with #tag1 #tag2 (3).
{Text: "Answer 4"},
},
CorrectPos: 0,
Tags: []string{"#tag1", "#tag2"},
}
q := new(Quiz)

View file

@ -14,12 +14,12 @@ type AttributeList map[string]string
type Participant struct {
Meta
Firstname string
Lastname string
Firstname string `json:"firstname" csv:"firstname"`
Lastname string `json:"lastname" csv:"lastname"`
Token string
Token string `json:"token" csv:"token"`
Attributes AttributeList `csv:"Attributes"`
Attributes AttributeList `csv:"attributes"`
}
func (p *Participant) String() string {

View file

@ -15,11 +15,11 @@ type Quiz struct {
Meta
Hash string `json:"hash"`
Question *Question `json:"question" gorm:"foreignKey:ID"`
Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"`
Tags []*Tag `json:"tags" yaml:"-" gorm:"-"`
Correct *Answer `json:"correct" gorm:"foreignKey:ID"`
CorrectPos uint `gorm:"-"` // Position of the correct answer during quiz creation
Question *Question `json:"question"`
Answers []*Answer `json:"answers"`
Tags []string `json:"tags" yaml:"-"`
Correct *Answer `json:"correct"`
CorrectPos uint // Position of the correct answer during quiz creation
Type int `json:"type"`
}
@ -33,6 +33,7 @@ func MarkdownToQuiz(quiz *Quiz, markdown string) error {
questionText := ""
answers := []*Answer{}
tags := make([]string, 0)
for _, line := range lines {
if strings.HasPrefix(line, "*") {
@ -45,6 +46,8 @@ func MarkdownToQuiz(quiz *Quiz, markdown string) error {
}
questionText += line
}
parseTags(&tags, line)
}
questionText = strings.TrimRight(questionText, "\n")
@ -61,8 +64,7 @@ func MarkdownToQuiz(quiz *Quiz, markdown string) error {
quiz.Question = question
quiz.Answers = answers
//quiz = &Quiz{Question: question, Answers: answers, CorrectPos: 0}
quiz.Tags = tags
if meta != nil {
quiz.Meta = *meta
@ -201,3 +203,30 @@ func readLine(reader *strings.Reader) (string, error) {
return sb.String(), nil
}
func parseTags(tags *[]string, text string) {
// Trim the following chars
trimChars := "*:.,/\\@()[]{}<>"
// Split the text into words
words := strings.Fields(text)
for _, word := range words {
// If the word starts with '#', it is considered as a tag
if strings.HasPrefix(word, "#") {
// Check if the tag already exists in the tags slice
exists := false
for _, tag := range *tags {
if tag == word {
exists = true
break
}
}
// If the tag does not exist in the tags slice, add it
if !exists {
*tags = append(*tags, strings.TrimRight(word, trimChars))
}
}
}
}

View file

@ -5,14 +5,22 @@ import (
"fmt"
)
type ParticipantAnswer struct {
Quiz *Quiz `json:"question"`
Answer *Answer `json:"answer"`
Correct bool `json:"correct"`
}
type Response struct {
Meta
Questions map[string]string
SessionTitle string `json:"session_title"`
Participant *Participant `json:"participant"`
Answers []*ParticipantAnswer `json:"answers"`
}
func (r *Response) String() string {
return fmt.Sprintf("Questions/Answers: %v", r.Questions)
return fmt.Sprintf("Questions/Answers: %v", r.Answers)
}
func (r *Response) GetHash() string {

View file

@ -9,16 +9,21 @@ import (
type Session struct {
Meta
Name string
Exams map[string]*Exam
Title string `json:"title"`
Description string `json:"description"`
Participants map[string]*Participant `json:"participants"`
Quizzes map[string]*Quiz `json:"quizzes"`
Answers map[string]*Answer `json:"answers"`
Exams map[string]*Exam `json:"exams"`
}
func (s *Session) String() string {
return s.Name
return s.Title
}
func (s *Session) GetHash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Name)))
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Title)))
}
func (s *Session) Marshal() ([]byte, error) {

View file

@ -1,14 +1,5 @@
package models
import (
"time"
"gorm.io/gorm"
)
type Tag struct {
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string `json:"name" gorm:"primaryKey"`
Name string `json:"name"`
}

View file

@ -79,24 +79,32 @@ func DefaultIndexDirFunc[T FileStorable, K store.Storer[T]](s *FileStore[T, K])
filename := file.Name()
fullPath := filepath.Join(s.Dir, filename)
content, err := os.ReadFile(fullPath)
fileInfo, err := os.Stat(fullPath)
if err != nil {
return err
panic(err)
}
entity := s.CreateEntityFunc()
if fileInfo.Size() > 0 {
content, err := os.ReadFile(fullPath)
if err != nil {
return err
}
err = entity.Unmarshal(content)
if err != nil {
return err
entity := s.CreateEntityFunc()
err = entity.Unmarshal(content)
if err != nil {
return fmt.Errorf("An error occurred unmarshalling %v: %v", filename, err)
}
// mEntity, err := s.Create(entity, fullPath)
mEntity, err := s.Storer.Create(entity)
if err != nil {
return err
}
s.SetPath(mEntity, fullPath)
}
mEntity, err := s.Create(entity, fullPath)
if err != nil {
return err
}
s.SetPath(mEntity, fullPath)
}
return nil

View file

@ -14,7 +14,7 @@ func NewParticipantFileStore(config *FileStoreConfig[*models.Participant, *store
pStore := new(ParticipantFileStore)
pStore.FileStore, err = NewFileStore[*models.Participant, *store.ParticipantStore](config, store.NewParticipantStore())
pStore.FileStore, err = NewFileStore(config, store.NewParticipantStore())
if err != nil {
return nil, err
}

View file

@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
@ -60,7 +61,7 @@ func DefaultQuizIndexDirFunc(s *QuizFileStore) error {
err = entity.Unmarshal(content)
if err != nil {
return err
return fmt.Errorf("An error occurred unmarshalling %v: %v", filename, err)
}
var errQuizAlreadyPresent *store.ErrQuizAlreadyPresent

View file

@ -87,7 +87,7 @@ func (t *quizTestSuite) TestCreate() {
t.Equal(a.Text, quiz.Answers[i].Text)
}
for i, tag := range quizFromDisk.Tags {
t.Equal(tag.Name, quiz.Tags[i].Name)
t.Equal(tag, quiz.Tags[i])
}
}
}

View file

@ -8,7 +8,7 @@ import (
type ResponseFileStore = FileStore[*models.Response, *store.Store[*models.Response]]
func NewResponseFileStore(config *FileStoreConfig[*models.Response, *store.ResponseStore]) (*ResponseFileStore, error) {
return NewFileStore[*models.Response](config, store.NewStore[*models.Response]())
return NewFileStore(config, store.NewStore[*models.Response]())
}
func NewDefaultResponseFileStore() (*ResponseFileStore, error) {

View file

@ -5,10 +5,10 @@ import (
"git.andreafazzi.eu/andrea/probo/pkg/store"
)
type SessionFileStore = FileStore[*models.Session, *store.Store[*models.Session]]
type SessionFileStore = FileStore[*models.Session, *store.SessionStore]
func NewSessionFileStore(config *FileStoreConfig[*models.Session, *store.SessionStore]) (*SessionFileStore, error) {
return NewFileStore[*models.Session](config, store.NewStore[*models.Session]())
return NewFileStore[*models.Session](config, store.NewSessionStore())
}
func NewDefaultSessionFileStore() (*SessionFileStore, error) {

View file

@ -58,7 +58,7 @@ func (s *QuizStore) Create(quiz *models.Quiz) (*models.Quiz, error) {
answers = append(answers, storedAnswer)
}
tags := make([]*models.Tag, 0)
tags := make([]string, 0)
q, err := s.Store.Create(&models.Quiz{
Meta: quiz.Meta,
@ -96,7 +96,7 @@ func (s *QuizStore) Update(quiz *models.Quiz, id string) (*models.Quiz, error) {
answers = append(answers, storedAnswer)
}
tags := make([]*models.Tag, 0)
tags := make([]string, 0)
q, err := s.Store.Update(&models.Quiz{
Question: parseTags[*models.Question](&tags, question)[0],
@ -112,49 +112,7 @@ func (s *QuizStore) Update(quiz *models.Quiz, id string) (*models.Quiz, error) {
return q, nil
}
func (s *QuizStore) FilterInCollection(collection *models.Collection, filter map[string]string) []*models.Quiz {
quizzes := s.ReadAll()
if filter == nil {
return quizzes
}
tagsValue := filter["tags"]
if tagsValue == "" || len(tagsValue) == 0 {
return quizzes
}
fTags := strings.Split(tagsValue, ",")
filteredQuizzes := s.Filter(quizzes, func(q *models.Quiz) bool {
count := 0
for _, qTag := range q.Tags {
if s.isTagInFilter(qTag, fTags) {
count++
}
}
if count == len(fTags) {
return true
}
return false
})
collection.Quizzes = filteredQuizzes
return collection.Quizzes
}
func (s *QuizStore) isTagInFilter(tag *models.Tag, fTags []string) bool {
for _, t := range fTags {
if tag.Name == strings.TrimSpace(t) {
return true
}
}
return false
}
func parseTags[T fmt.Stringer](tags *[]*models.Tag, entities ...T) []T {
func parseTags[T fmt.Stringer](tags *[]string, entities ...T) []T {
for _, entity := range entities {
// Trim the following chars
trimChars := "*:.,/\\@()[]{}<>"
@ -168,7 +126,7 @@ func parseTags[T fmt.Stringer](tags *[]*models.Tag, entities ...T) []T {
// Check if the tag already exists in the tags slice
exists := false
for _, tag := range *tags {
if tag.Name == word {
if tag == word {
exists = true
break
}
@ -176,7 +134,7 @@ func parseTags[T fmt.Stringer](tags *[]*models.Tag, entities ...T) []T {
// If the tag does not exist in the tags slice, add it
if !exists {
*tags = append(*tags, &models.Tag{Name: strings.TrimRight(word, trimChars)})
*tags = append(*tags, strings.TrimRight(word, trimChars))
}
}
}

View file

@ -183,45 +183,6 @@ func (t *quizTestSuite) TestDeleteQuiz() {
}
}
func (t *quizTestSuite) TestFilter() {
store := NewQuizStore()
quiz_1, _ := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Question text with #tag1."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2 with #tag2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
quiz_2, _ := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Question text with #tag3."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2 with #tag4"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
quizzes := store.Filter([]*models.Quiz{quiz_1, quiz_2}, func(q *models.Quiz) bool {
for _, t := range q.Tags {
if t.Name == "#tag1" {
return true
}
}
return false
})
t.Equal(1, len(quizzes))
}
func (t *quizTestSuite) TestParseTextForTags() {
store := NewQuizStore()
@ -244,8 +205,8 @@ func (t *quizTestSuite) TestParseTextForTags() {
t.Nil(err, "Quiz should be found in the store.")
t.Equal(2, len(storedQuiz.Tags))
if !t.Failed() {
t.Equal("#tag1", storedQuiz.Tags[0].Name)
t.Equal("#tag2", storedQuiz.Tags[1].Name)
t.Equal("#tag1", storedQuiz.Tags[0])
t.Equal("#tag2", storedQuiz.Tags[1])
}
}

View file

@ -1,5 +1,86 @@
package store
import "git.andreafazzi.eu/andrea/probo/pkg/models"
import (
"fmt"
type SessionStore = Store[*models.Session]
"git.andreafazzi.eu/andrea/probo/pkg/models"
)
type SessionStore struct {
*Store[*models.Session]
}
type ErrSessionAlreadyPresent struct {
hash string
}
func (e *ErrSessionAlreadyPresent) Error() string {
return fmt.Sprintf("Session with hash %v is already present in the store.", e.hash)
}
func NewSessionStore() *SessionStore {
return &SessionStore{
Store: NewStore[*models.Session](),
}
}
func (s *SessionStore) Create(session *models.Session) (*models.Session, error) {
if hash := session.GetHash(); hash != "" {
session, ok := s.hashes[hash]
if ok {
return session, &ErrSessionAlreadyPresent{hash}
}
}
session.Participants = make(map[string]*models.Participant, 0)
session.Quizzes = make(map[string]*models.Quiz, 0)
session.Answers = make(map[string]*models.Answer, 0)
for _, exam := range session.Exams {
session.Participants[exam.Participant.ID] = exam.Participant
for _, quiz := range exam.Quizzes {
session.Quizzes[quiz.ID] = quiz
for _, answer := range quiz.Answers {
session.Answers[answer.ID] = answer
}
}
}
sess, err := s.Store.Create(session)
if err != nil {
return nil, err
}
return sess, nil
}
func (s *SessionStore) Update(session *models.Session, id string) (*models.Session, error) {
_, err := s.Read(id)
if err != nil {
return session, err
}
session.Quizzes = make(map[string]*models.Quiz, 0)
session.Answers = make(map[string]*models.Answer, 0)
for _, exam := range session.Exams {
for _, quiz := range exam.Quizzes {
session.Quizzes[quiz.ID] = quiz
for _, answer := range quiz.Answers {
session.Answers[answer.ID] = answer
}
}
}
sess, err := s.Store.Update(session, id)
if err != nil {
return nil, err
}
return sess, nil
}

View file

@ -30,12 +30,6 @@ type Storer[T Storable] interface {
Json() ([]byte, error)
}
// type FilterStorer[T Storable] interface {
// Storer[T]
// Filter([]T, func(T) bool) []T
// }
type Store[T Storable] struct {
ids map[string]T
hashes map[string]T

View file

@ -1,4 +1,4 @@
Lastname,Firstname,Token,Attributes
lastname,firstname,token,attributes
CABRERA,GAIA,982998,class:1 D LIN
VERDI,ANNA,868424,class:1 D LIN
BIANCHI,ELENA,795233,class:1 D LIN

1 Lastname lastname Firstname firstname Token token Attributes attributes
2 CABRERA CABRERA GAIA GAIA 982998 982998 class:1 D LIN class:1 D LIN
3 VERDI VERDI ANNA ANNA 868424 868424 class:1 D LIN class:1 D LIN
4 BIANCHI BIANCHI ELENA ELENA 795233 795233 class:1 D LIN class:1 D LIN