Compare commits

..

17 commits

87 changed files with 3335 additions and 5937 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
.log
data

View file

@ -1,89 +0,0 @@
package list
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
)
func newItemDelegate(keys *delegateKeyMap) list.DefaultDelegate {
d := list.NewDefaultDelegate()
d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd {
var title string
if i, ok := m.SelectedItem().(Item); ok {
title = i.Title()
} else {
return nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.choose):
return m.NewStatusMessage(statusMessageStyle("You chose " + title))
case key.Matches(msg, keys.remove):
index := m.Index()
m.RemoveItem(index)
if len(m.Items()) == 0 {
keys.remove.SetEnabled(false)
}
return m.NewStatusMessage(statusMessageStyle("Deleted " + title))
}
}
return nil
}
help := []key.Binding{keys.choose, keys.remove}
d.ShortHelpFunc = func() []key.Binding {
return help
}
d.FullHelpFunc = func() [][]key.Binding {
return [][]key.Binding{help}
}
return d
}
type delegateKeyMap struct {
choose key.Binding
remove key.Binding
}
// Additional short help entries. This satisfies the help.KeyMap interface and
// is entirely optional.
func (d delegateKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
d.choose,
d.remove,
}
}
// Additional full help entries. This satisfies the help.KeyMap interface and
// is entirely optional.
func (d delegateKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{
d.choose,
d.remove,
},
}
}
func newDelegateKeyMap() *delegateKeyMap {
return &delegateKeyMap{
choose: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "choose"),
),
remove: key.NewBinding(
key.WithKeys("x", "backspace"),
key.WithHelp("x", "delete"),
),
}
}

View file

@ -1,158 +0,0 @@
package list
import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
var (
appStyle = lipgloss.NewStyle().Padding(1, 2)
titleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFDF5")).
Background(lipgloss.Color("#25A065")).
Padding(0, 1)
statusMessageStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#04B575"}).
Render
)
type Item struct {
title string
description string
}
func NewItem(title string, description string) Item {
return Item{title, description}
}
func (i Item) Title() string { return i.title }
func (i Item) Description() string { return i.description }
func (i Item) FilterValue() string { return i.title }
type listKeyMap struct {
toggleSpinner key.Binding
toggleTitleBar key.Binding
toggleStatusBar key.Binding
togglePagination key.Binding
toggleHelpMenu key.Binding
}
func newListKeyMap() *listKeyMap {
return &listKeyMap{
toggleSpinner: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "toggle spinner"),
),
toggleTitleBar: key.NewBinding(
key.WithKeys("T"),
key.WithHelp("T", "toggle title"),
),
toggleStatusBar: key.NewBinding(
key.WithKeys("S"),
key.WithHelp("S", "toggle status"),
),
togglePagination: key.NewBinding(
key.WithKeys("P"),
key.WithHelp("P", "toggle pagination"),
),
toggleHelpMenu: key.NewBinding(
key.WithKeys("H"),
key.WithHelp("H", "toggle help"),
),
}
}
type model struct {
list list.Model
keys *listKeyMap
delegateKeys *delegateKeyMap
}
func NewList(title string, items []list.Item) model {
var (
delegateKeys = newDelegateKeyMap()
listKeys = newListKeyMap()
)
// Setup list
delegate := newItemDelegate(delegateKeys)
groceryList := list.New(items, delegate, 0, 0)
groceryList.Title = title
groceryList.Styles.Title = titleStyle
groceryList.AdditionalFullHelpKeys = func() []key.Binding {
return []key.Binding{
listKeys.toggleSpinner,
listKeys.toggleTitleBar,
listKeys.toggleStatusBar,
listKeys.togglePagination,
listKeys.toggleHelpMenu,
}
}
return model{
list: groceryList,
keys: listKeys,
delegateKeys: delegateKeys,
}
}
func (m model) Init() tea.Cmd {
return tea.EnterAltScreen
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
h, v := appStyle.GetFrameSize()
m.list.SetSize(msg.Width-h, msg.Height-v)
case tea.KeyMsg:
// Don't match any of the keys below if we're actively filtering.
if m.list.FilterState() == list.Filtering {
break
}
switch {
case key.Matches(msg, m.keys.toggleSpinner):
cmd := m.list.ToggleSpinner()
return m, cmd
case key.Matches(msg, m.keys.toggleTitleBar):
v := !m.list.ShowTitle()
m.list.SetShowTitle(v)
m.list.SetShowFilter(v)
m.list.SetFilteringEnabled(v)
return m, nil
case key.Matches(msg, m.keys.toggleStatusBar):
m.list.SetShowStatusBar(!m.list.ShowStatusBar())
return m, nil
case key.Matches(msg, m.keys.togglePagination):
m.list.SetShowPagination(!m.list.ShowPagination())
return m, nil
case key.Matches(msg, m.keys.toggleHelpMenu):
m.list.SetShowHelp(!m.list.ShowHelp())
return m, nil
}
}
// This will also call our delegate's update function.
newListModel, cmd := m.list.Update(msg)
m.list = newListModel
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m model) View() string {
return appStyle.Render(m.list.View())
}

View file

@ -1,81 +0,0 @@
package main
import (
"log"
"log/slog"
"os"
"time"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/lmittmann/tint"
"github.com/urfave/cli"
)
func main() {
slog.SetDefault(slog.New(
tint.NewHandler(os.Stdout, &tint.Options{
Level: slog.LevelInfo,
TimeFormat: time.Kitchen,
}),
))
_, err := os.Stat(file.DefaultBaseDir)
if err != nil {
slog.Info("Base directory not found. Creating the folder structure...")
for _, dir := range file.Dirs {
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
log.Fatal(err)
}
slog.Info("Create", "directory", dir)
}
slog.Info("Folder structure created without issues.")
os.Exit(0)
}
app := &cli.App{
Name: "probo-cli",
Usage: "Quiz Management System for Hackers!",
Commands: []*cli.Command{
{
Name: "session",
Aliases: []string{"s"},
Usage: "options for command 'session'",
Subcommands: []*cli.Command{
{
Name: "push",
Usage: "Create a new exam session",
Action: push,
},
{
Name: "pull",
Usage: "Download responses from a session",
Action: pull,
},
{
Name: "scores",
Usage: "Show the scores for the given session",
Action: scores,
},
},
},
{
Name: "participant",
Aliases: []string{"p"},
Usage: "options for command 'participant'",
Subcommands: []*cli.Command{
{
Name: "import",
Usage: "Import participants from a CSV file",
Action: importCSV,
},
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View file

@ -1,31 +0,0 @@
package main
import (
"fmt"
"log/slog"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/urfave/cli/v2"
)
func importCSV(cCtx *cli.Context) error {
path := cCtx.Args().First()
if path == "" {
return cli.Exit("Path for the CSV file not given.", 1)
}
pStore, err := file.NewDefaultParticipantFileStore()
if err != nil {
return cli.Exit(fmt.Sprintf("An error occurred: %v", err), 1)
}
participants, err := pStore.ImportCSV(path)
if err != nil {
return cli.Exit(fmt.Sprintf("An error occurred: %v", err), 1)
}
slog.Info("Imported participants from csv file", "nParticipants", len(participants))
return nil
}

View file

@ -1,144 +0,0 @@
package main
import (
"fmt"
"log"
"git.andreafazzi.eu/andrea/probo/cmd/textinput"
"git.andreafazzi.eu/andrea/probo/pkg/sessionmanager"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
tea "github.com/charmbracelet/bubbletea"
"github.com/urfave/cli/v2"
)
func push(cCtx *cli.Context) error {
pStore, err := file.NewDefaultParticipantFileStore()
if err != nil {
return cli.Exit(fmt.Sprintf("An error occurred: %v", err), 1)
}
participants := pStore.ReadAll()
if len(participants) == 0 {
return cli.Exit("No participants found!", 1)
}
sessionName := cCtx.Args().First()
if cCtx.Args().Len() < 1 {
input := textinput.NewTextInput("My exam session")
p := tea.NewProgram(input)
if _, err := p.Run(); err != nil {
log.Fatal(err)
}
sessionName = input.Value()
}
sStore, err := file.NewDefaultSessionFileStore()
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
qStore, err := file.NewDefaultQuizFileStore()
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
sm, err := sessionmanager.NewSessionManager(
"http://localhost:8080/",
pStore.Storer,
qStore.Storer,
nil,
nil,
)
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
session, err := sm.Push(sessionName)
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
_, err = sStore.Create(session)
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
log.Println("Session upload completed with success!")
for _, p := range pStore.ReadAll() {
log.Printf("http://localhost:8080/%v/%v", session.ID, p.Token)
}
return nil
}
func pull(cCtx *cli.Context) error {
if cCtx.Args().Len() < 1 {
log.Fatalf("Please provide a session ID as first argument of pull.")
}
rStore, err := file.NewDefaultResponseFileStore()
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
pStore, err := file.NewDefaultParticipantFileStore()
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
qStore, err := file.NewDefaultQuizFileStore()
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
sm, err := sessionmanager.NewSessionManager(
"http://localhost:8080/",
pStore.Storer,
qStore.Storer,
nil,
nil,
)
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
err = sm.Pull(rStore, cCtx.Args().First())
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
log.Println("Responses download completed with success!")
return nil
}
func scores(cCtx *cli.Context) error {
if cCtx.Args().Len() < 1 {
log.Fatalf("Please provide a session name as first argument of 'score'.")
}
sStore, err := file.NewDefaultSessionFileStore()
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
session, err := sStore.Read(cCtx.Args().First())
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
rStore, err := file.NewDefaultResponseFileStore()
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
scores, err := sessionmanager.NewScores(rStore, session)
if err != nil {
log.Fatalf("An error occurred: %v", err)
}
fmt.Println(scores)
return nil
}

View file

@ -1,66 +0,0 @@
package textinput
import (
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type (
errMsg error
)
type model struct {
textInput textinput.Model
err error
}
func NewTextInput(placeholder string) *model {
ti := textinput.New()
ti.Placeholder = placeholder
ti.Focus()
ti.CharLimit = 156
ti.Width = 20
return &model{
textInput: ti,
err: nil,
}
}
func (m *model) Value() string {
return m.textInput.Value()
}
func (m *model) Init() tea.Cmd {
return textinput.Blink
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
}
// We handle errors just like any other message
case errMsg:
m.err = msg
return m, nil
}
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m *model) View() string {
return fmt.Sprintf(
"\nPlease insert the name of the session\n\n%s\n\n%s",
m.textInput.View(),
"(esc to quit)",
) + "\n"
}

8
cmd/common.go Normal file
View file

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

61
cmd/filter.go Normal file
View file

@ -0,0 +1,61 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"os"
"git.andreafazzi.eu/andrea/probo/cmd/filter"
"git.andreafazzi.eu/andrea/probo/cmd/util"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
var filterCmd = &cobra.Command{
Use: "filter {participants,quizzes,responses}",
Short: "Filter the given store",
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/filter/*.tmpl"),
Run: runFilter,
}
func init() {
rootCmd.AddCommand(filterCmd)
filterCmd.PersistentFlags().StringP("input", "i", "", "Specify an input file")
}
func runFilter(cmd *cobra.Command, args []string) {
var storeType 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)
storeType = args[0]
model, err := tea.NewProgram(
filter.New(path, storeType, util.ReadStdin()),
tea.WithOutput(os.Stderr),
).Run()
if err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
result := model.(*filter.FilterModel)
if result.Result != "" {
fmt.Fprintf(os.Stdout, result.Result)
}
}

418
cmd/filter/filter.go Normal file
View file

@ -0,0 +1,418 @@
package filter
import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/alecthomas/chroma/quick"
"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 FilterModel struct {
// UI
textInput *textinput.Model
viewport *viewport.Model
group *group.Model
help *help.Model
statusBar *statusbar.Model
spinner spinner.Model
// Layout
document *layout.Layout
// Key bindings
bindings *keyBindings
// file store
store []any
result []any
// json
lastQuery string
FilteredJson string
InputJson string
Result string
// filter file
filterFilePath string
state int
filterType string
}
func New(path string, filterType string, stdin string) *FilterModel {
textInput := textinput.New(
textinput.WithPlaceholder("Write your jq filter here..."),
)
viewport := viewport.New()
group := group.New(
group.WithItems(textInput, viewport),
group.WithLayout(
layout.New(
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 0, 1, 0)}),
layout.WithItem(textInput),
layout.WithItem(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(filterTypeFormats[filterType]),
),
)
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),
)
return &FilterModel{
textInput: textInput,
viewport: viewport,
group: group,
statusBar: statusBar,
spinner: s,
document: document,
bindings: bindings,
help: help,
filterType: filterType,
filterFilePath: path,
InputJson: stdin,
}
}
func (m *FilterModel) 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 *FilterModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.document.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, m.bindings.quit):
m.FilteredJson = ""
return m, tea.Quit
case key.Matches(msg, m.bindings.enter):
m.marshalJSON()
return m, tea.Quit
}
case storeLoadedMsg:
cmds = append(cmds, m.handleStoreLoaded(msg))
case resultMsg:
cmds = append(cmds, m.handleFiltered(msg))
m.state = FilterState
case errorMsg:
m.handleError(msg)
m.state = ErrorState
}
cmds = m.handleState(msg, cmds)
return m, tea.Batch(cmds...)
}
func (m *FilterModel) View() string {
return m.document.View()
}
func (m *FilterModel) marshalJSON() {
if m.FilteredJson == "" {
return
}
if m.InputJson != "" {
m.Result = fmt.Sprintf("{%s, \"%s\": %s}", strings.Trim(m.InputJson, "{}"), m.filterType, m.FilteredJson)
} else {
var result interface{}
filtered := fmt.Sprintf("{\"%s\": %s}", m.filterType, m.FilteredJson)
err := json.Unmarshal([]byte(filtered), &result)
if err != nil {
panic(err)
}
resultJson, err := json.Marshal(result)
if err != nil {
panic(err)
}
m.Result = string(resultJson)
}
}
func (m *FilterModel) showErrorOnStatusBar(err error) {
m.statusBar.SetContent(
stateFormats[ErrorState][0],
fmt.Sprintf(stateFormats[ErrorState][1], err),
stateFormats[ErrorState][2],
)
}
func (m *FilterModel) handleError(msg tea.Msg) {
err := msg.(errorMsg)
m.statusBar.SetContent(
stateFormats[ErrorState][0],
fmt.Sprintf(stateFormats[ErrorState][1], err.error),
stateFormats[ErrorState][2],
)
}
func (m *FilterModel) handleStoreLoaded(msg tea.Msg) tea.Cmd {
return func() tea.Msg {
storeMsg := msg.(storeLoadedMsg)
m.store = storeMsg.store
m.state = FilterState
if m.filterFilePath != "" {
jq, err := os.ReadFile(m.filterFilePath)
if err != nil {
panic(err)
}
m.textInput.SetValue(strings.TrimSpace(string(jq)))
return m.query(strings.TrimSpace(string(jq)))
}
coloredJson, err := toColoredJson(m.store)
if err != nil {
return errorMsg{err}
}
m.viewport.SetContent(coloredJson)
return m.query(".")
}
}
func (m *FilterModel) handleFiltered(msg tea.Msg) tea.Cmd {
return func() tea.Msg {
m.result = msg.(resultMsg).result
coloredJson, err := toColoredJson(m.result)
if err != nil {
return errorMsg{err}
}
json, err := toJson(m.result)
if err != nil {
return errorMsg{err}
}
m.FilteredJson = json
m.viewport.SetContent(coloredJson)
return nil
}
}
func (m *FilterModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
_, cmd := m.group.Update(msg)
if m.state == LoadingStoreState {
return m.updateSpinner(msg, cmd, cmds)
}
cmds = append(cmds, cmd, m.query(m.textInput.Value()))
if m.state != ErrorState {
m.statusBar.SetContent(stateFormats[FilterState]...)
}
return cmds
}
func (m *FilterModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd {
m.spinner, cmd = m.spinner.Update(msg)
m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2])
cmds = append(cmds, cmd)
return cmds
}
func (m *FilterModel) loadStore() tea.Cmd {
return func() tea.Msg {
var jsonStore []byte
switch m.filterType {
case "participants":
pStore, err := file.NewDefaultParticipantFileStore()
if err != nil {
panic(err)
}
jsonStore, err = pStore.Storer.Json()
if err != nil {
panic(err)
}
case "quizzes":
qStore, err := file.NewDefaultQuizFileStore()
if err != nil {
panic(err)
}
jsonStore, err = qStore.Storer.Json()
if err != nil {
panic(err)
}
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!")
}
v := make([]any, 0)
err := json.Unmarshal(jsonStore, &v)
if err != nil {
return errorMsg{err}
}
return storeLoadedMsg{v}
}
}
func (m *FilterModel) query(input string) tea.Cmd {
return func() tea.Msg {
if input == m.lastQuery {
return nil
}
if m.state == LoadingStoreState {
return nil
}
m.lastQuery = input
query, err := gojq.Parse(input)
if err != nil {
return errorMsg{fmt.Errorf("jq query parse error: %v", err)}
}
var result []string
iter := query.Run(m.store)
for {
v, ok := iter.Next()
if !ok {
break
}
if err, ok := v.(error); ok {
return errorMsg{fmt.Errorf("jq query run error: %v", err)}
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return errorMsg{err}
}
result = append(result, string(b))
}
v := make([]any, 0)
err = json.Unmarshal([]byte(strings.Join(result, "\n")), &v)
if err != nil {
return errorMsg{err}
}
return resultMsg{v}
}
}
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 toJson(data []any) (string, error) {
result, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(result), nil
}
func sanitize(text string) string {
// FIXME: The use of a standard '-' character causes rendering
// issues within the viewport. Further investigation is
// required to resolve this problem.
return strings.Replace(text, "-", "", -1)
}

14
cmd/filter/format.go Normal file
View file

@ -0,0 +1,14 @@
package filter
var (
stateFormats = map[int][]string{
FilterState: []string{"FILTER 📖", "Write your jq command in the input box to start filtering. Press enter to return the result.", "STORE 🟢"},
LoadingStoreState: []string{"LOAD %s", "Loading the store...", "STORE 🔴"},
ErrorState: []string{"ERROR 📖", "%v", "STORE 🟢"},
}
filterTypeFormats = map[string]string{
"participants": "👫 Participants filter 👫",
"quizzes": "❓ Quizzes filter ❓",
"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
}

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

@ -0,0 +1,7 @@
package filter
const (
LoadingStoreState = iota
FilterState
ErrorState
)

39
cmd/import.go Normal file
View file

@ -0,0 +1,39 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// importCmd represents the import command
var importCmd = &cobra.Command{
Use: "import",
Short: "Import entities",
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("import called")
},
}
func init() {
rootCmd.AddCommand(importCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// importCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// importCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

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

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,22 +25,25 @@ 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 brief description of your application",
Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
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"),
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.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },

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

60
cmd/session.go Normal file
View file

@ -0,0 +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: "Create a new session or update a given one",
Long: "Create a new session or update a given one.",
Run: updateSession,
}
func init() {
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)
}
}

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

60
go.mod
View file

@ -1,61 +1,75 @@
module git.andreafazzi.eu/andrea/probo
go 1.21.6
go 1.22.2
require (
github.com/charmbracelet/bubbles v0.18.0
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/lipgloss v0.9.1
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/urfave/cli v1.22.14
github.com/urfave/cli/v2 v2.27.1
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/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // 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/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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/itchyny/timefmt-go v0.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-isatty v0.0.18 // 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-20211018074035-2e021307bc4b // 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.6 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/cobra v1.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.18.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // 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-20230905200255-921286631fa9 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.6.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
golang.org/x/text v0.14.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

145
go.sum
View file

@ -1,82 +1,126 @@
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
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/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
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/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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/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-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
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-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.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
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/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@ -93,41 +137,46 @@ 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/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk=
github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
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-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
@ -136,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,3 +1,8 @@
go 1.21.6
go 1.22.2
use .
toolchain go1.22.3
use (
.
../sugarfoam
)

View file

@ -1,34 +1,122 @@
cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ=
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/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=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/consul/api v1.25.1/go.mod h1:iiLVwR/htV7mas/sy0O+XSuEnrdBUUydemjxcUrAt4g=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
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/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8=
github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts=
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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=
go.etcd.io/etcd/client/v2 v2.305.10/go.mod h1:m3CKZi69HzilhVqtPDcjhSGp+kA1OmbNn0qamH80xjA=
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/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
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.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
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=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=

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

@ -7,7 +7,6 @@ import (
"os"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store"
"github.com/remogatto/prettytest"
)
@ -15,67 +14,67 @@ type examTestSuite struct {
prettytest.Suite
}
func (t *examTestSuite) TestCreate() {
participantStore, err := NewParticipantFileStore(
&FileStoreConfig[*models.Participant, *store.ParticipantStore]{
FilePathConfig: FilePathConfig{"testdata/exams/participants", "participant", ".json"},
IndexDirFunc: DefaultIndexDirFunc[*models.Participant, *store.ParticipantStore],
CreateEntityFunc: func() *models.Participant {
return &models.Participant{
Attributes: make(map[string]string),
}
},
},
)
// func (t *examTestSuite) TestCreate() {
// participantStore, err := NewParticipantFileStore(
// &FileStoreConfig[*models.Participant, *store.ParticipantStore]{
// FilePathConfig: FilePathConfig{"testdata/exams/participants", "participant", ".json"},
// IndexDirFunc: DefaultIndexDirFunc[*models.Participant, *store.ParticipantStore],
// CreateEntityFunc: func() *models.Participant {
// return &models.Participant{
// Attributes: make(map[string]string),
// }
// },
// },
// )
t.Nil(err)
// t.Nil(err)
quizStore, err := NewQuizFileStore(
&FileStoreConfig[*models.Quiz, *store.QuizStore]{
FilePathConfig: FilePathConfig{"testdata/exams/quizzes", "quiz", ".md"},
IndexDirFunc: DefaultQuizIndexDirFunc,
},
)
// quizStore, err := NewQuizFileStore(
// &FileStoreConfig[*models.Quiz, *store.QuizStore]{
// FilePathConfig: FilePathConfig{"testdata/exams/quizzes", "quiz", ".md"},
// IndexDirFunc: DefaultQuizIndexDirFunc,
// },
// )
t.Nil(err)
// t.Nil(err)
if !t.Failed() {
t.Equal(3, len(participantStore.ReadAll()))
// if !t.Failed() {
// t.Equal(3, len(participantStore.ReadAll()))
examStore, err := NewDefaultExamFileStore()
t.Nil(err)
// examStore, err := NewDefaultExamFileStore()
// t.Nil(err)
if !t.Failed() {
g := new(models.Group)
c := new(models.Collection)
// if !t.Failed() {
// g := new(models.Group)
// c := new(models.Collection)
participants := participantStore.Storer.FilterInGroup(g, map[string]string{"class": "1 D LIN"})
quizzes := quizStore.Storer.FilterInCollection(c, map[string]string{"tags": "#tag1"})
// participants := participantStore.Storer.FilterInGroup(g, map[string]string{"class": "1 D LIN"})
// quizzes := quizStore.Storer.FilterInCollection(c, map[string]string{"tags": "#tag1"})
for _, p := range participants {
e := new(models.Exam)
e.Participant = p
e.Quizzes = quizzes
// for _, p := range participants {
// e := new(models.Exam)
// e.Participant = p
// e.Quizzes = quizzes
_, err = examStore.Create(e)
t.Nil(err)
// _, err = examStore.Create(e)
// t.Nil(err)
defer os.Remove(examStore.GetPath(e))
// defer os.Remove(examStore.GetPath(e))
examFromDisk, err := readExamFromJSON(e.GetID())
t.Nil(err)
// examFromDisk, err := readExamFromJSON(e.GetID())
// t.Nil(err)
if !t.Failed() {
t.Not(t.Nil(examFromDisk.Participant))
if !t.Failed() {
t.Equal("Smith", examFromDisk.Participant.Lastname)
t.Equal(2, len(examFromDisk.Quizzes))
}
}
}
}
}
}
// if !t.Failed() {
// t.Not(t.Nil(examFromDisk.Participant))
// if !t.Failed() {
// t.Equal("Smith", examFromDisk.Participant.Lastname)
// t.Equal(2, len(examFromDisk.Quizzes))
// }
// }
// }
// }
// }
// }
func readExamFromJSON(examID string) (*models.Exam, error) {
// Build the path to the JSON file

View file

@ -12,7 +12,7 @@ import (
"git.andreafazzi.eu/andrea/probo/pkg/store"
)
type IndexDirFunc[T FileStorable, K Storer[T]] func(s *FileStore[T, K]) error
type IndexDirFunc[T FileStorable, K store.Storer[T]] func(s *FileStore[T, K]) error
var (
ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.")
@ -25,8 +25,13 @@ type FileStorable interface {
Unmarshal([]byte) error
}
type Storer[T store.Storable] interface {
store.Storer[T]
type FileStorer[T FileStorable] interface {
// store.Storer[T]
Create(T, ...string) (T, error)
ReadAll() []T
Read(string) (T, error)
Update(T, string) (T, error)
Delete(string) (T, error)
}
type FilePathConfig struct {
@ -35,14 +40,14 @@ type FilePathConfig struct {
FileSuffix string
}
type FileStoreConfig[T FileStorable, K Storer[T]] struct {
type FileStoreConfig[T FileStorable, K store.Storer[T]] struct {
FilePathConfig
IndexDirFunc func(*FileStore[T, K]) error
CreateEntityFunc func() T
NoIndexOnCreate bool
}
type FileStore[T FileStorable, K Storer[T]] struct {
type FileStore[T FileStorable, K store.Storer[T]] struct {
*FileStoreConfig[T, K]
Storer K
@ -51,7 +56,7 @@ type FileStore[T FileStorable, K Storer[T]] struct {
paths map[string]string
}
func DefaultIndexDirFunc[T FileStorable, K Storer[T]](s *FileStore[T, K]) error {
func DefaultIndexDirFunc[T FileStorable, K store.Storer[T]](s *FileStore[T, K]) error {
if s.CreateEntityFunc == nil {
return errors.New("CreateEntityFunc cannot be nil!")
}
@ -74,31 +79,39 @@ func DefaultIndexDirFunc[T FileStorable, K Storer[T]](s *FileStore[T, K]) error
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
}
func NewFileStore[T FileStorable, K Storer[T]](config *FileStoreConfig[T, K], storer K) (*FileStore[T, K], error) {
func NewFileStore[T FileStorable, K store.Storer[T]](config *FileStoreConfig[T, K], storer K) (*FileStore[T, K], error) {
store := &FileStore[T, K]{
FileStoreConfig: config,
Storer: storer,

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

@ -60,27 +60,3 @@ func generateToken() string {
return token
}
// func (s *ParticipantStore) FilterInGroup(group *models.Group, filter map[string]string) []*models.Participant {
// participants := s.ReadAll()
// if filter == nil {
// return participants
// }
// filteredParticipants := s.Filter(participants, func(p *models.Participant) bool {
// for pk, pv := range p.Attributes {
// for fk, fv := range filter {
// if pk == fk && pv == fv {
// return true
// }
// }
// }
// return false
// })
// group.Participants = filteredParticipants
// return group.Participants
// }

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

@ -1,6 +1,7 @@
package store
import (
"encoding/json"
"fmt"
"sync"
"time"
@ -26,12 +27,7 @@ type Storer[T Storable] interface {
Read(string) (T, error)
Update(T, string) (T, error)
Delete(string) (T, error)
}
type FilterStorer[T Storable] interface {
Storer[T]
Filter([]T, func(T) bool) []T
Json() ([]byte, error)
}
type Store[T Storable] struct {
@ -169,3 +165,7 @@ func (s *Store[T]) Delete(id string) (T, error) {
return sEntity, nil
}
func (s *Store[T]) Json() ([]byte, error) {
return json.Marshal(s.ReadAll())
}

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