Compare commits
34 commits
Author | SHA1 | Date | |
---|---|---|---|
b513964735 | |||
78e9e60ed5 | |||
007236bd0f | |||
875775e1e4 | |||
ebd20b53ed | |||
6127260b91 | |||
3250810364 | |||
1a9c9e6b8a | |||
6b34c2d29b | |||
7eaeb36578 | |||
be95c23d5f | |||
2c854d4f8b | |||
e5f6d3ffaf | |||
e161d936aa | |||
2331ca03b2 | |||
588cf064c1 | |||
e72e79d1f7 | |||
b50932124a | |||
81274dcf89 | |||
ea38c49009 | |||
132689aa1c | |||
68b7efa585 | |||
e05ea6dd25 | |||
7ff0d348b8 | |||
142741ab5f | |||
1c0119b342 | |||
d9dfccf040 | |||
c1545590d4 | |||
10620eae13 | |||
489226d5f5 | |||
4045a9c705 | |||
ac95b38fe8 | |||
3196982a64 | |||
3cdfa72403 |
146 changed files with 5659 additions and 7662 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1,3 @@
|
|||
.log
|
||||
data
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2022 Andrea Fazzi
|
||||
Copyright © 2024 Andrea Fazzi dev@andreafazzi.eu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
144
assets/static/css/neat.css
Normal file
144
assets/static/css/neat.css
Normal file
|
@ -0,0 +1,144 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
--light: #fff;
|
||||
--lesslight: #efefef;
|
||||
--dark: #404040;
|
||||
--moredark: #000;
|
||||
border-top: 5px solid var(--dark);
|
||||
line-height: 1.5em; /* This causes wrapping h1's to collapse too small */
|
||||
font-family: sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
button, input {
|
||||
font-size: 1em; /* Override browser default font shrinking*/
|
||||
}
|
||||
|
||||
input {
|
||||
border: 1px solid var(--dark);
|
||||
background-color: var(--lesslight);
|
||||
border-radius: .25em;
|
||||
padding: .5em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--lesslight);
|
||||
margin: 0.5em 0 0.5em 0;
|
||||
padding: 0.5em;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--lesslight);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--light);
|
||||
margin: 0;
|
||||
max-width: 800px;
|
||||
padding: 0 20px 20px 20px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
button, .button, input[type=submit] {
|
||||
display: inline-block;
|
||||
background-color: var(--dark);
|
||||
color: var(--light);
|
||||
text-align: center;
|
||||
padding: .5em;
|
||||
border-radius: .25em;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover, .button:hover, input[type=submit]:hover {
|
||||
color: var(--lesslight);
|
||||
background-color: var(--moredark);
|
||||
}
|
||||
|
||||
/* Add a margin between side-by-side buttons */
|
||||
button + button, .button + .button, input[type=submit] + input[type=submit] {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bordered {
|
||||
border: 3px solid;
|
||||
}
|
||||
|
||||
.home {
|
||||
display: inline-block;
|
||||
background-color: var(--dark);
|
||||
color: var(--light);
|
||||
margin-top: 20px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
/* Desktop sizes */
|
||||
@media only screen and (min-width: 600px) {
|
||||
ol.twocol {
|
||||
column-count: 2;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Make everything in a row a column */
|
||||
.row > * {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row > *:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode overrides (confusingly inverse) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--light: #222;
|
||||
--lesslight: #333;
|
||||
--dark: #eee;
|
||||
--moredark: #fefefe;
|
||||
}
|
||||
/* This fixes an odd blue then white shadow on FF in dark mode */
|
||||
*:focus {
|
||||
outline: var(--light);
|
||||
box-shadow: 0 0 0 .25em royalblue;
|
||||
}
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
@media print {
|
||||
.home {
|
||||
display: none;
|
||||
}
|
||||
}
|
28
assets/templates/exam.tpl
Normal file
28
assets/templates/exam.tpl
Normal file
|
@ -0,0 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- <link rel="stylesheet" href="https://unpkg.com/mvp.css"> -->
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/neat.css">
|
||||
<title>Exam</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Exam</h1>
|
||||
<h2>{{.Participant.Firstname}} {{.Participant.Lastname}}</h2>
|
||||
<form action="/{{.SessionID}}/{{.Participant.Token}}" method="post">
|
||||
{{range $index, $quiz := .Quizzes}}
|
||||
<h3>Question {{$index}}:</h3>
|
||||
<p>{{$quiz.Question.Text}}</p>
|
||||
{{range $answer := $quiz.Answers}}
|
||||
<input type="radio"
|
||||
id="{{$quiz.Question.ID}}_{{$answer.ID}}" name="{{$quiz.Question.ID}}"
|
||||
value="{{$answer.ID}}">
|
||||
<label
|
||||
for="{{$answer.ID}}">{{$answer.Text}}</label><br>
|
||||
{{end}}
|
||||
<br>
|
||||
{{end}}
|
||||
<button type="submit">Invia</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
290
backup/main.go
Normal file
290
backup/main.go
Normal file
|
@ -0,0 +1,290 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
"github.com/lmittmann/tint"
|
||||
)
|
||||
|
||||
var (
|
||||
DefaultAssetDir = "assets"
|
||||
DefaultDataDir = "data"
|
||||
DefaultSessionDir = "sessions"
|
||||
DefaultResponseDir = "responses"
|
||||
DefaultTemplateDir = "templates"
|
||||
DefaultStaticDir = "static"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SessionDir string
|
||||
ResponseDir string
|
||||
TemplateDir string
|
||||
StaticDir string
|
||||
}
|
||||
|
||||
type ExamTemplateData struct {
|
||||
*models.Exam
|
||||
|
||||
SessionID string
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
config *Config
|
||||
mux *http.ServeMux
|
||||
|
||||
sessionFileStore *file.SessionFileStore
|
||||
responseFileStore *file.ResponseFileStore
|
||||
}
|
||||
|
||||
func GetDefaultTemplateDir() string {
|
||||
return filepath.Join(DefaultAssetDir, DefaultTemplateDir)
|
||||
}
|
||||
|
||||
func GetDefaultStaticDir() string {
|
||||
return filepath.Join(DefaultAssetDir, DefaultStaticDir)
|
||||
}
|
||||
|
||||
func GetDefaultSessionDir() string {
|
||||
return filepath.Join(DefaultDataDir, DefaultSessionDir)
|
||||
}
|
||||
|
||||
func GetDefaultResponseDir() string {
|
||||
return filepath.Join(DefaultDataDir, DefaultResponseDir)
|
||||
}
|
||||
|
||||
func NewServer(config *Config) (*Server, error) {
|
||||
_, err := os.Stat(config.SessionDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = os.Stat(config.TemplateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = os.Stat(config.StaticDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sStore, err := file.NewSessionFileStore(
|
||||
&file.FileStoreConfig[*models.Session, *store.SessionStore]{
|
||||
FilePathConfig: file.FilePathConfig{Dir: config.SessionDir, FilePrefix: "session", FileSuffix: ".json"},
|
||||
IndexDirFunc: file.DefaultIndexDirFunc[*models.Session, *store.SessionStore],
|
||||
CreateEntityFunc: func() *models.Session {
|
||||
return &models.Session{}
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rStore, err := file.NewResponseFileStore(
|
||||
&file.FileStoreConfig[*models.Response, *store.ResponseStore]{
|
||||
FilePathConfig: file.FilePathConfig{Dir: config.ResponseDir, FilePrefix: "response", FileSuffix: ".json"},
|
||||
IndexDirFunc: file.DefaultIndexDirFunc[*models.Response, *store.ResponseStore],
|
||||
CreateEntityFunc: func() *models.Response {
|
||||
return &models.Response{}
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rStore.FilePathConfig = file.FilePathConfig{
|
||||
Dir: config.ResponseDir,
|
||||
FilePrefix: "response",
|
||||
FileSuffix: ".json",
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
config,
|
||||
http.NewServeMux(),
|
||||
sStore,
|
||||
rStore,
|
||||
}
|
||||
|
||||
s.mux.Handle("/static/", http.StripPrefix("/static", http.FileServer(http.Dir(config.StaticDir))))
|
||||
s.mux.HandleFunc("/create", s.createExamSessionHandler)
|
||||
s.mux.HandleFunc("/responses/", s.getResponsesHandler)
|
||||
s.mux.HandleFunc("/", s.getExamHandler)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func NewDefaultServer() (*Server, error) {
|
||||
return NewServer(&Config{
|
||||
SessionDir: GetDefaultSessionDir(),
|
||||
ResponseDir: GetDefaultResponseDir(),
|
||||
TemplateDir: GetDefaultTemplateDir(),
|
||||
StaticDir: GetDefaultStaticDir(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) getResponsesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
result := make([]*models.Response, 0)
|
||||
|
||||
urlParts := strings.Split(r.URL.Path, "/")
|
||||
|
||||
sessionID := urlParts[2]
|
||||
|
||||
if r.Method == "GET" {
|
||||
session, err := s.sessionFileStore.Read(sessionID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, exam := range session.Exams {
|
||||
responses := s.responseFileStore.ReadAll()
|
||||
for _, r := range responses {
|
||||
if r.ID == exam.ID {
|
||||
result = append(result, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
err = json.NewEncoder(w).Encode(result)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) createExamSessionHandler(w http.ResponseWriter, r *http.Request) {
|
||||
session := new(models.Session)
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = session.Unmarshal(data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
memorySession, err := s.sessionFileStore.Create(session)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = json.NewEncoder(w).Encode(memorySession)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Received a new session", "session", memorySession)
|
||||
|
||||
}
|
||||
|
||||
func (s *Server) getExamHandler(w http.ResponseWriter, r *http.Request) {
|
||||
urlParts := strings.Split(r.URL.Path, "/")
|
||||
|
||||
sessionID := urlParts[1]
|
||||
token := urlParts[2]
|
||||
|
||||
session, err := s.sessionFileStore.Read(sessionID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
exam := session.Exams[token]
|
||||
|
||||
if r.Method == "GET" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
tplData, err := os.ReadFile(filepath.Join(GetDefaultTemplateDir(), "exam.tpl"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
}
|
||||
tmpl := template.Must(template.New("exam").Parse(string(tplData)))
|
||||
|
||||
err = tmpl.Execute(w, ExamTemplateData{exam, session.ID})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response := new(models.Response)
|
||||
response.UniqueIDFunc = func() string {
|
||||
return exam.GetID()
|
||||
}
|
||||
|
||||
response.Questions = make(map[string]string)
|
||||
for qID, values := range r.Form {
|
||||
for _, aID := range values {
|
||||
response.Questions[qID] = aID
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.responseFileStore.Create(response)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte("<p>Thank you for your response.</p>"))
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func generateRandomID() string {
|
||||
id := ""
|
||||
for i := 0; i < 6; i++ {
|
||||
id += strconv.Itoa(rand.Intn(9) + 1)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(
|
||||
tint.NewHandler(os.Stdout, &tint.Options{
|
||||
Level: slog.LevelInfo,
|
||||
TimeFormat: time.Kitchen,
|
||||
}),
|
||||
))
|
||||
|
||||
server, err := NewDefaultServer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
slog.Info("Probo server started.")
|
||||
http.ListenAndServe(":8080", server)
|
||||
}
|
289
backup/server_test.go
Normal file
289
backup/server_test.go
Normal file
|
@ -0,0 +1,289 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"github.com/lmittmann/tint"
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
var examPayload = `
|
||||
{
|
||||
"ID": "fe0a7ee0-f31a-413d-f123-ab5068bcaaaa",
|
||||
"Name": "Test session",
|
||||
"Exams": {
|
||||
"111222": {
|
||||
"id": "fe0a7ee0-f31a-413d-ba81-ab5068bc4c73",
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"Participant": {
|
||||
"ID": "1234",
|
||||
"Firstname": "John",
|
||||
"Lastname": "Smith",
|
||||
"Token": "111222",
|
||||
"Attributes": {
|
||||
"class": "1 D LIN"
|
||||
}
|
||||
},
|
||||
"Quizzes": [
|
||||
{
|
||||
"id": "0610939b-a1a3-4d0e-bbc4-2aae0e8ee4b9",
|
||||
"created_at": "2023-11-27T17:51:53.910642221+01:00",
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"hash": "",
|
||||
"question": {
|
||||
"id": "98c0eec9-677f-464e-9e3e-864a859f29a3",
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"text": "Question text with #tag1."
|
||||
},
|
||||
"answers": [
|
||||
{
|
||||
"id": "1ab5ff1f-74c8-4efc-abdc-d03a3640f3bc",
|
||||
"text": "Answer 1"
|
||||
},
|
||||
{
|
||||
"id": "74547724-b905-476f-8cfc-6ee633f92ef3",
|
||||
"text": "Answer 2"
|
||||
},
|
||||
{
|
||||
"id": "96c1a8ee-c50c-4ebc-89e4-9f3ca356adbd",
|
||||
"text": "Answer 3"
|
||||
},
|
||||
{
|
||||
"id": "16c4b95e-64ce-4666-8cbe-b66fa59eb23b",
|
||||
"text": "Answer 4"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"CreatedAt": "0001-01-01T00:00:00Z",
|
||||
"UpdatedAt": "0001-01-01T00:00:00Z",
|
||||
"DeletedAt": null,
|
||||
"name": "#tag1"
|
||||
}
|
||||
],
|
||||
"correct": {
|
||||
"id": "1ab5ff1f-74c8-4efc-abdc-d03a3640f3bc",
|
||||
"text": "Answer 1"
|
||||
},
|
||||
"CorrectPos": 0,
|
||||
"type": 0
|
||||
},
|
||||
{
|
||||
"id": "915818c4-b0ce-4efc-8def-7fc8d5fa7454",
|
||||
"created_at": "2023-11-27T17:51:53.91077796+01:00",
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"hash": "",
|
||||
"question": {
|
||||
"id": "70793f0d-2855-4140-814e-40167464424b",
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"text": "Another question text with #tag1."
|
||||
},
|
||||
"answers": [
|
||||
{
|
||||
"id": "1ab5ff1f-74c8-4efc-abdc-d03a3640f3bc",
|
||||
"text": "Answer 1"
|
||||
},
|
||||
{
|
||||
"id": "74547724-b905-476f-8cfc-6ee633f92ef3",
|
||||
"text": "Answer 2"
|
||||
},
|
||||
{
|
||||
"id": "96c1a8ee-c50c-4ebc-89e4-9f3ca356adbd",
|
||||
"text": "Answer 3"
|
||||
},
|
||||
{
|
||||
"id": "16c4b95e-64ce-4666-8cbe-b66fa59eb23b",
|
||||
"text": "Answer 4"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"CreatedAt": "0001-01-01T00:00:00Z",
|
||||
"UpdatedAt": "0001-01-01T00:00:00Z",
|
||||
"DeletedAt": null,
|
||||
"name": "#tag1"
|
||||
}
|
||||
],
|
||||
"correct": {
|
||||
"id": "1ab5ff1f-74c8-4efc-abdc-d03a3640f3bc",
|
||||
"text": "Answer 1"
|
||||
},
|
||||
"CorrectPos": 0,
|
||||
"type": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"333444": {
|
||||
"id": "12345678-abcd-efgh-ijkl-9876543210ef",
|
||||
"created_at": "2023-12-01T12:00:00Z",
|
||||
"updated_at": "2023-12-01T12:00:00Z",
|
||||
"Participant": {
|
||||
"ID": "5678",
|
||||
"Firstname": "Jane",
|
||||
"Lastname": "Doe",
|
||||
"Token": "333444",
|
||||
"Attributes": {
|
||||
"class": "2 A SCI"
|
||||
}
|
||||
},
|
||||
"Quizzes": [
|
||||
{
|
||||
"id": "22222222-abcd-efgh-ijkl-9876543210ef",
|
||||
"created_at": "2023-12-01T12:00:00Z",
|
||||
"updated_at": "2023-12-01T12:00:00Z",
|
||||
"hash": "",
|
||||
"question": {
|
||||
"id": "33333333-abcd-efgh-ijkl-9876543210ef",
|
||||
"created_at": "2023-12-01T12:00:00Z",
|
||||
"updated_at": "2023-12-01T12:00:00Z",
|
||||
"text": "Sample question text."
|
||||
},
|
||||
"answers": [
|
||||
{
|
||||
"id": "44444444-abcd-efgh-ijkl-9876543210ef",
|
||||
"text": "Option 1"
|
||||
},
|
||||
{
|
||||
"id": "55555555-abcd-efgh-ijkl-9876543210ef",
|
||||
"text": "Option 2"
|
||||
},
|
||||
{
|
||||
"id": "66666666-abcd-efgh-ijkl-9876543210ef",
|
||||
"text": "Option 3"
|
||||
},
|
||||
{
|
||||
"id": "77777777-abcd-efgh-ijkl-9876543210ef",
|
||||
"text": "Option 4"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"CreatedAt": "2023-12-01T12:00:00Z",
|
||||
"UpdatedAt": "2023-12-01T12:00:00Z",
|
||||
"DeletedAt": null,
|
||||
"name": "#tag2"
|
||||
}
|
||||
],
|
||||
"correct": {
|
||||
"id": "44444444-abcd-efgh-ijkl-9876543210ef",
|
||||
"text": "Option 1"
|
||||
},
|
||||
"CorrectPos": 0,
|
||||
"type": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type serverTestSuite struct {
|
||||
prettytest.Suite
|
||||
}
|
||||
|
||||
func TestRunner(t *testing.T) {
|
||||
slog.SetDefault(slog.New(
|
||||
tint.NewHandler(os.Stderr, &tint.Options{
|
||||
Level: slog.LevelError,
|
||||
TimeFormat: time.Kitchen,
|
||||
}),
|
||||
))
|
||||
|
||||
prettytest.Run(
|
||||
t,
|
||||
new(serverTestSuite),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *serverTestSuite) TestCreate() {
|
||||
|
||||
DefaultDataDir = "testdata"
|
||||
|
||||
s, err := NewDefaultServer()
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(s.createExamSessionHandler)
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
t.Equal(http.StatusOK, response.Code)
|
||||
|
||||
if !t.Failed() {
|
||||
var session *models.Session
|
||||
|
||||
err := json.Unmarshal(response.Body.Bytes(), &session)
|
||||
t.Nil(err)
|
||||
|
||||
path := filepath.Join(GetDefaultSessionDir(), "session_fe0a7ee0-f31a-413d-f123-ab5068bcaaaa.json")
|
||||
|
||||
_, err = os.Stat(path)
|
||||
t.Nil(err)
|
||||
|
||||
defer os.Remove(path)
|
||||
|
||||
t.Equal("fe0a7ee0-f31a-413d-f123-ab5068bcaaaa", session.ID)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *serverTestSuite) TestRead() {
|
||||
|
||||
DefaultDataDir = "testdata"
|
||||
|
||||
s, err := NewDefaultServer()
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
request, _ := http.NewRequest(http.MethodPost, "/create", strings.NewReader(examPayload))
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(s.createExamSessionHandler)
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
t.Equal(http.StatusOK, response.Code)
|
||||
|
||||
if !t.Failed() {
|
||||
var session *models.Session
|
||||
|
||||
err := json.Unmarshal(response.Body.Bytes(), &session)
|
||||
t.Nil(err)
|
||||
|
||||
path := filepath.Join(GetDefaultSessionDir(), "session_fe0a7ee0-f31a-413d-f123-ab5068bcaaaa.json")
|
||||
_, err = os.Stat(path)
|
||||
t.Nil(err)
|
||||
|
||||
if !t.Failed() {
|
||||
defer os.RemoveAll(path)
|
||||
|
||||
request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/%s/%s", session.ID, "111222"), nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(s.getExamHandler)
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
t.Equal(http.StatusOK, response.Code)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
106
client/client.go
106
client/client.go
|
@ -1,106 +0,0 @@
|
|||
package client
|
||||
|
||||
import "git.andreafazzi.eu/andrea/probo/models"
|
||||
|
||||
type Question struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type Answer struct {
|
||||
Text string `json:"text"`
|
||||
Correct bool `json:"correct"`
|
||||
}
|
||||
|
||||
type Quiz struct {
|
||||
Question *Question `json:"question"`
|
||||
Answers []*Answer `json:"answers"`
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type Participant struct {
|
||||
Firstname string `json:"firstname"`
|
||||
Lastname string `json:"lastname"`
|
||||
Token uint `json:"token"`
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
}
|
||||
|
||||
type Exam struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
|
||||
ParticipantID string `json:"participant_id"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
}
|
||||
|
||||
type BaseResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ReadAllQuizResponse struct {
|
||||
BaseResponse
|
||||
Content []*models.Quiz `json:"content"`
|
||||
}
|
||||
|
||||
type CreateQuizResponse struct {
|
||||
BaseResponse
|
||||
Content *models.Quiz `json:"content"`
|
||||
}
|
||||
|
||||
type UpdateQuizResponse struct {
|
||||
BaseResponse
|
||||
Content *models.Quiz `json:"content"`
|
||||
}
|
||||
|
||||
type CreateQuestionRequest struct {
|
||||
*Question
|
||||
}
|
||||
|
||||
type CreateAnswerRequest struct {
|
||||
*Answer
|
||||
}
|
||||
|
||||
type CreateUpdateQuizRequest struct {
|
||||
*Quiz
|
||||
*models.Meta
|
||||
}
|
||||
|
||||
type DeleteQuizRequest struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type CreateUpdateCollectionRequest struct {
|
||||
*Collection
|
||||
}
|
||||
|
||||
type DeleteCollectionRequest struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type CreateUpdateExamRequest struct {
|
||||
*Exam
|
||||
}
|
||||
|
||||
type DeleteExamRequest struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type ReadExamByIDRequest struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type CreateUpdateParticipantRequest struct {
|
||||
*Participant
|
||||
}
|
||||
|
||||
type DeleteParticipantRequest struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type ReadParticipantByIDRequest struct {
|
||||
ID string
|
||||
}
|
8
cmd/common.go
Normal file
8
cmd/common.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package cmd
|
||||
|
||||
var logo = ` ____ _
|
||||
| _ \ _ __ ___ | |__ ___
|
||||
| |_) | '__/ _ \| '_ \ / _ \
|
||||
| __/| | | (_) | |_) | (_) |
|
||||
|_| |_| \___/|_.__/ \___/
|
||||
`
|
61
cmd/filter.go
Normal file
61
cmd/filter.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/filter"
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/util"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var filterCmd = &cobra.Command{
|
||||
Use: "filter {participants,quizzes,responses}",
|
||||
Short: "Filter the given store",
|
||||
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/filter/*.tmpl"),
|
||||
Run: runFilter,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(filterCmd)
|
||||
filterCmd.PersistentFlags().StringP("input", "i", "", "Specify an input file")
|
||||
}
|
||||
|
||||
func runFilter(cmd *cobra.Command, args []string) {
|
||||
var storeType string
|
||||
|
||||
path, err := cmd.Flags().GetString("input")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
f := util.LogToFile()
|
||||
if f != nil {
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
|
||||
storeType = args[0]
|
||||
|
||||
model, err := tea.NewProgram(
|
||||
filter.New(path, storeType, util.ReadStdin()),
|
||||
tea.WithOutput(os.Stderr),
|
||||
).Run()
|
||||
if err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result := model.(*filter.FilterModel)
|
||||
|
||||
if result.Result != "" {
|
||||
fmt.Fprintf(os.Stdout, result.Result)
|
||||
}
|
||||
}
|
418
cmd/filter/filter.go
Normal file
418
cmd/filter/filter.go
Normal file
|
@ -0,0 +1,418 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
"github.com/alecthomas/chroma/quick"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/itchyny/gojq"
|
||||
foam "github.com/remogatto/sugarfoam"
|
||||
"github.com/remogatto/sugarfoam/components/group"
|
||||
"github.com/remogatto/sugarfoam/components/header"
|
||||
"github.com/remogatto/sugarfoam/components/help"
|
||||
"github.com/remogatto/sugarfoam/components/statusbar"
|
||||
"github.com/remogatto/sugarfoam/components/textinput"
|
||||
"github.com/remogatto/sugarfoam/components/viewport"
|
||||
"github.com/remogatto/sugarfoam/layout"
|
||||
)
|
||||
|
||||
type FilterModel struct {
|
||||
// UI
|
||||
textInput *textinput.Model
|
||||
viewport *viewport.Model
|
||||
group *group.Model
|
||||
help *help.Model
|
||||
statusBar *statusbar.Model
|
||||
spinner spinner.Model
|
||||
|
||||
// Layout
|
||||
document *layout.Layout
|
||||
|
||||
// Key bindings
|
||||
bindings *keyBindings
|
||||
|
||||
// file store
|
||||
store []any
|
||||
result []any
|
||||
|
||||
// json
|
||||
lastQuery string
|
||||
FilteredJson string
|
||||
InputJson string
|
||||
Result string
|
||||
|
||||
// filter file
|
||||
filterFilePath string
|
||||
|
||||
state int
|
||||
|
||||
filterType string
|
||||
}
|
||||
|
||||
func New(path string, filterType string, stdin string) *FilterModel {
|
||||
textInput := textinput.New(
|
||||
textinput.WithPlaceholder("Write your jq filter here..."),
|
||||
)
|
||||
|
||||
viewport := viewport.New()
|
||||
|
||||
group := group.New(
|
||||
group.WithItems(textInput, viewport),
|
||||
group.WithLayout(
|
||||
layout.New(
|
||||
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 0, 1, 0)}),
|
||||
layout.WithItem(textInput),
|
||||
layout.WithItem(viewport),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
bindings := newBindings(group)
|
||||
statusBar := statusbar.New(bindings)
|
||||
|
||||
s := spinner.New(
|
||||
spinner.WithStyle(
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("265"))),
|
||||
)
|
||||
s.Spinner = spinner.Dot
|
||||
|
||||
header := header.New(
|
||||
header.WithContent(
|
||||
lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
Render(filterTypeFormats[filterType]),
|
||||
),
|
||||
)
|
||||
|
||||
help := help.New(
|
||||
bindings,
|
||||
help.WithStyles(&foam.Styles{NoBorder: lipgloss.NewStyle().Padding(1, 1)}))
|
||||
|
||||
document := layout.New(
|
||||
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
|
||||
layout.WithItem(header),
|
||||
layout.WithItem(group),
|
||||
layout.WithItem(help),
|
||||
layout.WithItem(statusBar),
|
||||
)
|
||||
|
||||
return &FilterModel{
|
||||
textInput: textInput,
|
||||
viewport: viewport,
|
||||
group: group,
|
||||
statusBar: statusBar,
|
||||
spinner: s,
|
||||
document: document,
|
||||
bindings: bindings,
|
||||
help: help,
|
||||
filterType: filterType,
|
||||
filterFilePath: path,
|
||||
InputJson: stdin,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (m *FilterModel) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
cmds = append(cmds, m.group.Init(), m.loadStore(), m.spinner.Tick)
|
||||
|
||||
m.group.Focus()
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *FilterModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.document.SetSize(msg.Width, msg.Height)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.bindings.quit):
|
||||
m.FilteredJson = ""
|
||||
return m, tea.Quit
|
||||
|
||||
case key.Matches(msg, m.bindings.enter):
|
||||
m.marshalJSON()
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case storeLoadedMsg:
|
||||
cmds = append(cmds, m.handleStoreLoaded(msg))
|
||||
|
||||
case resultMsg:
|
||||
cmds = append(cmds, m.handleFiltered(msg))
|
||||
m.state = FilterState
|
||||
|
||||
case errorMsg:
|
||||
m.handleError(msg)
|
||||
m.state = ErrorState
|
||||
}
|
||||
|
||||
cmds = m.handleState(msg, cmds)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *FilterModel) View() string {
|
||||
return m.document.View()
|
||||
}
|
||||
|
||||
func (m *FilterModel) marshalJSON() {
|
||||
if m.FilteredJson == "" {
|
||||
return
|
||||
}
|
||||
if m.InputJson != "" {
|
||||
m.Result = fmt.Sprintf("{%s, \"%s\": %s}", strings.Trim(m.InputJson, "{}"), m.filterType, m.FilteredJson)
|
||||
} else {
|
||||
var result interface{}
|
||||
|
||||
filtered := fmt.Sprintf("{\"%s\": %s}", m.filterType, m.FilteredJson)
|
||||
err := json.Unmarshal([]byte(filtered), &result)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
resultJson, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m.Result = string(resultJson)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FilterModel) showErrorOnStatusBar(err error) {
|
||||
m.statusBar.SetContent(
|
||||
stateFormats[ErrorState][0],
|
||||
fmt.Sprintf(stateFormats[ErrorState][1], err),
|
||||
stateFormats[ErrorState][2],
|
||||
)
|
||||
}
|
||||
|
||||
func (m *FilterModel) handleError(msg tea.Msg) {
|
||||
err := msg.(errorMsg)
|
||||
|
||||
m.statusBar.SetContent(
|
||||
stateFormats[ErrorState][0],
|
||||
fmt.Sprintf(stateFormats[ErrorState][1], err.error),
|
||||
stateFormats[ErrorState][2],
|
||||
)
|
||||
}
|
||||
|
||||
func (m *FilterModel) handleStoreLoaded(msg tea.Msg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
storeMsg := msg.(storeLoadedMsg)
|
||||
m.store = storeMsg.store
|
||||
m.state = FilterState
|
||||
|
||||
if m.filterFilePath != "" {
|
||||
jq, err := os.ReadFile(m.filterFilePath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m.textInput.SetValue(strings.TrimSpace(string(jq)))
|
||||
return m.query(strings.TrimSpace(string(jq)))
|
||||
}
|
||||
|
||||
coloredJson, err := toColoredJson(m.store)
|
||||
if err != nil {
|
||||
return errorMsg{err}
|
||||
}
|
||||
|
||||
m.viewport.SetContent(coloredJson)
|
||||
|
||||
return m.query(".")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FilterModel) handleFiltered(msg tea.Msg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
m.result = msg.(resultMsg).result
|
||||
|
||||
coloredJson, err := toColoredJson(m.result)
|
||||
if err != nil {
|
||||
return errorMsg{err}
|
||||
}
|
||||
|
||||
json, err := toJson(m.result)
|
||||
if err != nil {
|
||||
return errorMsg{err}
|
||||
}
|
||||
|
||||
m.FilteredJson = json
|
||||
|
||||
m.viewport.SetContent(coloredJson)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FilterModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
|
||||
_, cmd := m.group.Update(msg)
|
||||
|
||||
if m.state == LoadingStoreState {
|
||||
return m.updateSpinner(msg, cmd, cmds)
|
||||
}
|
||||
|
||||
cmds = append(cmds, cmd, m.query(m.textInput.Value()))
|
||||
|
||||
if m.state != ErrorState {
|
||||
m.statusBar.SetContent(stateFormats[FilterState]...)
|
||||
}
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *FilterModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd {
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
|
||||
m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2])
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *FilterModel) loadStore() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
var jsonStore []byte
|
||||
|
||||
switch m.filterType {
|
||||
case "participants":
|
||||
pStore, err := file.NewDefaultParticipantFileStore()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
jsonStore, err = pStore.Storer.Json()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
case "quizzes":
|
||||
qStore, err := file.NewDefaultQuizFileStore()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
jsonStore, err = qStore.Storer.Json()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
case "responses":
|
||||
qStore, err := file.NewDefaultResponseFileStore()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
jsonStore, err = qStore.Storer.Json()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
default:
|
||||
panic("Unknown filter type!")
|
||||
}
|
||||
|
||||
v := make([]any, 0)
|
||||
|
||||
err := json.Unmarshal(jsonStore, &v)
|
||||
if err != nil {
|
||||
return errorMsg{err}
|
||||
}
|
||||
|
||||
return storeLoadedMsg{v}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FilterModel) query(input string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if input == m.lastQuery {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.state == LoadingStoreState {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.lastQuery = input
|
||||
|
||||
query, err := gojq.Parse(input)
|
||||
if err != nil {
|
||||
return errorMsg{fmt.Errorf("jq query parse error: %v", err)}
|
||||
}
|
||||
|
||||
var result []string
|
||||
|
||||
iter := query.Run(m.store)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
if err, ok := v.(error); ok {
|
||||
return errorMsg{fmt.Errorf("jq query run error: %v", err)}
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return errorMsg{err}
|
||||
}
|
||||
|
||||
result = append(result, string(b))
|
||||
}
|
||||
|
||||
v := make([]any, 0)
|
||||
|
||||
err = json.Unmarshal([]byte(strings.Join(result, "\n")), &v)
|
||||
if err != nil {
|
||||
return errorMsg{err}
|
||||
}
|
||||
|
||||
return resultMsg{v}
|
||||
}
|
||||
}
|
||||
|
||||
func toColoredJson(data []any) (string, error) {
|
||||
result, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
coloredBytes := make([]byte, 0)
|
||||
buffer := bytes.NewBuffer(coloredBytes)
|
||||
|
||||
err = quick.Highlight(buffer, string(result), "json", "terminal16m", "dracula")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return sanitize(buffer.String()), nil
|
||||
}
|
||||
|
||||
func toJson(data []any) (string, error) {
|
||||
result, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
func sanitize(text string) string {
|
||||
// FIXME: The use of a standard '-' character causes rendering
|
||||
// issues within the viewport. Further investigation is
|
||||
// required to resolve this problem.
|
||||
return strings.Replace(text, "-", "–", -1)
|
||||
}
|
14
cmd/filter/format.go
Normal file
14
cmd/filter/format.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package filter
|
||||
|
||||
var (
|
||||
stateFormats = map[int][]string{
|
||||
FilterState: []string{"FILTER 📖", "Write your jq command in the input box to start filtering. Press enter to return the result.", "STORE 🟢"},
|
||||
LoadingStoreState: []string{"LOAD %s", "Loading the store...", "STORE 🔴"},
|
||||
ErrorState: []string{"ERROR 📖", "%v", "STORE 🟢"},
|
||||
}
|
||||
filterTypeFormats = map[string]string{
|
||||
"participants": "👫 Participants filter 👫",
|
||||
"quizzes": "❓ Quizzes filter ❓",
|
||||
"responses": "📝 Responses filter 📝",
|
||||
}
|
||||
)
|
55
cmd/filter/keymap.go
Normal file
55
cmd/filter/keymap.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
package filter
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/remogatto/sugarfoam/components/group"
|
||||
"github.com/remogatto/sugarfoam/components/viewport"
|
||||
)
|
||||
|
||||
type keyBindings struct {
|
||||
group *group.Model
|
||||
|
||||
quit, enter key.Binding
|
||||
}
|
||||
|
||||
func (k *keyBindings) ShortHelp() []key.Binding {
|
||||
keys := make([]key.Binding, 0)
|
||||
|
||||
current := k.group.Current()
|
||||
|
||||
switch item := current.(type) {
|
||||
case *viewport.Model:
|
||||
keys = append(
|
||||
keys,
|
||||
item.KeyMap.Up,
|
||||
item.KeyMap.Down,
|
||||
)
|
||||
}
|
||||
|
||||
keys = append(
|
||||
keys,
|
||||
k.quit,
|
||||
)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func (k keyBindings) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{
|
||||
k.quit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newBindings(g *group.Model) *keyBindings {
|
||||
return &keyBindings{
|
||||
group: g,
|
||||
quit: key.NewBinding(
|
||||
key.WithKeys("esc"), key.WithHelp("esc", "Quit app"),
|
||||
),
|
||||
enter: key.NewBinding(
|
||||
key.WithKeys("enter"), key.WithHelp("enter", "Quit app and return the results"),
|
||||
),
|
||||
}
|
||||
}
|
13
cmd/filter/message.go
Normal file
13
cmd/filter/message.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package filter
|
||||
|
||||
type storeLoadedMsg struct {
|
||||
store []any
|
||||
}
|
||||
|
||||
type resultMsg struct {
|
||||
result []any
|
||||
}
|
||||
|
||||
type errorMsg struct {
|
||||
error error
|
||||
}
|
7
cmd/filter/state.go
Normal file
7
cmd/filter/state.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package filter
|
||||
|
||||
const (
|
||||
LoadingStoreState = iota
|
||||
FilterState
|
||||
ErrorState
|
||||
)
|
39
cmd/import.go
Normal file
39
cmd/import.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// importCmd represents the import command
|
||||
var importCmd = &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Import entities",
|
||||
Long: `A longer description that spans multiple lines and likely contains examples
|
||||
and usage of using your command. For example:
|
||||
|
||||
Cobra is a CLI library for Go that empowers applications.
|
||||
This application is a tool to generate the needed files
|
||||
to quickly create a Cobra application.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("import called")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(importCmd)
|
||||
|
||||
// Here you will define your flags and configuration settings.
|
||||
|
||||
// Cobra supports Persistent Flags which will work for this command
|
||||
// and all subcommands, e.g.:
|
||||
// importCmd.PersistentFlags().String("foo", "", "A help for foo")
|
||||
|
||||
// Cobra supports local flags which will only run when this command
|
||||
// is called directly, e.g.:
|
||||
// importCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
||||
}
|
38
cmd/init.go
Normal file
38
cmd/init.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/util"
|
||||
"git.andreafazzi.eu/andrea/probo/embed"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// initCmd represents the init command
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize a working directory",
|
||||
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/init/*.tmpl"),
|
||||
Run: runInit,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(initCmd)
|
||||
}
|
||||
|
||||
func runInit(cmd *cobra.Command, args []string) {
|
||||
err := embed.CopyToWorkingDirectory(embed.Data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = embed.CopyToWorkingDirectory(embed.Templates)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = embed.CopyToWorkingDirectory(embed.Public)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
}
|
59
cmd/rank.go
Normal file
59
cmd/rank.go
Normal file
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/rank"
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/util"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// rankCmd represents the rank command
|
||||
var rankCmd = &cobra.Command{
|
||||
Use: "rank",
|
||||
Short: "Show a ranking from the given responses.",
|
||||
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/rank/*.tmpl"),
|
||||
Run: runRank,
|
||||
}
|
||||
|
||||
func runRank(cmd *cobra.Command, args []string) {
|
||||
path, err := cmd.Flags().GetString("input")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
f := util.LogToFile()
|
||||
if f != nil {
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
|
||||
model, err := tea.NewProgram(
|
||||
rank.New(path, util.ReadStdin()),
|
||||
tea.WithOutput(os.Stderr),
|
||||
).Run()
|
||||
if err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result := model.(*rank.RankModel)
|
||||
|
||||
if result.Result != "" {
|
||||
fmt.Fprintf(os.Stdout, result.Result)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(rankCmd)
|
||||
rootCmd.PersistentFlags().StringP("input", "i", "", "Specify an input file")
|
||||
}
|
9
cmd/rank/format.go
Normal file
9
cmd/rank/format.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package rank
|
||||
|
||||
var (
|
||||
stateFormats = map[int][]string{
|
||||
BrowseState: []string{"BROWSE 📖", "Total scores: %d", "🟢"},
|
||||
ExecutingScriptState: []string{"EXE %s", "Executing script...", "🔴"},
|
||||
ErrorState: []string{"ERROR 📖", "%v", "🔴"},
|
||||
}
|
||||
)
|
62
cmd/rank/keymap.go
Normal file
62
cmd/rank/keymap.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
package rank
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/remogatto/sugarfoam/components/group"
|
||||
"github.com/remogatto/sugarfoam/components/table"
|
||||
"github.com/remogatto/sugarfoam/components/viewport"
|
||||
)
|
||||
|
||||
type keyBindings struct {
|
||||
group *group.Model
|
||||
|
||||
quit, enter key.Binding
|
||||
}
|
||||
|
||||
func (k *keyBindings) ShortHelp() []key.Binding {
|
||||
keys := make([]key.Binding, 0)
|
||||
|
||||
current := k.group.Current()
|
||||
|
||||
switch item := current.(type) {
|
||||
case *table.Model:
|
||||
keys = append(
|
||||
keys,
|
||||
item.KeyMap.LineUp,
|
||||
item.KeyMap.LineDown,
|
||||
)
|
||||
|
||||
case *viewport.Model:
|
||||
keys = append(
|
||||
keys,
|
||||
item.KeyMap.Up,
|
||||
item.KeyMap.Down,
|
||||
)
|
||||
}
|
||||
|
||||
keys = append(
|
||||
keys,
|
||||
k.group.KeyMap.FocusNext,
|
||||
k.group.KeyMap.FocusPrev,
|
||||
k.quit,
|
||||
)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func (k keyBindings) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{
|
||||
k.quit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newBindings(g *group.Model) *keyBindings {
|
||||
return &keyBindings{
|
||||
group: g,
|
||||
quit: key.NewBinding(
|
||||
key.WithKeys("esc"), key.WithHelp("esc", "quit app"),
|
||||
),
|
||||
}
|
||||
}
|
19
cmd/rank/message.go
Normal file
19
cmd/rank/message.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package rank
|
||||
|
||||
import "git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
|
||||
type storeLoadedMsg struct {
|
||||
store *file.SessionFileStore
|
||||
}
|
||||
|
||||
type resultMsg struct {
|
||||
result []any
|
||||
}
|
||||
|
||||
type errorMsg struct {
|
||||
error error
|
||||
}
|
||||
|
||||
type scriptExecutedMsg struct {
|
||||
result string
|
||||
}
|
448
cmd/rank/rank.go
Normal file
448
cmd/rank/rank.go
Normal file
|
@ -0,0 +1,448 @@
|
|||
package rank
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"text/template"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
btTable "github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/d5/tengo/v2"
|
||||
"github.com/d5/tengo/v2/stdlib"
|
||||
foam "github.com/remogatto/sugarfoam"
|
||||
"github.com/remogatto/sugarfoam/components/group"
|
||||
"github.com/remogatto/sugarfoam/components/header"
|
||||
"github.com/remogatto/sugarfoam/components/help"
|
||||
"github.com/remogatto/sugarfoam/components/statusbar"
|
||||
"github.com/remogatto/sugarfoam/components/table"
|
||||
"github.com/remogatto/sugarfoam/components/viewport"
|
||||
"github.com/remogatto/sugarfoam/layout"
|
||||
"github.com/remogatto/sugarfoam/layout/tiled"
|
||||
)
|
||||
|
||||
var responseTmpl = `{{range $answer := .Answers}}
|
||||
{{$answer.Quiz|toMarkdown $.Width}}
|
||||
R: {{$answer|toLipgloss}}
|
||||
{{end}}
|
||||
`
|
||||
|
||||
type ParticipantScore struct {
|
||||
Participant *models.Participant `json:"participant"`
|
||||
Response *models.Response `json:"response"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
type Rank struct {
|
||||
Scores []*ParticipantScore `json:"scores"`
|
||||
}
|
||||
|
||||
type RankModel struct {
|
||||
// UI
|
||||
viewport *viewport.Model
|
||||
table *table.Model
|
||||
group *group.Model
|
||||
help *help.Model
|
||||
statusBar *statusbar.Model
|
||||
spinner spinner.Model
|
||||
|
||||
// Layout
|
||||
document *layout.Layout
|
||||
|
||||
// Key bindings
|
||||
bindings *keyBindings
|
||||
|
||||
// json
|
||||
InputJson string
|
||||
Result string
|
||||
|
||||
// session
|
||||
rank *Rank
|
||||
|
||||
// response
|
||||
responseTmpl *template.Template
|
||||
|
||||
// filter file
|
||||
scriptFilePath string
|
||||
|
||||
// markdown
|
||||
mdRenderer *glamour.TermRenderer
|
||||
|
||||
state int
|
||||
}
|
||||
|
||||
func New(path string, stdin string) *RankModel {
|
||||
viewport := viewport.New()
|
||||
|
||||
table := table.New(table.WithRelWidths(10, 20, 30, 30, 10))
|
||||
table.Model.SetColumns([]btTable.Column{
|
||||
{Title: "Pos", Width: 5},
|
||||
{Title: "Token", Width: 10},
|
||||
{Title: "Lastname", Width: 40},
|
||||
{Title: "Firstname", Width: 40},
|
||||
{Title: "Score", Width: 5},
|
||||
})
|
||||
|
||||
group := group.New(
|
||||
group.WithItems(table, viewport),
|
||||
group.WithLayout(
|
||||
layout.New(
|
||||
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 1)}),
|
||||
layout.WithItem(tiled.New(table, viewport)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
bindings := newBindings(group)
|
||||
statusBar := statusbar.New(bindings)
|
||||
|
||||
s := spinner.New(
|
||||
spinner.WithStyle(
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("265"))),
|
||||
)
|
||||
s.Spinner = spinner.Dot
|
||||
|
||||
header := header.New(
|
||||
header.WithContent(
|
||||
lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
Render("😎 Rank 😎"),
|
||||
),
|
||||
)
|
||||
|
||||
help := help.New(
|
||||
bindings,
|
||||
help.WithStyles(&foam.Styles{NoBorder: lipgloss.NewStyle().Padding(1, 1)}))
|
||||
|
||||
document := layout.New(
|
||||
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
|
||||
layout.WithItem(header),
|
||||
layout.WithItem(group),
|
||||
layout.WithItem(help),
|
||||
layout.WithItem(statusBar),
|
||||
)
|
||||
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle("dracula"),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
tmpl, err := template.New("response").
|
||||
Funcs(template.FuncMap{
|
||||
"toMarkdown": func(width int, quiz *models.Quiz) string {
|
||||
md, err := models.QuizToMarkdown(quiz)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
result, err := renderer.Render(md)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
"toLipgloss": func(answer *models.ParticipantAnswer) string {
|
||||
color := "#ff0000"
|
||||
if answer.Correct {
|
||||
color = "#00ff00"
|
||||
}
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(color)).Render(answer.Answer.Text)
|
||||
},
|
||||
}).
|
||||
Parse(responseTmpl)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &RankModel{
|
||||
table: table,
|
||||
viewport: viewport,
|
||||
group: group,
|
||||
statusBar: statusBar,
|
||||
spinner: s,
|
||||
document: document,
|
||||
responseTmpl: tmpl,
|
||||
bindings: bindings,
|
||||
help: help,
|
||||
scriptFilePath: path,
|
||||
InputJson: stdin,
|
||||
mdRenderer: renderer,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (m *RankModel) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
cmds = append(cmds, m.group.Init(), m.executeScript(), m.spinner.Tick)
|
||||
|
||||
m.group.Focus()
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *RankModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.handleWindowSize(msg)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.bindings.quit):
|
||||
cmds = append(cmds, tea.Quit)
|
||||
}
|
||||
|
||||
case scriptExecutedMsg:
|
||||
m.handleScriptExecuted(msg)
|
||||
|
||||
case errorMsg:
|
||||
m.handleError(msg)
|
||||
m.state = ErrorState
|
||||
}
|
||||
|
||||
cmds = m.handleState(msg, cmds)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *RankModel) View() string {
|
||||
return m.document.View()
|
||||
}
|
||||
|
||||
func (m *RankModel) executeScript() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.scriptFilePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
rankJson, err := json.Marshal(Rank{Scores: make([]*ParticipantScore, 0)})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile(m.scriptFilePath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
s := tengo.NewScript(script)
|
||||
|
||||
s.SetImports(stdlib.GetModuleMap("fmt", "json", "rand", "times"))
|
||||
_ = s.Add("input", m.InputJson)
|
||||
_ = s.Add("output", string(rankJson))
|
||||
|
||||
c, err := s.Compile()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := c.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return scriptExecutedMsg{fmt.Sprintf("%s", c.Get("output"))}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (m *RankModel) showErrorOnStatusBar(err error) {
|
||||
m.statusBar.SetContent(
|
||||
stateFormats[ErrorState][0],
|
||||
fmt.Sprintf(stateFormats[ErrorState][1], err),
|
||||
stateFormats[ErrorState][2],
|
||||
)
|
||||
}
|
||||
|
||||
func (m *RankModel) updateTableContent() {
|
||||
rows := make([]btTable.Row, 0)
|
||||
|
||||
for i, score := range m.rank.Scores {
|
||||
rows = append(rows, btTable.Row{
|
||||
strconv.Itoa(i + 1),
|
||||
score.Participant.Token,
|
||||
score.Participant.Lastname,
|
||||
score.Participant.Firstname,
|
||||
strconv.Itoa(score.Score),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
}
|
||||
|
||||
func (m *RankModel) updateViewportContent() {
|
||||
if len(m.table.Rows()) == 0 {
|
||||
panic(errors.New("No scores available!"))
|
||||
}
|
||||
|
||||
currentPos := m.table.SelectedRow()[0]
|
||||
|
||||
pos, err := strconv.Atoi(currentPos)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
currentResponse := m.rank.Scores[pos-1]
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
var response struct {
|
||||
*models.Response
|
||||
Width int
|
||||
} = struct {
|
||||
*models.Response
|
||||
Width int
|
||||
}{currentResponse.Response, m.viewport.GetWidth()}
|
||||
|
||||
err = m.responseTmpl.Execute(&buf, response)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
m.viewport.SetContent(buf.String())
|
||||
}
|
||||
|
||||
func (m *RankModel) handleWindowSize(msg tea.WindowSizeMsg) {
|
||||
m.group.SetSize(msg.Width, msg.Height)
|
||||
m.document.SetSize(msg.Width, msg.Height)
|
||||
m.mdRenderer = m.createMDRenderer()
|
||||
}
|
||||
|
||||
func (m *RankModel) handleError(msg tea.Msg) {
|
||||
err := msg.(errorMsg)
|
||||
|
||||
m.statusBar.SetContent(
|
||||
stateFormats[ErrorState][0],
|
||||
fmt.Sprintf(stateFormats[ErrorState][1], err.error),
|
||||
stateFormats[ErrorState][2],
|
||||
)
|
||||
}
|
||||
|
||||
func (m *RankModel) handleScriptExecuted(msg tea.Msg) {
|
||||
rank := new(Rank)
|
||||
jsonData := []byte(msg.(scriptExecutedMsg).result)
|
||||
|
||||
err := json.Unmarshal(jsonData, &rank)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
slices.SortFunc(rank.Scores,
|
||||
func(a, b *ParticipantScore) int {
|
||||
return cmp.Compare(b.Score, a.Score)
|
||||
})
|
||||
|
||||
m.rank = rank
|
||||
|
||||
m.updateTableContent()
|
||||
m.updateViewportContent()
|
||||
|
||||
m.state = BrowseState
|
||||
}
|
||||
|
||||
func (m *RankModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
|
||||
_, cmd := m.group.Update(msg)
|
||||
|
||||
if m.state == ExecutingScriptState {
|
||||
return m.updateSpinner(msg, cmd, cmds)
|
||||
}
|
||||
|
||||
if m.state == BrowseState {
|
||||
m.updateViewportContent()
|
||||
}
|
||||
|
||||
if m.state != ErrorState {
|
||||
m.statusBar.SetContent(
|
||||
stateFormats[BrowseState][0],
|
||||
fmt.Sprintf(stateFormats[BrowseState][1], len(m.rank.Scores)),
|
||||
stateFormats[BrowseState][2],
|
||||
)
|
||||
}
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *RankModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd {
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
|
||||
m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2])
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *RankModel) loadStore() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
sStore, err := file.NewDefaultSessionFileStore()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return storeLoadedMsg{sStore}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *RankModel) createMDRenderer() *glamour.TermRenderer {
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle("dracula"),
|
||||
glamour.WithWordWrap(m.viewport.GetWidth()),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
// func toColoredJson(data []any) (string, error) {
|
||||
// result, err := json.MarshalIndent(data, "", " ")
|
||||
// if err != nil {
|
||||
// return "", err
|
||||
// }
|
||||
|
||||
// coloredBytes := make([]byte, 0)
|
||||
// buffer := bytes.NewBuffer(coloredBytes)
|
||||
|
||||
// err = quick.Highlight(buffer, string(result), "json", "terminal16m", "dracula")
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
|
||||
// return sanitize(buffer.String()), nil
|
||||
// }
|
||||
|
||||
// func sanitize(text string) string {
|
||||
// // FIXME: The use of a standard '-' character causes rendering
|
||||
// // issues within the viewport. Further investigation is
|
||||
// // required to resolve this problem.
|
||||
// return strings.Replace(text, "-", "–", -1)
|
||||
// }
|
||||
|
||||
// func desanitize(text string) string {
|
||||
// // FIXME: The use of a standard '-' character causes rendering
|
||||
// // issues within the viewport. Further investigation is
|
||||
// // required to resolve this problem.
|
||||
// return strings.Replace(text, "–", "-", -1)
|
||||
// }
|
7
cmd/rank/state.go
Normal file
7
cmd/rank/state.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package rank
|
||||
|
||||
const (
|
||||
ExecutingScriptState = iota
|
||||
BrowseState
|
||||
ErrorState
|
||||
)
|
97
cmd/root.go
Normal file
97
cmd/root.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
Copyright © 2024 Andrea Fazzi dev@andreafazzi.eu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/util"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
)
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "probo",
|
||||
Short: "A Quiz Management System",
|
||||
/*Long: `
|
||||
Probo is a CLI/TUI application that allows for the quick
|
||||
creation of quizzes from markdown files and their distribution
|
||||
as web pages.`,*/
|
||||
Long: util.RenderMarkdownTemplates("cli/*.tmpl", "cli/root/*.tmpl"),
|
||||
|
||||
// Uncomment the following line if your bare application
|
||||
// has an action associated with it:
|
||||
// Run: func(cmd *cobra.Command, args []string) { },
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
// Here you will define your flags and configuration settings.
|
||||
// Cobra supports persistent flags, which, if defined here,
|
||||
// will be global for your application.
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.probo.yaml)")
|
||||
|
||||
// Cobra also supports local flags, which will only run
|
||||
// when this action is called directly.
|
||||
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
||||
}
|
||||
|
||||
// initConfig reads in config file and ENV variables if set.
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
// Use config file from the flag.
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
// Find home directory.
|
||||
home, err := os.UserHomeDir()
|
||||
cobra.CheckErr(err)
|
||||
|
||||
// Search config in home directory with name ".probo" (without extension).
|
||||
viper.AddConfigPath(home)
|
||||
viper.SetConfigType("yaml")
|
||||
viper.SetConfigName(".probo")
|
||||
}
|
||||
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
// If a config file is found, read it in.
|
||||
if err := viper.ReadInConfig(); err == nil {
|
||||
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
|
||||
}
|
||||
}
|
82
cmd/serve.go
Normal file
82
cmd/serve.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/serve"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// serveCmd represents the serve command
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Launch a web server to adminster exam sessions",
|
||||
Long: `A longer description that spans multiple lines and likely contains examples
|
||||
and usage of using your command. For example:
|
||||
|
||||
Cobra is a CLI library for Go that empowers applications.
|
||||
This application is a tool to generate the needed files
|
||||
to quickly create a Cobra application.`,
|
||||
Run: runServer,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) {
|
||||
sStore, err := file.NewDefaultSessionFileStore()
|
||||
if err != nil {
|
||||
log.Fatal("Session store loading", "err", err)
|
||||
}
|
||||
|
||||
rStore, err := file.NewDefaultResponseFileStore()
|
||||
if err != nil {
|
||||
log.Fatal("Session store loading", "err", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
loginController := serve.NewController(sStore, rStore).
|
||||
WithTemplates(
|
||||
"templates/login/layout-login.html.tmpl",
|
||||
"templates/login/login.html.tmpl",
|
||||
).
|
||||
WithHandlerFunc(serve.LoginHandler)
|
||||
|
||||
sessionsController := serve.NewController(sStore, rStore).
|
||||
WithTemplates(
|
||||
"templates/sessions/layout-sessions.html.tmpl",
|
||||
"templates/sessions/sessions.html.tmpl",
|
||||
).
|
||||
WithHandlerFunc(serve.SessionsHandler)
|
||||
|
||||
examController := serve.NewController(sStore, rStore).
|
||||
WithTemplates(
|
||||
"templates/exam/layout-exam.html.tmpl",
|
||||
"templates/exam/exam.html.tmpl",
|
||||
).
|
||||
WithHandlerFunc(serve.ExamHandler)
|
||||
|
||||
mux.Handle("GET /login", serve.Recover(loginController))
|
||||
mux.Handle("POST /login", serve.Recover(loginController))
|
||||
|
||||
mux.Handle("GET /sessions", serve.Recover(sessionsController))
|
||||
mux.Handle("GET /sessions/{uuid}/exams/{participantID}", serve.Recover(examController))
|
||||
mux.Handle("POST /sessions/{uuid}/exams/{participantID}", serve.Recover(examController))
|
||||
|
||||
mux.Handle("GET /public/", http.StripPrefix("/public", http.FileServer(http.Dir("public"))))
|
||||
|
||||
log.Info("Listening...")
|
||||
|
||||
err = http.ListenAndServe(":3000", mux)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
}
|
64
cmd/serve/controller.go
Normal file
64
cmd/serve/controller.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
package serve
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
|
||||
sStore *file.SessionFileStore
|
||||
rStore *file.ResponseFileStore
|
||||
|
||||
template *template.Template
|
||||
}
|
||||
|
||||
func NewController(sStore *file.SessionFileStore, rStore *file.ResponseFileStore) *Controller {
|
||||
return &Controller{
|
||||
sStore: sStore,
|
||||
rStore: rStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) WithHandlerFunc(f func(c *Controller, w http.ResponseWriter, r *http.Request)) *Controller {
|
||||
hf := func(w http.ResponseWriter, r *http.Request) {
|
||||
f(c, w, r)
|
||||
}
|
||||
|
||||
c.handlerFunc = hf
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Controller) WithTemplates(paths ...string) *Controller {
|
||||
tmpl, err := template.ParseFiles(paths...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
c.template = tmpl
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Controller) ExecuteTemplate(w http.ResponseWriter, data any) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := c.template.Execute(&buf, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
|
||||
buf.WriteTo(w)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Controller) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
c.handlerFunc.ServeHTTP(w, r)
|
||||
}
|
81
cmd/serve/exam.go
Normal file
81
cmd/serve/exam.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
package serve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
var ExamHandler = func(c *Controller, w http.ResponseWriter, r *http.Request) {
|
||||
_, err := ValidateJwtCookie(r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
participantID := r.PathValue("participantID")
|
||||
|
||||
session, err := c.sStore.Read(r.PathValue("uuid"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
exam, ok := session.Exams[participantID]
|
||||
if !ok {
|
||||
panic(errors.New("Exam not found in the store!"))
|
||||
}
|
||||
|
||||
examWithSession := struct {
|
||||
*models.Exam
|
||||
Session *models.Session
|
||||
}{exam, session}
|
||||
|
||||
switch r.Method {
|
||||
|
||||
case http.MethodGet:
|
||||
log.Info("Sending exam to", "participant", exam.Participant, "exam", exam)
|
||||
|
||||
err = c.ExecuteTemplate(w, examWithSession)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
case http.MethodPost:
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
answers := make([]*models.ParticipantAnswer, 0)
|
||||
|
||||
participant := session.Participants[participantID]
|
||||
|
||||
for quizID, values := range r.Form {
|
||||
correct := false
|
||||
|
||||
quiz := session.Quizzes[quizID]
|
||||
|
||||
for _, answerID := range values {
|
||||
if quiz.Correct.ID == answerID {
|
||||
correct = true
|
||||
}
|
||||
answers = append(answers, &models.ParticipantAnswer{Quiz: quiz, Answer: session.Answers[answerID], Correct: correct})
|
||||
}
|
||||
}
|
||||
|
||||
response, err := c.rStore.Create(
|
||||
&models.Response{
|
||||
SessionTitle: session.Title,
|
||||
Participant: participant,
|
||||
Answers: answers,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
log.Info("Saving response", "response", response)
|
||||
|
||||
}
|
||||
|
||||
}
|
36
cmd/serve/jwt.go
Normal file
36
cmd/serve/jwt.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
package serve
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
)
|
||||
|
||||
const jwtExpiresAt = time.Hour
|
||||
|
||||
type Claims struct {
|
||||
Token string `json:"token"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
var (
|
||||
jwtKey = []byte("my-secret")
|
||||
)
|
||||
|
||||
func ValidateJwtCookie(r *http.Request) (*jwt.Token, error) {
|
||||
cookie, err := r.Cookie("Authorize")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return jwtKey, nil
|
||||
})
|
||||
|
||||
return token, err
|
||||
}
|
76
cmd/serve/login.go
Normal file
76
cmd/serve/login.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
package serve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/golang-jwt/jwt"
|
||||
)
|
||||
|
||||
var LoginHandler = func(c *Controller, w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
pToken := r.FormValue("participantToken")
|
||||
if pToken == "" {
|
||||
panic(errors.New("Token not found parsing the request!"))
|
||||
}
|
||||
|
||||
log.Info("Received", "participantToken", pToken)
|
||||
|
||||
var loggedParticipant *models.Participant
|
||||
|
||||
done := false
|
||||
for _, session := range c.sStore.ReadAll() {
|
||||
if done {
|
||||
break
|
||||
}
|
||||
for _, exam := range session.Exams {
|
||||
if pToken == exam.Participant.Token {
|
||||
loggedParticipant = exam.Participant
|
||||
done = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Participant logged in as", "participant", loggedParticipant)
|
||||
|
||||
if loggedParticipant == nil {
|
||||
panic(errors.New("Participant not found!"))
|
||||
}
|
||||
|
||||
claims := &Claims{
|
||||
Token: pToken,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: time.Now().Add(jwtExpiresAt).Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
tokenString, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtKey))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "Authorize",
|
||||
Value: tokenString,
|
||||
Expires: time.Now().Add(jwtExpiresAt),
|
||||
})
|
||||
|
||||
log.Info("Released", "jwt", tokenString)
|
||||
log.Info("Redirect to", "url", "/sessions")
|
||||
|
||||
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
err := c.ExecuteTemplate(w, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
22
cmd/serve/recover.go
Normal file
22
cmd/serve/recover.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
package serve
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
func Recover(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
err := recover()
|
||||
if err != nil {
|
||||
log.Error("Recovering from", "err", err)
|
||||
http.Error(w, err.(error).Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
39
cmd/serve/sessions.go
Normal file
39
cmd/serve/sessions.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package serve
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"github.com/golang-jwt/jwt"
|
||||
)
|
||||
|
||||
var SessionsHandler = func(c *Controller, w http.ResponseWriter, r *http.Request) {
|
||||
token, err := ValidateJwtCookie(r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
|
||||
var participantSessions []struct {
|
||||
ParticipantID string
|
||||
*models.Session
|
||||
}
|
||||
|
||||
for _, session := range c.sStore.ReadAll() {
|
||||
for _, exam := range session.Exams {
|
||||
if exam.Participant.Token == claims["token"] {
|
||||
s := struct {
|
||||
ParticipantID string
|
||||
*models.Session
|
||||
}{exam.Participant.ID, session}
|
||||
participantSessions = append(participantSessions, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = c.ExecuteTemplate(w, participantSessions)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
60
cmd/session.go
Normal file
60
cmd/session.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/session"
|
||||
"git.andreafazzi.eu/andrea/probo/cmd/util"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// sessionCmd represents the session command
|
||||
var sessionCmd = &cobra.Command{
|
||||
Use: "session",
|
||||
Short: "Create a new session or update a given one",
|
||||
Long: "Create a new session or update a given one.",
|
||||
Run: updateSession,
|
||||
}
|
||||
|
||||
func init() {
|
||||
updateCmd.AddCommand(sessionCmd)
|
||||
|
||||
sessionCmd.Flags().StringP("script", "s", "", "Execute a tengo script to initiate a session")
|
||||
}
|
||||
|
||||
func updateSession(cmd *cobra.Command, args []string) {
|
||||
f := util.LogToFile()
|
||||
if f != nil {
|
||||
defer f.Close()
|
||||
}
|
||||
|
||||
path, err := cmd.Flags().GetString("script")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
|
||||
model, err := tea.NewProgram(
|
||||
session.New(path, util.ReadStdin()),
|
||||
tea.WithOutput(os.Stderr),
|
||||
).Run()
|
||||
if err != nil {
|
||||
fmt.Println("Error running program:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result := model.(*session.SessionModel)
|
||||
|
||||
if result.Result != "" {
|
||||
fmt.Fprintf(os.Stdout, result.Result)
|
||||
}
|
||||
|
||||
}
|
9
cmd/session/format.go
Normal file
9
cmd/session/format.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package session
|
||||
|
||||
var (
|
||||
stateFormats = map[int][]string{
|
||||
BrowseState: []string{"BROWSE 📖", "Total sessions in the store: %d, Exams in the current session: %d", "STORE 🟢"},
|
||||
LoadingStoreState: []string{"LOAD %s", "Loading the store...", "STORE 🔴"},
|
||||
ErrorState: []string{"ERROR 📖", "%v", "STORE 🟢"},
|
||||
}
|
||||
)
|
69
cmd/session/keymap.go
Normal file
69
cmd/session/keymap.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/remogatto/sugarfoam/components/form"
|
||||
"github.com/remogatto/sugarfoam/components/group"
|
||||
"github.com/remogatto/sugarfoam/components/table"
|
||||
"github.com/remogatto/sugarfoam/components/viewport"
|
||||
)
|
||||
|
||||
type keyBindings struct {
|
||||
group *group.Model
|
||||
|
||||
quit, enter key.Binding
|
||||
}
|
||||
|
||||
func (k *keyBindings) ShortHelp() []key.Binding {
|
||||
keys := make([]key.Binding, 0)
|
||||
|
||||
current := k.group.Current()
|
||||
|
||||
switch item := current.(type) {
|
||||
case *table.Model:
|
||||
keys = append(
|
||||
keys,
|
||||
item.KeyMap.LineUp,
|
||||
item.KeyMap.LineDown,
|
||||
)
|
||||
|
||||
case *viewport.Model:
|
||||
keys = append(
|
||||
keys,
|
||||
item.KeyMap.Up,
|
||||
item.KeyMap.Down,
|
||||
)
|
||||
case *form.Model:
|
||||
keys = append(
|
||||
keys,
|
||||
item.KeyBinds()...,
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
keys = append(
|
||||
keys,
|
||||
k.group.KeyMap.FocusNext,
|
||||
k.group.KeyMap.FocusPrev,
|
||||
k.quit,
|
||||
)
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func (k keyBindings) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{
|
||||
k.quit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newBindings(g *group.Model) *keyBindings {
|
||||
return &keyBindings{
|
||||
group: g,
|
||||
quit: key.NewBinding(
|
||||
key.WithKeys("esc"), key.WithHelp("esc", "quit app"),
|
||||
),
|
||||
}
|
||||
}
|
19
cmd/session/message.go
Normal file
19
cmd/session/message.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package session
|
||||
|
||||
import "git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
|
||||
type storeLoadedMsg struct {
|
||||
store *file.SessionFileStore
|
||||
}
|
||||
|
||||
type resultMsg struct {
|
||||
result []any
|
||||
}
|
||||
|
||||
type errorMsg struct {
|
||||
error error
|
||||
}
|
||||
|
||||
type scriptExecutedMsg struct {
|
||||
result string
|
||||
}
|
452
cmd/session/session.go
Normal file
452
cmd/session/session.go
Normal file
|
@ -0,0 +1,452 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
btTable "github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/d5/tengo/v2"
|
||||
"github.com/d5/tengo/v2/stdlib"
|
||||
foam "github.com/remogatto/sugarfoam"
|
||||
"github.com/remogatto/sugarfoam/components/form"
|
||||
"github.com/remogatto/sugarfoam/components/group"
|
||||
"github.com/remogatto/sugarfoam/components/header"
|
||||
"github.com/remogatto/sugarfoam/components/help"
|
||||
"github.com/remogatto/sugarfoam/components/statusbar"
|
||||
"github.com/remogatto/sugarfoam/components/table"
|
||||
"github.com/remogatto/sugarfoam/components/viewport"
|
||||
"github.com/remogatto/sugarfoam/layout"
|
||||
"github.com/remogatto/sugarfoam/layout/tiled"
|
||||
)
|
||||
|
||||
var mockSession = `
|
||||
{"participants":[{"Attributes":{"class":"Math 101A"},"created_at":"2024-04-03T09:22:11.201142494+02:00","firstname":"THOMAS","id":"1024758d-0dba-4bec-aaab-339e87a0649b","lastname":"MANN","token":"427628","updated_at":"2024-05-27T13:48:50.096492274+02:00"},{"Attributes":{"class":"Math 101A"},"created_at":"2024-04-04T10:23:12.203143495+02:00","firstname":"JAMES","id":"f3c5a7bd-6fda-4bce-bbbb-333e88a0650c","lastname":"BROWN","token":"428629","updated_at":"2024-05-27T13:48:50.075089555+02:00"},{"Attributes":{"class":"Math 101A"},"created_at":"2024-04-05T11:24:13.204144496+02:00","firstname":"LUCY","id":"g4d6c8cd-7eda-4cce-cccc-444e89a0661d","lastname":"DOE","token":"429630","updated_at":"2024-05-27T13:48:50.079134348+02:00"}], "quizzes": [{"CorrectPos":0,"answers":[{"created_at":"2024-05-27T13:55:26.448679335+02:00","id":"6771592e-4e2c-45f1-b14f-91f943a5616b","text":"3 + 1 = 1 + 3","updated_at":"2024-05-27T13:55:26.448679428+02:00"},{"created_at":"2024-05-27T13:55:26.448681412+02:00","id":"bad7db2a-f5be-4f22-879a-fd783bb1c0a9","text":"3 - 1 = 1 - 3","updated_at":"2024-05-27T13:55:26.448681456+02:00"},{"created_at":"2024-05-27T13:55:26.448683251+02:00","id":"8ae29e51-65a7-4618-8074-58ee90c4fb83","text":"3 / 2 = 2 / 3","updated_at":"2024-05-27T13:55:26.448683289+02:00"},{"created_at":"2024-05-27T13:55:26.448685048+02:00","id":"7d2a9491-338d-4274-9e62-3d2641788777","text":"-3 + 1 = -1 + 3","updated_at":"2024-05-27T13:55:26.448685085+02:00"}],"correct":{"created_at":"2024-05-27T13:55:26.448679335+02:00","id":"6771592e-4e2c-45f1-b14f-91f943a5616b","text":"3 + 1 = 1 + 3","updated_at":"2024-05-27T13:55:26.448679428+02:00"},"created_at":"2024-05-27T13:49:24.170080837+02:00","hash":"","id":"6a21d3fa-d63c-4acf-9238-551692ed74c4","question":{"created_at":"2024-05-27T13:55:26.448673273+02:00","id":"bf19c1f1-6896-4ca2-b7dc-1657adcce4cd","text":"In #mathematics, for the commutative property of addition, we have\nthat:","updated_at":"2024-05-27T13:55:26.448673633+02:00"},"tags":["#mathematics"],"type":0,"updated_at":"2024-05-27T13:55:26.448701931+02:00"},{"CorrectPos":0,"answers":[{"created_at":"2024-05-27T13:55:26.449222989+02:00","id":"37f741c2-12de-4463-b6c0-42b8b5249e7a","text":"3 * (4 + 5) = 3 * 4 + 3 * 5","updated_at":"2024-05-27T13:55:26.449223079+02:00"},{"created_at":"2024-05-27T13:55:26.44922686+02:00","id":"c60b1b81-5e16-4401-831c-d9fffba1d1eb","text":"6 * (2 - 3) = 6 * 2 + 6 * 3","updated_at":"2024-05-27T13:55:26.449226924+02:00"},{"created_at":"2024-05-27T13:55:26.449230299+02:00","id":"fb0ba30c-1e4e-41a1-9630-7f18aa24a6db","text":"7 * (8 + 2) = 7 * 8 - 7 * 2","updated_at":"2024-05-27T13:55:26.449230351+02:00"},{"created_at":"2024-05-27T13:55:26.449233993+02:00","id":"0cfd6fbb-7aac-4d35-aa17-3e62f5297c68","text":"2 * (1 + 2) = (- 2) * 1 - 4","updated_at":"2024-05-27T13:55:26.449234055+02:00"}],"correct":{"created_at":"2024-05-27T13:55:26.449222989+02:00","id":"37f741c2-12de-4463-b6c0-42b8b5249e7a","text":"3 * (4 + 5) = 3 * 4 + 3 * 5","updated_at":"2024-05-27T13:55:26.449223079+02:00"},"created_at":"2024-05-27T13:49:24.173550821+02:00","hash":"","id":"40aa188a-46c6-4f26-9a1e-4d5a028ba367","question":{"created_at":"2024-05-27T13:55:26.449216807+02:00","id":"723acb0a-5c60-4062-8615-fd3494220b4d","text":"In #mathematics, the distributive property allows us to multiply a sum\nor difference by a single number. Which statement correctly applies\nthis property?","updated_at":"2024-05-27T13:55:26.449216918+02:00"},"tags":["#mathematics"],"type":0,"updated_at":"2024-05-27T13:55:26.449281612+02:00"},{"CorrectPos":0,"answers":[{"created_at":"2024-05-27T13:55:26.449440615+02:00","id":"11be2252-45f0-44c1-a9f0-19dcdcd45b68","text":"1","updated_at":"2024-05-27T13:55:26.449440713+02:00"},{"created_at":"2024-05-27T13:55:26.449452222+02:00","id":"971fbf3b-27b1-4b67-9636-ac681056ce62","text":"0","updated_at":"2024-05-27T13:55:26.449452291+02:00"},{"created_at":"2024-05-27T13:55:26.449455664+02:00","id":"d35104b1-37f9-4312-aa21-a470650cb4dc","text":"2","updated_at":"2024-05-27T13:55:26.44945573+02:00"},{"created_at":"2024-05-27T13:55:26.449459322+02:00","id":"220077d2-1994-40ed-8d28-f39d2ebb8dcf","text":"3","updated_at":"2024-05-27T13:55:26.449459397+02:00"}],"correct":{"created_at":"2024-05-27T13:55:26.449440615+02:00","id":"11be2252-45f0-44c1-a9f0-19dcdcd45b68","text":"1","updated_at":"2024-05-27T13:55:26.449440713+02:00"},"created_at":"2024-05-27T13:49:24.175865713+02:00","hash":"","id":"f9fab318-3373-4729-a80f-b681460824e3","question":{"created_at":"2024-05-27T13:55:26.449432971+02:00","id":"af7d973a-a83c-4a18-ac36-92ab6442ef07","text":"In #mathematics, certain numbers have special properties. Identify the\nidentity element for multiplication among the options given:","updated_at":"2024-05-27T13:55:26.449433136+02:00"},"tags":["#mathematics"],"type":0,"updated_at":"2024-05-27T13:55:26.449476666+02:00"}]}`
|
||||
|
||||
type SessionModel struct {
|
||||
// UI
|
||||
form *form.Model
|
||||
viewport *viewport.Model
|
||||
table *table.Model
|
||||
group *group.Model
|
||||
help *help.Model
|
||||
statusBar *statusbar.Model
|
||||
spinner spinner.Model
|
||||
|
||||
// Layout
|
||||
document *layout.Layout
|
||||
|
||||
// Key bindings
|
||||
bindings *keyBindings
|
||||
|
||||
// store
|
||||
store *file.SessionFileStore
|
||||
lenStore int
|
||||
result []any
|
||||
|
||||
// json
|
||||
InputJson string
|
||||
Result string
|
||||
|
||||
// session
|
||||
session *models.Session
|
||||
|
||||
// markdown
|
||||
mdRenderer *glamour.TermRenderer
|
||||
|
||||
// filter file
|
||||
scriptFilePath string
|
||||
|
||||
state int
|
||||
}
|
||||
|
||||
func New(path string, stdin string) *SessionModel {
|
||||
form := form.New(
|
||||
form.WithGroups(huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Key("sessionTitle").
|
||||
Title("Session title").
|
||||
Description("Enter the title of the session").
|
||||
Validate(func(str string) error {
|
||||
if str == "" {
|
||||
return errors.New("You must set a session name!")
|
||||
}
|
||||
return nil
|
||||
|
||||
}),
|
||||
)))
|
||||
|
||||
formBinding := huh.NewDefaultKeyMap()
|
||||
formBinding.Input.Next = key.NewBinding(key.WithKeys("down"), key.WithHelp("down", "next"))
|
||||
formBinding.Input.Prev = key.NewBinding(key.WithKeys("up"), key.WithHelp("up", "prev"))
|
||||
formBinding.Confirm.Next = key.NewBinding(key.WithKeys("down"), key.WithHelp("down", "next"))
|
||||
formBinding.Confirm.Prev = key.NewBinding(key.WithKeys("up"), key.WithHelp("up", "prev"))
|
||||
|
||||
form.WithShowHelp(false).WithTheme(huh.ThemeDracula()).WithKeyMap(formBinding)
|
||||
|
||||
viewport := viewport.New()
|
||||
|
||||
table := table.New(table.WithRelWidths(20, 10, 25, 25, 20))
|
||||
table.Model.SetColumns([]btTable.Column{
|
||||
{Title: "UUID", Width: 20},
|
||||
{Title: "Token", Width: 20},
|
||||
{Title: "Lastname", Width: 20},
|
||||
{Title: "Firstname", Width: 20},
|
||||
{Title: "Class", Width: 20},
|
||||
})
|
||||
|
||||
group := group.New(
|
||||
group.WithItems(form, table, viewport),
|
||||
group.WithLayout(
|
||||
layout.New(
|
||||
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 1)}),
|
||||
layout.WithItem(form),
|
||||
layout.WithItem(tiled.New(table, viewport)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
bindings := newBindings(group)
|
||||
statusBar := statusbar.New(bindings)
|
||||
|
||||
s := spinner.New(
|
||||
spinner.WithStyle(
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color("265"))),
|
||||
)
|
||||
s.Spinner = spinner.Dot
|
||||
|
||||
header := header.New(
|
||||
header.WithContent(
|
||||
lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
Render("✨ Create session ✨"),
|
||||
),
|
||||
)
|
||||
|
||||
help := help.New(
|
||||
bindings,
|
||||
help.WithStyles(&foam.Styles{NoBorder: lipgloss.NewStyle().Padding(1, 1)}))
|
||||
|
||||
document := layout.New(
|
||||
layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
|
||||
layout.WithItem(header),
|
||||
layout.WithItem(group),
|
||||
layout.WithItem(help),
|
||||
layout.WithItem(statusBar),
|
||||
)
|
||||
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle("dracula"),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &SessionModel{
|
||||
form: form,
|
||||
table: table,
|
||||
viewport: viewport,
|
||||
group: group,
|
||||
statusBar: statusBar,
|
||||
spinner: s,
|
||||
document: document,
|
||||
mdRenderer: renderer,
|
||||
bindings: bindings,
|
||||
help: help,
|
||||
scriptFilePath: path,
|
||||
InputJson: stdin,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (m *SessionModel) Init() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
cmds = append(cmds, m.group.Init(), m.loadStore(), m.spinner.Tick)
|
||||
|
||||
m.group.Focus()
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *SessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.handleWindowSize(msg)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.bindings.quit):
|
||||
cmds = append(cmds, tea.Quit)
|
||||
}
|
||||
|
||||
case storeLoadedMsg:
|
||||
cmds = append(cmds, m.handleStoreLoaded(msg))
|
||||
|
||||
case scriptExecutedMsg:
|
||||
m.handleScriptExecuted(msg)
|
||||
|
||||
case errorMsg:
|
||||
m.handleError(msg)
|
||||
m.state = ErrorState
|
||||
}
|
||||
|
||||
cmds = m.handleState(msg, cmds)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *SessionModel) View() string {
|
||||
return m.document.View()
|
||||
}
|
||||
|
||||
func (m *SessionModel) marshalJSON() (string, error) {
|
||||
session, err := m.createSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resultJson, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(resultJson), nil
|
||||
}
|
||||
|
||||
func (m *SessionModel) executeScript() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.scriptFilePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionJson, err := json.Marshal(models.Session{Exams: map[string]*models.Exam{}})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile(m.scriptFilePath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
s := tengo.NewScript(script)
|
||||
|
||||
s.SetImports(stdlib.GetModuleMap("fmt", "json", "rand", "times"))
|
||||
_ = s.Add("input", m.InputJson)
|
||||
_ = s.Add("output", string(sessionJson))
|
||||
|
||||
c, err := s.Compile()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := c.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return scriptExecutedMsg{fmt.Sprintf("%s", c.Get("output"))}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionModel) createSession() (*models.Session, error) {
|
||||
m.session.Title = m.form.GetString("sessionTitle")
|
||||
|
||||
session, err := m.store.Storer.Create(m.session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, err
|
||||
}
|
||||
|
||||
func (m *SessionModel) showErrorOnStatusBar(err error) {
|
||||
m.statusBar.SetContent(
|
||||
stateFormats[ErrorState][0],
|
||||
fmt.Sprintf(stateFormats[ErrorState][1], err),
|
||||
stateFormats[ErrorState][2],
|
||||
)
|
||||
}
|
||||
|
||||
func (m *SessionModel) updateTableContent(session *models.Session) {
|
||||
rows := make([]btTable.Row, 0)
|
||||
|
||||
for _, exam := range session.Exams {
|
||||
rows = append(rows, btTable.Row{
|
||||
exam.Participant.ID,
|
||||
exam.Participant.Token,
|
||||
exam.Participant.Lastname,
|
||||
exam.Participant.Firstname,
|
||||
exam.Participant.Attributes.Get("class"),
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
m.table.SetRows(rows)
|
||||
}
|
||||
|
||||
func (m *SessionModel) updateViewportContent(session *models.Session) {
|
||||
if len(m.table.Rows()) == 0 {
|
||||
panic(errors.New("Session have not exams"))
|
||||
}
|
||||
|
||||
currentUUID := m.table.SelectedRow()[0]
|
||||
currentExam := session.Exams[currentUUID]
|
||||
|
||||
if currentExam == nil {
|
||||
panic("Current token is not associate to any exam!")
|
||||
}
|
||||
|
||||
// md, err := currentExam.ToMarkdown()
|
||||
// if err != nil {
|
||||
// m.showErrorOnStatusBar(err)
|
||||
// }
|
||||
|
||||
// data, err := currentExam.Marshal()
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
|
||||
// result, err := m.mdRenderer.Render(md)
|
||||
// if err != nil {
|
||||
// m.showErrorOnStatusBar(err)
|
||||
// }
|
||||
|
||||
// m.viewport.SetContent(result)
|
||||
// m.viewport.SetContent(string(data))
|
||||
m.viewport.SetContent(mockSession)
|
||||
}
|
||||
|
||||
func (m *SessionModel) createMDRenderer() *glamour.TermRenderer {
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithStandardStyle("dracula"),
|
||||
glamour.WithWordWrap(m.viewport.GetWidth()),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
func (m *SessionModel) handleWindowSize(msg tea.WindowSizeMsg) {
|
||||
m.group.SetSize(msg.Width, msg.Height)
|
||||
m.document.SetSize(msg.Width, msg.Height)
|
||||
m.mdRenderer = m.createMDRenderer()
|
||||
}
|
||||
|
||||
func (m *SessionModel) handleError(msg tea.Msg) {
|
||||
err := msg.(errorMsg)
|
||||
|
||||
m.statusBar.SetContent(
|
||||
stateFormats[ErrorState][0],
|
||||
fmt.Sprintf(stateFormats[ErrorState][1], err.error),
|
||||
stateFormats[ErrorState][2],
|
||||
)
|
||||
}
|
||||
|
||||
func (m *SessionModel) handleScriptExecuted(msg tea.Msg) {
|
||||
session := new(models.Session)
|
||||
jsonData := []byte(msg.(scriptExecutedMsg).result)
|
||||
|
||||
err := json.Unmarshal(jsonData, &session)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
m.session = session
|
||||
|
||||
m.updateTableContent(session)
|
||||
m.updateViewportContent(session)
|
||||
|
||||
m.state = BrowseState
|
||||
}
|
||||
|
||||
func (m *SessionModel) handleStoreLoaded(msg tea.Msg) tea.Cmd {
|
||||
storeMsg := msg.(storeLoadedMsg)
|
||||
|
||||
m.store = storeMsg.store
|
||||
m.lenStore = len(m.store.ReadAll())
|
||||
|
||||
return m.executeScript()
|
||||
|
||||
}
|
||||
|
||||
func (m *SessionModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
|
||||
_, cmd := m.group.Update(msg)
|
||||
|
||||
if m.state == LoadingStoreState {
|
||||
return m.updateSpinner(msg, cmd, cmds)
|
||||
}
|
||||
|
||||
if m.form.State == huh.StateCompleted {
|
||||
var err error
|
||||
|
||||
m.Result, err = m.marshalJSON()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
cmds = append(cmds, tea.Quit)
|
||||
}
|
||||
|
||||
if len(m.form.Errors()) > 0 {
|
||||
m.state = ErrorState
|
||||
for _, err := range m.form.Errors() {
|
||||
m.showErrorOnStatusBar(err)
|
||||
}
|
||||
} else {
|
||||
m.state = BrowseState
|
||||
}
|
||||
|
||||
if m.state == BrowseState {
|
||||
m.updateViewportContent(m.session)
|
||||
}
|
||||
|
||||
if m.state != ErrorState {
|
||||
m.statusBar.SetContent(
|
||||
stateFormats[BrowseState][0],
|
||||
fmt.Sprintf(stateFormats[BrowseState][1], m.lenStore, len(m.session.Exams)),
|
||||
stateFormats[BrowseState][2],
|
||||
)
|
||||
}
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *SessionModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd {
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
|
||||
m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2])
|
||||
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *SessionModel) loadStore() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
sStore, err := file.NewDefaultSessionFileStore()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return storeLoadedMsg{sStore}
|
||||
}
|
||||
}
|
7
cmd/session/state.go
Normal file
7
cmd/session/state.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package session
|
||||
|
||||
const (
|
||||
LoadingStoreState = iota
|
||||
BrowseState
|
||||
ErrorState
|
||||
)
|
29
cmd/update.go
Normal file
29
cmd/update.go
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// updateCmd represents the update command
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Create or update an entity",
|
||||
Long: `A longer description that spans multiple lines and likely contains examples
|
||||
and usage of using your command. For example:
|
||||
|
||||
Cobra is a CLI library for Go that empowers applications.
|
||||
This application is a tool to generate the needed files
|
||||
to quickly create a Cobra application.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("update called")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
}
|
74
cmd/util/util.go
Normal file
74
cmd/util/util.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/embed"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
)
|
||||
|
||||
func RenderMarkdownTemplates(paths ...string) string {
|
||||
tmpl, err := template.ParseFS(embed.CLI, paths...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err = tmpl.Execute(&buf, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
result, err := glamour.Render(buf.String(), "dark")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func LogToFile() *os.File {
|
||||
if len(os.Getenv("DEBUG")) > 0 {
|
||||
f, err := tea.LogToFile("probo-debug.log", "[DEBUG]")
|
||||
if err != nil {
|
||||
fmt.Println("fatal:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadStdin() string {
|
||||
var b strings.Builder
|
||||
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if stat.Mode()&os.ModeNamedPipe != 0 || stat.Size() != 0 {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
for {
|
||||
r, _, err := reader.ReadRune()
|
||||
if err != nil && err == io.EOF {
|
||||
break
|
||||
}
|
||||
_, err = b.WriteRune(r)
|
||||
if err != nil {
|
||||
fmt.Println("Error getting input:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
33
embed/cli/filter/description.tmpl
Normal file
33
embed/cli/filter/description.tmpl
Normal file
|
@ -0,0 +1,33 @@
|
|||
{{define "description"}}
|
||||
# Filters
|
||||
|
||||
**Filters can be used to make selections among stores.**
|
||||
|
||||
Filters allow you to narrow down selections across various stores. By
|
||||
using filters, you can select participants, quizzes, and
|
||||
responses. The command triggers a Text User Interface (TUI) that runs
|
||||
a `jq` filter, displaying the outcome in real-time. After you're content
|
||||
with the filtered JSON, pressing ⏎ will present the result on
|
||||
stdout, enabling you to further process it by piping it forward.
|
||||
|
||||
## Examples
|
||||
|
||||
1. Apply a filter to participants:
|
||||
|
||||
```
|
||||
probo filter participants
|
||||
```
|
||||
|
||||
2. Apply a filter to quizzes using the `jq` filter in `tags.jq` file:
|
||||
|
||||
```
|
||||
probo filter quizzes -i data/filters/tags.jq
|
||||
```
|
||||
|
||||
3. Apply a filter to participants, then pass the output through
|
||||
another filter. The final result is saved in a JSON file:
|
||||
|
||||
```
|
||||
probo filter participants | probo filter quizzes > data/json/selection.json
|
||||
```
|
||||
{{end}}
|
27
embed/cli/init/description.tmpl
Normal file
27
embed/cli/init/description.tmpl
Normal file
|
@ -0,0 +1,27 @@
|
|||
{{define "description"}}
|
||||
# Init
|
||||
|
||||
**Init initializes the current working directory.**
|
||||
|
||||
The `init` command creates, within the current directory, a file and
|
||||
folder structure prepared for immediate use of `probo`. The filesystem
|
||||
structure is as follows:
|
||||
```
|
||||
|
||||
├── filters
|
||||
├── json
|
||||
├── participants
|
||||
├── quizzes
|
||||
├── responses
|
||||
├── sessions
|
||||
└── templates
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
1. Initialize the current directory:
|
||||
|
||||
```
|
||||
probo init
|
||||
```
|
||||
{{end}}
|
2
embed/cli/layout.tmpl
Normal file
2
embed/cli/layout.tmpl
Normal file
|
@ -0,0 +1,2 @@
|
|||
{{template "logo" .}}
|
||||
{{template "description" .}}
|
10
embed/cli/logo.tmpl
Normal file
10
embed/cli/logo.tmpl
Normal file
|
@ -0,0 +1,10 @@
|
|||
{{define "logo"}}
|
||||
```
|
||||
____ _
|
||||
| _ \ _ __ ___ | |__ ___
|
||||
| |_) | '__/ _ \| '_ \ / _ \
|
||||
| __/| | | (_) | |_) | (_) |
|
||||
|_| |_| \___/|_.__/ \___/____
|
||||
|_____|
|
||||
```
|
||||
{{end}}
|
21
embed/cli/rank/description.tmpl
Normal file
21
embed/cli/rank/description.tmpl
Normal file
|
@ -0,0 +1,21 @@
|
|||
{{define "description"}}
|
||||
# Rank
|
||||
|
||||
**The `rank` command generates a ranking of results based on participant answers.**
|
||||
|
||||
With the `rank` command, it is possible to generate a ranking of
|
||||
results based on the answers provided by participants. The command
|
||||
accepts on standard input either a JSON ready for display or the
|
||||
result of a filter applied to the answers that needs to be processed
|
||||
through a `tengo` script to conform to the JSON schema.
|
||||
|
||||
## Example
|
||||
|
||||
Filtra le risposte date dai partecipanti al test "Math Test" e produce
|
||||
una classifica dei punteggi assegnando +1 punto per ogni risposta
|
||||
esatta fornita.
|
||||
|
||||
```
|
||||
probo filter responses -i data/filters/math_test.jq | probo rank -s data/scripts/score.tengo
|
||||
```
|
||||
{{end}}
|
50
embed/cli/root/description.tmpl
Normal file
50
embed/cli/root/description.tmpl
Normal file
|
@ -0,0 +1,50 @@
|
|||
{{define "description"}}
|
||||
# Probo
|
||||
|
||||
`probo` is a quiz management system designed for command line
|
||||
enthusiasts. `probo` aims to highlight themes such as privacy,
|
||||
interoperability, accessibility, decentralization, and quick usage
|
||||
speed.
|
||||
|
||||
`probo` organizes information in plain text files and folders so that
|
||||
data can be easily revised through version control systems.
|
||||
|
||||
`probo` contains a built-in web server that allows quizzes to be
|
||||
administered to participants and their answers received.
|
||||
|
||||
`probo` is a self-contained application that can be distributed through
|
||||
a simple executable containing all necessary assets for its operation.
|
||||
|
||||
# Quickstart
|
||||
|
||||
1. Initialize the working directory. The command will build a scaffold
|
||||
containing example participants, quizzes, and filters
|
||||
|
||||
```
|
||||
probo init
|
||||
```
|
||||
|
||||
2. Filter participants and quizzes and create an exam
|
||||
session.
|
||||
|
||||
```
|
||||
probo filter participant -i data/filters/9th_grade.jq \
|
||||
| probo filter quizzes -i data/filters/difficult_easy.jq \
|
||||
| probo create session --name="My First Session" > data/sessions/my_session.json
|
||||
```
|
||||
|
||||
3. Run the web server to allow participants to respond.
|
||||
|
||||
```
|
||||
probo serve
|
||||
```
|
||||
|
||||
4. Share the *qrcode* generated from the previous step with the
|
||||
participants and wait for them to finish responding.
|
||||
|
||||
5. Explore the results.
|
||||
|
||||
```
|
||||
probo filter responses -f '.' | probo rank
|
||||
```
|
||||
{{end}}
|
71
embed/embed.go
Normal file
71
embed/embed.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
package embed
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed cli/*
|
||||
CLI embed.FS
|
||||
|
||||
//go:embed templates/*
|
||||
Templates embed.FS
|
||||
|
||||
//go:embed public/*
|
||||
Public embed.FS
|
||||
|
||||
//go:embed data/*
|
||||
Data embed.FS
|
||||
)
|
||||
|
||||
func CopyToWorkingDirectory(data embed.FS) error {
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := fs.WalkDir(data, ".", func(path string, info fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
log.Info(err)
|
||||
return err
|
||||
}
|
||||
fullDstPath := filepath.Join(currentDir, path)
|
||||
|
||||
if info.IsDir() {
|
||||
log.Info("Creating folder", "path", path)
|
||||
if err := os.MkdirAll(fullDstPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
srcFile, err := data.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
dstFile, err := os.Create(fullDstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
log.Info("Copying file", "path", path)
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
40
embed/public/css/login.css
Normal file
40
embed/public/css/login.css
Normal file
|
@ -0,0 +1,40 @@
|
|||
.container {
|
||||
display: grid;
|
||||
width: 300px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.container label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.container input[type="text"],
|
||||
.container input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.container button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
23
embed/templates/exam/exam.html.tmpl
Normal file
23
embed/templates/exam/exam.html.tmpl
Normal file
|
@ -0,0 +1,23 @@
|
|||
{{ define "content" }}
|
||||
<div class="container">
|
||||
{{range $quiz := .Quizzes }}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card my-2">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{$quiz.Question}}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-body-secondary">Una sola scelta possibile</h6>
|
||||
{{range $answer := $quiz.Answers}}
|
||||
<input type="radio"
|
||||
id="{{$quiz.ID}}_{{$answer.ID}}" name="{{$quiz.ID}}"
|
||||
value="{{$answer.ID}}">
|
||||
<label
|
||||
for="{{$quiz.ID}}_{{$answer.ID}}">{{$answer.Text}}</label><br>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
37
embed/templates/exam/layout-exam.html.tmpl
Normal file
37
embed/templates/exam/layout-exam.html.tmpl
Normal file
|
@ -0,0 +1,37 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Test di {{.Participant}}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar bg-body-tertiary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="24" fill="currentColor" class="bi bi-book d-inline-block align-text-top" viewBox="0 0 16 16">
|
||||
<path d="M1 2.828c.885-.37 2.154-.769 3.388-.893 1.33-.134 2.458.063 3.112.752v9.746c-.935-.53-2.12-.603-3.213-.493-1.18.12-2.37.461-3.287.811zm7.5-.141c.654-.689 1.782-.886 3.112-.752 1.234.124 2.503.523 3.388.893v9.923c-.918-.35-2.107-.692-3.287-.81-1.094-.111-2.278-.039-3.213.492zM8 1.783C7.015.936 5.587.81 4.287.94c-1.514.153-3.042.672-3.994 1.105A.5.5 0 0 0 0 2.5v11a.5.5 0 0 0 .707.455c.882-.4 2.303-.881 3.68-1.02 1.409-.142 2.59.087 3.223.877a.5.5 0 0 0 .78 0c.633-.79 1.814-1.019 3.222-.877 1.378.139 2.8.62 3.681 1.02A.5.5 0 0 0 16 13.5v-11a.5.5 0 0 0-.293-.455c-.952-.433-2.48-.952-3.994-1.105C10.413.809 8.985.936 8 1.783"/>
|
||||
</svg>
|
||||
Probo_
|
||||
</a>
|
||||
|
||||
<span class="navbar-text">
|
||||
{{.Session.Title}}
|
||||
</span>
|
||||
|
||||
<div class="text-end">
|
||||
<input type="submit" value="Salva" class="btn btn-primary me-2" form="submit-exam-form"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<form action="/sessions/{{.Session.ID}}/exams/{{.Participant.ID}}" method="POST" id="submit-exam-form"/>
|
||||
|
||||
{{template "content" .}}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
29
embed/templates/login/layout-login.html.tmpl
Normal file
29
embed/templates/login/layout-login.html.tmpl
Normal file
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Probo login</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
{{template "content" .}}
|
||||
|
||||
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top fixed-bottom">
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<a href="/" class="mb-3 me-2 mb-md-0 text-body-secondary text-decoration-none lh-1">
|
||||
<svg class="bi" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
|
||||
</a>
|
||||
<span class="mb-3 mb-md-0 text-body-secondary">© 2024 Andrea Fazzi</span>
|
||||
</div>
|
||||
|
||||
<ul class="nav col-md-4 justify-content-end list-unstyled d-flex">
|
||||
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#twitter"></use></svg></a></li>
|
||||
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#instagram"></use></svg></a></li>
|
||||
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#facebook"></use></svg></a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
25
embed/templates/login/login.html.tmpl
Normal file
25
embed/templates/login/login.html.tmpl
Normal file
|
@ -0,0 +1,25 @@
|
|||
{{ define "content" }}
|
||||
<div class="container col-xl-10 col-xxl-8 px-4 py-5">
|
||||
<div class="row align-items-center g-lg-5 py-5">
|
||||
<div class="col-lg-7 text-center text-lg-start">
|
||||
<h1 class="display-4 fw-bold lh-1 text-body-emphasis mb-3">🎓 Probo_</h1>
|
||||
<p class="col-lg-10 fs-4">
|
||||
Una piattaforma per la creazione, la gestione e la
|
||||
somministrazione di test <em>the hacker way</em>. Inserisci il
|
||||
tuo <strong>token</strong> personale per iniziare.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-10 mx-auto col-lg-5">
|
||||
<form method="post" action="/login" class="p-4 p-md-5 border rounded-3 bg-body-tertiary">
|
||||
<div class="form-floating mb-3">
|
||||
<input type="password" class="form-control" name="participantToken" id="participantToken" placeholder="Token">
|
||||
<label for="participantToken">Inserisci il token...</label>
|
||||
</div>
|
||||
<button class="w-100 btn btn-lg btn-primary" type="submit">Inizia</button>
|
||||
<hr class="my-4">
|
||||
<small class="text-body-secondary">Fai un bel respiro e non aver paura: non è un test su di te ma su quello che sai!</small>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
55
embed/templates/sessions/layout-sessions.html.tmpl
Normal file
55
embed/templates/sessions/layout-sessions.html.tmpl
Normal file
|
@ -0,0 +1,55 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Probo login</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="mb-4 p-3 text-bg-dark border-bottom">
|
||||
<div class="container">
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none">
|
||||
<span class="fs-4 mx-2">Probo_</span>
|
||||
</a>
|
||||
|
||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||
<li><a href="#" class="nav-link px-2 text-secondary">I tuoi test</a></li>
|
||||
</ul>
|
||||
|
||||
<form class="col-12 col-lg-auto mb-3 mb-lg-0 me-lg-3" role="search">
|
||||
<input type="search" class="form-control form-control-dark text-bg-dark" placeholder="Cerca..." aria-label="Search">
|
||||
</form>
|
||||
|
||||
<div class="text-end">
|
||||
<button type="button" class="btn btn-outline-light me-2">Cerca...</button>
|
||||
<button type="button" class="btn btn-warning">Esci</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{template "content" .}}
|
||||
|
||||
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
||||
<div class="col-md-4 d-flex align-items-center">
|
||||
<a href="/" class="mb-3 me-2 mb-md-0 text-body-secondary text-decoration-none lh-1">
|
||||
<svg class="bi" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
|
||||
</a>
|
||||
<span class="mb-3 mb-md-0 text-body-secondary">© 2024 Andrea Fazzi</span>
|
||||
</div>
|
||||
|
||||
<ul class="nav col-md-4 justify-content-end list-unstyled d-flex">
|
||||
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#twitter"></use></svg></a></li>
|
||||
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#instagram"></use></svg></a></li>
|
||||
<li class="ms-3"><a class="text-body-secondary" href="#"><svg class="bi" width="24" height="24"><use xlink:href="#facebook"></use></svg></a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
18
embed/templates/sessions/sessions.html.tmpl
Normal file
18
embed/templates/sessions/sessions.html.tmpl
Normal file
|
@ -0,0 +1,18 @@
|
|||
{{ define "content" }}
|
||||
<div class="container">
|
||||
{{range $session := . }}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card my-2">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{$session.Title}}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-body-secondary">{{$session.CreatedAt}}</h6>
|
||||
<p class="card-text">{{$session.Description}}</p>
|
||||
<a href="/sessions/{{$session.ID}}/exams/{{$session.ParticipantID}}" class="btn btn-primary">Inizia!</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
88
go.mod
88
go.mod
|
@ -1,31 +1,77 @@
|
|||
module git.andreafazzi.eu/andrea/probo
|
||||
|
||||
go 1.21
|
||||
go 1.22.2
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.3.1
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181
|
||||
github.com/charmbracelet/bubbletea v0.25.0
|
||||
github.com/charmbracelet/glamour v0.6.0
|
||||
github.com/charmbracelet/huh v0.3.0
|
||||
github.com/charmbracelet/lipgloss v0.10.0
|
||||
github.com/charmbracelet/log v0.4.0
|
||||
github.com/charmbracelet/x/ansi v0.1.2
|
||||
github.com/d5/tengo/v2 v2.17.0
|
||||
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.14
|
||||
github.com/lmittmann/tint v1.0.4
|
||||
github.com/muesli/termenv v0.15.2
|
||||
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8
|
||||
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.18.2
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.9.0 // indirect
|
||||
github.com/go-yaml/yaml v2.1.0+incompatible // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/kr/pretty v0.2.1 // indirect
|
||||
github.com/kr/text v0.1.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/containerd/console v1.0.4 // indirect
|
||||
github.com/dlclark/regexp2 v1.4.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.5 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
modernc.org/libc v1.24.1 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/sqlite v1.26.0 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.9.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/yuin/goldmark v1.5.2 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.1 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
|
||||
golang.org/x/net v0.19.0 // indirect
|
||||
golang.org/x/sync v0.6.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/term v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
222
go.sum
222
go.sum
|
@ -1,53 +1,187 @@
|
|||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181 h1:ntdtXC9+kcgQYvqa6nyLZLniCEUOhWQknLlz38JpDpM=
|
||||
github.com/charmbracelet/bubbles v0.18.1-0.20240309002305-b9e62cbfe181/go.mod h1:Zlzkn8WOd6QS7RC1BXAY1iw1VLq+xT70UZ1vkEtnrvo=
|
||||
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
|
||||
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
|
||||
github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
|
||||
github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
|
||||
github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE=
|
||||
github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA=
|
||||
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
|
||||
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
|
||||
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
|
||||
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
|
||||
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
|
||||
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
|
||||
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/d5/tengo/v2 v2.17.0 h1:BWUN9NoJzw48jZKiYDXDIF3QrIVZRm1uV1gTzeZ2lqM=
|
||||
github.com/d5/tengo/v2 v2.17.0/go.mod h1:XRGjEs5I9jYIKTxly6HCF8oiiilk5E/RYXOZ5b0DZC8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
|
||||
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
|
||||
github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o=
|
||||
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a h1:RYfmiM0zluBJOiPDJseKLEN4BapJ42uSi9SZBQ2YyiA=
|
||||
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/itchyny/gojq v0.12.14 h1:6k8vVtsrhQSYgSGg827AD+PVVaB1NLXEdX+dda2oZCc=
|
||||
github.com/itchyny/gojq v0.12.14/go.mod h1:y1G7oO7XkcR1LPZO59KyoCRy08T3j9vDYRV0GgYSS+s=
|
||||
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=
|
||||
github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 h1:nRDwTcxV9B3elxMt+1xINX0bwaPdpouqp5fbynexY8U=
|
||||
github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8/go.mod h1:jOEnp79oIHy5cvQSHeLcgVJk1GHOOHJHQWps/d1N5Yo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af h1:rSEwVRdJxMq4RK2kI1LEVhM5J3yg3pcvlRYy1vjn7mQ=
|
||||
github.com/remogatto/sugarfoam v0.0.0-20240418083243-766dd70853af/go.mod h1:WeyW6WPrlPDwa48kDIytaLxXKyRjOwLp4BEd2tEGY1Y=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
|
||||
github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
|
||||
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
|
||||
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
8
go.work
Normal file
8
go.work
Normal file
|
@ -0,0 +1,8 @@
|
|||
go 1.22.2
|
||||
|
||||
toolchain go1.22.3
|
||||
|
||||
use (
|
||||
.
|
||||
../sugarfoam
|
||||
)
|
122
go.work.sum
Normal file
122
go.work.sum
Normal file
|
@ -0,0 +1,122 @@
|
|||
cloud.google.com/go v0.110.10/go.mod h1:v1OoFqYxiBkUrruItNM3eT4lLByNjxmJSV/xDKJNnic=
|
||||
cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ=
|
||||
cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
|
||||
cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI=
|
||||
cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||
github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/hashicorp/consul/api v1.25.1/go.mod h1:iiLVwR/htV7mas/sy0O+XSuEnrdBUUydemjxcUrAt4g=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8=
|
||||
github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
|
||||
github.com/remogatto/imgcat v0.0.0-20240318115229-ee6a34ad38fe/go.mod h1:e6G+BhMs87z7k9UKiGmV8tLWguKaNic9zlb3N+yC5Vc=
|
||||
github.com/sagikazarmark/crypt v0.17.0/go.mod h1:SMtHTvdmsZMuY/bpZoqokSoChIrcJ/epOxZN58PbZDg=
|
||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
|
||||
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/srwiley/oksvg v0.0.0-20220128195007-1f435e4c2b44/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220128185129-2efea2b9ea41/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/etcd/api/v3 v3.5.10/go.mod h1:TidfmT4Uycad3NM/o25fG3J07odo4GBB9hoxaodFCtI=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.10/go.mod h1:DYivfIviIuQ8+/lCq4vcxuseg2P2XbHygkKwFo9fc8U=
|
||||
go.etcd.io/etcd/client/v2 v2.305.10/go.mod h1:m3CKZi69HzilhVqtPDcjhSGp+kA1OmbNn0qamH80xjA=
|
||||
go.etcd.io/etcd/client/v3 v3.5.10/go.mod h1:RVeBnDz2PUEZqTpgqwAtUd8nAPf5kjyFyND7P1VkOKc=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
google.golang.org/api v0.153.0/go.mod h1:3qNJX5eOmhiWYc67jRA/3GsDw97UFb5ivv7Y2PrriAY=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
|
||||
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
|
@ -1,26 +0,0 @@
|
|||
package hasher
|
||||
|
||||
import (
|
||||
"git.andreafazzi.eu/andrea/probo/client"
|
||||
)
|
||||
|
||||
type HashFunc func(string) string
|
||||
|
||||
type Hasher interface {
|
||||
// Hash returns a slice of hashes. The first one is an
|
||||
// hash calculated from the question using
|
||||
// QuestionHash. Following hashes are calculated from the
|
||||
// answers using AnswerHash.
|
||||
QuizHashes(quiz *client.Quiz) []string
|
||||
|
||||
// QuestionHash returns an hash calculated from a field of
|
||||
// Question struct.
|
||||
QuestionHash(question *client.Question) string
|
||||
|
||||
// AnswerHash returns an hash calculated from a field of
|
||||
// Answer struct.
|
||||
AnswerHash(answer *client.Answer) string
|
||||
|
||||
// Calculate calculates a checksum from all the given hashes.
|
||||
Calculate(hashes []string) string
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
package hasher
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/client"
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
type testSuite struct {
|
||||
prettytest.Suite
|
||||
}
|
||||
|
||||
func TestRunner(t *testing.T) {
|
||||
prettytest.Run(
|
||||
t,
|
||||
new(testSuite),
|
||||
)
|
||||
}
|
||||
|
||||
func (t *testSuite) TestQuizHashes() {
|
||||
h := NewDefaultHash(DefaultSHA256HashingFn)
|
||||
|
||||
firstQuizRequest := &client.CreateQuizRequest{
|
||||
Question: &client.CreateQuestionRequest{
|
||||
Text: "Question 1"},
|
||||
Answers: []*client.CreateAnswerRequest{
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: false},
|
||||
{Text: "Answer 1", Correct: true},
|
||||
},
|
||||
}
|
||||
|
||||
secondQuizRequest := &client.CreateQuizRequest{
|
||||
Question: &client.CreateQuestionRequest{
|
||||
Text: "Question 1"},
|
||||
Answers: []*client.CreateAnswerRequest{
|
||||
{Text: "Answer 1", Correct: false},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: true},
|
||||
},
|
||||
}
|
||||
|
||||
thirdQuizRequest := &client.CreateQuizRequest{
|
||||
Question: &client.CreateQuestionRequest{
|
||||
Text: "Question 2"},
|
||||
Answers: []*client.CreateAnswerRequest{
|
||||
{Text: "Answer 1", Correct: false},
|
||||
{Text: "Answer 2", Correct: false},
|
||||
{Text: "Answer 3", Correct: true},
|
||||
},
|
||||
}
|
||||
|
||||
hashesFromFirstRequest := h.QuizHashes(firstQuizRequest)
|
||||
hashesFromSecondRequest := h.QuizHashes(secondQuizRequest)
|
||||
hashesFromThirdRequest := h.QuizHashes(thirdQuizRequest)
|
||||
|
||||
t.Equal(5, len(hashesFromFirstRequest))
|
||||
|
||||
t.True(hashesFromFirstRequest[1] == hashesFromSecondRequest[2], "Answers' hashes should maintain original request's order")
|
||||
t.True(hashesFromFirstRequest[4] == hashesFromSecondRequest[4], "Quiz hash should be the same because quizzes are duplicated")
|
||||
t.True(hashesFromFirstRequest[1] != hashesFromThirdRequest[1], "Questions' hashes should not be the same because texts are different")
|
||||
t.True(hashesFromFirstRequest[4] != hashesFromThirdRequest[4], "Quiz hash should not be the same because quizzes are not duplicated")
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
package sha256
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/client"
|
||||
"git.andreafazzi.eu/andrea/probo/hasher"
|
||||
)
|
||||
|
||||
var DefaultSHA256HashingFn = func(text string) string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(text)))
|
||||
}
|
||||
|
||||
type Default256Hasher struct {
|
||||
hashFn hasher.HashFunc
|
||||
}
|
||||
|
||||
func NewDefault256Hasher(hashFn hasher.HashFunc) *Default256Hasher {
|
||||
return &Default256Hasher{hashFn}
|
||||
}
|
||||
|
||||
func (h *Default256Hasher) QuizHashes(quiz *client.Quiz) []string {
|
||||
result := make([]string, 0)
|
||||
|
||||
result = append(result, h.QuestionHash(quiz.Question))
|
||||
|
||||
for _, a := range quiz.Answers {
|
||||
result = append(result, h.AnswerHash(a))
|
||||
}
|
||||
|
||||
result = append(result, h.Calculate(result))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *Default256Hasher) QuestionHash(question *client.Question) string {
|
||||
return h.hashFn(question.Text)
|
||||
}
|
||||
|
||||
func (h *Default256Hasher) AnswerHash(answer *client.Answer) string {
|
||||
return h.hashFn(answer.Text)
|
||||
}
|
||||
|
||||
func (h *Default256Hasher) Calculate(hashes []string) string {
|
||||
orderedHashes := make([]string, len(hashes))
|
||||
|
||||
copy(orderedHashes, hashes)
|
||||
sort.Strings(orderedHashes)
|
||||
|
||||
return h.hashFn(strings.Join(orderedHashes, ""))
|
||||
}
|
112
logger/logger.go
112
logger/logger.go
|
@ -1,112 +0,0 @@
|
|||
package logger
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
Disabled = iota
|
||||
InfoLevel
|
||||
WarningLevel
|
||||
DebugLevel
|
||||
)
|
||||
|
||||
type (
|
||||
// struct for holding response details
|
||||
responseData struct {
|
||||
status int
|
||||
size int
|
||||
}
|
||||
|
||||
// our http.ResponseWriter implementation
|
||||
loggingResponseWriter struct {
|
||||
http.ResponseWriter // compose original http.ResponseWriter
|
||||
responseData *responseData
|
||||
}
|
||||
)
|
||||
|
||||
var loggerLevel int = InfoLevel
|
||||
|
||||
func (r *loggingResponseWriter) Write(b []byte) (int, error) {
|
||||
size, err := r.ResponseWriter.Write(b) // write response using original http.ResponseWriter
|
||||
r.responseData.size += size // capture size
|
||||
return size, err
|
||||
}
|
||||
|
||||
func (r *loggingResponseWriter) WriteHeader(statusCode int) {
|
||||
r.ResponseWriter.WriteHeader(statusCode) // write status code using original http.ResponseWriter
|
||||
r.responseData.status = statusCode // capture status code
|
||||
}
|
||||
|
||||
func WithLogging(h http.Handler) http.Handler {
|
||||
loggingFn := func(rw http.ResponseWriter, req *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
responseData := &responseData{
|
||||
status: 0,
|
||||
size: 0,
|
||||
}
|
||||
lrw := loggingResponseWriter{
|
||||
ResponseWriter: rw, // compose original http.ResponseWriter
|
||||
responseData: responseData,
|
||||
}
|
||||
h.ServeHTTP(&lrw, req) // inject our implementation of http.ResponseWriter
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
if loggerLevel >= InfoLevel {
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"URI": req.RequestURI,
|
||||
"Method": req.Method,
|
||||
"Status": responseData.status,
|
||||
"Duration": duration,
|
||||
"Size": responseData.size,
|
||||
}).Info("Completed.")
|
||||
}
|
||||
}
|
||||
return http.HandlerFunc(loggingFn)
|
||||
}
|
||||
|
||||
func SetLevel(level int) {
|
||||
loggerLevel = level
|
||||
}
|
||||
|
||||
func Info(v ...interface{}) {
|
||||
if loggerLevel >= InfoLevel {
|
||||
log.Println(v...)
|
||||
}
|
||||
}
|
||||
|
||||
func Infof(format string, v ...interface{}) {
|
||||
if loggerLevel >= InfoLevel {
|
||||
log.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
func Warning(v ...interface{}) {
|
||||
if loggerLevel >= WarningLevel {
|
||||
log.Println(v...)
|
||||
}
|
||||
}
|
||||
|
||||
func Warningf(format string, v ...interface{}) {
|
||||
if loggerLevel >= WarningLevel {
|
||||
log.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
func Debug(v ...interface{}) {
|
||||
if loggerLevel >= DebugLevel {
|
||||
log.Println(v...)
|
||||
}
|
||||
}
|
||||
|
||||
func Debugf(format string, v ...interface{}) {
|
||||
if loggerLevel >= DebugLevel {
|
||||
log.Printf(format, v...)
|
||||
}
|
||||
}
|
44
main.go
44
main.go
|
@ -1,28 +1,30 @@
|
|||
/*
|
||||
Copyright © 2024 Andrea Fazzi dev@andreafazzi.eu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/hasher/sha256"
|
||||
"git.andreafazzi.eu/andrea/probo/logger"
|
||||
"git.andreafazzi.eu/andrea/probo/store/memory"
|
||||
"github.com/sirupsen/logrus"
|
||||
"git.andreafazzi.eu/andrea/probo/cmd"
|
||||
)
|
||||
|
||||
const port = "8080"
|
||||
|
||||
func main() {
|
||||
logger.SetLevel(logger.DebugLevel)
|
||||
|
||||
server := NewProboCollectorServer(
|
||||
memory.NewMemoryProboCollectorStore(
|
||||
sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn),
|
||||
),
|
||||
)
|
||||
|
||||
addr := "http://localhost:" + port
|
||||
logrus.WithField("address", addr).Info("Probo Collector is up&running...")
|
||||
|
||||
log.Fatal(http.ListenAndServe(":"+port, server))
|
||||
cmd.Execute()
|
||||
}
|
||||
|
|
121
misc/logseq/.gitignore
vendored
121
misc/logseq/.gitignore
vendored
|
@ -1,121 +0,0 @@
|
|||
.DS_Store
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
.env.production
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
*~
|
|
@ -1,21 +0,0 @@
|
|||
Copyright (c) 2022 Andrea Fazzi
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -1,4 +0,0 @@
|
|||
# What's that?
|
||||
|
||||
A very basic boilerplate useful to start devoloping a
|
||||
[Logseq](https://logseq.com/) plugin.
|
|
@ -1,10 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-hierarchy" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="gray" fill="gray" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<circle cx="5" cy="19" r="2" />
|
||||
<circle cx="19" cy="19" r="2" />
|
||||
<path d="M6.5 17.5l5.5 -4.5l5.5 4.5" />
|
||||
<line x1="12" y1="7" x2="12" y2="13" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 471 B |
4707
misc/logseq/package-lock.json
generated
4707
misc/logseq/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"logseq": {
|
||||
"id": "logseq-probo-plugin",
|
||||
"title": "logseq-probo-plugin",
|
||||
"icon": "./icon.svg"
|
||||
},
|
||||
"name": "logseq-probo-plugin",
|
||||
"version": "1.2.0",
|
||||
"description": "",
|
||||
"main": "dist/index.html",
|
||||
"targets": {
|
||||
"main": false
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "parcel build --no-source-maps src/index.html --public-url ./"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Andrea Fazzi",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@logseq/libs": "^0.0.1-alpha.35",
|
||||
"js-base64": "^3.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"buffer": "^6.0.3",
|
||||
"parcel": "^2.2.0"
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="index.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,121 +0,0 @@
|
|||
import "@logseq/libs";
|
||||
import { BlockEntity } from "@logseq/libs/dist/LSPlugin.user";
|
||||
|
||||
const endpoint = 'http://localhost:3000/quizzes';
|
||||
|
||||
const uniqueIdentifier = () =>
|
||||
Math.random()
|
||||
.toString(36)
|
||||
.replace(/[^a-z]+/g, "");
|
||||
|
||||
const sanitizeBlockContent = (text: string) => text.replace(/((?<=::).*|.*::)/g, "").replace(/{.*}/, "").trim()
|
||||
|
||||
async function fetchQuizzes() {
|
||||
const { status: status, content: quizzes } = await fetch(endpoint).then(res => res.json())
|
||||
const ret = quizzes || []
|
||||
|
||||
return ret.map((quiz, i) => {
|
||||
return `${i + 1}. ${quiz.Question.Text}`
|
||||
})
|
||||
}
|
||||
|
||||
const render = (id, slot, status: ("modified" | "saved" | "error")) => {
|
||||
logseq.provideUI({
|
||||
key: `${id}`,
|
||||
slot,
|
||||
reset: true,
|
||||
template: `
|
||||
${status === 'saved' ? '<button data-on-click="createOrUpdateQuiz" class="renderBtn">Save</button><span>saved</span>' : '<button data-on-click="createOrUpdateQuiz" class="renderBtn">Save</button>'}
|
||||
|
||||
`,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
const main = () => {
|
||||
console.log("logseq-probo-plugin LOADED!");
|
||||
|
||||
logseq.Editor.registerSlashCommand("Get All Quizzes", async () => {
|
||||
const currBlock = await logseq.Editor.getCurrentBlock();
|
||||
|
||||
let blocks = await fetchQuizzes()
|
||||
|
||||
blocks = blocks.map((it: BlockEntity) => ({ content: it }))
|
||||
|
||||
await logseq.Editor.insertBatchBlock(currBlock.uuid, blocks, {
|
||||
sibling: false
|
||||
})
|
||||
});
|
||||
|
||||
logseq.Editor.registerSlashCommand("Create a new Probo quiz", async () => {
|
||||
await logseq.Editor.insertAtEditingCursor(
|
||||
`{{renderer :probo_${uniqueIdentifier()}}}`
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
logseq.App.onMacroRendererSlotted(async ({ slot, payload }) => {
|
||||
const [type] = payload.arguments;
|
||||
|
||||
if (!type.startsWith(":probo")) return
|
||||
|
||||
const id = type.split("_")[1]?.trim();
|
||||
const proboId = `probo_${id}`;
|
||||
|
||||
let status: ("modified" | "saved" | "error")
|
||||
|
||||
logseq.provideModel({
|
||||
async createOrUpdateQuiz() {
|
||||
const parentBlock = await logseq.Editor.getBlock(payload.uuid, { includeChildren: true });
|
||||
const answers = parentBlock.children.map((answer: BlockEntity, i: number) => {
|
||||
return { text: answer.content, correct: (i == 0) ? true : false }
|
||||
})
|
||||
|
||||
const quiz = {
|
||||
question: { text: sanitizeBlockContent(parentBlock.content) },
|
||||
answers: answers
|
||||
}
|
||||
|
||||
if (parentBlock.properties.proboQuizUuid) {
|
||||
const res = await fetch(endpoint + `/update/${parentBlock.properties.proboQuizUuid}`, { method: 'PUT', body: JSON.stringify(quiz) })
|
||||
const data = await res.json();
|
||||
await logseq.Editor.upsertBlockProperty(parentBlock.uuid, `probo-quiz-uuid`, data.content.ID)
|
||||
render(proboId, slot, "saved")
|
||||
} else {
|
||||
const res = await fetch(endpoint + '/create', { method: 'POST', body: JSON.stringify(quiz) })
|
||||
const data = await res.json();
|
||||
await logseq.Editor.upsertBlockProperty(parentBlock.uuid, `probo-quiz-uuid`, data.content.ID)
|
||||
render(proboId, slot, "saved")
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
logseq.provideStyle(`
|
||||
.renderBtn {
|
||||
border: 1px solid white;
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
margin-right: 5px;
|
||||
font-size: 80%;
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.renderBtn:hover {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
`);
|
||||
|
||||
logseq.provideUI({
|
||||
key: `${proboId}`,
|
||||
slot,
|
||||
reset: true,
|
||||
template: `<button data-on-click="createOrUpdateQuiz" class="renderBtn">Save</button>`,
|
||||
});
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
logseq.ready(main).catch(console.error);
|
|
@ -1,30 +0,0 @@
|
|||
package models
|
||||
|
||||
type Filter struct {
|
||||
Tags []*Tag
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
Meta
|
||||
|
||||
Name string `json:"name"`
|
||||
Filter *Filter `json:"filter"`
|
||||
|
||||
Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"`
|
||||
}
|
||||
|
||||
func (c *Collection) String() string {
|
||||
return c.Name
|
||||
}
|
||||
|
||||
func (c *Collection) GetID() string {
|
||||
return c.ID
|
||||
}
|
||||
|
||||
func (c *Collection) SetID(id string) {
|
||||
c.ID = id
|
||||
}
|
||||
|
||||
func (c *Collection) GetHash() string {
|
||||
return ""
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Exam struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
|
||||
Name string
|
||||
Description string
|
||||
|
||||
Collection *Collection `gorm:"foreignKey:ID"`
|
||||
Participant []*Participant `gorm:"many2many:exam_participants"`
|
||||
|
||||
// Responses []string
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
package models
|
||||
|
||||
type Group struct {
|
||||
Name string
|
||||
Participants []*Participant
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Meta struct {
|
||||
ID string `json:"id" yaml:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
package models
|
||||
|
||||
type Participant struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
|
||||
Firstname string
|
||||
Lastname string
|
||||
|
||||
Token uint
|
||||
|
||||
Attributes map[string]string
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
Name string `json:"name" gorm:"primaryKey"`
|
||||
}
|
|
@ -6,7 +6,8 @@ import (
|
|||
)
|
||||
|
||||
type Answer struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
// ID string `json:"id" gorm:"primaryKey"`
|
||||
Meta
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
|
@ -14,14 +15,6 @@ func (a *Answer) String() string {
|
|||
return a.Text
|
||||
}
|
||||
|
||||
func (a *Answer) GetID() string {
|
||||
return a.ID
|
||||
}
|
||||
|
||||
func (a *Answer) SetID(id string) {
|
||||
a.ID = id
|
||||
}
|
||||
|
||||
func (a *Answer) GetHash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(a.Text)))
|
||||
}
|
28
pkg/models/collection.go
Normal file
28
pkg/models/collection.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
package models
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Collection struct {
|
||||
Meta
|
||||
|
||||
Name string `json:"name"`
|
||||
// Filter *Filter `json:"filter"`
|
||||
|
||||
Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"`
|
||||
}
|
||||
|
||||
func (c *Collection) String() string {
|
||||
return c.Name
|
||||
}
|
||||
|
||||
func (c *Collection) GetHash() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Collection) Marshal() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
func (c *Collection) Unmarshal(data []byte) error {
|
||||
return json.Unmarshal(data, c)
|
||||
}
|
50
pkg/models/exam.go
Normal file
50
pkg/models/exam.go
Normal file
|
@ -0,0 +1,50 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Exam struct {
|
||||
Meta
|
||||
|
||||
Participant *Participant `json:"participant"`
|
||||
Quizzes []*Quiz `json:"quizzes"`
|
||||
}
|
||||
|
||||
func (e *Exam) String() string {
|
||||
return fmt.Sprintf("%v's exam with %v quizzes.", e.Participant, len(e.Quizzes))
|
||||
}
|
||||
|
||||
func (e *Exam) GetHash() string {
|
||||
qHashes := ""
|
||||
for _, q := range e.Quizzes {
|
||||
qHashes += q.GetHash()
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{e.Participant.GetHash(), qHashes}, ""))))
|
||||
}
|
||||
|
||||
func (e *Exam) Marshal() ([]byte, error) {
|
||||
return json.Marshal(e)
|
||||
|
||||
}
|
||||
|
||||
func (e *Exam) Unmarshal(data []byte) error {
|
||||
return json.Unmarshal(data, e)
|
||||
}
|
||||
|
||||
func (e *Exam) ToMarkdown() (string, error) {
|
||||
result := ""
|
||||
for _, quiz := range e.Quizzes {
|
||||
quizMD, err := QuizToMarkdown(quiz)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result += fmt.Sprintf(quizMD)
|
||||
result += "\n"
|
||||
}
|
||||
|
||||
return strings.TrimRight(fmt.Sprintf("# %s %s \n %s", e.Participant.Lastname, e.Participant.Firstname, result), "\n"), nil
|
||||
}
|
9
pkg/models/filters.go
Normal file
9
pkg/models/filters.go
Normal file
|
@ -0,0 +1,9 @@
|
|||
package models
|
||||
|
||||
// type Filter struct {
|
||||
// Tags []*Tag
|
||||
// }
|
||||
|
||||
// type ParticipantFilter struct {
|
||||
// Attributes map[string]string
|
||||
// }
|
27
pkg/models/group.go
Normal file
27
pkg/models/group.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"github.com/gocarina/gocsv"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
Meta
|
||||
Name string
|
||||
Participants []*Participant
|
||||
}
|
||||
|
||||
func (g *Group) String() string {
|
||||
return g.Name
|
||||
}
|
||||
|
||||
func (g *Group) GetHash() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (g *Group) Marshal() ([]byte, error) {
|
||||
return gocsv.MarshalBytes(g.Participants)
|
||||
}
|
||||
|
||||
func (g *Group) Unmarshal(data []byte) error {
|
||||
return gocsv.UnmarshalBytes(data, &g.Participants)
|
||||
}
|
31
pkg/models/group_test.go
Normal file
31
pkg/models/group_test.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
type groupTestSuite struct {
|
||||
prettytest.Suite
|
||||
}
|
||||
|
||||
func (t *groupTestSuite) TestMarshal() {
|
||||
t.Pending()
|
||||
|
||||
// group := &Group{
|
||||
// Name: "Example group",
|
||||
// Participants: []*Participant{
|
||||
// {"123", "John", "Doe", 12345, map[string]string{"class": "1 D LIN", "age": "18"}},
|
||||
// {"456", "Jack", "Sparrow", 67890, map[string]string{"class": "1 D LIN", "age": "24"}},
|
||||
// },
|
||||
// }
|
||||
|
||||
// expected := `id,firstname,lastname,token,attributes
|
||||
// 123,John,Doe,12345,"age:18,class:1 D LIN"
|
||||
// 456,Jack,Sparrow,67890,"age:24,class:1 D LIN"
|
||||
// `
|
||||
|
||||
// csv, err := group.Marshal()
|
||||
|
||||
// t.Nil(err)
|
||||
// t.Equal(expected, string(csv))
|
||||
}
|
38
pkg/models/meta.go
Normal file
38
pkg/models/meta.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Meta struct {
|
||||
ID string `json:"id" yaml:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
|
||||
|
||||
UniqueIDFunc func() string `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
func (m *Meta) GetID() string {
|
||||
if m.UniqueIDFunc != nil {
|
||||
return m.UniqueIDFunc()
|
||||
}
|
||||
return m.ID
|
||||
}
|
||||
|
||||
func (m *Meta) SetID(id string) {
|
||||
m.ID = id
|
||||
}
|
||||
|
||||
func (m *Meta) SetCreatedAt(t time.Time) {
|
||||
m.CreatedAt = t
|
||||
}
|
||||
|
||||
func (m *Meta) SetUpdatedAt(t time.Time) {
|
||||
m.UpdatedAt = t
|
||||
}
|
||||
|
||||
func (m *Meta) GetCreatedAt() time.Time {
|
||||
return m.CreatedAt
|
||||
}
|
||||
|
||||
func (m *Meta) GetUpdatedAt() time.Time {
|
||||
return m.UpdatedAt
|
||||
}
|
|
@ -16,6 +16,7 @@ func TestRunner(t *testing.T) {
|
|||
prettytest.Run(
|
||||
t,
|
||||
new(testSuite),
|
||||
new(groupTestSuite),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -40,13 +41,16 @@ Question text with #tag1 #tag2 (3).
|
|||
{Text: "Answer 4"},
|
||||
},
|
||||
CorrectPos: 0,
|
||||
Tags: []string{"#tag1", "#tag2"},
|
||||
}
|
||||
|
||||
quiz, _, err := MarkdownToQuiz(markdown)
|
||||
q := new(Quiz)
|
||||
|
||||
err := MarkdownToQuiz(q, markdown)
|
||||
t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err))
|
||||
|
||||
if !t.Failed() {
|
||||
t.True(reflect.DeepEqual(quiz, expectedQuiz), fmt.Sprintf("Expected %+v, got %+v", expectedQuiz, quiz))
|
||||
t.True(reflect.DeepEqual(q, expectedQuiz), fmt.Sprintf("Expected %+v got %+v", expectedQuiz, q))
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -74,3 +78,7 @@ func (t *testSuite) TestMarkdownFromQuiz() {
|
|||
`, md)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testSuite) TestParticipantTokenMarshalCSV() {
|
||||
|
||||
}
|
108
pkg/models/participant.go
Normal file
108
pkg/models/participant.go
Normal file
|
@ -0,0 +1,108 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AttributeList map[string]string
|
||||
|
||||
type Participant struct {
|
||||
Meta
|
||||
|
||||
Firstname string `json:"firstname" csv:"firstname"`
|
||||
Lastname string `json:"lastname" csv:"lastname"`
|
||||
|
||||
Token string `json:"token" csv:"token"`
|
||||
|
||||
Attributes AttributeList `csv:"attributes"`
|
||||
}
|
||||
|
||||
func (p *Participant) String() string {
|
||||
return fmt.Sprintf("%s %s", p.Lastname, p.Firstname)
|
||||
}
|
||||
|
||||
func (p *Participant) GetHash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(append([]string{p.Lastname, p.Firstname}, p.AttributesToSlice()...), ""))))
|
||||
}
|
||||
|
||||
func (p *Participant) AttributesToSlice() []string {
|
||||
result := make([]string, 0)
|
||||
|
||||
for k, v := range p.Attributes {
|
||||
result = append(result, k, v)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (p *Participant) Marshal() ([]byte, error) {
|
||||
return json.Marshal(p)
|
||||
|
||||
}
|
||||
|
||||
func (p *Participant) Unmarshal(data []byte) error {
|
||||
return json.Unmarshal(data, p)
|
||||
}
|
||||
|
||||
func (al AttributeList) MarshalCSV() (string, error) {
|
||||
result := convertMapToKeyValueOrderedString(al)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (al *AttributeList) UnmarshalCSV(csv string) error {
|
||||
if *al == nil {
|
||||
*al = make(AttributeList)
|
||||
}
|
||||
|
||||
pairs := strings.Split(csv, ",")
|
||||
|
||||
for _, pair := range pairs {
|
||||
attrVal := strings.Split(pair, ":")
|
||||
if len(attrVal) != 2 {
|
||||
return errors.New("Invalid input format for attribute list.")
|
||||
}
|
||||
|
||||
attr := strings.TrimSpace(attrVal[0])
|
||||
val := strings.TrimSpace(attrVal[1])
|
||||
|
||||
(*al)[attr] = val
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (al AttributeList) Get(key string) string {
|
||||
return al[key]
|
||||
}
|
||||
|
||||
func (al AttributeList) String() string {
|
||||
result := make([]string, 0)
|
||||
for k, v := range al {
|
||||
result = append(result, fmt.Sprintf("%s: %s", k, v))
|
||||
}
|
||||
return strings.Join(result, ",")
|
||||
}
|
||||
|
||||
func convertMapToKeyValueOrderedString(m map[string]string) string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
var result strings.Builder
|
||||
for _, key := range keys {
|
||||
result.WriteString(key)
|
||||
result.WriteString(":")
|
||||
result.WriteString(m[key])
|
||||
result.WriteString(",")
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(result.String(), ",")
|
||||
}
|
|
@ -14,14 +14,6 @@ func (q *Question) String() string {
|
|||
return q.Text
|
||||
}
|
||||
|
||||
func (q *Question) GetID() string {
|
||||
return q.ID
|
||||
}
|
||||
|
||||
func (q *Question) SetID(id string) {
|
||||
q.ID = id
|
||||
}
|
||||
|
||||
func (q *Question) GetHash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(q.Text)))
|
||||
}
|
|
@ -15,24 +15,25 @@ type Quiz struct {
|
|||
Meta
|
||||
|
||||
Hash string `json:"hash"`
|
||||
Question *Question `json:"question" gorm:"foreignKey:ID"`
|
||||
Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"`
|
||||
Tags []*Tag `json:"tags" yaml:"-" gorm:"-"`
|
||||
Correct *Answer `json:"correct" gorm:"foreignKey:ID"`
|
||||
CorrectPos uint `gorm:"-"` // Position of the correct answer during quiz creation
|
||||
Question *Question `json:"question"`
|
||||
Answers []*Answer `json:"answers"`
|
||||
Tags []string `json:"tags" yaml:"-"`
|
||||
Correct *Answer `json:"correct"`
|
||||
CorrectPos uint // Position of the correct answer during quiz creation
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
func MarkdownToQuiz(markdown string) (*Quiz, *Meta, error) {
|
||||
func MarkdownToQuiz(quiz *Quiz, markdown string) error {
|
||||
meta, remainingMarkdown, err := ParseMetaHeaderFromMarkdown(markdown)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
lines := strings.Split(remainingMarkdown, "\n")
|
||||
|
||||
questionText := ""
|
||||
answers := []*Answer{}
|
||||
tags := make([]string, 0)
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "*") {
|
||||
|
@ -45,26 +46,31 @@ func MarkdownToQuiz(markdown string) (*Quiz, *Meta, error) {
|
|||
}
|
||||
questionText += line
|
||||
}
|
||||
|
||||
parseTags(&tags, line)
|
||||
}
|
||||
|
||||
questionText = strings.TrimRight(questionText, "\n")
|
||||
|
||||
if questionText == "" {
|
||||
return nil, nil, fmt.Errorf("Question text should not be empty.")
|
||||
return fmt.Errorf("Question text should not be empty.")
|
||||
}
|
||||
|
||||
if len(answers) < 2 {
|
||||
return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
|
||||
return fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
|
||||
}
|
||||
|
||||
question := &Question{Text: questionText}
|
||||
quiz := &Quiz{Question: question, Answers: answers, CorrectPos: 0}
|
||||
|
||||
quiz.Question = question
|
||||
quiz.Answers = answers
|
||||
quiz.Tags = tags
|
||||
|
||||
if meta != nil {
|
||||
quiz.Meta = *meta
|
||||
}
|
||||
|
||||
return quiz, meta, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func QuizToMarkdown(quiz *Quiz) (string, error) {
|
||||
|
@ -96,18 +102,21 @@ func QuizToMarkdown(quiz *Quiz) (string, error) {
|
|||
return markdown, nil
|
||||
}
|
||||
|
||||
func (q *Quiz) GetID() string {
|
||||
return q.ID
|
||||
}
|
||||
|
||||
func (q *Quiz) SetID(id string) {
|
||||
q.ID = id
|
||||
}
|
||||
|
||||
func (q *Quiz) GetHash() string {
|
||||
return q.calculateHash()
|
||||
}
|
||||
|
||||
func (q *Quiz) Marshal() ([]byte, error) {
|
||||
result, err := QuizToMarkdown(q)
|
||||
|
||||
return []byte(result), err
|
||||
|
||||
}
|
||||
|
||||
func (q *Quiz) Unmarshal(data []byte) error {
|
||||
return MarkdownToQuiz(q, string(data))
|
||||
}
|
||||
|
||||
func (q *Quiz) calculateHash() string {
|
||||
result := make([]string, 0)
|
||||
|
||||
|
@ -194,3 +203,30 @@ func readLine(reader *strings.Reader) (string, error) {
|
|||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func parseTags(tags *[]string, text string) {
|
||||
// Trim the following chars
|
||||
trimChars := "*:.,/\\@()[]{}<>"
|
||||
|
||||
// Split the text into words
|
||||
words := strings.Fields(text)
|
||||
|
||||
for _, word := range words {
|
||||
// If the word starts with '#', it is considered as a tag
|
||||
if strings.HasPrefix(word, "#") {
|
||||
// Check if the tag already exists in the tags slice
|
||||
exists := false
|
||||
for _, tag := range *tags {
|
||||
if tag == word {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If the tag does not exist in the tags slice, add it
|
||||
if !exists {
|
||||
*tags = append(*tags, strings.TrimRight(word, trimChars))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
37
pkg/models/response.go
Normal file
37
pkg/models/response.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ParticipantAnswer struct {
|
||||
Quiz *Quiz `json:"question"`
|
||||
Answer *Answer `json:"answer"`
|
||||
Correct bool `json:"correct"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Meta
|
||||
|
||||
SessionTitle string `json:"session_title"`
|
||||
Participant *Participant `json:"participant"`
|
||||
Answers []*ParticipantAnswer `json:"answers"`
|
||||
}
|
||||
|
||||
func (r *Response) String() string {
|
||||
return fmt.Sprintf("Questions/Answers: %v", r.Answers)
|
||||
}
|
||||
|
||||
func (r *Response) GetHash() string {
|
||||
// return fmt.Sprintf("%x", sha256.Sum256([]byte(r.QuestionID+r.AnswerID)))
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *Response) Marshal() ([]byte, error) {
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
func (r *Response) Unmarshal(data []byte) error {
|
||||
return json.Unmarshal(data, r)
|
||||
}
|
35
pkg/models/session.go
Normal file
35
pkg/models/session.go
Normal file
|
@ -0,0 +1,35 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
Meta
|
||||
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
|
||||
Participants map[string]*Participant `json:"participants"`
|
||||
Quizzes map[string]*Quiz `json:"quizzes"`
|
||||
Answers map[string]*Answer `json:"answers"`
|
||||
Exams map[string]*Exam `json:"exams"`
|
||||
}
|
||||
|
||||
func (s *Session) String() string {
|
||||
return s.Title
|
||||
}
|
||||
|
||||
func (s *Session) GetHash() string {
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(s.Title)))
|
||||
}
|
||||
|
||||
func (s *Session) Marshal() ([]byte, error) {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
func (s *Session) Unmarshal(data []byte) error {
|
||||
return json.Unmarshal(data, s)
|
||||
}
|
5
pkg/models/tag.go
Normal file
5
pkg/models/tag.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
type Tag struct {
|
||||
Name string `json:"name"`
|
||||
}
|
52
pkg/sessionmanager/score.go
Normal file
52
pkg/sessionmanager/score.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
package sessionmanager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
)
|
||||
|
||||
type Score struct {
|
||||
Exam *models.Exam
|
||||
Score float32
|
||||
}
|
||||
|
||||
type Scores []*Score
|
||||
|
||||
func NewScores(responseFileStore *file.ResponseFileStore, session *models.Session) (Scores, error) {
|
||||
var scores Scores
|
||||
|
||||
for _, exam := range session.Exams {
|
||||
response, err := responseFileStore.Read(exam.ID)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
scores = append(scores, CalcScore(response, exam))
|
||||
}
|
||||
|
||||
return scores, nil
|
||||
}
|
||||
|
||||
func (ss Scores) String() string {
|
||||
var result string
|
||||
|
||||
for _, s := range ss {
|
||||
result += fmt.Sprintf("%v\t%f\n", s.Exam.Participant, s.Score)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func CalcScore(response *models.Response, exam *models.Exam) *Score {
|
||||
var score float32
|
||||
for _, q := range exam.Quizzes {
|
||||
answerId := response.Questions[q.Question.ID]
|
||||
if answerId == q.Correct.ID {
|
||||
score++
|
||||
}
|
||||
}
|
||||
return &Score{exam, score}
|
||||
}
|
125
pkg/sessionmanager/sessionmanager.go
Normal file
125
pkg/sessionmanager/sessionmanager.go
Normal file
|
@ -0,0 +1,125 @@
|
|||
package sessionmanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store/file"
|
||||
)
|
||||
|
||||
type SessionManager struct {
|
||||
ParticipantStore *store.ParticipantStore
|
||||
QuizStore *store.QuizStore
|
||||
|
||||
ParticipantFilter map[string]string
|
||||
QuizFilter map[string]string
|
||||
ServerURL string
|
||||
Token int
|
||||
|
||||
examFileStore *file.ExamFileStore
|
||||
}
|
||||
|
||||
func NewSessionManager(url string, pStore *store.ParticipantStore, qStore *store.QuizStore, pFilter map[string]string, qFilter map[string]string) (*SessionManager, error) {
|
||||
sm := new(SessionManager)
|
||||
|
||||
sm.ServerURL = url
|
||||
|
||||
var err error
|
||||
|
||||
sm.examFileStore, err = file.NewDefaultExamFileStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range pStore.ReadAll() {
|
||||
_, err := sm.examFileStore.Create(&models.Exam{
|
||||
Participant: p,
|
||||
Quizzes: qStore.ReadAll(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return sm, nil
|
||||
}
|
||||
|
||||
func (sm *SessionManager) GetExams() []*models.Exam {
|
||||
return sm.examFileStore.ReadAll()
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Pull(rStore *file.ResponseFileStore, sessionID string) error {
|
||||
url, err := url.JoinPath(sm.ServerURL, "responses/", sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rr, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
responseBody, err := io.ReadAll(rr.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
responses := make([]*models.Response, 0)
|
||||
|
||||
err = json.Unmarshal(responseBody, &responses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, response := range responses {
|
||||
_, err := rStore.Create(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Push(name string) (*models.Session, error) {
|
||||
session := &models.Session{
|
||||
Name: name,
|
||||
Exams: make(map[string]*models.Exam),
|
||||
}
|
||||
|
||||
for _, e := range sm.GetExams() {
|
||||
session.Exams[e.Participant.Token] = e
|
||||
}
|
||||
|
||||
payload, err := session.Marshal()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url, err := url.JoinPath(sm.ServerURL, "create")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := http.Post(url, "application/json", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(responseBody, &session)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
5
pkg/store/exam.go
Normal file
5
pkg/store/exam.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package store
|
||||
|
||||
import "git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
|
||||
type ExamStore = Store[*models.Exam]
|
49
pkg/store/file/defaults.go
Normal file
49
pkg/store/file/defaults.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
package file
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
var (
|
||||
DefaultBaseDir = "data"
|
||||
DefaultQuizzesSubdir = "quizzes"
|
||||
DefaultCollectionsSubdir = "collections"
|
||||
DefaultParticipantsSubdir = "participants"
|
||||
DefaultGroupsSubdir = "groups"
|
||||
DefaultExamsSubdir = "exams"
|
||||
DefaultResponsesSubdir = "responses"
|
||||
DefaultSessionSubdir = "sessions"
|
||||
|
||||
Dirs = []string{
|
||||
GetDefaultQuizzesDir(),
|
||||
GetDefaultParticipantsDir(),
|
||||
GetDefaultExamsDir(),
|
||||
GetDefaultSessionDir(),
|
||||
}
|
||||
)
|
||||
|
||||
func GetDefaultQuizzesDir() string {
|
||||
return filepath.Join(DefaultBaseDir, DefaultQuizzesSubdir)
|
||||
}
|
||||
|
||||
func GetDefaultCollectionsDir() string {
|
||||
return filepath.Join(DefaultBaseDir, DefaultCollectionsSubdir)
|
||||
}
|
||||
|
||||
func GetDefaultParticipantsDir() string {
|
||||
return filepath.Join(DefaultBaseDir, DefaultParticipantsSubdir)
|
||||
}
|
||||
|
||||
func GetDefaultGroupsDir() string {
|
||||
return filepath.Join(DefaultBaseDir, DefaultGroupsSubdir)
|
||||
}
|
||||
|
||||
func GetDefaultExamsDir() string {
|
||||
return filepath.Join(DefaultBaseDir, DefaultExamsSubdir)
|
||||
}
|
||||
|
||||
func GetDefaultSessionDir() string {
|
||||
return filepath.Join(DefaultBaseDir, DefaultSessionSubdir)
|
||||
}
|
||||
|
||||
func GetDefaultResponsesDir() string {
|
||||
return filepath.Join(DefaultBaseDir, DefaultResponsesSubdir)
|
||||
}
|
28
pkg/store/file/exam.go
Normal file
28
pkg/store/file/exam.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store"
|
||||
)
|
||||
|
||||
type ExamFileStore = FileStore[*models.Exam, *store.Store[*models.Exam]]
|
||||
|
||||
func NewExamFileStore(config *FileStoreConfig[*models.Exam, *store.ExamStore]) (*ExamFileStore, error) {
|
||||
return NewFileStore[*models.Exam](config, store.NewStore[*models.Exam]())
|
||||
}
|
||||
|
||||
func NewDefaultExamFileStore() (*ExamFileStore, error) {
|
||||
return NewExamFileStore(
|
||||
&FileStoreConfig[*models.Exam, *store.ExamStore]{
|
||||
FilePathConfig: FilePathConfig{GetDefaultExamsDir(), "exam", ".json"},
|
||||
IndexDirFunc: DefaultIndexDirFunc[*models.Exam, *store.ExamStore],
|
||||
CreateEntityFunc: func() *models.Exam {
|
||||
return &models.Exam{
|
||||
Participant: &models.Participant{},
|
||||
Quizzes: make([]*models.Quiz, 0),
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
}
|
103
pkg/store/file/exam_test.go
Normal file
103
pkg/store/file/exam_test.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"github.com/remogatto/prettytest"
|
||||
)
|
||||
|
||||
type examTestSuite struct {
|
||||
prettytest.Suite
|
||||
}
|
||||
|
||||
// func (t *examTestSuite) TestCreate() {
|
||||
// participantStore, err := NewParticipantFileStore(
|
||||
// &FileStoreConfig[*models.Participant, *store.ParticipantStore]{
|
||||
// FilePathConfig: FilePathConfig{"testdata/exams/participants", "participant", ".json"},
|
||||
// IndexDirFunc: DefaultIndexDirFunc[*models.Participant, *store.ParticipantStore],
|
||||
// CreateEntityFunc: func() *models.Participant {
|
||||
// return &models.Participant{
|
||||
// Attributes: make(map[string]string),
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
// )
|
||||
|
||||
// t.Nil(err)
|
||||
|
||||
// quizStore, err := NewQuizFileStore(
|
||||
// &FileStoreConfig[*models.Quiz, *store.QuizStore]{
|
||||
// FilePathConfig: FilePathConfig{"testdata/exams/quizzes", "quiz", ".md"},
|
||||
// IndexDirFunc: DefaultQuizIndexDirFunc,
|
||||
// },
|
||||
// )
|
||||
|
||||
// t.Nil(err)
|
||||
|
||||
// if !t.Failed() {
|
||||
// t.Equal(3, len(participantStore.ReadAll()))
|
||||
|
||||
// examStore, err := NewDefaultExamFileStore()
|
||||
// t.Nil(err)
|
||||
|
||||
// if !t.Failed() {
|
||||
// g := new(models.Group)
|
||||
// c := new(models.Collection)
|
||||
|
||||
// participants := participantStore.Storer.FilterInGroup(g, map[string]string{"class": "1 D LIN"})
|
||||
// quizzes := quizStore.Storer.FilterInCollection(c, map[string]string{"tags": "#tag1"})
|
||||
|
||||
// for _, p := range participants {
|
||||
// e := new(models.Exam)
|
||||
// e.Participant = p
|
||||
// e.Quizzes = quizzes
|
||||
|
||||
// _, err = examStore.Create(e)
|
||||
// t.Nil(err)
|
||||
|
||||
// defer os.Remove(examStore.GetPath(e))
|
||||
|
||||
// examFromDisk, err := readExamFromJSON(e.GetID())
|
||||
// t.Nil(err)
|
||||
|
||||
// if !t.Failed() {
|
||||
// t.Not(t.Nil(examFromDisk.Participant))
|
||||
// if !t.Failed() {
|
||||
// t.Equal("Smith", examFromDisk.Participant.Lastname)
|
||||
// t.Equal(2, len(examFromDisk.Quizzes))
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
func readExamFromJSON(examID string) (*models.Exam, error) {
|
||||
// Build the path to the JSON file
|
||||
jsonPath := fmt.Sprintf("testdata/exams/exam_%s.json", examID)
|
||||
|
||||
// Open the JSON file
|
||||
file, err := os.Open(jsonPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open JSON file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read the JSON data from the file
|
||||
jsonData, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read JSON data: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the JSON data into an Exam object
|
||||
var exam models.Exam
|
||||
if err := json.Unmarshal(jsonData, &exam); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON data: %w", err)
|
||||
}
|
||||
|
||||
return &exam, nil
|
||||
}
|
232
pkg/store/file/file.go
Normal file
232
pkg/store/file/file.go
Normal file
|
@ -0,0 +1,232 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store"
|
||||
)
|
||||
|
||||
type IndexDirFunc[T FileStorable, K store.Storer[T]] func(s *FileStore[T, K]) error
|
||||
|
||||
var (
|
||||
ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.")
|
||||
)
|
||||
|
||||
type FileStorable interface {
|
||||
store.Storable
|
||||
|
||||
Marshal() ([]byte, error)
|
||||
Unmarshal([]byte) error
|
||||
}
|
||||
|
||||
type FileStorer[T FileStorable] interface {
|
||||
// store.Storer[T]
|
||||
Create(T, ...string) (T, error)
|
||||
ReadAll() []T
|
||||
Read(string) (T, error)
|
||||
Update(T, string) (T, error)
|
||||
Delete(string) (T, error)
|
||||
}
|
||||
|
||||
type FilePathConfig struct {
|
||||
Dir string
|
||||
FilePrefix string
|
||||
FileSuffix string
|
||||
}
|
||||
|
||||
type FileStoreConfig[T FileStorable, K store.Storer[T]] struct {
|
||||
FilePathConfig
|
||||
IndexDirFunc func(*FileStore[T, K]) error
|
||||
CreateEntityFunc func() T
|
||||
NoIndexOnCreate bool
|
||||
}
|
||||
|
||||
type FileStore[T FileStorable, K store.Storer[T]] struct {
|
||||
*FileStoreConfig[T, K]
|
||||
|
||||
Storer K
|
||||
|
||||
lock sync.RWMutex
|
||||
paths map[string]string
|
||||
}
|
||||
|
||||
func DefaultIndexDirFunc[T FileStorable, K store.Storer[T]](s *FileStore[T, K]) error {
|
||||
if s.CreateEntityFunc == nil {
|
||||
return errors.New("CreateEntityFunc cannot be nil!")
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(s.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entityFiles := make([]fs.DirEntry, 0)
|
||||
|
||||
for _, file := range files {
|
||||
filename := file.Name()
|
||||
if !file.IsDir() && strings.HasSuffix(filename, s.FileSuffix) {
|
||||
entityFiles = append(entityFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range entityFiles {
|
||||
filename := file.Name()
|
||||
fullPath := filepath.Join(s.Dir, filename)
|
||||
|
||||
fileInfo, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if fileInfo.Size() > 0 {
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entity := s.CreateEntityFunc()
|
||||
|
||||
err = entity.Unmarshal(content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("An error occurred unmarshalling %v: %v", filename, err)
|
||||
}
|
||||
|
||||
// mEntity, err := s.Create(entity, fullPath)
|
||||
mEntity, err := s.Storer.Create(entity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.SetPath(mEntity, fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func NewFileStore[T FileStorable, K store.Storer[T]](config *FileStoreConfig[T, K], storer K) (*FileStore[T, K], error) {
|
||||
store := &FileStore[T, K]{
|
||||
FileStoreConfig: config,
|
||||
Storer: storer,
|
||||
paths: make(map[string]string, 0),
|
||||
}
|
||||
|
||||
if !config.NoIndexOnCreate {
|
||||
err := store.IndexDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) Create(entity T, path ...string) (T, error) {
|
||||
e, err := s.Storer.Create(entity)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
data, err := e.Marshal()
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
if len(path) == 0 {
|
||||
path = append(path, filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix)))
|
||||
}
|
||||
|
||||
file, err := os.Create(path[0])
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
_, err = file.Write(data)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
s.SetPath(e, path[0])
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) Update(entity T, id string) (T, error) {
|
||||
e, err := s.Storer.Update(entity, id)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
filePath := s.GetPath(e)
|
||||
|
||||
data, err := e.Marshal()
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
_, err = file.Write(data)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) Read(id string) (T, error) {
|
||||
return s.Storer.Read(id)
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) ReadAll() []T {
|
||||
return s.Storer.ReadAll()
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) Delete(id string) (T, error) {
|
||||
e, err := s.Storer.Delete(id)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
err = os.Remove(s.GetPath(e))
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) IndexDir() error {
|
||||
return s.IndexDirFunc(s)
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) GetPath(entity T) string {
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
return s.paths[entity.GetID()]
|
||||
}
|
||||
|
||||
func (s *FileStore[T, K]) SetPath(entity T, path string) string {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
s.paths[entity.GetID()] = path
|
||||
|
||||
return path
|
||||
}
|
|
@ -9,9 +9,12 @@ import (
|
|||
var testdataDir = "./testdata"
|
||||
|
||||
func TestRunner(t *testing.T) {
|
||||
DefaultBaseDir = "testdata"
|
||||
|
||||
prettytest.Run(
|
||||
t,
|
||||
new(quizTestSuite),
|
||||
new(collectionTestSuite),
|
||||
new(participantTestSuite),
|
||||
new(examTestSuite),
|
||||
)
|
||||
}
|
53
pkg/store/file/participant.go
Normal file
53
pkg/store/file/participant.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
package file
|
||||
|
||||
import (
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/models"
|
||||
"git.andreafazzi.eu/andrea/probo/pkg/store"
|
||||
)
|
||||
|
||||
type ParticipantFileStore struct {
|
||||
*FileStore[*models.Participant, *store.ParticipantStore]
|
||||
}
|
||||
|
||||
func NewParticipantFileStore(config *FileStoreConfig[*models.Participant, *store.ParticipantStore]) (*ParticipantFileStore, error) {
|
||||
var err error
|
||||
|
||||
pStore := new(ParticipantFileStore)
|
||||
|
||||
pStore.FileStore, err = NewFileStore(config, store.NewParticipantStore())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pStore, nil
|
||||
}
|
||||
|
||||
func NewDefaultParticipantFileStore() (*ParticipantFileStore, error) {
|
||||
return NewParticipantFileStore(
|
||||
&FileStoreConfig[*models.Participant, *store.ParticipantStore]{
|
||||
FilePathConfig: FilePathConfig{GetDefaultParticipantsDir(), "participant", ".json"},
|
||||
IndexDirFunc: DefaultIndexDirFunc[*models.Participant, *store.ParticipantStore],
|
||||
CreateEntityFunc: func() *models.Participant {
|
||||
return &models.Participant{
|
||||
Attributes: make(map[string]string),
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (ps *ParticipantFileStore) ImportCSV(path string) ([]*models.Participant, error) {
|
||||
participants, err := ps.FileStore.Storer.ImportCSV(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range participants {
|
||||
_, err := ps.Create(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return participants, nil
|
||||
}
|
36
pkg/store/file/participant_test.go
Normal file
36
pkg/store/file/participant_test.go
Normal file
|
@ -0,0 +1,36 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue