probo/cmd/session/session.go

477 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package session
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/alecthomas/chroma/quick"
"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"
)
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{
sanitize(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[desanitize(currentUUID)]
if currentExam == nil {
panic("Current token is not associate to any exam!")
}
md, err := currentExam.ToMarkdown()
if err != nil {
m.showErrorOnStatusBar(err)
}
result, err := m.mdRenderer.Render(md)
if err != nil {
m.showErrorOnStatusBar(err)
}
m.viewport.SetContent(sanitize(result))
}
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}
}
}
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)
}