mirror of https://github.com/dirtbags/moth.git
Compare commits
2 Commits
d87be0bfcb
...
c0761933a9
Author | SHA1 | Date |
---|---|---|
Neale Pickett | c0761933a9 | |
Neale Pickett | 4ce0dcf11a |
|
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- We are now using djb2xor instead of sha256 to hash puzzle answers
|
- We are now using djb2xor instead of sha256 to hash puzzle answers
|
||||||
- Lots of work on the built-in theme
|
- Lots of work on the built-in theme
|
||||||
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
|
- [moth.mjs](theme/moth.mjs) is now the standard MOTH library for ECMAScript
|
||||||
|
- Devel mode no longer accepts an empty team ID
|
||||||
|
|
||||||
## [v4.4.9] - 2022-05-12
|
## [v4.4.9] - 2022-05-12
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
@ -128,7 +128,7 @@ func TestHttpd(t *testing.T) {
|
||||||
|
|
||||||
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
|
if r := hs.TestRequest("/answer", map[string]string{"cat": "pategory", "points": "1", "answer": "answer123"}); r.Result().StatusCode != 200 {
|
||||||
t.Error(r.Result())
|
t.Error(r.Result())
|
||||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"error awarding points: points already awarded to this team in this category"}}` {
|
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"points already awarded to this team in this category"}}` {
|
||||||
t.Error("Unexpected body", r.Body.String())
|
t.Error("Unexpected body", r.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -156,7 +156,7 @@ func (mh *MothRequestHandler) CheckAnswer(cat string, points int, answer string)
|
||||||
return fmt.Errorf("invalid team ID")
|
return fmt.Errorf("invalid team ID")
|
||||||
}
|
}
|
||||||
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
|
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
|
||||||
return fmt.Errorf("error awarding points: %s", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -522,6 +522,9 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
|
||||||
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
|
if teamID == "" {
|
||||||
|
return "", fmt.Errorf("Empty team ID")
|
||||||
|
}
|
||||||
return fmt.Sprintf("«devel:%s»", teamID), nil
|
return fmt.Sprintf("«devel:%s»", teamID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,6 +75,9 @@ input {
|
||||||
.category h2 {
|
.category h2 {
|
||||||
margin: 0 0.2em;
|
margin: 0 0.2em;
|
||||||
}
|
}
|
||||||
|
.category .solved {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
nav ul, .category ul {
|
nav ul, .category ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.2em 1em;
|
padding: 0.2em 1em;
|
||||||
|
|
|
@ -34,10 +34,30 @@ function WhenDOMLoaded(cb) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interprets a String as a Boolean.
|
||||||
|
*
|
||||||
|
* Values like "no" or "disabled" to mean false here.
|
||||||
|
*
|
||||||
|
* @param {String} s
|
||||||
|
* @returns {Boolean}
|
||||||
|
*/
|
||||||
|
function Truthy(s) {
|
||||||
|
switch (s.toLowerCase()) {
|
||||||
|
case "disabled":
|
||||||
|
case "no":
|
||||||
|
case "off":
|
||||||
|
case "false":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Millisecond,
|
Millisecond,
|
||||||
Second,
|
Second,
|
||||||
Minute,
|
Minute,
|
||||||
Toast,
|
Toast,
|
||||||
WhenDOMLoaded,
|
WhenDOMLoaded,
|
||||||
|
Truthy,
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"TrackSolved": true,
|
||||||
|
"__sentry__": "this is here so you don't have to remember to take the comma off the last item"
|
||||||
|
}
|
|
@ -22,9 +22,19 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="puzzles"></div>
|
<div class="puzzles"></div>
|
||||||
|
|
||||||
<div class="toasts"></div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="notification" data-track-solved="no">
|
||||||
|
<p>
|
||||||
|
Solved puzzle tracking: <b>disabled</b>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your team's Incident Coordinator can help coordinate team activity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toasts"></div>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
|
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
|
||||||
|
|
|
@ -5,12 +5,15 @@ import * as moth from "./moth.mjs"
|
||||||
import * as common from "./common.mjs"
|
import * as common from "./common.mjs"
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
constructor(basePath=".") {
|
constructor(basePath=".") {
|
||||||
|
this.configURL = new URL("config.json", location)
|
||||||
|
this.config = {}
|
||||||
|
|
||||||
this.server = new moth.Server(basePath)
|
this.server = new moth.Server(basePath)
|
||||||
|
|
||||||
let uuid = Math.floor(Math.random() * 1000000).toString(16)
|
let uuid = Math.floor(Math.random() * 1000000).toString(16)
|
||||||
this.fakeRegistration = {
|
this.fakeRegistration = {
|
||||||
TeamId: uuid,
|
TeamID: uuid,
|
||||||
TeamName: `Team ${uuid}`,
|
TeamName: `Team ${uuid}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,8 +24,11 @@ class App {
|
||||||
e.addEventListener("click", () => this.Logout())
|
e.addEventListener("click", () => this.Logout())
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(() => this.Update(), common.Minute/3)
|
setInterval(() => this.UpdateState(), common.Minute/3)
|
||||||
this.Update()
|
setInterval(() => this.UpdateConfig(), common.Minute* 5)
|
||||||
|
|
||||||
|
this.UpdateConfig()
|
||||||
|
.finally(() => this.UpdateState())
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLoginSubmit(event) {
|
handleLoginSubmit(event) {
|
||||||
|
@ -33,14 +39,14 @@ class App {
|
||||||
/**
|
/**
|
||||||
* Attempt to log in to the server.
|
* Attempt to log in to the server.
|
||||||
*
|
*
|
||||||
* @param {String} teamId
|
* @param {String} teamID
|
||||||
* @param {String} teamName
|
* @param {String} teamName
|
||||||
*/
|
*/
|
||||||
async Login(teamId, teamName) {
|
async Login(teamID, teamName) {
|
||||||
try {
|
try {
|
||||||
await this.server.Login(teamId, teamName)
|
await this.server.Login(teamID, teamName)
|
||||||
common.Toast(`Logged in (team id = ${teamId})`)
|
common.Toast(`Logged in (team id = ${teamID})`)
|
||||||
this.Update()
|
this.UpdateState()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
common.Toast(error)
|
common.Toast(error)
|
||||||
|
@ -54,13 +60,24 @@ class App {
|
||||||
try {
|
try {
|
||||||
this.server.Reset()
|
this.server.Reset()
|
||||||
common.Toast("Logged out")
|
common.Toast("Logged out")
|
||||||
this.Update()
|
this.UpdateState()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
common.Toast(error)
|
common.Toast(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update app configuration.
|
||||||
|
*
|
||||||
|
* Configuration can be updated less frequently than state, to reduce server
|
||||||
|
* load, since configuration should (hopefully) change less frequently.
|
||||||
|
*/
|
||||||
|
async UpdateConfig() {
|
||||||
|
let resp = await fetch(this.configURL)
|
||||||
|
this.config = await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the entire page.
|
* Update the entire page.
|
||||||
*
|
*
|
||||||
|
@ -68,12 +85,18 @@ class App {
|
||||||
* what's returned. If we're in development mode and not logged in, auto
|
* what's returned. If we're in development mode and not logged in, auto
|
||||||
* login too.
|
* login too.
|
||||||
*/
|
*/
|
||||||
async Update() {
|
async UpdateState() {
|
||||||
this.state = await this.server.GetState()
|
this.state = await this.server.GetState()
|
||||||
for (let e of document.querySelectorAll(".messages")) {
|
for (let e of document.querySelectorAll(".messages")) {
|
||||||
e.innerHTML = this.state.Messages
|
e.innerHTML = this.state.Messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update elements with data-track-solved
|
||||||
|
for (let e of document.querySelectorAll("[data-track-solved]")) {
|
||||||
|
// Only display if data-track-solved is the same as config.trackSolved
|
||||||
|
e.classList.toggle("hidden", common.Truthy(e.dataset.trackSolved) != this.config.TrackSolved)
|
||||||
|
}
|
||||||
|
|
||||||
for (let e of document.querySelectorAll(".login")) {
|
for (let e of document.querySelectorAll(".login")) {
|
||||||
this.renderLogin(e, !this.server.LoggedIn())
|
this.renderLogin(e, !this.server.LoggedIn())
|
||||||
}
|
}
|
||||||
|
@ -84,7 +107,7 @@ class App {
|
||||||
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
|
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
|
||||||
common.Toast("Automatically logging in to devel server")
|
common.Toast("Automatically logging in to devel server")
|
||||||
console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration)
|
console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration)
|
||||||
return this.Login(this.fakeRegistration.TeamId, this.fakeRegistration.TeamName)
|
return this.Login(this.fakeRegistration.TeamID, this.fakeRegistration.TeamName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,9 +156,13 @@ class App {
|
||||||
a.textContent = puzzle.Points
|
a.textContent = puzzle.Points
|
||||||
a.href = url
|
a.href = url
|
||||||
a.target = "_blank"
|
a.target = "_blank"
|
||||||
|
|
||||||
|
if (this.config.TrackSolved) {
|
||||||
|
a.classList.toggle("solved", this.state.IsSolved(puzzle))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.state.HasUnsolved(cat)) {
|
if (!this.state.ContainsUnsolved(cat)) {
|
||||||
l.appendChild(document.createElement("li")).textContent = "✿"
|
l.appendChild(document.createElement("li")).textContent = "✿"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -130,7 +130,7 @@ class Puzzle {
|
||||||
/** Point value of this puzzle */
|
/** Point value of this puzzle */
|
||||||
this.Points = Number(points)
|
this.Points = Number(points)
|
||||||
|
|
||||||
/** Error returned trying to fetch this puzzle */
|
/** Error returned trying to retrieve this puzzle */
|
||||||
this.Error = {
|
this.Error = {
|
||||||
/** Status code provided by server */
|
/** Status code provided by server */
|
||||||
Status: 0,
|
Status: 0,
|
||||||
|
@ -265,7 +265,7 @@ class State {
|
||||||
/** Log of points awarded
|
/** Log of points awarded
|
||||||
* @type {Award[]}
|
* @type {Award[]}
|
||||||
*/
|
*/
|
||||||
this.PointsLog = obj.PointsLog.map((t,i,c,p) => new Award(t,i,c,p))
|
this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -283,7 +283,7 @@ class State {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a category has unsolved puzzles.
|
* Check whether a category contains unsolved puzzles.
|
||||||
*
|
*
|
||||||
* The server adds a puzzle with 0 points in every "solved" category,
|
* The server adds a puzzle with 0 points in every "solved" category,
|
||||||
* so this just checks whether there is a 0-point puzzle in the category's point list.
|
* so this just checks whether there is a 0-point puzzle in the category's point list.
|
||||||
|
@ -291,7 +291,7 @@ class State {
|
||||||
* @param {String} category
|
* @param {String} category
|
||||||
* @returns {Boolean}
|
* @returns {Boolean}
|
||||||
*/
|
*/
|
||||||
HasUnsolved(category) {
|
ContainsUnsolved(category) {
|
||||||
return !this.PointsByCategory[category].includes(0)
|
return !this.PointsByCategory[category].includes(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -330,6 +330,26 @@ class State {
|
||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has this puzzle been solved by this team?
|
||||||
|
*
|
||||||
|
* @param {Puzzle} puzzle
|
||||||
|
* @param {String} teamID Team to check, default the logged-in team
|
||||||
|
* @returns {Boolean}
|
||||||
|
*/
|
||||||
|
IsSolved(puzzle, teamID="self") {
|
||||||
|
for (let award of this.PointsLog) {
|
||||||
|
if (
|
||||||
|
(award.Category == puzzle.Category)
|
||||||
|
&& (award.Points == puzzle.Points)
|
||||||
|
&& (award.TeamID == teamID)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -347,8 +367,8 @@ class Server {
|
||||||
throw("Must provide baseURL")
|
throw("Must provide baseURL")
|
||||||
}
|
}
|
||||||
this.baseUrl = new URL(baseUrl, location)
|
this.baseUrl = new URL(baseUrl, location)
|
||||||
this.teamIdKey = this.baseUrl.toString() + " teamID"
|
this.teamIDKey = this.baseUrl.toString() + " teamID"
|
||||||
this.TeamId = localStorage[this.teamIdKey]
|
this.TeamID = localStorage[this.teamIDKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -357,30 +377,24 @@ class Server {
|
||||||
* If anything other than a 2xx code is returned,
|
* If anything other than a 2xx code is returned,
|
||||||
* this function throws an error.
|
* this function throws an error.
|
||||||
*
|
*
|
||||||
* This always sends teamId.
|
* This always sends teamID.
|
||||||
* If args is set, POST will be used instead of GET
|
* If args is set, POST will be used instead of GET
|
||||||
*
|
*
|
||||||
* @param {String} path Path to API endpoint
|
* @param {String} path Path to API endpoint
|
||||||
* @param {Object.<String,String>} args Key/Values to send in POST data
|
* @param {Object.<String,String>} args Key/Values to send in POST data
|
||||||
* @returns {Promise.<Response>} Response
|
* @returns {Promise.<Response>} Response
|
||||||
*/
|
*/
|
||||||
fetch(path, args) {
|
fetch(path, args={}) {
|
||||||
let url = new URL(path, this.baseUrl)
|
let body = new URLSearchParams(args)
|
||||||
if (this.TeamId & (!(args && args.id))) {
|
if (this.TeamID && !body.has("id")) {
|
||||||
url.searchParams.set("id", this.TeamId)
|
body.set("id", this.TeamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args) {
|
let url = new URL(path, this.baseUrl)
|
||||||
let formData = new FormData()
|
return fetch(url, {
|
||||||
for (let k in args) {
|
method: "POST",
|
||||||
formData.set(k, args[k])
|
body,
|
||||||
}
|
})
|
||||||
return fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return fetch(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -390,7 +404,7 @@ class Server {
|
||||||
* @param {Object.<String,String>} args Key/Values to send in POST
|
* @param {Object.<String,String>} args Key/Values to send in POST
|
||||||
* @returns {Promise.<Object>} JSend Data
|
* @returns {Promise.<Object>} JSend Data
|
||||||
*/
|
*/
|
||||||
async call(path, args) {
|
async call(path, args={}) {
|
||||||
let resp = await this.fetch(path, args)
|
let resp = await this.fetch(path, args)
|
||||||
let obj = await resp.json()
|
let obj = await resp.json()
|
||||||
switch (obj.status) {
|
switch (obj.status) {
|
||||||
|
@ -407,7 +421,10 @@ class Server {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make a new URL for the given resource.
|
* Make a new URL for the given resource.
|
||||||
*
|
*
|
||||||
|
* The returned URL instance will be absolute, and immune to changes to the
|
||||||
|
* page that would affect relative URLs.
|
||||||
|
*
|
||||||
* @returns {URL}
|
* @returns {URL}
|
||||||
*/
|
*/
|
||||||
URL(url) {
|
URL(url) {
|
||||||
|
@ -420,7 +437,7 @@ class Server {
|
||||||
* @returns {Boolean}
|
* @returns {Boolean}
|
||||||
*/
|
*/
|
||||||
LoggedIn() {
|
LoggedIn() {
|
||||||
return this.TeamId ? true : false
|
return this.TeamID ? true : false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -429,8 +446,8 @@ class Server {
|
||||||
* This is equivalent to logging out.
|
* This is equivalent to logging out.
|
||||||
*/
|
*/
|
||||||
Reset() {
|
Reset() {
|
||||||
localStorage.removeItem(this.teamIdKey)
|
localStorage.removeItem(this.teamIDKey)
|
||||||
this.TeamId = null
|
this.TeamID = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -450,15 +467,15 @@ class Server {
|
||||||
* This calls the server's registration endpoint; if the call succeds, or
|
* This calls the server's registration endpoint; if the call succeds, or
|
||||||
* fails with "team already exists", the login is returned as successful.
|
* fails with "team already exists", the login is returned as successful.
|
||||||
*
|
*
|
||||||
* @param {String} teamId
|
* @param {String} teamID
|
||||||
* @param {String} teamName
|
* @param {String} teamName
|
||||||
* @returns {Promise.<String>} Success message from server
|
* @returns {Promise.<String>} Success message from server
|
||||||
*/
|
*/
|
||||||
async Login(teamId, teamName) {
|
async Login(teamID, teamName) {
|
||||||
let data = await this.call("/register", {id: teamId, name: teamName})
|
let data = await this.call("/register", {id: teamID, name: teamName})
|
||||||
this.TeamId = teamId
|
this.TeamID = teamID
|
||||||
this.TeamName = teamName
|
this.TeamName = teamName
|
||||||
localStorage[this.teamIdKey] = teamId
|
localStorage[this.teamIDKey] = teamID
|
||||||
return data.description || data.short
|
return data.description || data.short
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,11 +26,11 @@ async function formSubmitHandler(event) {
|
||||||
console.info(`Proposed answer: ${proposed}`)
|
console.info(`Proposed answer: ${proposed}`)
|
||||||
try {
|
try {
|
||||||
message = await window.app.puzzle.SubmitAnswer(proposed)
|
message = await window.app.puzzle.SubmitAnswer(proposed)
|
||||||
|
common.Toast(message)
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
common.Toast(err)
|
common.Toast(err)
|
||||||
}
|
}
|
||||||
common.Toast(message)
|
|
||||||
console.groupEnd("Submit answer")
|
console.groupEnd("Submit answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,41 +1,55 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>KSA Report</title>
|
<title>KSA Report</title>
|
||||||
<script src="ksa.mjs" type="module"></script>
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<script src="ksa.mjs" type="module" async></script>
|
||||||
|
<script src="../background.mjs" type="module" async></script>
|
||||||
<link rel="stylesheet" href="../basic.css">
|
<link rel="stylesheet" href="../basic.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>KSA Report</h1>
|
<h1>KSA Report</h1>
|
||||||
<p>
|
<main>
|
||||||
This report shows all KSAs covered by this server so far.
|
<p>
|
||||||
This is not a report on your progress, but rather
|
This report shows all KSAs covered by this server so far.
|
||||||
what you would have covered if you had worked every exercise available.
|
This is not a report on your progress, but rather
|
||||||
</p>
|
what you would have covered if you had worked every exercise available.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="notification">
|
||||||
|
<p class="doing"></p>
|
||||||
|
<progress class="doing"></progress>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>All KSAs across all content</h2>
|
||||||
|
<ul class="allKSAs"></ul>
|
||||||
|
|
||||||
<div class="KSAsByCategory">
|
|
||||||
<h2>All KSAs by Category</h2>
|
<h2>All KSAs by Category</h2>
|
||||||
</div>
|
<div class="KSAsByCategory">
|
||||||
|
</div>
|
||||||
<table class="puzzles">
|
|
||||||
<thead>
|
<h2>KSAs by Puzzle</h2>
|
||||||
<tr>
|
<table class="puzzles">
|
||||||
<th>Category</th>
|
<thead>
|
||||||
<th>Points</th>
|
|
||||||
<th>KSAs</th>
|
|
||||||
<th>Errors</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<template id="puzzlerow">
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="category"></td>
|
<th>Category</th>
|
||||||
<td class="points"></td>
|
<th>Points</th>
|
||||||
<td class="ksas"></td>
|
<th>KSAs</th>
|
||||||
<td><pre class="error"></pre></td>
|
<th>Errors</th>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<template id="puzzlerow">
|
||||||
|
<tr>
|
||||||
|
<td class="category"></td>
|
||||||
|
<td class="points"></td>
|
||||||
|
<td class="ksas"></td>
|
||||||
|
<td><pre class="error"></pre></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
|
@ -1,73 +1,140 @@
|
||||||
import * as moth from "../moth.mjs"
|
import * as moth from "../moth.mjs"
|
||||||
|
import * as common from "../common.mjs"
|
||||||
|
|
||||||
function doing(what) {
|
const server = new moth.Server("../")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update "doing" indicators
|
||||||
|
*
|
||||||
|
* @param {String | null} what Text to display, or null to not update text
|
||||||
|
* @param {Number | null} finished Percentage complete to display, or null to not update progress
|
||||||
|
*/
|
||||||
|
function doing(what, finished = null) {
|
||||||
for (let e of document.querySelectorAll(".doing")) {
|
for (let e of document.querySelectorAll(".doing")) {
|
||||||
|
e.classList.remove("hidden")
|
||||||
if (what) {
|
if (what) {
|
||||||
e.style.display = "inherit"
|
e.textContent = what
|
||||||
|
}
|
||||||
|
if (finished) {
|
||||||
|
e.value = finished
|
||||||
} else {
|
} else {
|
||||||
e.style.display = "none"
|
e.removeAttribute("value")
|
||||||
}
|
|
||||||
for (let p of e.querySelectorAll("p")) {
|
|
||||||
p.textContent = what
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function done() {
|
||||||
|
for (let e of document.querySelectorAll(".doing")) {
|
||||||
|
e.classList.add("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function GetNice() {
|
||||||
|
let NiceElementsByIdentifier = {}
|
||||||
|
let resp = await fetch("NICEFramework2017.json")
|
||||||
|
let obj = await resp.json()
|
||||||
|
for (let e of obj.elements) {
|
||||||
|
NiceElementsByIdentifier[e.element_identifier] = e
|
||||||
|
}
|
||||||
|
return NiceElementsByIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a puzzle, and fill its KSAs and rows.
|
||||||
|
*
|
||||||
|
* This is done once per puzzle, in an asynchronous function, allowing the
|
||||||
|
* application to perform multiple blocking operations simultaneously.
|
||||||
|
*/
|
||||||
|
async function FetchAndFill(puzzle, KSAs, rows) {
|
||||||
|
try {
|
||||||
|
await puzzle.Populate()
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// Keep on going with whatever Populate was able to fill
|
||||||
|
}
|
||||||
|
for (let KSA of (puzzle.KSAs || [])) {
|
||||||
|
KSAs.add(KSA)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let row of rows) {
|
||||||
|
row.querySelector(".category").textContent = puzzle.Category
|
||||||
|
row.querySelector(".points").textContent = puzzle.Points
|
||||||
|
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
|
||||||
|
row.querySelector(".error").textContent = puzzle.Error.Body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
let server = new moth.Server("../")
|
doing("Fetching NICE framework data")
|
||||||
|
let nicePromise = GetNice()
|
||||||
|
|
||||||
doing("Retrieving server state")
|
doing("Retrieving server state")
|
||||||
let state = await server.GetState()
|
let state = await server.GetState()
|
||||||
|
|
||||||
doing("Retrieving all puzzles")
|
doing("Retrieving all puzzles")
|
||||||
|
let KSAsByCategory = {}
|
||||||
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
|
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
|
||||||
let puzzles = state.Puzzles()
|
let puzzles = state.Puzzles()
|
||||||
for (let puzzle of puzzles) {
|
let promises = []
|
||||||
await puzzle.Populate().catch(x => {})
|
for (let category of state.Categories()) {
|
||||||
|
KSAsByCategory[category] = new Set()
|
||||||
}
|
}
|
||||||
|
let pending = puzzles.length
|
||||||
doing("Filling tables")
|
|
||||||
let KSAsByCategory = {}
|
|
||||||
for (let puzzle of puzzles) {
|
for (let puzzle of puzzles) {
|
||||||
let KSAs = KSAsByCategory[puzzle.Category]
|
// Make space in the table, so everything fills in sorted order
|
||||||
if (!KSAs) {
|
let rows = []
|
||||||
KSAs = new Set()
|
|
||||||
KSAsByCategory[puzzle.Category] = KSAs
|
|
||||||
}
|
|
||||||
for (let KSA of (puzzle.KSAs || [])) {
|
|
||||||
KSAs.add(KSA)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let tbody of document.querySelectorAll("tbody")) {
|
for (let tbody of document.querySelectorAll("tbody")) {
|
||||||
let row = puzzlerowTemplate.content.cloneNode(true)
|
let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild
|
||||||
row.querySelector(".category").textContent = puzzle.Category
|
|
||||||
row.querySelector(".points").textContent = puzzle.Points
|
|
||||||
row.querySelector(".ksas").textContent = (puzzle.KSAs || []).join(" ")
|
|
||||||
row.querySelector(".error").textContent = puzzle.Error.Body
|
|
||||||
tbody.appendChild(row)
|
tbody.appendChild(row)
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue up a fetch, and update progress bar
|
||||||
|
let promise = FetchAndFill(puzzle, KSAsByCategory[puzzle.Category], rows)
|
||||||
|
promises.push(promise)
|
||||||
|
promise.then(() => doing(null, 1 - (--pending / puzzles.length)))
|
||||||
|
|
||||||
|
if (promises.length > 50) {
|
||||||
|
// Chrome runs out of resources if you queue up too many of these at once
|
||||||
|
await Promise.all(promises)
|
||||||
|
promises = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await Promise.all(promises)
|
||||||
|
|
||||||
|
doing("Retrieving NICE identifiers")
|
||||||
|
let NiceElementsByIdentifier = await nicePromise
|
||||||
|
|
||||||
|
|
||||||
doing("Filling KSAs By Category")
|
doing("Filling KSAs By Category")
|
||||||
|
let allKSAs = new Set()
|
||||||
for (let div of document.querySelectorAll(".KSAsByCategory")) {
|
for (let div of document.querySelectorAll(".KSAsByCategory")) {
|
||||||
for (let category of state.Categories()) {
|
for (let category of state.Categories()) {
|
||||||
|
doing(`Filling KSAs for category: ${category}`)
|
||||||
let KSAs = [...KSAsByCategory[category]]
|
let KSAs = [...KSAsByCategory[category]]
|
||||||
KSAs.sort()
|
KSAs.sort()
|
||||||
|
|
||||||
div.appendChild(document.createElement("h3")).textContent = category
|
div.appendChild(document.createElement("h3")).textContent = category
|
||||||
let ul = div.appendChild(document.createElement("ul"))
|
let ul = div.appendChild(document.createElement("ul"))
|
||||||
for (let k of KSAs) {
|
for (let k of KSAs) {
|
||||||
ul.appendChild(document.createElement("li")).textContent = k
|
let ksa = k.split(/\s+/)[0]
|
||||||
|
let ne = NiceElementsByIdentifier[ksa] || { text: "???" }
|
||||||
|
let text = `${ksa}: ${ne.text}`
|
||||||
|
ul.appendChild(document.createElement("li")).textContent = text
|
||||||
|
allKSAs.add(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
doing()
|
doing("Filling KSAs")
|
||||||
|
for (let e of document.querySelectorAll(".allKSAs")) {
|
||||||
|
let KSAs = [...allKSAs]
|
||||||
|
KSAs.sort()
|
||||||
|
for (let text of KSAs) {
|
||||||
|
e.appendChild(document.createElement("li")).textContent = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
common.WhenDOMLoaded(init)
|
||||||
document.addEventListener("DOMContentLoaded", init)
|
|
||||||
} else {
|
|
||||||
init()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue