Compare commits

..

34 commits

Author SHA1 Message Date
b513964735 Remove backup file 2024-06-05 12:18:09 +02:00
78e9e60ed5 Add rank 2024-06-05 12:16:08 +02:00
007236bd0f Include Session in exam template 2024-05-25 18:09:31 +02:00
875775e1e4 Output the created session on stdout 2024-05-24 08:10:04 +02:00
ebd20b53ed merge 2024-05-22 20:40:13 +02:00
6127260b91 Use templates for rendering long CLI description 2024-05-22 20:37:50 +02:00
3250810364 Add embed and documentation templates 2024-05-22 10:00:10 +02:00
1a9c9e6b8a Working on CLI templates 2024-05-13 12:00:47 +02:00
6b34c2d29b Working on responses 2024-05-12 10:15:48 +02:00
7eaeb36578 Refactoring 2024-04-24 09:29:01 +02:00
be95c23d5f Add update session command 2024-04-22 14:24:29 +02:00
2c854d4f8b Tags are a string slice 2024-04-12 13:39:49 +02:00
e5f6d3ffaf Replace colorjson with chroma 2024-04-12 09:52:11 +02:00
e161d936aa Remove table from JSON filter 2024-04-10 08:51:51 +02:00
2331ca03b2 Generalize filters 2024-04-10 08:44:49 +02:00
588cf064c1 Filters partially work 2024-03-27 11:45:33 +01:00
e72e79d1f7 Use sugarfoam 2024-03-25 15:53:20 +01:00
b50932124a Restructure the fs and add cobra 2024-02-06 09:03:57 +01:00
81274dcf89 Add generateToken function 2024-02-06 08:35:07 +01:00
ea38c49009 Working on the TUI 2023-12-27 15:05:11 +01:00
132689aa1c Reorganize folders 2023-12-21 17:38:05 +01:00
68b7efa585 Something like an MVP 2023-12-17 18:56:20 +01:00
e05ea6dd25 Rename SessionManager 2023-12-12 09:22:07 +01:00
7ff0d348b8 Rename SessionManager 2023-12-12 09:21:55 +01:00
142741ab5f Working on server and cli 2023-12-11 09:32:50 +01:00
1c0119b342 Working on CLI 2023-12-05 22:11:08 +01:00
d9dfccf040 Initial dev of probo-cli and server 2023-12-05 09:12:32 +01:00
c1545590d4 First implementation of probo server component 2023-12-01 09:34:26 +01:00
10620eae13 TestCreate exam almost completed 2023-11-28 16:19:49 +01:00
489226d5f5 Introduce a CreateEntityFunc 2023-11-21 18:24:10 +01:00
4045a9c705 First implementation of Group 2023-11-21 15:12:13 +01:00
ac95b38fe8 Add Marshal/Unmarshal 2023-11-20 14:14:09 +01:00
3196982a64 Add NoIndexOnCreate config option 2023-11-18 12:01:57 +01:00
3cdfa72403 Completed first refactoring with Go generics 2023-11-18 11:12:07 +01:00
146 changed files with 5659 additions and 7662 deletions

2
.gitignore vendored
View file

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

View file

@ -1,6 +1,6 @@
MIT License
The MIT License (MIT)
Copyright (c) 2022 Andrea Fazzi
Copyright © 2024 Andrea Fazzi dev@andreafazzi.eu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -9,13 +9,13 @@ 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 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.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

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

144
assets/static/css/neat.css Normal file
View file

@ -0,0 +1,144 @@
:root {
color-scheme: light dark;
--light: #fff;
--lesslight: #efefef;
--dark: #404040;
--moredark: #000;
border-top: 5px solid var(--dark);
line-height: 1.5em; /* This causes wrapping h1's to collapse too small */
font-family: sans-serif;
font-size: 16px;
}
* {
box-sizing: border-box;
color: var(--dark);
}
button, input {
font-size: 1em; /* Override browser default font shrinking*/
}
input {
border: 1px solid var(--dark);
background-color: var(--lesslight);
border-radius: .25em;
padding: .5em;
}
pre {
background-color: var(--lesslight);
margin: 0.5em 0 0.5em 0;
padding: 0.5em;
overflow: auto;
}
code {
background-color: var(--lesslight);
}
body {
background-color: var(--light);
margin: 0;
max-width: 800px;
padding: 0 20px 20px 20px;
margin-left: auto;
margin-right: auto;
}
img {
max-width: 100%;
height: auto;
}
button, .button, input[type=submit] {
display: inline-block;
background-color: var(--dark);
color: var(--light);
text-align: center;
padding: .5em;
border-radius: .25em;
text-decoration: none;
border: none;
cursor: pointer;
}
button:hover, .button:hover, input[type=submit]:hover {
color: var(--lesslight);
background-color: var(--moredark);
}
/* Add a margin between side-by-side buttons */
button + button, .button + .button, input[type=submit] + input[type=submit] {
margin-left: 1em;
}
.center {
display: block;
margin-left: auto;
margin-right: auto;
text-align: center;
}
.bordered {
border: 3px solid;
}
.home {
display: inline-block;
background-color: var(--dark);
color: var(--light);
margin-top: 20px;
padding: 5px 10px 5px 10px;
text-decoration: none;
font-weight: bold;
}
/* Desktop sizes */
@media only screen and (min-width: 600px) {
ol.twocol {
column-count: 2;
}
.row {
display: flex;
flex-direction: row;
padding: 0;
width: 100%;
}
/* Make everything in a row a column */
.row > * {
display: block;
flex: 1 1 auto;
max-width: 100%;
width: 100%;
}
.row > *:not(:last-child) {
margin-right: 10px;
}
}
/* Dark mode overrides (confusingly inverse) */
@media (prefers-color-scheme: dark) {
:root {
--light: #222;
--lesslight: #333;
--dark: #eee;
--moredark: #fefefe;
}
/* This fixes an odd blue then white shadow on FF in dark mode */
*:focus {
outline: var(--light);
box-shadow: 0 0 0 .25em royalblue;
}
}
/* Printing */
@media print {
.home {
display: none;
}
}

28
assets/templates/exam.tpl Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- <link rel="stylesheet" href="https://unpkg.com/mvp.css"> -->
<link rel="stylesheet" type="text/css" href="/static/css/neat.css">
<title>Exam</title>
</head>
<body>
<h1>Exam</h1>
<h2>{{.Participant.Firstname}} {{.Participant.Lastname}}</h2>
<form action="/{{.SessionID}}/{{.Participant.Token}}" method="post">
{{range $index, $quiz := .Quizzes}}
<h3>Question {{$index}}:</h3>
<p>{{$quiz.Question.Text}}</p>
{{range $answer := $quiz.Answers}}
<input type="radio"
id="{{$quiz.Question.ID}}_{{$answer.ID}}" name="{{$quiz.Question.ID}}"
value="{{$answer.ID}}">
<label
for="{{$answer.ID}}">{{$answer.Text}}</label><br>
{{end}}
<br>
{{end}}
<button type="submit">Invia</button>
</form>
</body>
</html>

290
backup/main.go Normal file
View file

@ -0,0 +1,290 @@
package main
import (
"encoding/json"
"io"
"log/slog"
"math/rand"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"text/template"
"time"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
"github.com/lmittmann/tint"
)
var (
DefaultAssetDir = "assets"
DefaultDataDir = "data"
DefaultSessionDir = "sessions"
DefaultResponseDir = "responses"
DefaultTemplateDir = "templates"
DefaultStaticDir = "static"
)
type Config struct {
SessionDir string
ResponseDir string
TemplateDir string
StaticDir string
}
type ExamTemplateData struct {
*models.Exam
SessionID string
}
type Server struct {
config *Config
mux *http.ServeMux
sessionFileStore *file.SessionFileStore
responseFileStore *file.ResponseFileStore
}
func GetDefaultTemplateDir() string {
return filepath.Join(DefaultAssetDir, DefaultTemplateDir)
}
func GetDefaultStaticDir() string {
return filepath.Join(DefaultAssetDir, DefaultStaticDir)
}
func GetDefaultSessionDir() string {
return filepath.Join(DefaultDataDir, DefaultSessionDir)
}
func GetDefaultResponseDir() string {
return filepath.Join(DefaultDataDir, DefaultResponseDir)
}
func NewServer(config *Config) (*Server, error) {
_, err := os.Stat(config.SessionDir)
if err != nil {
return nil, err
}
_, err = os.Stat(config.TemplateDir)
if err != nil {
return nil, err
}
_, err = os.Stat(config.StaticDir)
if err != nil {
return nil, err
}
sStore, err := file.NewSessionFileStore(
&file.FileStoreConfig[*models.Session, *store.SessionStore]{
FilePathConfig: file.FilePathConfig{Dir: config.SessionDir, FilePrefix: "session", FileSuffix: ".json"},
IndexDirFunc: file.DefaultIndexDirFunc[*models.Session, *store.SessionStore],
CreateEntityFunc: func() *models.Session {
return &models.Session{}
},
},
)
if err != nil {
return nil, err
}
rStore, err := file.NewResponseFileStore(
&file.FileStoreConfig[*models.Response, *store.ResponseStore]{
FilePathConfig: file.FilePathConfig{Dir: config.ResponseDir, FilePrefix: "response", FileSuffix: ".json"},
IndexDirFunc: file.DefaultIndexDirFunc[*models.Response, *store.ResponseStore],
CreateEntityFunc: func() *models.Response {
return &models.Response{}
},
},
)
if err != nil {
return nil, err
}
rStore.FilePathConfig = file.FilePathConfig{
Dir: config.ResponseDir,
FilePrefix: "response",
FileSuffix: ".json",
}
s := &Server{
config,
http.NewServeMux(),
sStore,
rStore,
}
s.mux.Handle("/static/", http.StripPrefix("/static", http.FileServer(http.Dir(config.StaticDir))))
s.mux.HandleFunc("/create", s.createExamSessionHandler)
s.mux.HandleFunc("/responses/", s.getResponsesHandler)
s.mux.HandleFunc("/", s.getExamHandler)
return s, nil
}
func NewDefaultServer() (*Server, error) {
return NewServer(&Config{
SessionDir: GetDefaultSessionDir(),
ResponseDir: GetDefaultResponseDir(),
TemplateDir: GetDefaultTemplateDir(),
StaticDir: GetDefaultStaticDir(),
})
}
func (s *Server) getResponsesHandler(w http.ResponseWriter, r *http.Request) {
result := make([]*models.Response, 0)
urlParts := strings.Split(r.URL.Path, "/")
sessionID := urlParts[2]
if r.Method == "GET" {
session, err := s.sessionFileStore.Read(sessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, exam := range session.Exams {
responses := s.responseFileStore.ReadAll()
for _, r := range responses {
if r.ID == exam.ID {
result = append(result, r)
}
}
}
err = json.NewEncoder(w).Encode(result)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func (s *Server) createExamSessionHandler(w http.ResponseWriter, r *http.Request) {
session := new(models.Session)
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = session.Unmarshal(data)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
memorySession, err := s.sessionFileStore.Create(session)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = json.NewEncoder(w).Encode(memorySession)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
slog.Info("Received a new session", "session", memorySession)
}
func (s *Server) getExamHandler(w http.ResponseWriter, r *http.Request) {
urlParts := strings.Split(r.URL.Path, "/")
sessionID := urlParts[1]
token := urlParts[2]
session, err := s.sessionFileStore.Read(sessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
exam := session.Exams[token]
if r.Method == "GET" {
w.Header().Set("Content-Type", "text/html")
tplData, err := os.ReadFile(filepath.Join(GetDefaultTemplateDir(), "exam.tpl"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmpl := template.Must(template.New("exam").Parse(string(tplData)))
err = tmpl.Execute(w, ExamTemplateData{exam, session.ID})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response := new(models.Response)
response.UniqueIDFunc = func() string {
return exam.GetID()
}
response.Questions = make(map[string]string)
for qID, values := range r.Form {
for _, aID := range values {
response.Questions[qID] = aID
}
}
_, err = s.responseFileStore.Create(response)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Write([]byte("<p>Thank you for your response.</p>"))
return
}
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
func generateRandomID() string {
id := ""
for i := 0; i < 6; i++ {
id += strconv.Itoa(rand.Intn(9) + 1)
}
return id
}
func main() {
slog.SetDefault(slog.New(
tint.NewHandler(os.Stdout, &tint.Options{
Level: slog.LevelInfo,
TimeFormat: time.Kitchen,
}),
))
server, err := NewDefaultServer()
if err != nil {
panic(err)
}
slog.Info("Probo server started.")
http.ListenAndServe(":8080", server)
}

289
backup/server_test.go Normal file
View file

@ -0,0 +1,289 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"github.com/lmittmann/tint"
"github.com/remogatto/prettytest"
)
var examPayload = `
{
"ID": "fe0a7ee0-f31a-413d-f123-ab5068bcaaaa",
"Name": "Test session",
"Exams": {
"111222": {
"id": "fe0a7ee0-f31a-413d-ba81-ab5068bc4c73",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"Participant": {
"ID": "1234",
"Firstname": "John",
"Lastname": "Smith",
"Token": "111222",
"Attributes": {
"class": "1 D LIN"
}
},
"Quizzes": [
{
"id": "0610939b-a1a3-4d0e-bbc4-2aae0e8ee4b9",
"created_at": "2023-11-27T17:51:53.910642221+01:00",
"updated_at": "0001-01-01T00:00:00Z",
"hash": "",
"question": {
"id": "98c0eec9-677f-464e-9e3e-864a859f29a3",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"text": "Question text with #tag1."
},
"answers": [
{
"id": "1ab5ff1f-74c8-4efc-abdc-d03a3640f3bc",
"text": "Answer 1"
},
{
"id": "74547724-b905-476f-8cfc-6ee633f92ef3",
"text": "Answer 2"
},
{
"id": "96c1a8ee-c50c-4ebc-89e4-9f3ca356adbd",
"text": "Answer 3"
},
{
"id": "16c4b95e-64ce-4666-8cbe-b66fa59eb23b",
"text": "Answer 4"
}
],
"tags": [
{
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"DeletedAt": null,
"name": "#tag1"
}
],
"correct": {
"id": "1ab5ff1f-74c8-4efc-abdc-d03a3640f3bc",
"text": "Answer 1"
},
"CorrectPos": 0,
"type": 0
},
{
"id": "915818c4-b0ce-4efc-8def-7fc8d5fa7454",
"created_at": "2023-11-27T17:51:53.91077796+01:00",
"updated_at": "0001-01-01T00:00:00Z",
"hash": "",
"question": {
"id": "70793f0d-2855-4140-814e-40167464424b",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"text": "Another question text with #tag1."
},
"answers": [
{
"id": "1ab5ff1f-74c8-4efc-abdc-d03a3640f3bc",
"text": "Answer 1"
},
{
"id": "74547724-b905-476f-8cfc-6ee633f92ef3",
"text": "Answer 2"
},
{
"id": "96c1a8ee-c50c-4ebc-89e4-9f3ca356adbd",
"text": "Answer 3"
},
{
"id": "16c4b95e-64ce-4666-8cbe-b66fa59eb23b",
"text": "Answer 4"
}
],
"tags": [
{
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"DeletedAt": null,
"name": "#tag1"
}
],
"correct": {
"id": "1ab5ff1f-74c8-4efc-abdc-d03a3640f3bc",
"text": "Answer 1"
},
"CorrectPos": 0,
"type": 0
}
]
}
},
"333444": {
"id": "12345678-abcd-efgh-ijkl-9876543210ef",
"created_at": "2023-12-01T12:00:00Z",
"updated_at": "2023-12-01T12:00:00Z",
"Participant": {
"ID": "5678",
"Firstname": "Jane",
"Lastname": "Doe",
"Token": "333444",
"Attributes": {
"class": "2 A SCI"
}
},
"Quizzes": [
{
"id": "22222222-abcd-efgh-ijkl-9876543210ef",
"created_at": "2023-12-01T12:00:00Z",
"updated_at": "2023-12-01T12:00:00Z",
"hash": "",
"question": {
"id": "33333333-abcd-efgh-ijkl-9876543210ef",
"created_at": "2023-12-01T12:00:00Z",
"updated_at": "2023-12-01T12:00:00Z",
"text": "Sample question text."
},
"answers": [
{
"id": "44444444-abcd-efgh-ijkl-9876543210ef",
"text": "Option 1"
},
{
"id": "55555555-abcd-efgh-ijkl-9876543210ef",
"text": "Option 2"
},
{
"id": "66666666-abcd-efgh-ijkl-9876543210ef",
"text": "Option 3"
},
{
"id": "77777777-abcd-efgh-ijkl-9876543210ef",
"text": "Option 4"
}
],
"tags": [
{
"CreatedAt": "2023-12-01T12:00:00Z",
"UpdatedAt": "2023-12-01T12:00:00Z",
"DeletedAt": null,
"name": "#tag2"
}
],
"correct": {
"id": "44444444-abcd-efgh-ijkl-9876543210ef",
"text": "Option 1"
},
"CorrectPos": 0,
"type": 0
}
]
}
}
`
type serverTestSuite struct {
prettytest.Suite
}
func TestRunner(t *testing.T) {
slog.SetDefault(slog.New(
tint.NewHandler(os.Stderr, &tint.Options{
Level: slog.LevelError,
TimeFormat: time.Kitchen,
}),
))
prettytest.Run(
t,
new(serverTestSuite),
)
}
func (t *serverTestSuite) TestCreate() {
DefaultDataDir = "testdata"
s, err := NewDefaultServer()
t.Nil(err)
if !t.Failed() {
request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload))
response := httptest.NewRecorder()
handler := http.HandlerFunc(s.createExamSessionHandler)
handler.ServeHTTP(response, request)
t.Equal(http.StatusOK, response.Code)
if !t.Failed() {
var session *models.Session
err := json.Unmarshal(response.Body.Bytes(), &session)
t.Nil(err)
path := filepath.Join(GetDefaultSessionDir(), "session_fe0a7ee0-f31a-413d-f123-ab5068bcaaaa.json")
_, err = os.Stat(path)
t.Nil(err)
defer os.Remove(path)
t.Equal("fe0a7ee0-f31a-413d-f123-ab5068bcaaaa", session.ID)
}
}
}
func (t *serverTestSuite) TestRead() {
DefaultDataDir = "testdata"
s, err := NewDefaultServer()
t.Nil(err)
if !t.Failed() {
request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload))
response := httptest.NewRecorder()
handler := http.HandlerFunc(s.createExamSessionHandler)
handler.ServeHTTP(response, request)
t.Equal(http.StatusOK, response.Code)
if !t.Failed() {
var session *models.Session
err := json.Unmarshal(response.Body.Bytes(), &session)
t.Nil(err)
path := filepath.Join(GetDefaultSessionDir(), "session_fe0a7ee0-f31a-413d-f123-ab5068bcaaaa.json")
_, err = os.Stat(path)
t.Nil(err)
if !t.Failed() {
defer os.RemoveAll(path)
request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/%s/%s", session.ID, "111222"), nil)
response := httptest.NewRecorder()
handler := http.HandlerFunc(s.getExamHandler)
handler.ServeHTTP(response, request)
t.Equal(http.StatusOK, response.Code)
}
}
}
}

View file

@ -1,106 +0,0 @@
package client
import "git.andreafazzi.eu/andrea/probo/models"
type Question struct {
Text string `json:"text"`
}
type Answer struct {
Text string `json:"text"`
Correct bool `json:"correct"`
}
type Quiz struct {
Question *Question `json:"question"`
Answers []*Answer `json:"answers"`
}
type Collection struct {
Name string `json:"name"`
Query string `json:"query"`
}
type Participant struct {
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
Token uint `json:"token"`
Attributes map[string]string `json:"attributes"`
}
type Exam struct {
Name string `json:"name"`
Description string `json:"description"`
ParticipantID string `json:"participant_id"`
CollectionID string `json:"collection_id"`
}
type BaseResponse struct {
Status string `json:"status"`
Message string `json:"message"`
}
type ReadAllQuizResponse struct {
BaseResponse
Content []*models.Quiz `json:"content"`
}
type CreateQuizResponse struct {
BaseResponse
Content *models.Quiz `json:"content"`
}
type UpdateQuizResponse struct {
BaseResponse
Content *models.Quiz `json:"content"`
}
type CreateQuestionRequest struct {
*Question
}
type CreateAnswerRequest struct {
*Answer
}
type CreateUpdateQuizRequest struct {
*Quiz
*models.Meta
}
type DeleteQuizRequest struct {
ID string
}
type CreateUpdateCollectionRequest struct {
*Collection
}
type DeleteCollectionRequest struct {
ID string
}
type CreateUpdateExamRequest struct {
*Exam
}
type DeleteExamRequest struct {
ID string
}
type ReadExamByIDRequest struct {
ID string
}
type CreateUpdateParticipantRequest struct {
*Participant
}
type DeleteParticipantRequest struct {
ID string
}
type ReadParticipantByIDRequest struct {
ID string
}

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
)

97
cmd/root.go Normal file
View file

@ -0,0 +1,97 @@
/*
Copyright © 2024 Andrea Fazzi dev@andreafazzi.eu
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.
*/
package cmd
import (
"fmt"
"os"
"git.andreafazzi.eu/andrea/probo/cmd/util"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "probo",
Short: "A Quiz Management System",
/*Long: `
Probo is a CLI/TUI application that allows for the quick
creation of quizzes from markdown files and their distribution
as web pages.`,*/
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/root/*.tmpl"),
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.probo.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".probo" (without extension).
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".probo")
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}

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

88
go.mod
View file

@ -1,31 +1,77 @@
module git.andreafazzi.eu/andrea/probo
go 1.21
go 1.22.2
require (
github.com/google/uuid v1.3.1
github.com/julienschmidt/httprouter v1.3.0
github.com/sirupsen/logrus v1.8.1
github.com/alecthomas/chroma v0.10.0
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/glamour v0.6.0
github.com/charmbracelet/huh v0.3.0
github.com/charmbracelet/lipgloss v0.10.0
github.com/charmbracelet/log v0.4.0
github.com/charmbracelet/x/ansi v0.1.2
github.com/d5/tengo/v2 v2.17.0
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.14
github.com/lmittmann/tint v1.0.4
github.com/muesli/termenv v0.15.2
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8
github.com/remogatto/sugarfoam v0.0.0-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.5
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/glebarez/sqlite v1.9.0 // indirect
github.com/go-yaml/yaml v2.1.0+incompatible // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kr/pretty v0.2.1 // indirect
github.com/kr/text v0.1.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/containerd/console v1.0.4 // indirect
github.com/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/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.20 // indirect
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.13.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/sqlite v1.26.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/microcosm-cc/bluemonday v1.0.26 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // 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/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yuin/goldmark v1.5.2 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/term v0.18.0 // indirect
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
gopkg.in/yaml.v3 v3.0.1 // indirect
)

222
go.sum
View file

@ -1,53 +1,187 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181 h1:ntdtXC9+kcgQYvqa6nyLZLniCEUOhWQknLlz38JpDpM=
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181/go.mod h1:Zlzkn8WOd6QS7RC1BXAY1iw1VLq+xT70UZ1vkEtnrvo=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE=
github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA=
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/d5/tengo/v2 v2.17.0 h1:BWUN9NoJzw48jZKiYDXDIF3QrIVZRm1uV1gTzeZ2lqM=
github.com/d5/tengo/v2 v2.17.0/go.mod h1:XRGjEs5I9jYIKTxly6HCF8oiiilk5E/RYXOZ5b0DZC8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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/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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af h1:rSEwVRdJxMq4RK2kI1LEVhM5J3yg3pcvlRYy1vjn7mQ=
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af/go.mod h1:WeyW6WPrlPDwa48kDIytaLxXKyRjOwLp4BEd2tEGY1Y=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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/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=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
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=

8
go.work Normal file
View file

@ -0,0 +1,8 @@
go 1.22.2
toolchain go1.22.3
use (
.
../sugarfoam
)

122
go.work.sum Normal file
View file

@ -0,0 +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/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/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/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/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.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=

View file

@ -1,26 +0,0 @@
package hasher
import (
"git.andreafazzi.eu/andrea/probo/client"
)
type HashFunc func(string) string
type Hasher interface {
// Hash returns a slice of hashes. The first one is an
// hash calculated from the question using
// QuestionHash. Following hashes are calculated from the
// answers using AnswerHash.
QuizHashes(quiz *client.Quiz) []string
// QuestionHash returns an hash calculated from a field of
// Question struct.
QuestionHash(question *client.Question) string
// AnswerHash returns an hash calculated from a field of
// Answer struct.
AnswerHash(answer *client.Answer) string
// Calculate calculates a checksum from all the given hashes.
Calculate(hashes []string) string
}

View file

@ -1,64 +0,0 @@
package hasher
import (
"testing"
"git.andreafazzi.eu/andrea/probo/client"
"github.com/remogatto/prettytest"
)
type testSuite struct {
prettytest.Suite
}
func TestRunner(t *testing.T) {
prettytest.Run(
t,
new(testSuite),
)
}
func (t *testSuite) TestQuizHashes() {
h := NewDefaultHash(DefaultSHA256HashingFn)
firstQuizRequest := &client.CreateQuizRequest{
Question: &client.CreateQuestionRequest{
Text: "Question 1"},
Answers: []*client.CreateAnswerRequest{
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 1", Correct: true},
},
}
secondQuizRequest := &client.CreateQuizRequest{
Question: &client.CreateQuestionRequest{
Text: "Question 1"},
Answers: []*client.CreateAnswerRequest{
{Text: "Answer 1", Correct: false},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: true},
},
}
thirdQuizRequest := &client.CreateQuizRequest{
Question: &client.CreateQuestionRequest{
Text: "Question 2"},
Answers: []*client.CreateAnswerRequest{
{Text: "Answer 1", Correct: false},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: true},
},
}
hashesFromFirstRequest := h.QuizHashes(firstQuizRequest)
hashesFromSecondRequest := h.QuizHashes(secondQuizRequest)
hashesFromThirdRequest := h.QuizHashes(thirdQuizRequest)
t.Equal(5, len(hashesFromFirstRequest))
t.True(hashesFromFirstRequest[1] == hashesFromSecondRequest[2], "Answers' hashes should maintain original request's order")
t.True(hashesFromFirstRequest[4] == hashesFromSecondRequest[4], "Quiz hash should be the same because quizzes are duplicated")
t.True(hashesFromFirstRequest[1] != hashesFromThirdRequest[1], "Questions' hashes should not be the same because texts are different")
t.True(hashesFromFirstRequest[4] != hashesFromThirdRequest[4], "Quiz hash should not be the same because quizzes are not duplicated")
}

View file

@ -1,54 +0,0 @@
package sha256
import (
"crypto/sha256"
"fmt"
"sort"
"strings"
"git.andreafazzi.eu/andrea/probo/client"
"git.andreafazzi.eu/andrea/probo/hasher"
)
var DefaultSHA256HashingFn = func(text string) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(text)))
}
type Default256Hasher struct {
hashFn hasher.HashFunc
}
func NewDefault256Hasher(hashFn hasher.HashFunc) *Default256Hasher {
return &Default256Hasher{hashFn}
}
func (h *Default256Hasher) QuizHashes(quiz *client.Quiz) []string {
result := make([]string, 0)
result = append(result, h.QuestionHash(quiz.Question))
for _, a := range quiz.Answers {
result = append(result, h.AnswerHash(a))
}
result = append(result, h.Calculate(result))
return result
}
func (h *Default256Hasher) QuestionHash(question *client.Question) string {
return h.hashFn(question.Text)
}
func (h *Default256Hasher) AnswerHash(answer *client.Answer) string {
return h.hashFn(answer.Text)
}
func (h *Default256Hasher) Calculate(hashes []string) string {
orderedHashes := make([]string, len(hashes))
copy(orderedHashes, hashes)
sort.Strings(orderedHashes)
return h.hashFn(strings.Join(orderedHashes, ""))
}

View file

@ -1,112 +0,0 @@
package logger
import (
"log"
"net/http"
"time"
"github.com/sirupsen/logrus"
)
const (
Disabled = iota
InfoLevel
WarningLevel
DebugLevel
)
type (
// struct for holding response details
responseData struct {
status int
size int
}
// our http.ResponseWriter implementation
loggingResponseWriter struct {
http.ResponseWriter // compose original http.ResponseWriter
responseData *responseData
}
)
var loggerLevel int = InfoLevel
func (r *loggingResponseWriter) Write(b []byte) (int, error) {
size, err := r.ResponseWriter.Write(b) // write response using original http.ResponseWriter
r.responseData.size += size // capture size
return size, err
}
func (r *loggingResponseWriter) WriteHeader(statusCode int) {
r.ResponseWriter.WriteHeader(statusCode) // write status code using original http.ResponseWriter
r.responseData.status = statusCode // capture status code
}
func WithLogging(h http.Handler) http.Handler {
loggingFn := func(rw http.ResponseWriter, req *http.Request) {
start := time.Now()
responseData := &responseData{
status: 0,
size: 0,
}
lrw := loggingResponseWriter{
ResponseWriter: rw, // compose original http.ResponseWriter
responseData: responseData,
}
h.ServeHTTP(&lrw, req) // inject our implementation of http.ResponseWriter
duration := time.Since(start)
if loggerLevel >= InfoLevel {
logrus.WithFields(logrus.Fields{
"URI": req.RequestURI,
"Method": req.Method,
"Status": responseData.status,
"Duration": duration,
"Size": responseData.size,
}).Info("Completed.")
}
}
return http.HandlerFunc(loggingFn)
}
func SetLevel(level int) {
loggerLevel = level
}
func Info(v ...interface{}) {
if loggerLevel >= InfoLevel {
log.Println(v...)
}
}
func Infof(format string, v ...interface{}) {
if loggerLevel >= InfoLevel {
log.Printf(format, v...)
}
}
func Warning(v ...interface{}) {
if loggerLevel >= WarningLevel {
log.Println(v...)
}
}
func Warningf(format string, v ...interface{}) {
if loggerLevel >= WarningLevel {
log.Printf(format, v...)
}
}
func Debug(v ...interface{}) {
if loggerLevel >= DebugLevel {
log.Println(v...)
}
}
func Debugf(format string, v ...interface{}) {
if loggerLevel >= DebugLevel {
log.Printf(format, v...)
}
}

44
main.go
View file

@ -1,28 +1,30 @@
/*
Copyright © 2024 Andrea Fazzi dev@andreafazzi.eu
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.
*/
package main
import (
"log"
"net/http"
"git.andreafazzi.eu/andrea/probo/hasher/sha256"
"git.andreafazzi.eu/andrea/probo/logger"
"git.andreafazzi.eu/andrea/probo/store/memory"
"github.com/sirupsen/logrus"
"git.andreafazzi.eu/andrea/probo/cmd"
)
const port = "8080"
func main() {
logger.SetLevel(logger.DebugLevel)
server := NewProboCollectorServer(
memory.NewMemoryProboCollectorStore(
sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
),
)
addr := "http://localhost:" + port
logrus.WithField("address", addr).Info("Probo Collector is up&running...")
log.Fatal(http.ListenAndServe(":"+port, server))
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 @@
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

@ -1,30 +0,0 @@
package models
type Filter struct {
Tags []*Tag
}
type Collection struct {
Meta
Name string `json:"name"`
Filter *Filter `json:"filter"`
Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"`
}
func (c *Collection) String() string {
return c.Name
}
func (c *Collection) GetID() string {
return c.ID
}
func (c *Collection) SetID(id string) {
c.ID = id
}
func (c *Collection) GetHash() string {
return ""
}

View file

@ -1,23 +0,0 @@
package models
import (
"time"
"gorm.io/gorm"
)
type Exam struct {
ID string `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Description string
Collection *Collection `gorm:"foreignKey:ID"`
Participant []*Participant `gorm:"many2many:exam_participants"`
// Responses []string
}

View file

@ -1,6 +0,0 @@
package models
type Group struct {
Name string
Participants []*Participant
}

View file

@ -1,9 +0,0 @@
package models
import "time"
type Meta struct {
ID string `json:"id" yaml:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
}

View file

@ -1,12 +0,0 @@
package models
type Participant struct {
ID string `gorm:"primaryKey"`
Firstname string
Lastname string
Token uint
Attributes map[string]string
}

View file

@ -1,14 +0,0 @@
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"`
}

View file

@ -6,7 +6,8 @@ import (
)
type Answer struct {
ID string `json:"id" gorm:"primaryKey"`
// ID string `json:"id" gorm:"primaryKey"`
Meta
Text string `json:"text"`
}
@ -14,14 +15,6 @@ func (a *Answer) String() string {
return a.Text
}
func (a *Answer) GetID() string {
return a.ID
}
func (a *Answer) SetID(id string) {
a.ID = id
}
func (a *Answer) GetHash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(a.Text)))
}

28
pkg/models/collection.go Normal file
View file

@ -0,0 +1,28 @@
package models
import "encoding/json"
type Collection struct {
Meta
Name string `json:"name"`
// Filter *Filter `json:"filter"`
Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"`
}
func (c *Collection) String() string {
return c.Name
}
func (c *Collection) GetHash() string {
return ""
}
func (c *Collection) Marshal() ([]byte, error) {
return json.Marshal(c)
}
func (c *Collection) Unmarshal(data []byte) error {
return json.Unmarshal(data, c)
}

50
pkg/models/exam.go Normal file
View file

@ -0,0 +1,50 @@
package models
import (
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
)
type Exam struct {
Meta
Participant *Participant `json:"participant"`
Quizzes []*Quiz `json:"quizzes"`
}
func (e *Exam) String() string {
return fmt.Sprintf("%v's exam with %v quizzes.", e.Participant, len(e.Quizzes))
}
func (e *Exam) GetHash() string {
qHashes := ""
for _, q := range e.Quizzes {
qHashes += q.GetHash()
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{e.Participant.GetHash(), qHashes}, ""))))
}
func (e *Exam) Marshal() ([]byte, error) {
return json.Marshal(e)
}
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
}

9
pkg/models/filters.go Normal file
View file

@ -0,0 +1,9 @@
package models
// type Filter struct {
// Tags []*Tag
// }
// type ParticipantFilter struct {
// Attributes map[string]string
// }

27
pkg/models/group.go Normal file
View file

@ -0,0 +1,27 @@
package models
import (
"github.com/gocarina/gocsv"
)
type Group struct {
Meta
Name string
Participants []*Participant
}
func (g *Group) String() string {
return g.Name
}
func (g *Group) GetHash() string {
return ""
}
func (g *Group) Marshal() ([]byte, error) {
return gocsv.MarshalBytes(g.Participants)
}
func (g *Group) Unmarshal(data []byte) error {
return gocsv.UnmarshalBytes(data, &g.Participants)
}

31
pkg/models/group_test.go Normal file
View file

@ -0,0 +1,31 @@
package models
import (
"github.com/remogatto/prettytest"
)
type groupTestSuite struct {
prettytest.Suite
}
func (t *groupTestSuite) TestMarshal() {
t.Pending()
// group := &Group{
// Name: "Example group",
// Participants: []*Participant{
// {"123", "John", "Doe", 12345, map[string]string{"class": "1 D LIN", "age": "18"}},
// {"456", "Jack", "Sparrow", 67890, map[string]string{"class": "1 D LIN", "age": "24"}},
// },
// }
// expected := `id,firstname,lastname,token,attributes
// 123,John,Doe,12345,"age:18,class:1 D LIN"
// 456,Jack,Sparrow,67890,"age:24,class:1 D LIN"
// `
// csv, err := group.Marshal()
// t.Nil(err)
// t.Equal(expected, string(csv))
}

