summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimon Parri <simonparri@ganzeria.com>2025-02-16 23:29:02 -0600
committerSimon Parri <simonparri@ganzeria.com>2025-02-16 23:36:39 -0600
commitca51ae66c40c0f040b81a5ed7b39602eb30c4ad2 (patch)
tree01546304cf59582de29fd12e3c639e3aa1174397
downloadonpoint-ca51ae66c40c0f040b81a5ed7b39602eb30c4ad2.tar.gz
onpoint-ca51ae66c40c0f040b81a5ed7b39602eb30c4ad2.zip
Add version 0.1v0.1
-rw-r--r--.gitignore8
-rw-r--r--Rakefile130
-rw-r--r--_.erb.html57
-rw-r--r--card.html61
-rw-r--r--deck.erb.html50
-rw-r--r--ie.html35
-rw-r--r--manifest.json4
-rw-r--r--reviewer.html77
-rw-r--r--settings.html50
-rw-r--r--storage.html60
-rw-r--r--utils.html44
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>
diff --git a/ie.html b/ie.html
new file mode 100644
index 0000000..89f69de
--- /dev/null
+++ b/ie.html
@@ -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>