Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7efc97d0dde0b2a3e729ccf9
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/exercises.ts
// Turns a lesson's hand-written words and sentences into a varied set of exercises, the same way each
// time for the same lesson and attempt. Practice, mistakes review and jump tests reuse the same builders.
import type { Course, Sentence, Word } from "../../functions/lib/course";
import { fold } from "./answer";
import { indexOf, sentenceKey, tiles, wordKey, type Item } from "./courses";
import type { CourseState } from "./progress";
import { today, weakness } from "./srs";
import { openMistakes } from "./progress";
export type Dir = "t2e" | "e2t";
export type Ex =
| { kind: "pick"; dir: Dir; prompt: string; options: string[]; answer: string; item: string }
| { kind: "match"; pairs: { t: string; e: string; item: string }[] }
| { kind: "build"; dir: Dir; prompt: string; tiles: string[]; accept: string[]; answer: string; item: string }
| { kind: "listen"; tiles: string[] | null; text: string; accept: string[]; translation: string; item: string }
| { kind: "blank"; before: string; after: string; options: string[]; answer: string; full: string; translation: string; item: string }
| { kind: "type"; dir: Dir; prompt: string; accept: string[]; answer: string; item: string }
| { kind: "speak"; text: string; translation: string; item: string };
export interface Abilities { listen: boolean; speak: boolean }
type Plan = "build-t2e" | "build-e2t" | "blank" | "listen" | "listen-tiles" | "type-e2t" | "type-t2e" | "speak";
function rng(seed: string) {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619);
return () => {
h += 0x6d2b79f5;
let t = h;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle<T>(list: T[], r: () => number): T[] {
const a = [...list];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(r() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const same = (a: string, b: string) => fold(a.toLowerCase()) === fold(b.toLowerCase());
class Builder {
constructor(private course: Course, private r: () => number, private pool: Word[], private sentences: Sentence[]) {}
/** Lowercases the first tile unless it is a name or "I", so it does not give the start away. German keeps its capitals (nouns). */
private tidy(list: string[], english: boolean): string[] {
if (this.course.id === "de" && !english) return list;
const proper = indexOf(this.course).proper;
return list.map((t, i) => (i === 0 && !proper.has(t) ? t.charAt(0).toLowerCase() + t.slice(1) : t));
}
private distractTiles(answer: string[], english: boolean, n: number): string[] {
const taken = new Set(answer.map((t) => fold(t.toLowerCase())));
const proper = indexOf(this.course).proper;
const out: string[] = [];
for (const s of shuffle(this.sentences, this.r)) {
for (const t of shuffle(tiles(english ? s.e : s.t), this.r)) {
const k = fold(t.toLowerCase());
if (taken.has(k)) continue;
taken.add(k);
out.push(proper.has(t) || (!english && this.course.id === "de") ? t : t.toLowerCase());
if (out.length >= n) return out;
}
}
return out;
}
private distractWords(w: Word, dir: Dir, n: number): string[] {
const mine = [w.e, ...(w.ea || [])];
const out: string[] = [];
for (const d of shuffle(this.pool, this.r)) {
if (d.t === w.t || same(d.t, w.t)) continue;
const theirs = [d.e, ...(d.ea || [])];
if (theirs.some((x) => mine.some((y) => same(x, y)))) continue;
const label = dir === "t2e" ? d.e : d.t;
if (out.some((x) => same(x, label))) continue;
out.push(label);
if (out.length >= n) break;
}
return out;
}
pick(w: Word, dir: Dir): Ex {
const answer = dir === "t2e" ? w.e : w.t;
return { kind: "pick", dir, prompt: dir === "t2e" ? w.t : w.e, answer, options: shuffle([answer, ...this.distractWords(w, dir, 3)], this.r), item: wordKey(w) };
}
match(words: Word[]): Ex | null {
const pairs: { t: string; e: string; item: string }[] = [];
for (const w of [...words, ...shuffle(this.pool, this.r)]) {
if (pairs.some((p) => same(p.t, w.t) || same(p.e, w.e) || (w.ea || []).some((x) => same(x, p.e)))) continue;
pairs.push({ t: w.t, e: w.e, item: wordKey(w) });
if (pairs.length === 5) break;
}
return pairs.length >= 3 ? { kind: "match", pairs } : null;
}
sentence(s: Sentence, plan: Plan): Ex {
const item = sentenceKey(s);
switch (plan) {
case "build-t2e":
case "build-e2t": {
const dir: Dir = plan === "build-t2e" ? "t2e" : "e2t";
const answer = dir === "t2e" ? s.e : s.t;
const own = this.tidy(tiles(answer), dir === "t2e");
const extra = this.distractTiles(own, dir === "t2e", own.length > 4 ? 4 : 3);
return { kind: "build", dir, prompt: dir === "t2e" ? s.t : s.e, tiles: shuffle([...own, ...extra], this.r), answer, accept: dir === "t2e" ? [s.e, ...(s.ea || [])] : [s.t, ...(s.ta || [])], item };
}
case "listen":
case "listen-tiles": {
const own = this.tidy(tiles(s.t), false);
const withTiles = plan === "listen-tiles" ? shuffle([...own, ...this.distractTiles(own, false, 3)], this.r) : null;
return { kind: "listen", text: s.t, tiles: withTiles, accept: [s.t, ...(s.ta || [])], translation: s.e, item };
}
case "blank": {
const found = [...s.t.matchAll(/[\p{L}\p{M}]+(?:['’-][\p{L}\p{M}]+)*/gu)];
const taught = new Set(this.pool.flatMap((w) => tiles(w.t)).map((t) => fold(t.toLowerCase())));
const candidates = found.filter((m) => m[0].length >= 2 && taught.has(fold(m[0].toLowerCase())));
const chosen = (candidates.length ? candidates : found).reduce((a, b) => (b[0].length > a[0].length ? b : a));
const answer = chosen[0];
const options = [answer];
const capital = /^\p{Lu}/u.test(answer);
for (const w of shuffle(this.pool, this.r)) {
const t = tiles(w.t).reduce((a, x) => (x.length > a.length ? x : a), "");
const label = capital ? t.charAt(0).toUpperCase() + t.slice(1) : t;
if (t.length < 2 || /['’]/.test(t) || options.some((o) => same(o, label))) continue;
options.push(label);
if (options.length === 3) break;
}
const at = chosen.index ?? 0;
return { kind: "blank", before: s.t.slice(0, at), after: s.t.slice(at + answer.length), answer, options: shuffle(options, this.r), full: s.t, translation: s.e, item };
}
case "type-e2t":
return { kind: "type", dir: "e2t", prompt: s.e, accept: [s.t, ...(s.ta || [])], answer: s.t, item };
case "type-t2e":
return { kind: "type", dir: "t2e", prompt: s.t, accept: [s.e, ...(s.ea || [])], answer: s.e, item };
case "speak":
return { kind: "speak", text: s.t, translation: s.e, item };
}
}
}
/** Swaps out exercise types this device cannot do (no voice, no microphone) or a beginner should not get yet. */
function fit(plan: Plan, s: Sentence, can: Abilities, beginner: boolean): Plan {
if ((plan === "listen" || plan === "listen-tiles") && !can.listen) return beginner ? "build-e2t" : "type-e2t";
if (plan === "listen" && beginner) return "listen-tiles";
if (plan === "type-e2t" && beginner) return "build-e2t";
if (plan === "speak" && (!can.speak || tiles(s.t).length > 8)) return "build-t2e";
return plan;
}
const LESSON_PLAN: Plan[] = ["build-t2e", "blank", "listen-tiles", "build-e2t", "type-e2t", "speak", "type-t2e", "listen", "blank"];
/** A lesson: new words first, then sentences in every exercise type, with one match-the-pairs round. */
export function lessonExercises(course: Course, u: number, l: number, attempt: number, can: Abilities): Ex[] {
const index = indexOf(course);
const at = index.steps.findIndex((s) => s.u === u && s.l === l);
const step = index.steps[at];
const r = rng(`${course.id}.${u}.${l}.${attempt}`);
const pool = index.steps.slice(0, at + 1).flatMap((s) => s.lesson.words);
const b = new Builder(course, r, pool, step.unit.lessons.flatMap((x) => x.sentences));
const beginner = at < 2;
const words = shuffle(step.lesson.words, r);
const picks = words.slice(0, words.length >= 6 ? 5 : 4).map((w, i) => b.pick(w, i % 2 ? "e2t" : "t2e"));
const sents = shuffle(step.lesson.sentences, r).map((s, i) => b.sentence(s, fit(LESSON_PLAN[i % LESSON_PLAN.length], s, can, beginner)));
const match = b.match(step.lesson.words);
const out: Ex[] = [picks[0], picks[1], sents[0], picks[2], sents[1], picks[3]];
if (match) out.push(match);
out.push(...sents.slice(2, 5));
if (picks[4]) out.push(picks[4]);
out.push(...sents.slice(5));
return out.filter(Boolean).slice(0, 15);
}
const REVIEW_PLAN: Plan[] = ["type-e2t", "build-t2e", "listen", "blank", "type-t2e", "speak", "build-e2t"];
function reviewSet(course: Course, items: Item[], learned: Word[], seed: string, can: Abilities): Ex[] {
const r = rng(seed);
const index = indexOf(course);
const sentences = [...index.items.values()].filter((i) => i.sentence).map((i) => i.sentence!);
const b = new Builder(course, r, learned.length >= 4 ? learned : index.steps.flatMap((s) => s.lesson.words).slice(0, 30), sentences);
const words = items.filter((i) => i.word).map((i) => i.word!);
const sents = items.filter((i) => i.sentence).map((i) => i.sentence!);
const out: Ex[] = [];
const match = words.length >= 4 ? b.match(words.slice(0, 5)) : null;
const rest = match ? words.slice(5) : words;
rest.forEach((w, i) => out.push(b.pick(w, i % 2 ? "e2t" : "t2e")));
sents.forEach((s, i) => out.push(b.sentence(s, fit(REVIEW_PLAN[i % REVIEW_PLAN.length], s, can, false))));
const mixed = shuffle(out, r);
if (match) mixed.splice(Math.min(2, mixed.length), 0, match);
return mixed.slice(0, 15);
}
function learnedWords(course: Course, cs: CourseState): Word[] {
return indexOf(course).steps.filter((s) => cs.done[s.key]).flatMap((s) => s.lesson.words);
}
/** Practice: the weakest and most overdue words and sentences, by their review cards. */
export function practiceExercises(course: Course, cs: CourseState, can: Abilities): Ex[] {
const index = indexOf(course);
const day = today();
const ranked = Object.entries(cs.srs)
.filter(([k]) => index.items.has(k))
.sort((a, b) => weakness(a[1], day) - weakness(b[1], day))
.map(([k]) => index.items.get(k)!);
const words = ranked.filter((i) => i.word).slice(0, 6);
const sents = ranked.filter((i) => i.sentence).slice(0, 12 - Math.min(words.length, 5));
return reviewSet(course, [...words, ...sents], learnedWords(course, cs), `practice.${Date.now()}`, can);
}
/** Mistakes review: what the learner missed most recently. */
export function mistakeExercises(course: Course, cs: CourseState, can: Abilities): Ex[] {
const index = indexOf(course);
const items = openMistakes(cs).map((k) => index.items.get(k)).filter((i): i is Item => Boolean(i)).slice(0, 12);
return reviewSet(course, items, learnedWords(course, cs), `mistakes.${Date.now()}`, can);
}
const JUMP_PLAN: Plan[] = ["build-e2t", "type-t2e", "listen", "blank", "build-t2e", "type-e2t"];
/** A jump test to start at `unit`: sentences from every earlier unit, weighted toward the later ones. */
export function jumpExercises(course: Course, unit: number, can: Abilities): Ex[] {
const r = rng(`jump.${course.id}.${unit}.${Date.now()}`);
const index = indexOf(course);
const before = index.steps.filter((s) => s.u < unit);
const pool = before.flatMap((s) => s.lesson.words);
const b = new Builder(course, r, pool, before.flatMap((s) => s.lesson.sentences));
const chosen: Sentence[] = [];
for (let i = 0; i < 12; i++) {
const u = 1 + Math.floor(Math.sqrt(r()) * (unit - 1));
const options = before.filter((s) => s.u === u).flatMap((s) => s.lesson.sentences).filter((s) => !chosen.includes(s));
if (options.length) chosen.push(options[Math.floor(r() * options.length)]);
}
return chosen.map((s, i) => b.sentence(s, fit(JUMP_PLAN[i % JUMP_PLAN.length], s, can, false)));
}
/** Every item an exercise tests. */
export const itemsOf = (ex: Ex): string[] => (ex.kind === "match" ? ex.pairs.map((p) => p.item) : [ex.item]);
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync