Compare commits
No commits in common. "master" and "parent_node_is_question" have entirely different histories.
master
...
parent_nod
131 changed files with 6091 additions and 6917 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,3 +1 @@
|
||||||
.log
|
.log
|
||||||
data
|
|
||||||
|
|
||||||
|
|
|
@ -1,144 +0,0 @@
|
||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
<!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
290
backup/main.go
|
@ -1,290 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,289 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
49
client/client.go
Normal file
49
client/client.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import "git.andreafazzi.eu/andrea/probo/models"
|
||||||
|
|
||||||
|
type Question struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Answer struct {
|
||||||
|
Text string
|
||||||
|
Correct bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Quiz struct {
|
||||||
|
Question *Question `json:"question"`
|
||||||
|
Answers []*Answer `json:"answers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -1,8 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
var logo = ` ____ _
|
|
||||||
| _ \ _ __ ___ | |__ ___
|
|
||||||
| |_) | '__/ _ \| '_ \ / _ \
|
|
||||||
| __/| | | (_) | |_) | (_) |
|
|
||||||
|_| |_| \___/|_.__/ \___/
|
|
||||||
`
|
|
|
@ -1,61 +0,0 @@
|
||||||
/*
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,418 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
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 📝",
|
|
||||||
}
|
|
||||||
)
|
|
|
@ -1,55 +0,0 @@
|
||||||
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"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
package filter
|
|
||||||
|
|
||||||
type storeLoadedMsg struct {
|
|
||||||
store []any
|
|
||||||
}
|
|
||||||
|
|
||||||
type resultMsg struct {
|
|
||||||
result []any
|
|
||||||
}
|
|
||||||
|
|
||||||
type errorMsg struct {
|
|
||||||
error error
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
package filter
|
|
||||||
|
|
||||||
const (
|
|
||||||
LoadingStoreState = iota
|
|
||||||
FilterState
|
|
||||||
ErrorState
|
|
||||||
)
|
|
|
@ -1,39 +0,0 @@
|
||||||
/*
|
|
||||||
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
38
cmd/init.go
|
@ -1,38 +0,0 @@
|
||||||
/*
|
|
||||||
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
59
cmd/rank.go
|
@ -1,59 +0,0 @@
|
||||||
/*
|
|
||||||
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")
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
package rank
|
|
||||||
|
|
||||||
var (
|
|
||||||
stateFormats = map[int][]string{
|
|
||||||
BrowseState: []string{"BROWSE 📖", "Total scores: %d", "🟢"},
|
|
||||||
ExecutingScriptState: []string{"EXE %s", "Executing script...", "🔴"},
|
|
||||||
ErrorState: []string{"ERROR 📖", "%v", "🔴"},
|
|
||||||
}
|
|
||||||
)
|
|
|
@ -1,62 +0,0 @@
|
||||||
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"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
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
448
cmd/rank/rank.go
|
@ -1,448 +0,0 @@
|
||||||
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)
|
|
||||||
// }
|
|
|
@ -1,7 +0,0 @@
|
||||||
package rank
|
|
||||||
|
|
||||||
const (
|
|
||||||
ExecutingScriptState = iota
|
|
||||||
BrowseState
|
|
||||||
ErrorState
|
|
||||||
)
|
|
97
cmd/root.go
97
cmd/root.go
|
@ -1,97 +0,0 @@
|
||||||
/*
|
|
||||||
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
82
cmd/serve.go
|
@ -1,82 +0,0 @@
|
||||||
/*
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,64 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,81 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,76 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,39 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,60 +0,0 @@
|
||||||
/*
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
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 🟢"},
|
|
||||||
}
|
|
||||||
)
|
|
|
@ -1,69 +0,0 @@
|
||||||
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"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,452 +0,0 @@
|
||||||
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}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
package session
|
|
||||||
|
|
||||||
const (
|
|
||||||
LoadingStoreState = iota
|
|
||||||
BrowseState
|
|
||||||
ErrorState
|
|
||||||
)
|
|
|
@ -1,29 +0,0 @@
|
||||||
/*
|
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,74 +0,0 @@
|
||||||
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())
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
{{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}}
|
|
|
@ -1,27 +0,0 @@
|
||||||
{{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}}
|
|
|
@ -1,2 +0,0 @@
|
||||||
{{template "logo" .}}
|
|
||||||
{{template "description" .}}
|
|
|
@ -1,10 +0,0 @@
|
||||||
{{define "logo"}}
|
|
||||||
```
|
|
||||||
____ _
|
|
||||||
| _ \ _ __ ___ | |__ ___
|
|
||||||
| |_) | '__/ _ \| '_ \ / _ \
|
|
||||||
| __/| | | (_) | |_) | (_) |
|
|
||||||
|_| |_| \___/|_.__/ \___/____
|
|
||||||
|_____|
|
|
||||||
```
|
|
||||||
{{end}}
|
|
|
@ -1,21 +0,0 @@
|
||||||
{{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}}
|
|
|
@ -1,50 +0,0 @@
|
||||||
{{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}}
|
|
|
@ -1,71 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,40 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
{{ 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 }}
|
|
|
@ -1,37 +0,0 @@
|
||||||
<!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>
|
|
|
@ -1,29 +0,0 @@
|
||||||
<!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>
|
|
|
@ -1,25 +0,0 @@
|
||||||
{{ 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 }}
|
|
|
@ -1,55 +0,0 @@
|
||||||
<!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>
|
|
|
@ -1,18 +0,0 @@
|
||||||
{{ 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 }}
|
|
82
go.mod
82
go.mod
|
@ -1,77 +1,15 @@
|
||||||
module git.andreafazzi.eu/andrea/probo
|
module git.andreafazzi.eu/andrea/probo
|
||||||
|
|
||||||
go 1.22.2
|
go 1.17
|
||||||
|
|
||||||
|
require github.com/sirupsen/logrus v1.8.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma v0.10.0
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181
|
github.com/julienschmidt/httprouter v1.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbletea v0.25.0
|
github.com/kr/pretty v0.2.1 // indirect
|
||||||
github.com/charmbracelet/glamour v0.6.0
|
github.com/kr/text v0.1.0 // indirect
|
||||||
github.com/charmbracelet/huh v0.3.0
|
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 // indirect
|
||||||
github.com/charmbracelet/lipgloss v0.10.0
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect
|
||||||
github.com/charmbracelet/log v0.4.0
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
|
||||||
github.com/catppuccin/go v0.2.0 // indirect
|
|
||||||
github.com/containerd/console v1.0.4 // indirect
|
|
||||||
github.com/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/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
|
|
||||||
)
|
)
|
||||||
|
|
199
go.sum
199
go.sum
|
@ -1,187 +1,20 @@
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
|
||||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
|
||||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
|
||||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
|
||||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
|
||||||
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
|
||||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
|
||||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
|
||||||
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181 h1:ntdtXC9+kcgQYvqa6nyLZLniCEUOhWQknLlz38JpDpM=
|
|
||||||
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181/go.mod h1:Zlzkn8WOd6QS7RC1BXAY1iw1VLq+xT70UZ1vkEtnrvo=
|
|
||||||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
|
||||||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
|
||||||
github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
|
|
||||||
github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
|
|
||||||
github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE=
|
|
||||||
github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA=
|
|
||||||
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
|
|
||||||
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
|
|
||||||
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
|
|
||||||
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
|
|
||||||
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
|
|
||||||
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
|
||||||
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
|
|
||||||
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/d5/tengo/v2 v2.17.0 h1:BWUN9NoJzw48jZKiYDXDIF3QrIVZRm1uV1gTzeZ2lqM=
|
|
||||||
github.com/d5/tengo/v2 v2.17.0/go.mod h1:XRGjEs5I9jYIKTxly6HCF8oiiilk5E/RYXOZ5b0DZC8=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
|
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
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.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 h1:nRDwTcxV9B3elxMt+1xINX0bwaPdpouqp5fbynexY8U=
|
||||||
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8/go.mod h1:jOEnp79oIHy5cvQSHeLcgVJk1GHOOHJHQWps/d1N5Yo=
|
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8/go.mod h1:jOEnp79oIHy5cvQSHeLcgVJk1GHOOHJHQWps/d1N5Yo=
|
||||||
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af h1:rSEwVRdJxMq4RK2kI1LEVhM5J3yg3pcvlRYy1vjn7mQ=
|
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||||
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af/go.mod h1:WeyW6WPrlPDwa48kDIytaLxXKyRjOwLp4BEd2tEGY1Y=
|
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
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.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=
|
|
||||||
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
8
go.work
|
@ -1,8 +0,0 @@
|
||||||
go 1.22.2
|
|
||||||
|
|
||||||
toolchain go1.22.3
|
|
||||||
|
|
||||||
use (
|
|
||||||
.
|
|
||||||
../sugarfoam
|
|
||||||
)
|
|
122
go.work.sum
122
go.work.sum
|
@ -1,122 +0,0 @@
|
||||||
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=
|
|
26
hasher/hasher.go
Normal file
26
hasher/hasher.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
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
|
||||||
|
}
|
64
hasher/hasher_test.go
Normal file
64
hasher/hasher_test.go
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
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")
|
||||||
|
}
|
54
hasher/sha256/sha256.go
Normal file
54
hasher/sha256/sha256.go
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
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, ""))
|
||||||
|
}
|
112
logger/logger.go
Normal file
112
logger/logger.go
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
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...)
|
||||||
|
}
|
||||||
|
}
|
43
main.go
43
main.go
|
@ -1,30 +1,27 @@
|
||||||
/*
|
|
||||||
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.andreafazzi.eu/andrea/probo/cmd"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.andreafazzi.eu/andrea/probo/hasher/sha256"
|
||||||
|
"git.andreafazzi.eu/andrea/probo/store/memory"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const port = "3000"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cmd.Execute()
|
// 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))
|
||||||
}
|
}
|
||||||
|
|
121
misc/logseq/.gitignore
vendored
Normal file
121
misc/logseq/.gitignore
vendored
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
.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.*
|
||||||
|
|
||||||
|
*~
|
|
@ -1,6 +1,6 @@
|
||||||
The MIT License (MIT)
|
Copyright (c) 2022 Andrea Fazzi
|
||||||
|
|
||||||
Copyright © 2024 Andrea Fazzi dev@andreafazzi.eu
|
MIT License
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
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
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
furnished to do so, subject to the following conditions:
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
The above copyright notice and this permission notice shall be included in all
|
||||||
all copies or substantial portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
THE SOFTWARE.
|
SOFTWARE.
|
4
misc/logseq/README.md
Normal file
4
misc/logseq/README.md
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# What's that?
|
||||||
|
|
||||||
|
A very basic boilerplate useful to start devoloping a
|
||||||
|
[Logseq](https://logseq.com/) plugin.
|
10
misc/logseq/icon.svg
Normal file
10
misc/logseq/icon.svg
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|
After Width: | Height: | Size: 471 B |
4707
misc/logseq/package-lock.json
generated
Normal file
4707
misc/logseq/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
misc/logseq/package.json
Normal file
29
misc/logseq/package.json
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
13
misc/logseq/src/index.html
Normal file
13
misc/logseq/src/index.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!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>
|
121
misc/logseq/src/index.ts
Normal file
121
misc/logseq/src/index.ts
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
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);
|
|
@ -1,57 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/xml"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Questionnaire struct {
|
|
||||||
XMLName xml.Name `xml:"questionnaire"`
|
|
||||||
Section []struct {
|
|
||||||
Question []struct {
|
|
||||||
Text string `xml:"text"`
|
|
||||||
Response struct {
|
|
||||||
Fixed struct {
|
|
||||||
Category []struct {
|
|
||||||
Label string `xml:"label"`
|
|
||||||
} `xml:"category"`
|
|
||||||
} `xml:"fixed"`
|
|
||||||
} `xml:"response"`
|
|
||||||
} `xml:"question"`
|
|
||||||
} `xml:"section"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if len(flag.Args()) == 0 {
|
|
||||||
panic("A filename should be provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlFile, err := os.Open(flag.Arg(0))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer xmlFile.Close()
|
|
||||||
|
|
||||||
byteValue, _ := ioutil.ReadAll(xmlFile)
|
|
||||||
|
|
||||||
var questionnaire Questionnaire
|
|
||||||
xml.Unmarshal(byteValue, &questionnaire)
|
|
||||||
|
|
||||||
for i, section := range questionnaire.Section {
|
|
||||||
for j, question := range section.Question {
|
|
||||||
mdContent := question.Text + "\n\n"
|
|
||||||
for _, answer := range question.Response.Fixed.Category {
|
|
||||||
mdContent += "* " + answer.Label + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
mdFileName := fmt.Sprintf("question_%d_%d.md", i+1, j+1)
|
|
||||||
ioutil.WriteFile(mdFileName, []byte(mdContent), 0644)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
6
models/answer.go
Normal file
6
models/answer.go
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type Answer struct {
|
||||||
|
ID string
|
||||||
|
Text string
|
||||||
|
}
|
7
models/question.go
Normal file
7
models/question.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type Question struct {
|
||||||
|
ID string
|
||||||
|
Text string
|
||||||
|
AnswerIDs []string
|
||||||
|
}
|
9
models/test.go
Normal file
9
models/test.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type Quiz struct {
|
||||||
|
ID string
|
||||||
|
Question *Question
|
||||||
|
Answers []*Answer
|
||||||
|
Correct *Answer
|
||||||
|
Type int
|
||||||
|
}
|
|
@ -1,20 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Answer struct {
|
|
||||||
// ID string `json:"id" gorm:"primaryKey"`
|
|
||||||
Meta
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Answer) String() string {
|
|
||||||
return a.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Answer) GetHash() string {
|
|
||||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(a.Text)))
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,50 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
// type Filter struct {
|
|
||||||
// Tags []*Tag
|
|
||||||
// }
|
|
||||||
|
|
||||||
// type ParticipantFilter struct {
|
|
||||||
// Attributes map[string]string
|
|
||||||
// }
|
|
|
@ -1,27 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
|
@ -1,38 +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"`
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,84 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/remogatto/prettytest"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testSuite struct {
|
|
||||||
prettytest.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunner(t *testing.T) {
|
|
||||||
prettytest.Run(
|
|
||||||
t,
|
|
||||||
new(testSuite),
|
|
||||||
new(groupTestSuite),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *testSuite) TestQuizFromMarkdown() {
|
|
||||||
markdown := `Question text (1).
|
|
||||||
|
|
||||||
Question text (2).
|
|
||||||
|
|
||||||
Question text with #tag1 #tag2 (3).
|
|
||||||
|
|
||||||
* Answer 1
|
|
||||||
* Answer 2
|
|
||||||
* Answer 3
|
|
||||||
* Answer 4`
|
|
||||||
|
|
||||||
expectedQuiz := &Quiz{
|
|
||||||
Question: &Question{Text: "Question text (1).\n\nQuestion text (2).\n\nQuestion text with #tag1 #tag2 (3)."},
|
|
||||||
Answers: []*Answer{
|
|
||||||
{Text: "Answer 1"},
|
|
||||||
{Text: "Answer 2"},
|
|
||||||
{Text: "Answer 3"},
|
|
||||||
{Text: "Answer 4"},
|
|
||||||
},
|
|
||||||
CorrectPos: 0,
|
|
||||||
Tags: []string{"#tag1", "#tag2"},
|
|
||||||
}
|
|
||||||
|
|
||||||
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(q, expectedQuiz), fmt.Sprintf("Expected %+v got %+v", expectedQuiz, q))
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *testSuite) TestMarkdownFromQuiz() {
|
|
||||||
quiz := &Quiz{
|
|
||||||
Question: &Question{Text: "Newly created question text."},
|
|
||||||
Answers: []*Answer{
|
|
||||||
{Text: "Answer 1"},
|
|
||||||
{Text: "Answer 2"},
|
|
||||||
{Text: "Answer 3"},
|
|
||||||
{Text: "Answer 4"},
|
|
||||||
},
|
|
||||||
CorrectPos: 0,
|
|
||||||
}
|
|
||||||
md, err := QuizToMarkdown(quiz)
|
|
||||||
t.Nil(err, fmt.Sprintf("Conversion to markdown should not raise an error: %v", err))
|
|
||||||
if !t.Failed() {
|
|
||||||
t.Equal(`Newly created question text.
|
|
||||||
|
|
||||||
* Answer 1
|
|
||||||
* Answer 2
|
|
||||||
* Answer 3
|
|
||||||
* Answer 4
|
|
||||||
`, md)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *testSuite) TestParticipantTokenMarshalCSV() {
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,108 +0,0 @@
|
||||||
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(), ",")
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Question struct {
|
|
||||||
Meta
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Question) String() string {
|
|
||||||
return q.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Question) GetHash() string {
|
|
||||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(q.Text)))
|
|
||||||
}
|
|
|
@ -1,232 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Quiz struct {
|
|
||||||
Meta
|
|
||||||
|
|
||||||
Hash string `json:"hash"`
|
|
||||||
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(quiz *Quiz, markdown string) error {
|
|
||||||
meta, remainingMarkdown, err := ParseMetaHeaderFromMarkdown(markdown)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(remainingMarkdown, "\n")
|
|
||||||
|
|
||||||
questionText := ""
|
|
||||||
answers := []*Answer{}
|
|
||||||
tags := make([]string, 0)
|
|
||||||
|
|
||||||
for _, line := range lines {
|
|
||||||
if strings.HasPrefix(line, "*") {
|
|
||||||
answerText := strings.TrimPrefix(line, "* ")
|
|
||||||
answer := &Answer{Text: answerText}
|
|
||||||
answers = append(answers, answer)
|
|
||||||
} else {
|
|
||||||
if questionText != "" {
|
|
||||||
questionText += "\n"
|
|
||||||
}
|
|
||||||
questionText += line
|
|
||||||
}
|
|
||||||
|
|
||||||
parseTags(&tags, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
questionText = strings.TrimRight(questionText, "\n")
|
|
||||||
|
|
||||||
if questionText == "" {
|
|
||||||
return fmt.Errorf("Question text should not be empty.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(answers) < 2 {
|
|
||||||
return fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
|
|
||||||
}
|
|
||||||
|
|
||||||
question := &Question{Text: questionText}
|
|
||||||
|
|
||||||
quiz.Question = question
|
|
||||||
quiz.Answers = answers
|
|
||||||
quiz.Tags = tags
|
|
||||||
|
|
||||||
if meta != nil {
|
|
||||||
quiz.Meta = *meta
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func QuizToMarkdown(quiz *Quiz) (string, error) {
|
|
||||||
if quiz.Question == nil {
|
|
||||||
return "", errors.New("Quiz should contain a question but it wasn't provided.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(quiz.Answers) < 2 {
|
|
||||||
return "", errors.New("Quiz should contain at least 2 answers but none was provided.")
|
|
||||||
}
|
|
||||||
|
|
||||||
quiz.Correct = quiz.Answers[quiz.CorrectPos]
|
|
||||||
|
|
||||||
if quiz.Correct == nil {
|
|
||||||
return "", errors.New("Quiz should contain a correct answer but not was provided.")
|
|
||||||
}
|
|
||||||
|
|
||||||
correctAnswer := "* " + quiz.Correct.Text
|
|
||||||
var otherAnswers string
|
|
||||||
|
|
||||||
for pos, answer := range quiz.Answers {
|
|
||||||
if quiz.CorrectPos != uint(pos) {
|
|
||||||
otherAnswers += "* " + answer.Text + "\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers
|
|
||||||
|
|
||||||
return markdown, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
result = append(result, q.Question.GetHash())
|
|
||||||
|
|
||||||
for _, a := range q.Answers {
|
|
||||||
result = append(result, a.GetHash())
|
|
||||||
}
|
|
||||||
|
|
||||||
orderedHashes := make([]string, len(result))
|
|
||||||
|
|
||||||
copy(orderedHashes, result)
|
|
||||||
sort.Strings(orderedHashes)
|
|
||||||
|
|
||||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(orderedHashes, ""))))
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseMetaHeaderFromMarkdown(markdown string) (*Meta, string, error) {
|
|
||||||
reader := strings.NewReader(markdown)
|
|
||||||
var sb strings.Builder
|
|
||||||
var line string
|
|
||||||
var err error
|
|
||||||
for {
|
|
||||||
line, err = readLine(reader)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(line) == "---" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
line, err = readLine(reader)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(line) == "---" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sb.String() == "" {
|
|
||||||
return nil, markdown, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var meta Meta
|
|
||||||
err = yaml.Unmarshal([]byte(sb.String()), &meta)
|
|
||||||
if err != nil {
|
|
||||||
return nil, markdown, err
|
|
||||||
}
|
|
||||||
|
|
||||||
remainingMarkdown := markdown[strings.Index(markdown, "---\n"+sb.String()+"---\n")+len("---\n"+sb.String()+"---\n"):]
|
|
||||||
|
|
||||||
return &meta, remainingMarkdown, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readLine(reader *strings.Reader) (string, error) {
|
|
||||||
var sb strings.Builder
|
|
||||||
for {
|
|
||||||
r, _, err := reader.ReadRune()
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
return sb.String(), io.EOF
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteRune(r)
|
|
||||||
if r == '\n' {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,37 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
type Tag struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
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}
|
|
||||||
}
|
|
|
@ -1,125 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import "git.andreafazzi.eu/andrea/probo/pkg/models"
|
|
||||||
|
|
||||||
type ExamStore = Store[*models.Exam]
|
|
|
@ -1,49 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
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),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,103 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,232 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
package file
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/remogatto/prettytest"
|
|
||||||
)
|
|
||||||
|
|
||||||
var testdataDir = "./testdata"
|
|
||||||
|
|
||||||
func TestRunner(t *testing.T) {
|
|
||||||
DefaultBaseDir = "testdata"
|
|
||||||
|
|
||||||
prettytest.Run(
|
|
||||||
t,
|
|
||||||
new(quizTestSuite),
|
|
||||||
new(participantTestSuite),
|
|
||||||
new(examTestSuite),
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,53 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
package file
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
|
||||||
"github.com/remogatto/prettytest"
|
|
||||||
)
|
|
||||||
|
|
||||||
type participantTestSuite struct {
|
|
||||||
prettytest.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *participantTestSuite) TestCreate() {
|
|
||||||
pStore, err := NewDefaultParticipantFileStore()
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
p, err := pStore.Create(&models.Participant{
|
|
||||||
Lastname: "Doe",
|
|
||||||
Firstname: "John",
|
|
||||||
Attributes: map[string]string{"class": "1 D LIN"},
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
defer os.Remove(pStore.GetPath(p))
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
exists, err := os.Stat(pStore.GetPath(p))
|
|
||||||
|
|
||||||
t.Nil(err)
|
|
||||||
t.Not(t.Nil(exists))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,230 +0,0 @@
|
||||||
package file
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
|
||||||
"git.andreafazzi.eu/andrea/probo/pkg/store"
|
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type QuizFileStore = FileStore[*models.Quiz, *store.QuizStore]
|
|
||||||
|
|
||||||
func NewQuizFileStore(config *FileStoreConfig[*models.Quiz, *store.QuizStore]) (*QuizFileStore, error) {
|
|
||||||
return NewFileStore[*models.Quiz, *store.QuizStore](config, store.NewQuizStore())
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDefaultQuizFileStore() (*QuizFileStore, error) {
|
|
||||||
return NewQuizFileStore(
|
|
||||||
&FileStoreConfig[*models.Quiz, *store.QuizStore]{
|
|
||||||
FilePathConfig: FilePathConfig{GetDefaultQuizzesDir(), "quiz", ".md"},
|
|
||||||
IndexDirFunc: DefaultQuizIndexDirFunc,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultQuizIndexDirFunc(s *QuizFileStore) error {
|
|
||||||
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)
|
|
||||||
|
|
||||||
content, err := os.ReadFile(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var entity = new(models.Quiz)
|
|
||||||
|
|
||||||
err = entity.Unmarshal(content)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("An error occurred unmarshalling %v: %v", filename, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var errQuizAlreadyPresent *store.ErrQuizAlreadyPresent
|
|
||||||
|
|
||||||
mEntity, err := s.Storer.Create(entity)
|
|
||||||
if err != nil && !errors.As(err, &errQuizAlreadyPresent) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if entity.ID == "" {
|
|
||||||
writeQuizHeader(fullPath, &models.Meta{
|
|
||||||
ID: mEntity.ID,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
s.SetPath(mEntity, fullPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeQuizHeader(path string, meta *models.Meta) (*models.Meta, error) {
|
|
||||||
readMeta, err := readQuizHeader(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if readMeta == nil {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
|
|
||||||
header, err := yaml.Marshal(meta)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = buffer.WriteString("---\n" + string(header) + "---\n")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(&buffer, file)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err = os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(file, &buffer)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readQuizHeader(path string) (*models.Meta, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
meta, _, err := models.ParseMetaHeaderFromMarkdown(string(data))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) {
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
|
|
||||||
header, err := yaml.Marshal(meta)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
_, err = buffer.WriteString("---\n" + string(header) + "---\n")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = buffer.WriteString(content)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeQuizHeader(path string) (*models.Meta, error) {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
|
|
||||||
reader := bufio.NewReader(file)
|
|
||||||
|
|
||||||
var meta models.Meta
|
|
||||||
var line string
|
|
||||||
var sb strings.Builder
|
|
||||||
for {
|
|
||||||
line, err = reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(line) == "---" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
line, err = reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(line) == "---" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = yaml.Unmarshal([]byte(sb.String()), &meta)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(&buffer, reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err = os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(file, &buffer)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &meta, nil
|
|
||||||
}
|
|
|
@ -1,220 +0,0 @@
|
||||||
package file
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
|
||||||
"git.andreafazzi.eu/andrea/probo/pkg/store"
|
|
||||||
"github.com/remogatto/prettytest"
|
|
||||||
)
|
|
||||||
|
|
||||||
type quizTestSuite struct {
|
|
||||||
prettytest.Suite
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *quizTestSuite) TestReadAll() {
|
|
||||||
store, err := NewDefaultQuizFileStore()
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
result := store.ReadAll()
|
|
||||||
|
|
||||||
t.Equal(
|
|
||||||
4,
|
|
||||||
len(result),
|
|
||||||
fmt.Sprintf(
|
|
||||||
"The store contains 5 files but only 4 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v",
|
|
||||||
len(result),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
files, _ := os.ReadDir(GetDefaultQuizzesDir())
|
|
||||||
t.Equal(5, len(files))
|
|
||||||
|
|
||||||
_, err = removeQuizHeader(filepath.Join(store.Dir, "quiz_5.md"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *quizTestSuite) TestCreate() {
|
|
||||||
store, err := NewQuizFileStore(
|
|
||||||
&FileStoreConfig[*models.Quiz, *store.QuizStore]{
|
|
||||||
FilePathConfig: FilePathConfig{GetDefaultQuizzesDir(), "quiz", ".md"},
|
|
||||||
IndexDirFunc: DefaultQuizIndexDirFunc,
|
|
||||||
NoIndexOnCreate: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
quiz, err := store.Create(
|
|
||||||
&models.Quiz{
|
|
||||||
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2."},
|
|
||||||
Answers: []*models.Answer{
|
|
||||||
{Text: "Answer 1"},
|
|
||||||
{Text: "Answer 2"},
|
|
||||||
{Text: "Answer 3"},
|
|
||||||
{Text: "Answer 4"},
|
|
||||||
},
|
|
||||||
CorrectPos: 0,
|
|
||||||
})
|
|
||||||
t.Nil(err)
|
|
||||||
t.Equal(2, len(quiz.Tags))
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
path := store.GetPath(quiz)
|
|
||||||
t.True(path != "", "Path should not be empty.")
|
|
||||||
|
|
||||||
exists, err := os.Stat(path)
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
t.True(exists != nil, "The new quiz file was not created.")
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
quizFromDisk, err := readQuizFromDisk(path)
|
|
||||||
defer os.Remove(path)
|
|
||||||
|
|
||||||
quizFromDisk.Correct = quiz.Answers[0]
|
|
||||||
quizFromDisk.Tags = quiz.Tags
|
|
||||||
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
t.Equal(quizFromDisk.Question.Text, quiz.Question.Text)
|
|
||||||
for i, a := range quizFromDisk.Answers {
|
|
||||||
t.Equal(a.Text, quiz.Answers[i].Text)
|
|
||||||
}
|
|
||||||
for i, tag := range quizFromDisk.Tags {
|
|
||||||
t.Equal(tag, quiz.Tags[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *quizTestSuite) TestDelete() {
|
|
||||||
store, err := NewQuizFileStore(
|
|
||||||
&FileStoreConfig[*models.Quiz, *store.QuizStore]{
|
|
||||||
FilePathConfig: FilePathConfig{GetDefaultQuizzesDir(), "quiz", ".md"},
|
|
||||||
IndexDirFunc: DefaultQuizIndexDirFunc,
|
|
||||||
NoIndexOnCreate: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
quiz, err := store.Create(
|
|
||||||
&models.Quiz{
|
|
||||||
Question: &models.Question{Text: "This quiz should be deleted."},
|
|
||||||
Answers: []*models.Answer{
|
|
||||||
{Text: "Answer 1"},
|
|
||||||
{Text: "Answer 2"},
|
|
||||||
{Text: "Answer 3"},
|
|
||||||
{Text: "Answer 4"},
|
|
||||||
},
|
|
||||||
CorrectPos: 0,
|
|
||||||
})
|
|
||||||
t.Nil(err)
|
|
||||||
if !t.Failed() {
|
|
||||||
path := store.GetPath(quiz)
|
|
||||||
_, err := store.Delete(quiz.ID)
|
|
||||||
t.Nil(err, fmt.Sprintf("Quiz should be deleted without errors: %v", err))
|
|
||||||
if !t.Failed() {
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
t.Not(t.Nil(err))
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *quizTestSuite) TestUpdate() {
|
|
||||||
store, err := NewQuizFileStore(
|
|
||||||
&FileStoreConfig[*models.Quiz, *store.QuizStore]{
|
|
||||||
FilePathConfig: FilePathConfig{GetDefaultQuizzesDir(), "quiz", ".md"},
|
|
||||||
IndexDirFunc: DefaultQuizIndexDirFunc,
|
|
||||||
NoIndexOnCreate: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
quiz, err := store.Create(
|
|
||||||
&models.Quiz{
|
|
||||||
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2."},
|
|
||||||
Answers: []*models.Answer{
|
|
||||||
{Text: "Answer 1"},
|
|
||||||
{Text: "Answer 2"},
|
|
||||||
{Text: "Answer 3"},
|
|
||||||
{Text: "Answer 4"},
|
|
||||||
},
|
|
||||||
CorrectPos: 0,
|
|
||||||
})
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
_, err = store.Update(&models.Quiz{
|
|
||||||
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2 #tag3."},
|
|
||||||
Answers: []*models.Answer{
|
|
||||||
{Text: "Answer 1"},
|
|
||||||
{Text: "Answer 2"},
|
|
||||||
{Text: "Answer 3"},
|
|
||||||
{Text: "Answer 4"},
|
|
||||||
},
|
|
||||||
CorrectPos: 1,
|
|
||||||
}, quiz.ID)
|
|
||||||
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
updatedQuizFromMemory, err := store.Read(quiz.ID)
|
|
||||||
t.Equal(len(updatedQuizFromMemory.Tags), 3)
|
|
||||||
t.Equal("Answer 2", updatedQuizFromMemory.Correct.Text)
|
|
||||||
|
|
||||||
defer os.Remove(store.GetPath(quiz))
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *quizTestSuite) TestAutowriteHeader() {
|
|
||||||
store, err := NewDefaultQuizFileStore()
|
|
||||||
t.Nil(err)
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
meta, err := readQuizHeader(filepath.Join(store.Dir, "quiz_5.md"))
|
|
||||||
t.Nil(err)
|
|
||||||
if !t.Failed() {
|
|
||||||
t.Not(t.Nil(meta))
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
t.True(meta.ID != "", "ID should not be empty")
|
|
||||||
|
|
||||||
if !t.Failed() {
|
|
||||||
_, err = removeQuizHeader(filepath.Join(store.Dir, "quiz_5.md"))
|
|
||||||
t.True(err == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readQuizFromDisk(path string) (*models.Quiz, error) {
|
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := new(models.Quiz)
|
|
||||||
|
|
||||||
err = result.Unmarshal(content)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
package file
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
|
||||||
"git.andreafazzi.eu/andrea/probo/pkg/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ResponseFileStore = FileStore[*models.Response, *store.Store[*models.Response]]
|
|
||||||
|
|
||||||
func NewResponseFileStore(config *FileStoreConfig[*models.Response, *store.ResponseStore]) (*ResponseFileStore, error) {
|
|
||||||
return NewFileStore(config, store.NewStore[*models.Response]())
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDefaultResponseFileStore() (*ResponseFileStore, error) {
|
|
||||||
return NewResponseFileStore(
|
|
||||||
&FileStoreConfig[*models.Response, *store.ResponseStore]{
|
|
||||||
FilePathConfig: FilePathConfig{GetDefaultResponsesDir(), "response", ".json"},
|
|
||||||
IndexDirFunc: DefaultIndexDirFunc[*models.Response, *store.ResponseStore],
|
|
||||||
CreateEntityFunc: func() *models.Response {
|
|
||||||
return &models.Response{}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue