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
scripts/check-courses.mjs
// Checks course files for shape and common mistakes before they ship.
// Usage: node scripts/check-courses.mjs [es fr ...] [--coverage]
// --coverage also lists, per lesson, sentence words that no lesson so far has taught.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "functions", "courses");
const args = process.argv.slice(2);
const coverage = args.includes("--coverage");
const ids = args.filter((a) => !a.startsWith("--"));
const UNITS = { es: 8, fr: 8, de: 3, it: 3, pt: 3 };
const list = ids.length ? ids : Object.keys(UNITS).filter((id) => fs.existsSync(path.join(dir, `${id}.json`)));

const norm = (s) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^\p{L}\p{N}' ]/gu, " ").replace(/\s+/g, " ").trim();
const tokens = (s) => s.toLowerCase().replace(/[¿¡?!.,;:«»"“”()]/g, " ").split(/[\s ]+/).filter(Boolean);

let failed = false;
for (const id of list) {
  const errors = [];
  const warns = [];
  let course;
  try {
    course = JSON.parse(fs.readFileSync(path.join(dir, `${id}.json`), "utf8"));
  } catch (e) {
    console.log(`${id}: cannot read: ${e.message}`);
    failed = true;
    continue;
  }
  const str = (v, where) => { if (typeof v !== "string" || !v.trim() || v !== v.trim() || /\s{2,}/.test(v.replace(/ /g, "x"))) errors.push(`${where}: needs a trimmed, non-empty string, got ${JSON.stringify(v)}`); };
  const strs = (v, where) => { if (v === undefined) return; if (!Array.isArray(v)) return errors.push(`${where}: must be a list`); v.forEach((x, i) => str(x, `${where}[${i}]`)); };
  for (const k of ["id", "name", "native", "speech", "level", "about"]) str(course[k], k);
  if (course.id !== id) errors.push(`id is ${course.id}, file is ${id}`);
  if (!Array.isArray(course.units) || course.units.length !== UNITS[id]) errors.push(`expected ${UNITS[id]} units, found ${course.units?.length}`);
  const allText = JSON.stringify(course);
  if (/\p{Extended_Pictographic}/u.test(allText)) errors.push("contains emoji");
  if (/—/.test(allText)) errors.push("contains an em dash");
  const seenWords = new Map();
  const seenSentences = new Map();
  const taught = new Set();
  let words = 0, sentences = 0;
  (course.units || []).forEach((u, ui) => {
    const U = `unit ${ui + 1}`;
    if (u.n !== ui + 1) errors.push(`${U}: n is ${u.n}`);
    str(u.title, `${U}.title`); str(u.about, `${U}.about`);
    if (!u.tip) errors.push(`${U}: no tip`);
    else {
      str(u.tip.title, `${U}.tip.title`);
      if (!Array.isArray(u.tip.body) || u.tip.body.length < 2 || u.tip.body.length > 5) errors.push(`${U}.tip.body: 2 to 5 paragraphs`);
      else u.tip.body.forEach((p, i) => str(p, `${U}.tip.body[${i}]`));
      if (!Array.isArray(u.tip.examples) || u.tip.examples.length < 3 || u.tip.examples.length > 6) errors.push(`${U}.tip.examples: 3 to 6 pairs`);
      else u.tip.examples.forEach((p, i) => { if (!Array.isArray(p) || p.length !== 2) errors.push(`${U}.tip.examples[${i}]: [target, English]`); else p.forEach((x) => str(x, `${U}.tip.examples[${i}]`)); });
    }
    if (!Array.isArray(u.lessons) || u.lessons.length !== 5) errors.push(`${U}: expected 5 lessons`);
    (u.lessons || []).forEach((l, li) => {
      const L = `${ui + 1}.${li + 1}`;
      if (l.n !== li + 1) errors.push(`lesson ${L}: n is ${l.n}`);
      str(l.title, `lesson ${L}.title`);
      if (!Array.isArray(l.words) || l.words.length < 5 || l.words.length > 7) errors.push(`lesson ${L}: 5 to 7 words (found ${l.words?.length})`);
      if (!Array.isArray(l.sentences) || l.sentences.length < 7 || l.sentences.length > 9) errors.push(`lesson ${L}: 7 to 9 sentences (found ${l.sentences?.length})`);
      const glosses = new Set();
      (l.words || []).forEach((w, wi) => {
        const W = `lesson ${L} word ${wi + 1} (${w.t})`;
        str(w.t, `${W}.t`); str(w.e, `${W}.e`); strs(w.ea, `${W}.ea`);
        words++;
        const g = norm(w.e || "");
        if (glosses.has(g)) errors.push(`${W}: English "${w.e}" repeats in this lesson`);
        glosses.add(g);
        const k = norm(w.t || "");
        if (seenWords.has(k)) warns.push(`${W}: also taught in lesson ${seenWords.get(k)}`);
        else seenWords.set(k, L);
        for (const t of tokens(w.t || "")) taught.add(t);
      });
      const missing = new Set();
      (l.sentences || []).forEach((s, si) => {
        const S = `lesson ${L} sentence ${si + 1} (${s.t})`;
        str(s.t, `${S}.t`); str(s.e, `${S}.e`); strs(s.ta, `${S}.ta`); strs(s.ea, `${S}.ea`);
        sentences++;
        const n = tokens(s.t || "").length;
        if (n < 2 || n > 10) errors.push(`${S}: ${n} words (keep 2 to 10)`);
        const k = norm(s.t || "");
        if (seenSentences.has(k)) errors.push(`${S}: duplicate of lesson ${seenSentences.get(k)}`);
        else seenSentences.set(k, L);
        for (const alt of s.ta || []) if (norm(alt) === k) warns.push(`${S}: ta repeats t ("${alt}")`);
        for (const alt of s.ea || []) if (norm(alt) === norm(s.e || "")) warns.push(`${S}: ea repeats e ("${alt}")`);
        const all = [s.t, ...(s.ta || [])].filter(Boolean);
        if (id === "es") for (const x of all) {
          if (x.includes("?") && !x.includes("¿")) errors.push(`${S}: "?" without "¿" in "${x}"`);
          if (x.includes("!") && !x.includes("¡")) errors.push(`${S}: "!" without "¡" in "${x}"`);
        }
        if (id === "fr") for (const x of all) {
          if (/[^ ][?!:;]/.test(x)) warns.push(`${S}: French uses a no-break space (\\u00a0) before ? ! : ; in "${x}"`);
        }
        for (const t of tokens(s.t || "")) if (!taught.has(t)) missing.add(t);
      });
      if (coverage && missing.size) console.log(`  ${id} ${L} not yet taught: ${[...missing].join(", ")}`);
    });
  });
  const exercises = (course.units || []).flatMap((u) => u.lessons || []).map((l) => Math.min(15, 4 + 1 + (l.sentences?.length || 0) + ((l.words?.length || 0) >= 6 ? 1 : 0)));
  console.log(`${id}: ${course.units?.length} units, ${exercises.length} lessons, ${words} words, ${sentences} sentences, ${Math.min(...exercises)} to ${Math.max(...exercises)} exercises per lesson`);
  for (const w of warns) console.log(`  warn: ${w}`);
  for (const e of errors) console.log(`  error: ${e}`);
  if (errors.length) failed = true;
}
process.exit(failed ? 1 : 0);

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