probo/cmd/session/session.go

471 lines
9.7 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"
"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/help"
"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"
"github.com/remogatto/sugarfoam/components/form"
"github.com/remogatto/sugarfoam/components/group"
"github.com/remogatto/sugarfoam/components/header"
"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
// file store
store *file.SessionFileStore
result []any
// json
FilteredJson string
InputJson string
Result string
// session
session *models.Session
// markdown
mdRenderer *glamour.TermRenderer
// filter file
scriptFilePath string
state int
}
type keyBindings struct {
group *group.Model
quit, enter key.Binding
}
func (k *keyBindings) ShortHelp() []key.Binding {
keys := make([]key.Binding, 0)
current := k.group.Current()
switch item := current.(type) {
case *viewport.Model:
keys = append(
keys,
item.KeyMap.Up,
item.KeyMap.Down,
)
}
keys = append(
keys,
k.quit,
)
return keys
}
func (k keyBindings) FullHelp() [][]key.Binding {
return [][]key.Binding{
{
k.quit,
},
}
}
func newBindings(g *group.Model) *keyBindings {
return &keyBindings{
group: g,
quit: key.NewBinding(
key.WithKeys("esc"), key.WithHelp("esc", "Quit app"),
),
enter: key.NewBinding(
key.WithKeys("enter"), key.WithHelp("enter", "Quit app and return the results"),
),
}
}
func New(path string, stdin string) *SessionModel {
form := form.New(
form.WithGroups(huh.NewGroup(
huh.NewInput().
Title("Session name").
Description("Enter the name of the session"),
)))
form.
WithShowHelp(false).
WithTheme(huh.ThemeDracula())
viewport := viewport.New()
table := table.New(table.WithRelWidths(20, 30, 30, 20))
table.Model.SetColumns([]btTable.Column{
{Title: "Token", Width: 20},
{Title: "Lastname", Width: 20},
{Title: "Firstname", Width: 20},
{Title: "Class", Width: 20},
})
help := help.New()
group := group.New(
group.WithItems(form, table, viewport),
group.WithLayout(
layout.New(
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 0, 1, 0)}),
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 ✨"),
),
)
document := layout.New(
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
layout.WithItem(header),
layout.WithItem(group),
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):
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 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) executeScript(path string) 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 {
return errorMsg{err}
}
s := tengo.NewScript(script)
s.SetImports(stdlib.GetModuleMap("fmt", "json"))
_ = s.Add("input", m.InputJson)
_ = s.Add("output", string(sessionJson))
// compile the source
c, err := s.Compile()
if err != nil {
return errorMsg{err}
}
if err := c.Run(); err != nil {
return errorMsg{err}
}
return scriptExecutedMsg{fmt.Sprintf("%s", c.Get("output"))}
}
}
func (m *SessionModel) marshalJSON() {
var result interface{}
err := json.Unmarshal([]byte(m.FilteredJson), &result)
if err != nil {
panic(err)
}
resultJson, err := json.Marshal(result)
if err != nil {
panic(err)
}
m.Result = string(resultJson)
}
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 token, exam := range session.Exams {
rows = append(rows, btTable.Row{
token,
exam.Participant.Lastname,
exam.Participant.Firstname,
exam.Participant.Attributes.Get("class"),
})
}
m.table.SetRows(rows)
}
func (m *SessionModel) updateViewportContent(session *models.Session) {
currentToken := m.table.SelectedRow()[0]
currentExam := session.Exams[currentToken]
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(result)
}
func (m *SessionModel) createMDRenderer(width int) *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(msg.Width)
}
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
return m.executeScript(m.scriptFilePath)
}
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.state == BrowseState {
m.updateViewportContent(m.session)
}
cmds = append(cmds, cmd /*, m.query(m.textInput.Value())*/)
if m.state != ErrorState {
m.statusBar.SetContent(stateFormats[BrowseState]...)
}
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 {
return errorMsg{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 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)
}