Compare commits

9 Commits

Author SHA1 Message Date
Bel LaPointe
64e992c6b6 shuffle http 2023-04-07 14:01:52 -06:00
Bel LaPointe
0fd0981a39 Q can be image in http 2023-04-07 13:56:04 -06:00
Bel LaPointe
aacc5d2fc6 img clues in http 2023-04-07 13:53:11 -06:00
Bel LaPointe
08abc8b48f run gui 2023-04-07 13:47:33 -06:00
Bel LaPointe
c960de86f3 http writable 2023-04-07 13:46:29 -06:00
Bel LaPointe
7a464c2f09 test http POST api/questions/QID/answers 2023-04-07 13:44:42 -06:00
Bel LaPointe
c76da12b1a gui done enough 2023-04-07 13:33:09 -06:00
Bel LaPointe
ef25a77254 func q redo 2023-04-07 13:25:40 -06:00
Bel LaPointe
80d987b69e ok ui can rotate questions 2023-04-07 13:22:13 -06:00
4 changed files with 225 additions and 21 deletions

62
http.go
View File

@@ -7,7 +7,10 @@ import (
"fmt"
"net/http"
"os"
"os/signal"
"path"
"strings"
"syscall"
"time"
)
@@ -17,17 +20,29 @@ type Context struct {
}
func HTTP(port int, db DB) error {
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
defer can()
foo := func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
httpRoot(w, r)
default:
if r.URL.Path == "/" {
httpGUI(w, r)
} else if strings.HasPrefix(r.URL.Path, "/static/") {
httpStatic(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/questions") && strings.HasSuffix(r.URL.Path, "/answers") && r.Method == http.MethodPost {
httpPostQuestionAnswers(w, r)
} else {
http.NotFound(w, r)
}
}
foo = withAuth(foo)
foo = withDB(foo, db)
return http.ListenAndServe(fmt.Sprintf(":%d", port), http.HandlerFunc(foo))
foo = withCtx(foo, ctx)
go func() {
http.ListenAndServe(fmt.Sprintf(":%d", port), http.HandlerFunc(foo))
}()
<-ctx.Done()
return nil
}
func extract(ctx context.Context) Context {
@@ -40,6 +55,13 @@ func inject(ctx context.Context, v Context) context.Context {
return context.WithValue(ctx, "__context", v)
}
func withCtx(foo http.HandlerFunc, ctx context.Context) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(ctx)
foo(w, r)
}
}
func withDB(foo http.HandlerFunc, db DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c := extract(r.Context())
@@ -65,10 +87,10 @@ func withAuth(foo http.HandlerFunc) http.HandlerFunc {
}
//go:embed public/root.html
var httpRootHTML string
var httpGUIHTML string
func httpRoot(w http.ResponseWriter, r *http.Request) {
body := httpRootHTML
func httpGUI(w http.ResponseWriter, r *http.Request) {
body := httpGUIHTML
if os.Getenv("DEBUG") != "" {
b, _ := os.ReadFile("public/root.html")
body = string(b)
@@ -85,6 +107,13 @@ func httpRoot(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(body))
}
func httpStatic(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/static/")
p = strings.ReplaceAll(p, "/..", "/")
p = path.Clean(p)
http.ServeFile(w, r, p)
}
func httpAssignments(ctx context.Context) (interface{}, error) {
db := extract(ctx).DB
user := extract(ctx).User
@@ -97,3 +126,20 @@ func httpAssignments(ctx context.Context) (interface{}, error) {
}
return todo, nil
}
func httpPostQuestionAnswers(w http.ResponseWriter, r *http.Request) {
idq := IDQ(path.Base(strings.Split(r.URL.Path, "/answers")[0]))
var payload struct {
Answer string `json:"answer"`
Passed bool `json:"passed"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := extract(r.Context())
if err := ctx.DB.PushAnswer(ctx.User, idq, Renderable(payload.Answer), payload.Passed); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}

50
http_test.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
"time"
)
func TestHTTPPostQuestionAnswers(t *testing.T) {
p := path.Join(t.TempDir(), "db.yaml")
os.WriteFile(p, []byte("{}"), os.ModePerm)
db, err := newYamlDB(p)
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
r := httptest.NewRequest(
http.MethodPost,
"/api/questions/QID/answers",
strings.NewReader(`{"answer":"a", "passed": false}`),
)
r.SetBasicAuth("u", "")
withAuth(withDB(httpPostQuestionAnswers, db))(w, r)
if w.Code != http.StatusOK {
t.Error(w.Code)
}
_, got := db.LastAnswer("u", "QID")
if got == (Answer{}) {
t.Error("no answer pushed:", got)
}
if got.Q != "QID" {
t.Error(got.Q)
} else if got.A != "a" {
t.Error(got.A)
} else if time.Since(time.Unix(0, got.TS)) > time.Minute {
t.Error(got.TS)
} else if got.Author != "u" {
t.Error(got.Author)
} else if got.Pass != false {
t.Error(got.Pass)
}
}

View File

@@ -16,15 +16,17 @@
</header>
<body>
<!--{{USER}}-->
<form id="flash" action="" onsubmit="return false; trySolve(this.children.idq.value, this.children.answer.value); return false;">
<form id="flash" action="">
<h1 name="status"></h1>
<input type="text" name="idq" readonly=true value="" style="display: none;">
<div name="question"></div>
<div name="clues"></div>
<div name="solution"></div>
<input type="button" value="clue">
<input type="text" name="answer">
<input type="submit" value="submit">
<input type="button" value="skip" onclick="nextQuestion(this.parentElement)">
<input type="button" value="pass" onclick="passQuestion(this.parentElement)">
<input type="button" value="fail" onclick="failQuestion(this.parentElement)">
<input type="button" value="skip" onclick="skipQuestion(this.parentElement)">
</form>
</body>
<footer>
@@ -35,10 +37,66 @@
map((key) => [
[key, knowledgebase[key]]
])
function shuffle(array) {
let currentIndex = array.length, randomIndex;
while (currentIndex != 0) {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[array[currentIndex], array[randomIndex]] = [
array[randomIndex], array[currentIndex]];
}
return array;
}
shuffle(knowledgebase)
console.log(0, knowledgebase)
function passQuestion(form) {
if (!form.children.answer.retake) {
pushAnswer(form.children.idq.value, form.children.answer.value, true)
}
nextQuestion(form)
}
function failQuestion(form) {
if (!form.children.answer.retake) {
pushAnswer(form.children.idq.value, form.children.answer.value, false)
}
queueRedoQuestion(form)
nextQuestion(form)
}
function skipQuestion(form) {
queueRedoQuestion(form)
nextQuestion(form)
}
function queueRedoQuestion(form) {
knowledgebase.push([[
form.children.idq.value,
{
Q: form.children.question.value,
Clues: form.children.clues.value,
Solution: form.children.solution.value,
Retake: true,
},
]])
}
function pushAnswer(idq, answer, passed) {
http("post", `/api/questions/${idq}/answers`, noopcallback,
JSON.stringify(
{
"answer": answer,
"passed": new Boolean(passed),
}
),
)
}
function nextQuestion(form) {
form.children.answer.value = ""
let todo = knowledgebase.pop()
let todo = knowledgebase.shift()
if (!todo) {
todo = [0]
}
@@ -47,15 +105,29 @@
todo = ["", {Q: "ALL DONE"}]
}
form.children.status.innerHTML = knowledgebase.length.toString()
form.children.idq.value = todo[0]
if (todo[1].Q.startsWith("img:")) {
form.children.question.innerHTML = `<img src="static/${todo[1].Q.slice(4, 1000)}"/>`
} else {
form.children.question.innerHTML = `<h3>${todo[1].Q}</h3>`
}
form.children.question.value = todo[1].Q
let clues = ""
for (var i in todo[1].Clues) {
clues += `<details><summary>clue #${i}</summary>${todo[1].Clues[i]}</details>`
clues += `<details><summary>clue #${i}</summary>`
if (todo[1].Clues[i].startsWith("img:")) {
clues += `<img src="static/${todo[1].Clues[i].slice(4, 1000)}"/>`
} else {
clues += todo[1].Clues[i]
}
clues += `</details>`
}
form.children.clues.innerHTML = clues
form.children.clues.value = todo[1].Clues
let solution = ""
for (var i in todo[1].Solution) {
@@ -65,6 +137,26 @@
solution = `<details><summary>solution</summary>${solution}</details>`
}
form.children.solution.innerHTML = solution
form.children.solution.value = todo[1].Solution
form.children.answer.retake = todo[1].Retake
}
function noopcallback(responseBody, responseStatus) {
console.log(responseStatus, responseBody)
}
function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
callback(xmlhttp.responseText, xmlhttp.status)
}
};
xmlhttp.open(method, remote, true);
if (typeof body == "undefined") {
body = null
}
xmlhttp.send(body);
}
nextQuestion(document.getElementById("flash"))

30
testdata/sample.yaml vendored Normal file → Executable file
View File

@@ -2,39 +2,55 @@ knowledge:
questions:
uuid10:
q: whats a fieldset
clues:
- clue1 of 2
- clue2 of 2
solution:
- solution A
- solution B
clues:
- clue1 of 2
- img:testdata/tofugu.d/sa-hiragana-0.png
tags:
- ops
- data-platform
uuid11:
q: whats a responseset
clues:
- clue1 of 2
- clue2 of 2
solution:
- solution A
- solution B
clues:
- clue1 of 2
- clue2 of 2
tags:
- ops
- data-platform
answers:
4ad47d8f-2e9b-46fe-8a12-67db85efc6f8:
q: uuid11
a: ""
ts: 1680896808930227000
author: breel
pass: false
36648846-4e14-4518-b434-5d4ff3b0127b:
q: uuid10
a: ""
ts: 1680896805896000000
author: breel
pass: false
uuid20:
q: uuid10
a: a schema
ts: 123
author: breel
pass: false
uuid21:
q: uuid10
a: not a schema
ts: 122
author: breel
pass: false
users:
breel:
tags: {assignments: [ops]}
tags:
assignments:
- ops
cadence: 5s
resolution: 1