lingo-free.openapps.xyz

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.
Owned app · rev 8af89d2cc9f8
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.
← All files
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
8af89d2cc9 · 17 hours ago
Bind agent address in policy
0018350275 · 19 hours ago
Use OpenApps service URLs and add contribution strip
805365daf4 · 19 hours ago
Bounded source sync
3f2fb3b265 · 2 days ago
Bounded source sync
7dcb580324 · 2 days ago
Bounded source sync
dcd05c0944 · 2 days ago