38
pkg/models/meta.go Normal file
View file

@ -0,0 +1,38 @@
package models
import "time"
type Meta struct {
ID string `json:"id" yaml:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
UniqueIDFunc func() string `json:"-" yaml:"-"`
}
func (m *Meta) GetID() string {
if m.UniqueIDFunc != nil {
return m.UniqueIDFunc()
}
return m.ID
}
func (m *Meta) SetID(id string) {
m.ID = id
}
func (m *Meta) SetCreatedAt(t time.Time) {
m.CreatedAt = t
}
func (m *Meta) SetUpdatedAt(t time.Time) {
m.UpdatedAt = t
}
func (m *Meta) GetCreatedAt() time.Time {
return m.CreatedAt
}
func (m *Meta) GetUpdatedAt() time.Time {
return m.UpdatedAt
}

View file

@ -16,6 +16,7 @@ func TestRunner(t *testing.T) {
prettytest.Run(
t,
new(testSuite),
new(groupTestSuite),
)
}
@ -40,13 +41,16 @@ Question text with #tag1 #tag2 (3).
{Text: "Answer 4"},
},
CorrectPos: 0,
Tags: []string{"#tag1", "#tag2"},
}
quiz, _, err := MarkdownToQuiz(markdown)
q := new(Quiz)
err := MarkdownToQuiz(q, markdown)
t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err))
if !t.Failed() {
t.True(reflect.DeepEqual(quiz, expectedQuiz), fmt.Sprintf("Expected %+v, got %+v", expectedQuiz, quiz))
t.True(reflect.DeepEqual(q, expectedQuiz), fmt.Sprintf("Expected %+v got %+v", expectedQuiz, q))
}
}
@ -74,3 +78,7 @@ func (t *testSuite) TestMarkdownFromQuiz() {
`, md)
}
}
func (t *testSuite) TestParticipantTokenMarshalCSV() {
}

