mirror of https://github.com/dirtbags/moth.git
Compare commits
No commits in common. "c0761933a993a8af1df5fe12e52f9c525c111f47" and "d87be0bfcb5d9d15d38096e6fa992c3c66db29cb" have entirely different histories.
c0761933a9
...
d87be0bfcb
|
@ -9,7 +9,6 @@ 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
|
||||
- Lots of work on the built-in theme
|
||||
- [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
|
||||
### 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 {
|
||||
t.Error(r.Result())
|
||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"points already awarded to this team in this category"}}` {
|
||||
} else if r.Body.String() != `{"status":"fail","data":{"short":"not accepted","description":"error awarding points: points already awarded to this team in this category"}}` {
|
||||
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")
|
||||
}
|
||||
if err := mh.State.AwardPoints(mh.teamID, cat, points); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error awarding points: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
@ -522,9 +522,6 @@ func (ds *DevelState) TeamName(teamID string) (string, error) {
|
|||
if name, err := ds.StateProvider.TeamName(teamID); err == nil {
|
||||
return name, nil
|
||||
}
|
||||
if teamID == "" {
|
||||
return "", fmt.Errorf("Empty team ID")
|
||||
}
|
||||
return fmt.Sprintf("«devel:%s»", teamID), nil
|
||||
}
|
||||
|
||||
|
|
|
@ -75,9 +75,6 @@ input {
|
|||
.category h2 {
|
||||
margin: 0 0.2em;
|
||||
}
|
||||
.category .solved {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
nav ul, .category ul {
|
||||
margin: 0;
|
||||
padding: 0.2em 1em;
|
||||
|
|
|
@ -34,30 +34,10 @@ 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 {
|
||||
Millisecond,
|
||||
Second,
|
||||
Minute,
|
||||
Toast,
|
||||
WhenDOMLoaded,
|
||||
Truthy,
|
||||
}
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"TrackSolved": true,
|
||||
"__sentry__": "this is here so you don't have to remember to take the comma off the last item"
|
||||
}
|
|
@ -22,19 +22,9 @@
|
|||
</form>
|
||||
|
||||
<div class="puzzles"></div>
|
||||
</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>
|
||||
|
||||
</main>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="scoreboard.html" target="_blank">Scoreboard</a></li>
|
||||
|
|
|
@ -6,14 +6,11 @@ import * as common from "./common.mjs"
|
|||
|
||||
class App {
|
||||
constructor(basePath=".") {
|
||||
this.configURL = new URL("config.json", location)
|
||||
this.config = {}
|
||||
|
||||
this.server = new moth.Server(basePath)
|
||||
|
||||
let uuid = Math.floor(Math.random() * 1000000).toString(16)
|
||||
this.fakeRegistration = {
|
||||
TeamID: uuid,
|
||||
TeamId: uuid,
|
||||
TeamName: `Team ${uuid}`,
|
||||
}
|
||||
|
||||
|
@ -24,11 +21,8 @@ class App {
|
|||
e.addEventListener("click", () => this.Logout())
|
||||
}
|
||||
|
||||
setInterval(() => this.UpdateState(), common.Minute/3)
|
||||
setInterval(() => this.UpdateConfig(), common.Minute* 5)
|
||||
|
||||
this.UpdateConfig()
|
||||
.finally(() => this.UpdateState())
|
||||
setInterval(() => this.Update(), common.Minute/3)
|
||||
this.Update()
|
||||
}
|
||||
|
||||
handleLoginSubmit(event) {
|
||||
|
@ -39,14 +33,14 @@ class App {
|
|||
/**
|
||||
* Attempt to log in to the server.
|
||||
*
|
||||
* @param {String} teamID
|
||||
* @param {String} teamId
|
||||
* @param {String} teamName
|
||||
*/
|
||||
async Login(teamID, teamName) {
|
||||
async Login(teamId, teamName) {
|
||||
try {
|
||||
await this.server.Login(teamID, teamName)
|
||||
common.Toast(`Logged in (team id = ${teamID})`)
|
||||
this.UpdateState()
|
||||
await this.server.Login(teamId, teamName)
|
||||
common.Toast(`Logged in (team id = ${teamId})`)
|
||||
this.Update()
|
||||
}
|
||||
catch (error) {
|
||||
common.Toast(error)
|
||||
|
@ -60,24 +54,13 @@ class App {
|
|||
try {
|
||||
this.server.Reset()
|
||||
common.Toast("Logged out")
|
||||
this.UpdateState()
|
||||
this.Update()
|
||||
}
|
||||
catch (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.
|
||||
*
|
||||
|
@ -85,18 +68,12 @@ class App {
|
|||
* what's returned. If we're in development mode and not logged in, auto
|
||||
* login too.
|
||||
*/
|
||||
async UpdateState() {
|
||||
async Update() {
|
||||
this.state = await this.server.GetState()
|
||||
for (let e of document.querySelectorAll(".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")) {
|
||||
this.renderLogin(e, !this.server.LoggedIn())
|
||||
}
|
||||
|
@ -107,7 +84,7 @@ class App {
|
|||
if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
|
||||
common.Toast("Automatically logging in to devel server")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -156,13 +133,9 @@ class App {
|
|||
a.textContent = puzzle.Points
|
||||
a.href = url
|
||||
a.target = "_blank"
|
||||
|
||||
if (this.config.TrackSolved) {
|
||||
a.classList.toggle("solved", this.state.IsSolved(puzzle))
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.state.ContainsUnsolved(cat)) {
|
||||
if (!this.state.HasUnsolved(cat)) {
|
||||
l.appendChild(document.createElement("li")).textContent = "✿"
|
||||
}
|
||||
|
||||
|
|
|
@ -130,7 +130,7 @@ class Puzzle {
|
|||
/** Point value of this puzzle */
|
||||
this.Points = Number(points)
|
||||
|
||||
/** Error returned trying to retrieve this puzzle */
|
||||
/** Error returned trying to fetch this puzzle */
|
||||
this.Error = {
|
||||
/** Status code provided by server */
|
||||
Status: 0,
|
||||
|
@ -265,7 +265,7 @@ class State {
|
|||
/** Log of points awarded
|
||||
* @type {Award[]}
|
||||
*/
|
||||
this.PointsLog = obj.PointsLog.map(entry => new Award(entry[0], entry[1], entry[2], entry[3]))
|
||||
this.PointsLog = obj.PointsLog.map((t,i,c,p) => new Award(t,i,c,p))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -283,7 +283,7 @@ class State {
|
|||
}
|
||||
|
||||
/**
|
||||
* Check whether a category contains unsolved puzzles.
|
||||
* Check whether a category has unsolved puzzles.
|
||||
*
|
||||
* 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.
|
||||
|
@ -291,7 +291,7 @@ class State {
|
|||
* @param {String} category
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
ContainsUnsolved(category) {
|
||||
HasUnsolved(category) {
|
||||
return !this.PointsByCategory[category].includes(0)
|
||||
}
|
||||
|
||||
|
@ -330,26 +330,6 @@ class State {
|
|||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -367,8 +347,8 @@ class Server {
|
|||
throw("Must provide baseURL")
|
||||
}
|
||||
this.baseUrl = new URL(baseUrl, location)
|
||||
this.teamIDKey = this.baseUrl.toString() + " teamID"
|
||||
this.TeamID = localStorage[this.teamIDKey]
|
||||
this.teamIdKey = this.baseUrl.toString() + " teamID"
|
||||
this.TeamId = localStorage[this.teamIdKey]
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -377,25 +357,31 @@ class Server {
|
|||
* If anything other than a 2xx code is returned,
|
||||
* this function throws an error.
|
||||
*
|
||||
* This always sends teamID.
|
||||
* This always sends teamId.
|
||||
* If args is set, POST will be used instead of GET
|
||||
*
|
||||
* @param {String} path Path to API endpoint
|
||||
* @param {Object.<String,String>} args Key/Values to send in POST data
|
||||
* @returns {Promise.<Response>} Response
|
||||
*/
|
||||
fetch(path, args={}) {
|
||||
let body = new URLSearchParams(args)
|
||||
if (this.TeamID && !body.has("id")) {
|
||||
body.set("id", this.TeamID)
|
||||
fetch(path, args) {
|
||||
let url = new URL(path, this.baseUrl)
|
||||
if (this.TeamId & (!(args && args.id))) {
|
||||
url.searchParams.set("id", this.TeamId)
|
||||
}
|
||||
|
||||
let url = new URL(path, this.baseUrl)
|
||||
if (args) {
|
||||
let formData = new FormData()
|
||||
for (let k in args) {
|
||||
formData.set(k, args[k])
|
||||
}
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
body,
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
return fetch(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to a JSend API endpoint.
|
||||
|
@ -404,7 +390,7 @@ class Server {
|
|||
* @param {Object.<String,String>} args Key/Values to send in POST
|
||||
* @returns {Promise.<Object>} JSend Data
|
||||
*/
|
||||
async call(path, args={}) {
|
||||
async call(path, args) {
|
||||
let resp = await this.fetch(path, args)
|
||||
let obj = await resp.json()
|
||||
switch (obj.status) {
|
||||
|
@ -422,9 +408,6 @@ class Server {
|
|||
/**
|
||||
* 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}
|
||||
*/
|
||||
URL(url) {
|
||||
|
@ -437,7 +420,7 @@ class Server {
|
|||
* @returns {Boolean}
|
||||
*/
|
||||
LoggedIn() {
|
||||
return this.TeamID ? true : false
|
||||
return this.TeamId ? true : false
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -446,8 +429,8 @@ class Server {
|
|||
* This is equivalent to logging out.
|
||||
*/
|
||||
Reset() {
|
||||
localStorage.removeItem(this.teamIDKey)
|
||||
this.TeamID = null
|
||||
localStorage.removeItem(this.teamIdKey)
|
||||
this.TeamId = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -467,15 +450,15 @@ class Server {
|
|||
* This calls the server's registration endpoint; if the call succeds, or
|
||||
* fails with "team already exists", the login is returned as successful.
|
||||
*
|
||||
* @param {String} teamID
|
||||
* @param {String} teamId
|
||||
* @param {String} teamName
|
||||
* @returns {Promise.<String>} Success message from server
|
||||
*/
|
||||
async Login(teamID, teamName) {
|
||||
let data = await this.call("/register", {id: teamID, name: teamName})
|
||||
this.TeamID = teamID
|
||||
async Login(teamId, teamName) {
|
||||
let data = await this.call("/register", {id: teamId, name: teamName})
|
||||
this.TeamId = teamId
|
||||
this.TeamName = teamName
|
||||
localStorage[this.teamIDKey] = teamID
|
||||
localStorage[this.teamIdKey] = teamId
|
||||
return data.description || data.short
|
||||
}
|
||||
|
||||
|
|
|
@ -26,11 +26,11 @@ async function formSubmitHandler(event) {
|
|||
console.info(`Proposed answer: ${proposed}`)
|
||||
try {
|
||||
message = await window.app.puzzle.SubmitAnswer(proposed)
|
||||
common.Toast(message)
|
||||
}
|
||||
catch (err) {
|
||||
common.Toast(err)
|
||||
}
|
||||
common.Toast(message)
|
||||
console.groupEnd("Submit answer")
|
||||
}
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,35 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<title>KSA Report</title>
|
||||
<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>
|
||||
<script src="ksa.mjs" type="module"></script>
|
||||
<link rel="stylesheet" href="../basic.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>KSA Report</h1>
|
||||
<main>
|
||||
<p>
|
||||
This report shows all KSAs covered by this server so far.
|
||||
This is not a report on your progress, but rather
|
||||
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>
|
||||
|
||||
<h2>All KSAs by Category</h2>
|
||||
<div class="KSAsByCategory">
|
||||
<h2>All KSAs by Category</h2>
|
||||
</div>
|
||||
|
||||
<h2>KSAs by Puzzle</h2>
|
||||
<table class="puzzles">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -50,6 +37,5 @@
|
|||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,140 +1,73 @@
|
|||
import * as moth from "../moth.mjs"
|
||||
import * as common from "../common.mjs"
|
||||
|
||||
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) {
|
||||
function doing(what) {
|
||||
for (let e of document.querySelectorAll(".doing")) {
|
||||
e.classList.remove("hidden")
|
||||
if (what) {
|
||||
e.textContent = what
|
||||
}
|
||||
if (finished) {
|
||||
e.value = finished
|
||||
e.style.display = "inherit"
|
||||
} else {
|
||||
e.removeAttribute("value")
|
||||
e.style.display = "none"
|
||||
}
|
||||
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() {
|
||||
doing("Fetching NICE framework data")
|
||||
let nicePromise = GetNice()
|
||||
let server = new moth.Server("../")
|
||||
|
||||
doing("Retrieving server state")
|
||||
let state = await server.GetState()
|
||||
|
||||
doing("Retrieving all puzzles")
|
||||
let KSAsByCategory = {}
|
||||
let puzzlerowTemplate = document.querySelector("template#puzzlerow")
|
||||
let puzzles = state.Puzzles()
|
||||
let promises = []
|
||||
for (let category of state.Categories()) {
|
||||
KSAsByCategory[category] = new Set()
|
||||
}
|
||||
let pending = puzzles.length
|
||||
for (let puzzle of puzzles) {
|
||||
// Make space in the table, so everything fills in sorted order
|
||||
let rows = []
|
||||
await puzzle.Populate().catch(x => {})
|
||||
}
|
||||
|
||||
doing("Filling tables")
|
||||
let KSAsByCategory = {}
|
||||
for (let puzzle of puzzles) {
|
||||
let KSAs = KSAsByCategory[puzzle.Category]
|
||||
if (!KSAs) {
|
||||
KSAs = new Set()
|
||||
KSAsByCategory[puzzle.Category] = KSAs
|
||||
}
|
||||
for (let KSA of (puzzle.KSAs || [])) {
|
||||
KSAs.add(KSA)
|
||||
}
|
||||
|
||||
for (let tbody of document.querySelectorAll("tbody")) {
|
||||
let row = puzzlerowTemplate.content.cloneNode(true).firstElementChild
|
||||
let row = puzzlerowTemplate.content.cloneNode(true)
|
||||
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)
|
||||
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")
|
||||
let allKSAs = new Set()
|
||||
for (let div of document.querySelectorAll(".KSAsByCategory")) {
|
||||
for (let category of state.Categories()) {
|
||||
doing(`Filling KSAs for category: ${category}`)
|
||||
let KSAs = [...KSAsByCategory[category]]
|
||||
KSAs.sort()
|
||||
|
||||
div.appendChild(document.createElement("h3")).textContent = category
|
||||
let ul = div.appendChild(document.createElement("ul"))
|
||||
for (let k of KSAs) {
|
||||
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)
|
||||
ul.appendChild(document.createElement("li")).textContent = k
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
doing()
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init)
|
||||
} else {
|
||||
init()
|
||||
}
|
||||
|
||||
common.WhenDOMLoaded(init)
|
||||
|
|
Loading…
Reference in New Issue