diff options
-rw-r--r-- | .gitignore | 8 | ||||
-rw-r--r-- | Rakefile | 130 | ||||
-rw-r--r-- | _.erb.html | 57 | ||||
-rw-r--r-- | card.html | 61 | ||||
-rw-r--r-- | deck.erb.html | 50 | ||||
-rw-r--r-- | ie.html | 35 | ||||
-rw-r--r-- | manifest.json | 4 | ||||
-rw-r--r-- | reviewer.html | 77 | ||||
-rw-r--r-- | settings.html | 50 | ||||
-rw-r--r-- | storage.html | 60 | ||||
-rw-r--r-- | utils.html | 44 |
11 files changed, 576 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52766bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*~ + +qdb +summary.json +protobowl-*.json + +_.html +deck.html diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..38adb95 --- /dev/null +++ b/Rakefile @@ -0,0 +1,130 @@ +%w[json erb] + .each { require _1 } + +def fputs(f, c) + puts "fputs #{f}" + File.write f, c +end +def erb(tmpl, ctx={}) = + ERB.new(File.read(tmpl), trim_mode: "-") + .result_with_hash(ctx) + +module Component + def Component.parse(str) + script = []; style = []; html = [] + cur = html + str.lines.each do |line| + line = line.chomp + if line == "<script>" and cur.object_id == html.object_id + cur = script + elsif line == "<style>" and cur.object_id == html.object_id + cur = style + elsif line =~ /^<\/(style|script)>$/ and cur.object_id != html.object_id + cur = html + else + cur.push line + end + end + {style: style * "\n", + html: html * "\n", + script: script * "\n"} + end + + private + def Component.amalg(list, key) = + list.values.map { _1[key] }.filter { _1 != "" } * "\n" + public + def Component.parse_many(deps) + start = deps.map { [_1.split(".")[0], parse(read(_1, []))] }.to_h + start.merge(style: amalg(start, :style), + html: amalg(start, :html), + script: amalg(start, :script)) + end + + def Component.read(f, deps) + if not f =~ /\.erb/ + File.read f + else + erb f, parse_many(deps) + end + end +end + +DB = "protobowl-2019-10-15-ALL.json" +KEPT_KEYS = %w[category subcategory difficulty + year source tournament round num + question answer] + +$data = nil +def get_data + return $data if $data + $data = + File.read(DB) + .split("\n") + .map! { JSON.parse _1 } + .filter! { _1["type"] == "qb" } +end + +file DB do + sh "wget -N https://github.com/neotenic/database-dumps/raw/refs/heads/master/2019-10-15-ALL.json.xz" + sh "unxz 2019-10-15-ALL.json.xz" + mv "2019-10-15-ALL.json", DB +end + +file "summary.json": [DB] do + data = get_data + + difficulties = data.map { _1["difficulty"] }.uniq + categories = data.map { _1["category"] }.uniq + + fputs "summary.json", + JSON.generate({difficulties:, categories:}) +end.invoke # ensure it's generated now + +$difficulties, $categories = + JSON.parse(File.read "summary.json") + .values_at("difficulties", "categories") + +def dir_from(dif) = "qdb/#{dif.downcase}" +def name_from(dif, cat) = "#{dir_from dif}/#{cat.downcase.gsub(/\s/, "-")}.json" + +$files = [] +$difficulties.each do |dif| + dir = dir_from(dif) + directory dir + $categories.each do |cat| + f = name_from(dif, cat) + $files.push f + file f => [DB, dir] do + d = get_data + .filter { _1["difficulty"] == dif && + _1["category"] == cat } + .map { _1.slice(*KEPT_KEYS) } + fputs f, JSON.generate(d) + end + end +end + +file qdb: [DB, *$files] + +file "deck.html": ["deck.erb.html", "summary.json"] do + fputs "deck.html", erb("deck.erb.html") +end + +file "_.html": ["_.erb.html", + "utils.html", "storage.html", + "card.html", "reviewer.html", + "deck.html", "settings.html", + "ie.html"] do |t| + fputs "_.html", Component.read("_.erb.html", t.prereqs[1..-1]) +end + +task :clean do + ["qdb", "summary.json", "_.html", "deck.html"].map { rm_r _1 } +end + +task default: ["qdb", "_.html"] + +task :publish do + sh "rsync -rutv --delete qdb _.html manifest.json root@ba.ln.ea.cx:/var/www/onpoint/" +end diff --git a/_.erb.html b/_.erb.html new file mode 100644 index 0000000..0af9e10 --- /dev/null +++ b/_.erb.html @@ -0,0 +1,57 @@ +<!doctype html> +<title>On Point - Scholastic Bowl Flashcards</title> +<meta charset=utf8> +<link rel=manifest href=manifest.json> + +<style> +section:nth-of-type(2) { + width: 19em; + display: flex; + justify-content: space-between; +} + +<%= style %> +</style> + +<body> + +<template> +<section> +<%= card[:html] %> +<%= reviewer[:html] %> +</section> + +<hr> + +<section> +<%= deck[:html] %> +</section> + +<section> +<%= settings[:html] %> +</section> + +<section> +<%= ie[:html] %> +</section> +</template> + +<script> +<%= utils[:script] %> +<%= storage[:script] %> + +// Hacks! +let $loaded_p = (async () => { + $deck = await ṡ.get("deck") || [] + $settings = {...$default_settings, ...(await ṡ.get("settings"))} +})().then(() => { + let e = document.querySelector("template") + e.outerHTML = e.innerHTML +}) + +<%= card[:script] %> +<%= reviewer[:script] %> +<%= settings[:script] %> +<%= deck[:script] %> +<%= ie[:script] %> +</script> diff --git a/card.html b/card.html new file mode 100644 index 0000000..4d09e75 --- /dev/null +++ b/card.html @@ -0,0 +1,61 @@ +<style> +form[name=card] > p { visibility: hidden } +form[name=card].answering > p, form[name=card].answered > p { visibility: initial } +form[name=card].answering > p:nth-of-type(4) { visibility: hidden } +form[name=card].answered > p:nth-of-type(1) { visibility: hidden } +</style> + +<form name=card onsubmit="return false"> +<p> +<button name=buzz>Answer</button> +<p> +<output name=question></output> +<p> +<output name=answer></output> +<p> +<input type=submit name=0 value="Totally blanked"> +<input type=submit name=1 value="Vaguely remembered"> +<input type=submit name=2 value="Should have remembered"> +<input type=submit name=3 value="Remembered, but was hard"> +<input type=submit name=4 value="Remembered, but hesitated"> +<input type=submit name=5 value="Remembered perfectly"> +</form> + +<script> +async function ask({question, answer}, speed=250) { + let f = document.forms.card, + q = f.question, + a = f.answer, + b = f.buzz, + words = question.split(" "), + answered = false + + f.classList.remove("answered") + f.classList.add("answering") + a.value = "" + b.onclick = e => answered = true + + q.value = words[0] + for (let word of words.slice(1)) { + if (answered) { + q.value = question + break + } + await sleep(speed) + q.value += " " + word + } + await sleep(speed) + + a.value = answer + f.classList.remove("answering") + f.classList.add("answered") + + let grade = await new Promise(r => + f.onsubmit = e => (e.preventDefault(), r(+e.submitter.name))) + + f.classList.remove("answered") + a.value = "" + + return grade +} +</script> diff --git a/deck.erb.html b/deck.erb.html new file mode 100644 index 0000000..d95cee4 --- /dev/null +++ b/deck.erb.html @@ -0,0 +1,50 @@ +<form onsubmit="ṡ.set('deck', $deck).then(() => alert('Saved!')); return false"> +<p> +<input type=submit value="Save deck"> +</form> + +<div> +<p> +<button onclick="document.forms.deck_clear.showPopover()"> +Clear deck +</button> +<form popover name=deck_clear onsubmit="$deck = []; this.hidePopover(); return false"> +<p> +Really clear? +<p> +<input type=submit value=Yes> <button type=button onclick="document.forms.deck_clear.hidePopover()">No</button> +</form> +</div> + +<script> +async function import_deck$(form) { + let url = form.deck.value + form.status.value = "Fetching…" + let news = await (await fetch(url)).json() + news.sort((a, b) => a.num > b.num) + $deck = $deck.concat(news) + form.status.value = "Done!" +} +</script> + +<div> +<p> +<button onclick="document.forms.deck_import.showPopover()"> +Import question set +</button> +<form popover name=deck_import onsubmit="import_deck$(this); return false"> +<select name=deck> +<% $difficulties.each do |dif| -%> +<optgroup label=<%= dif %>> +<% $categories.each do |cat| -%> +<option value=<%= name_from dif, cat %>><%= dif %> - <%= cat %></option> +<% end -%> +</optgroup> +<% end -%> +</select> +<p> +<input type=submit value=Import> <button type=button onclick="document.forms.deck_import.hidePopover()">Cancel</button> +<p> +<output name=status></output> +</form> +</div> @@ -0,0 +1,35 @@ +<form name=import-export onsubmit="return false"> +<p> +<input type=submit name=import value="Import data"> +<input type=submit name=export value="Export data"> +</form> + +<script> +$loaded_p.then(() => { + let f = document.forms["import-export"] + f.onsubmit = async e => { + e.preventDefault() + switch (e.submitter.name) { + case "import": + let file = await new Promise(r => + ė("input", {type: "file", onchange(e) { r(this.files[0]) }}) + .ǀ(e => f.append(e)).ǀ(ff`click`).remove()) + let reader = new FileReader() + reader.onload = + e => ṡ.set_all(JSON.parse(reader.result)) + .then(() => location.reload()) + reader.onerror = e => alert(reader.error) + reader.readAsText(file) + break + case "export": + let data = await ṡ.get_all(["deck", "settings"]), + blob = new Blob([JSON.stringify (data)], {type: "application/json"}), + href = URL.createObjectURL(blob) + ė("a", {href, download: "onpoint-export.json"}) + .ǀ(e => f.append(e)).ǀ(ff`click`).remove() + URL.revokeObjectURL(href) + break + } + } +}) +</script> diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..331a16c --- /dev/null +++ b/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "On Point", + "display": "standalone" +} diff --git a/reviewer.html b/reviewer.html new file mode 100644 index 0000000..8f8be17 --- /dev/null +++ b/reviewer.html @@ -0,0 +1,77 @@ +<script> +// from the Wikipedia article on SuperMemo +function SM2(grade, {last_grade, last_review, repetitions, easiness, interval}) { + if (grade >= 3) { + if (repetitions == 0) + interval = 1 + else if (repetitions == 1) + interval = 6 + else + interval = Math.round(interval * easiness) + ++repetitions + } else { + repetitions = 0 + interval = 1 + } + + easiness += 0.1 - (5 - grade) * (0.8 + (5 - grade) * 0.02) + if (easiness < 1.3) + easiness = 1.3 + + return {last_grade, last_review, repetitions, easiness, interval} +} + +const initial_sm2 = () => ({repetitions: 0, + easiness: 2.5, + interval: 0}) + +async function grade(card) { + let {sm2} = card, + grade = await ask(card, $settings.speed) + sm2 ||= initial_sm2() + return SM2(grade, {...sm2, last_grade: grade, last_review: Date.now()}) +} + +const needs_review_p = (card, time_factor=1) => + (card.sm2 && + (card.sm2.last_grade < 4 || + (new Date().setHours(0, 0, 0, 0) - card.sm2.last_review) * time_factor >= + 24 * 60 * 60 * 1000 * card.sm2.interval)) + +const new_p = card => !card.sm2 + +let $reviewing_s = false +async function review$(deck, nc) { + if ($reviewing_s) return + $reviewing_s = true + async function r(news) { + let stack = deck + .filter(x => needs_review_p(x, $settings.time_factor)) + .sort((a, b) => a.sm2.easiness - b.sm2.easiness) + if (news?.length) stack = news.concat(stack) + for (let card of stack) { + card.sm2 = await grade(card) + if ($settings.autosave) + await ṡ.set("deck", $deck) + } + if (deck.some(x => needs_review_p(x, $settings.time_factor))) + r() + } + let news = deck.filter(new_p) + .shuffle().slice(0, nc) + await r(news) + $reviewing_s = false +} +</script> + +<form name=grader onsubmit="review$($deck, this.nc.value); this.nc.value = 0; return false"> +<p> +<label>Add <input type=number min=-1 name=nc> cards</label> +<p> +<input type=submit value=Start> +</form> + +<script> +$loaded_p.then(() => + document.forms.grader.nc.value = $settings.default_new_cards) +</script> diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..8a82ff8 --- /dev/null +++ b/settings.html @@ -0,0 +1,50 @@ +<script> +const $default_settings = { + default_new_cards: 5, + speed: 250, + time_factor: 1, + autosave: true, +} + +async function save_settings$(form) { + form.status.value = "Saving…" + $settings = { + default_new_cards: +form.default_new_cards.value, + speed: +form.speed.value, + time_factor: +form.time_factor.value, + autosave: form.autosave.checked, + } + await ṡ.set("settings", $settings) + form.status.value = "Saved!" +} + +function put_settings$({default_new_cards, speed, time_factor, autosave}, form) { + form.default_new_cards.value = default_new_cards + form.speed.value = speed + form.time_factor.value = time_factor + form.autosave.checked = autosave +} + +$loaded_p.then(() => + put_settings$($settings, document.forms.settings)) +</script> + +<p> +<button onclick="document.forms.settings.showPopover()"> +Settings +</button> +<form popover name=settings onsubmit="save_settings$(this).then(() => this.hidePopover()); return false"> +<p> +<label>Default new cards per session: <input type=number name=default_new_cards min=0></label> +<p> +<label>Word speed (in milliseconds): <input type=number name=speed min=0></label> +<p> +<label>Auto-save deck after each review? <input type=checkbox name=autosave></label> +<p> +<label>Review-time scale-factor: <input type=number name=time_factor min=0 step=any></label> +<p> +<input type=submit value="Save settings"> +<button type=button onclick="document.forms.settings.hidePopover()">Cancel</button> +<p> +<output name=status></output> +</form> diff --git a/storage.html b/storage.html new file mode 100644 index 0000000..f9e9bee --- /dev/null +++ b/storage.html @@ -0,0 +1,60 @@ +<script> +// const ṡ = { +// get: x => JSON.parse(localStorage.getItem("~simon/onpoint/"+x)), +// set: (x, y) => localStorage.setItem("~simon/onpoint/"+x, JSON.stringify(y)), +// } + +const ṡ = { + db: "~simon/onpoint", + store: "data", // keep in sync with schema + schema: 1, + + open() { + return new Promise((res, rej) => { + let req = indexedDB.open(this.db, this.schema) + req.onerror = e => rej(req.error) + req.onupgradeneeded = + e => e.target.result + .createObjectStore(this.store, {keyPath: "id"}) + req.onsuccess = e => res(e.target.result) + }) + }, + + call_store(store, meth, ...args) { + return new Promise((res, rej) => { + let req = store[meth](...args) + req.onerror = e => rej(req.error) + req.onsuccess = e => res(req.result) + }) + }, + + async get_store(type) { + return (await this.open()) + .transaction([this.store], type) + .objectStore(this.store) + }, + + async set(x, y) { + return this.call_store( + await this.get_store("readwrite"), "put", {id: x, data: y}) + }, + + async get(x) { + return ( + await this.call_store( + await this.get_store(), "get", x))?.data + }, + + async get_all(keys) { + let entries = + await Promise.all(keys.map(async k => [k, await this.get(k)])) + return Object.fromEntries(entries) + }, + + set_all(obj) { + return Object.entries(obj) + .map(([k, v]) => this.set(k, v)) + .ǀ(Promise.all) + }, +} +</script> diff --git a/utils.html b/utils.html new file mode 100644 index 0000000..5e96f56 --- /dev/null +++ b/utils.html @@ -0,0 +1,44 @@ +<script> +const sleep = t => new Promise(r => setTimeout(r, t)) + +Array.prototype.each = Array.prototype.forEach +Array.prototype.shuffle = function() { + for (let i = this.length; i >= 0; --i) { + let j = Math.floor(Math.random() * (i + 1)) + ;[this[i], this[j]] = [this[j], this[i]] + } + return this +} +Object.defineProperty(Array.prototype, -1, { + get() { return this[this.length-1] }, + set(x) { this[this.length-1] = x }, +}) + +Object.prototype.ǀ = function(f) { f(this); return this } +Object.prototype.in_p = function(coll) { return coll.includes(this) } + + +const fi = ss => x => x[ss[0]] +const ff = ss => (x, ...a) => x[ss[0]](...a) + +const ė = (name, props, ...children) => + Object.assign(document.createElement(name), props) + .ǀ(e => e.append(...children)) + +const Œ = Object.assign +const Æ = (size, fill) => Array(size).fill(fill) + +const 𝑓 = (ss, ...ii) => ( + ss = ss.flatMap((s, i) => [s, ii[i] ? `arguments[${ii[i]-1}]` : ""]), + new Function("", `return (${ss.join("")})`) +) + +const ı = i => Æ(i).map(𝑓`${2}`) + +const Enum = ss => + Object.fromEntries(ss[0] + .trim() + .split(/\s*,\s*/) + .filter(fi`length`) + .map(Array)) +</script> |