108
pkg/models/participant.go Normal file
View file

@ -0,0 +1,108 @@
package models
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
)
type AttributeList map[string]string
type Participant struct {
Meta
Firstname string `json:"firstname" csv:"firstname"`
Lastname string `json:"lastname" csv:"lastname"`
Token string `json:"token" csv:"token"`
Attributes AttributeList `csv:"attributes"`
}
func (p *Participant) String() string {
return fmt.Sprintf("%s %s", p.Lastname, p.Firstname)
}
func (p *Participant) GetHash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(append([]string{p.Lastname, p.Firstname}, p.AttributesToSlice()...), ""))))
}
func (p *Participant) AttributesToSlice() []string {
result := make([]string, 0)
for k, v := range p.Attributes {
result = append(result, k, v)
}
return result
}
func (p *Participant) Marshal() ([]byte, error) {
return json.Marshal(p)
}
func (p *Participant) Unmarshal(data []byte) error {
return json.Unmarshal(data, p)
}
func (al AttributeList) MarshalCSV() (string, error) {
result := convertMapToKeyValueOrderedString(al)
return result, nil
}
func (al *AttributeList) UnmarshalCSV(csv string) error {
if *al == nil {
*al = make(AttributeList)
}
pairs := strings.Split(csv, ",")
for _, pair := range pairs {
attrVal := strings.Split(pair, ":")
if len(attrVal) != 2 {
return errors.New("Invalid input format for attribute list.")
}
attr := strings.TrimSpace(attrVal[0])
val := strings.TrimSpace(attrVal[1])
(*al)[attr] = val
}
return nil
}
func (al AttributeList) Get(key string) string {
return al[key]
}
func (al AttributeList) String() string {
result := make([]string, 0)
for k, v := range al {
result = append(result, fmt.Sprintf("%s: %s", k, v))
}
return strings.Join(result, ",")
}
func convertMapToKeyValueOrderedString(m map[string]string) string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
var result strings.Builder
for _, key := range keys {
result.WriteString(key)
result.WriteString(":")
result.WriteString(m[key])
result.WriteString(",")
}
return strings.TrimSuffix(result.String(), ",")
}

View file

@ -14,14 +14,6 @@ func (q *Question) String() string {
return q.Text
}
func (q *Question) GetID() string {
return q.ID
}
func (q *Question) SetID(id string) {
q.ID = id
}
func (q *Question) GetHash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(q.Text)))
}

View file

@ -15,24 +15,25 @@ 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"`
}
func MarkdownToQuiz(markdown string) (*Quiz, *Meta, error) {
func MarkdownToQuiz(quiz *Quiz, markdown string) error {
meta, remainingMarkdown, err := ParseMetaHeaderFromMarkdown(markdown)
if err != nil {
return nil, nil, err
return err
}
lines := strings.Split(remainingMarkdown, "\n")
questionText := ""
answers := []*Answer{}
tags := make([]string, 0)
for _, line := range lines {
if strings.HasPrefix(line, "*") {
@ -45,26 +46,31 @@ func MarkdownToQuiz(markdown string) (*Quiz, *Meta, error) {
}
questionText += line
}
parseTags(&tags, line)
}
questionText = strings.TrimRight(questionText, "\n")
if questionText == "" {
return nil, nil, fmt.Errorf("Question text should not be empty.")
return fmt.Errorf("Question text should not be empty.")
}
if len(answers) < 2 {
return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
return fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
}
question := &Question{Text: questionText}
quiz := &Quiz{Question: question, Answers: answers, CorrectPos: 0}
quiz.Question = question
quiz.Answers = answers
quiz.Tags = tags
if meta != nil {
quiz.Meta = *meta
}
return quiz, meta, nil
return nil
}
func QuizToMarkdown(quiz *Quiz) (string, error) {
@ -96,18 +102,21 @@ func QuizToMarkdown(quiz *Quiz) (string, error) {
return markdown, nil
}
func (q *Quiz) GetID() string {
return q.ID
}
func (q *Quiz) SetID(id string) {
q.ID = id
}
func (q *Quiz) GetHash() string {
return q.calculateHash()
}
func (q *Quiz) Marshal() ([]byte, error) {
result, err := QuizToMarkdown(q)
return []byte(result), err
}
func (q *Quiz) Unmarshal(data []byte) error {
return MarkdownToQuiz(q, string(data))
}
func (q *Quiz) calculateHash() string {
result := make([]string, 0)
@ -194,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))
}
}
}
}

37
pkg/models/response.go Normal file
View file

@ -0,0 +1,37 @@
package models
import (
"encoding/json"
"fmt"
)
type ParticipantAnswer struct {
Quiz *Quiz `json:"question"`
Answer *Answer `json:"answer"`
Correct bool `json:"correct"`
}
type Response struct {
Meta
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.Answers)
}
func (r *Response) GetHash() string {
// return fmt.Sprintf("%x", sha256.Sum256([]byte(r.QuestionID+r.AnswerID)))
return ""
}
func (r *Response) Marshal() ([]byte, error) {
return json.Marshal(r)
}
func (r *Response) Unmarshal(data []byte) error {
return json.Unmarshal(data, r)
}

35
pkg/models/session.go Normal file
View file

@ -0,0 +1,35 @@
package models
import (
"crypto/sha256"
"encoding/json"
"fmt"
)
type Session struct {
Meta
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.Title
}
func (s *Session) GetHash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Title)))
}
func (s *Session) Marshal() ([]byte, error) {
return json.Marshal(s)
}
func (s *Session) Unmarshal(data []byte) error {
return json.Unmarshal(data, s)
}

5
pkg/models/tag.go Normal file
View file

@ -0,0 +1,5 @@
package models
type Tag struct {
Name string `json:"name"`
}

View file

@ -0,0 +1,52 @@
package sessionmanager
import (
"fmt"
"log"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
)
type Score struct {
Exam *models.Exam
Score float32
}
type Scores []*Score
func NewScores(responseFileStore *file.ResponseFileStore, session *models.Session) (Scores, error) {
var scores Scores
for _, exam := range session.Exams {
response, err := responseFileStore.Read(exam.ID)
if err != nil {
log.Println(err)
continue
}
scores = append(scores, CalcScore(response, exam))
}
return scores, nil
}
func (ss Scores) String() string {
var result string
for _, s := range ss {
result += fmt.Sprintf("%v\t%f\n", s.Exam.Participant, s.Score)
}
return result
}
func CalcScore(response *models.Response, exam *models.Exam) *Score {
var score float32
for _, q := range exam.Quizzes {
answerId := response.Questions[q.Question.ID]
if answerId == q.Correct.ID {
score++
}
}
return &Score{exam, score}
}

View file

@ -0,0 +1,125 @@
package sessionmanager
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store"
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
)
type SessionManager struct {
ParticipantStore *store.ParticipantStore
QuizStore *store.QuizStore
ParticipantFilter map[string]string
QuizFilter map[string]string
ServerURL string
Token int
examFileStore *file.ExamFileStore
}
func NewSessionManager(url string, pStore *store.ParticipantStore, qStore *store.QuizStore, pFilter map[string]string, qFilter map[string]string) (*SessionManager, error) {
sm := new(SessionManager)
sm.ServerURL = url
var err error
sm.examFileStore, err = file.NewDefaultExamFileStore()
if err != nil {
return nil, err
}
for _, p := range pStore.ReadAll() {
_, err := sm.examFileStore.Create(&models.Exam{
Participant: p,
Quizzes: qStore.ReadAll(),
})
if err != nil {
return nil, err
}
}
return sm, nil
}
func (sm *SessionManager) GetExams() []*models.Exam {
return sm.examFileStore.ReadAll()
}
func (sm *SessionManager) Pull(rStore *file.ResponseFileStore, sessionID string) error {
url, err := url.JoinPath(sm.ServerURL, "responses/", sessionID)
if err != nil {
return err
}
rr, err := http.Get(url)
if err != nil {
return err
}
responseBody, err := io.ReadAll(rr.Body)
if err != nil {
return err
}
responses := make([]*models.Response, 0)
err = json.Unmarshal(responseBody, &responses)
if err != nil {
return err
}
for _, response := range responses {
_, err := rStore.Create(response)
if err != nil {
return err
}
}
return nil
}
func (sm *SessionManager) Push(name string) (*models.Session, error) {
session := &models.Session{
Name: name,
Exams: make(map[string]*models.Exam),
}
for _, e := range sm.GetExams() {
session.Exams[e.Participant.Token] = e
}
payload, err := session.Marshal()
if err != nil {
return nil, err
}
url, err := url.JoinPath(sm.ServerURL, "create")
if err != nil {
return nil, err
}
response, err := http.Post(url, "application/json", bytes.NewReader(payload))
if err != nil {
return nil, err
}
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
err = json.Unmarshal(responseBody, &session)
if err != nil {
return nil, err
}
return session, nil
}

5
pkg/store/exam.go Normal file
View file

@ -0,0 +1,5 @@
package store
import "git.andreafazzi.eu/andrea/probo/pkg/models"
type ExamStore = Store[*models.Exam]

View file

@ -0,0 +1,49 @@
package file
import "path/filepath"
var (
DefaultBaseDir = "data"
DefaultQuizzesSubdir = "quizzes"
DefaultCollectionsSubdir = "collections"
DefaultParticipantsSubdir = "participants"
DefaultGroupsSubdir = "groups"
DefaultExamsSubdir = "exams"
DefaultResponsesSubdir = "responses"
DefaultSessionSubdir = "sessions"
Dirs = []string{
GetDefaultQuizzesDir(),
GetDefaultParticipantsDir(),
GetDefaultExamsDir(),
GetDefaultSessionDir(),
}
)
func GetDefaultQuizzesDir() string {
return filepath.Join(DefaultBaseDir, DefaultQuizzesSubdir)
}
func GetDefaultCollectionsDir() string {
return filepath.Join(DefaultBaseDir, DefaultCollectionsSubdir)
}
func GetDefaultParticipantsDir() string {
return filepath.Join(DefaultBaseDir, DefaultParticipantsSubdir)
}
func GetDefaultGroupsDir() string {
return filepath.Join(DefaultBaseDir, DefaultGroupsSubdir)
}
func GetDefaultExamsDir() string {
return filepath.Join(DefaultBaseDir, DefaultExamsSubdir)
}
func GetDefaultSessionDir() string {
return filepath.Join(DefaultBaseDir, DefaultSessionSubdir)
}
func GetDefaultResponsesDir() string {
return filepath.Join(DefaultBaseDir, DefaultResponsesSubdir)
}

28
pkg/store/file/exam.go Normal file
View file

@ -0,0 +1,28 @@
package file
import (
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store"
)
type ExamFileStore = FileStore[*models.Exam, *store.Store[*models.Exam]]
func NewExamFileStore(config *FileStoreConfig[*models.Exam, *store.ExamStore]) (*ExamFileStore, error) {
return NewFileStore[*models.Exam](config, store.NewStore[*models.Exam]())
}
func NewDefaultExamFileStore() (*ExamFileStore, error) {
return NewExamFileStore(
&FileStoreConfig[*models.Exam, *store.ExamStore]{
FilePathConfig: FilePathConfig{GetDefaultExamsDir(), "exam", ".json"},
IndexDirFunc: DefaultIndexDirFunc[*models.Exam, *store.ExamStore],
CreateEntityFunc: func() *models.Exam {
return &models.Exam{
Participant: &models.Participant{},
Quizzes: make([]*models.Quiz, 0),
}
},
},
)
}

103
pkg/store/file/exam_test.go Normal file
View file

@ -0,0 +1,103 @@
package file
import (
"encoding/json"
"fmt"
"io"
"os"
"git.andreafazzi.eu/andrea/probo/pkg/models"
"github.com/remogatto/prettytest"
)
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),
// }
// },
// },
// )
// t.Nil(err)
// quizStore, err := NewQuizFileStore(
// &FileStoreConfig[*models.Quiz, *store.QuizStore]{
// FilePathConfig: FilePathConfig{"testdata/exams/quizzes", "quiz", ".md"},
// IndexDirFunc: DefaultQuizIndexDirFunc,
// },
// )
// t.Nil(err)
// if !t.Failed() {
// t.Equal(3, len(participantStore.ReadAll()))
// examStore, err := NewDefaultExamFileStore()
// t.Nil(err)
// 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"})
// for _, p := range participants {
// e := new(models.Exam)
// e.Participant = p
// e.Quizzes = quizzes
// _, err = examStore.Create(e)
// t.Nil(err)
// defer os.Remove(examStore.GetPath(e))
// 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))
// }
// }
// }
// }
// }
// }
func readExamFromJSON(examID string) (*models.Exam, error) {
// Build the path to the JSON file
jsonPath := fmt.Sprintf("testdata/exams/exam_%s.json", examID)
// Open the JSON file
file, err := os.Open(jsonPath)
if err != nil {
return nil, fmt.Errorf("failed to open JSON file: %w", err)
}
defer file.Close()
// Read the JSON data from the file
jsonData, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read JSON data: %w", err)
}
// Unmarshal the JSON data into an Exam object
var exam models.Exam
if err := json.Unmarshal(jsonData, &exam); err != nil {
return nil, fmt.Errorf("failed to parse JSON data: %w", err)
}
return &exam, nil
}

232
pkg/store/file/file.go Normal file
View file

@ -0,0 +1,232 @@
package file
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"git.andreafazzi.eu/andrea/probo/pkg/store"
)
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.")
)
type FileStorable interface {
store.Storable
Marshal() ([]byte, error)
Unmarshal([]byte) error
}
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 {
Dir string
FilePrefix string
FileSuffix string
}
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 store.Storer[T]] struct {
*FileStoreConfig[T, K]
Storer K
lock sync.RWMutex
paths map[string]string
}
func DefaultIndexDirFunc[T FileStorable, K store.Storer[T]](s *FileStore[T, K]) error {
if s.CreateEntityFunc == nil {
return errors.New("CreateEntityFunc cannot be nil!")
}
files, err := os.ReadDir(s.Dir)
if err != nil {
return err
}
entityFiles := make([]fs.DirEntry, 0)
for _, file := range files {
filename := file.Name()
if !file.IsDir() && strings.HasSuffix(filename, s.FileSuffix) {
entityFiles = append(entityFiles, file)
}
}
for _, file := range entityFiles {
filename := file.Name()
fullPath := filepath.Join(s.Dir, filename)
fileInfo, err := os.Stat(fullPath)
if err != nil {
panic(err)
}
if fileInfo.Size() > 0 {
content, err := os.ReadFile(fullPath)
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)
}
}
return nil
}
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,
paths: make(map[string]string, 0),
}
if !config.NoIndexOnCreate {
err := store.IndexDir()
if err != nil {
return nil, err
}
}
return store, nil
}
func (s *FileStore[T, K]) Create(entity T, path ...string) (T, error) {
e, err := s.Storer.Create(entity)
if err != nil {
return e, err
}
data, err := e.Marshal()
if err != nil {
return e, err
}
if len(path) == 0 {
path = append(path, filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix)))
}
file, err := os.Create(path[0])
if err != nil {
return e, err
}
defer file.Close()
_, err = file.Write(data)
if err != nil {
return e, err
}
s.SetPath(e, path[0])
return e, nil
}
func (s *FileStore[T, K]) Update(entity T, id string) (T, error) {
e, err := s.Storer.Update(entity, id)
if err != nil {
return e, err
}
filePath := s.GetPath(e)
data, err := e.Marshal()
if err != nil {
return e, err
}
file, err := os.Create(filePath)
if err != nil {
return e, err
}
defer file.Close()
_, err = file.Write(data)
if err != nil {
return e, err
}
return e, nil
}
func (s *FileStore[T, K]) Read(id string) (T, error) {
return s.Storer.Read(id)
}
func (s *FileStore[T, K]) ReadAll() []T {
return s.Storer.ReadAll()
}
func (s *FileStore[T, K]) Delete(id string) (T, error) {
e, err := s.Storer.Delete(id)
if err != nil {
return e, err
}
err = os.Remove(s.GetPath(e))
if err != nil {
return e, err
}
return e, nil
}
func (s *FileStore[T, K]) IndexDir() error {
return s.IndexDirFunc(s)
}
func (s *FileStore[T, K]) GetPath(entity T) string {
s.lock.RLock()
defer s.lock.RUnlock()
return s.paths[entity.GetID()]
}
func (s *FileStore[T, K]) SetPath(entity T, path string) string {
s.lock.Lock()
defer s.lock.Unlock()
s.paths[entity.GetID()] = path
return path
}

View file

@ -9,9 +9,12 @@ import (
var testdataDir = "./testdata"
func TestRunner(t *testing.T) {
DefaultBaseDir = "testdata"
prettytest.Run(
t,
new(quizTestSuite),
new(collectionTestSuite),
new(participantTestSuite),
new(examTestSuite),
)
}

View file

@ -0,0 +1,53 @@
package file
import (
"git.andreafazzi.eu/andrea/probo/pkg/models"
"git.andreafazzi.eu/andrea/probo/pkg/store"
)
type ParticipantFileStore struct {
*FileStore[*models.Participant, *store.ParticipantStore]
}
func NewParticipantFileStore(config *FileStoreConfig[*models.Participant, *store.ParticipantStore]) (*ParticipantFileStore, error) {
var err error
pStore := new(ParticipantFileStore)
pStore.FileStore, err = NewFileStore(config, store.NewParticipantStore())
if err != nil {
return nil, err
}
return pStore, nil
}
func NewDefaultParticipantFileStore() (*ParticipantFileStore, error) {
return NewParticipantFileStore(
&FileStoreConfig[*models.Participant, *store.ParticipantStore]{
FilePathConfig: FilePathConfig{GetDefaultParticipantsDir(), "participant", ".json"},
IndexDirFunc: DefaultIndexDirFunc[*models.Participant, *store.ParticipantStore],
CreateEntityFunc: func() *models.Participant {
return &models.Participant{
Attributes: make(map[string]string),
}
},
},
)
}
func (ps *ParticipantFileStore) ImportCSV(path string) ([]*models.Participant, error) {
participants, err := ps.FileStore.Storer.ImportCSV(path)
if err != nil {
return nil, err
}
for _, p := range participants {
_, err := ps.Create(p)
if err != nil {
return nil, err
}
}
return participants, nil
}

Some files were not shown because too many files have changed in this diff Show more