Baustein 2: Inhalte einlesen und pruefen, Handouts, Suche, Glossar
Die fachlichen Inhalte liegen als Dateien unter content/ und werden beim Start eingelesen, geprueft und in den Speicher geladen. - Inhaltslader mit Zod-Schemata und Querverweispruefung - Fehlermeldungen nennen Datei und Zeile, gesammelt statt beim ersten Fehler geworfen; die Anwendung startet bei Fehlern nicht - npm run inhalte:pruefen prueft ohne Start - Handout-Ansicht mit festem Aufbau, Uebersicht nach Themenbloecken - Volltextsuche mit Gewichtung und Trefferausschnitt - Gelesen-Markierung je Handout - Glossar mit Suche und automatischer Verlinkung in den Handouts - Beispielinhalte: 4 Wochen, 12 Handouts, 24 Merkkarten, 4 Aufgaben, 4 Checklisten, 2 Lernzielkontrollen, 24 Glossareintraege - rahmenplan.yaml aus FIAusbV 2020 und KMK-Rahmenlehrplan, mit Fundstellen - gray-matter entfernt, YAML-Kopf wird selbst herausgeloest - npm run screenshots nimmt die Bilder fuer die Anleitung auf - 39 neue Tests, insgesamt 68 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e856349cd4
commit
1af5613e9b
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function GlossarSuche({ anfangswert }: { anfangswert: string }) {
|
||||
const [wert, setzeWert] = useState(anfangswert);
|
||||
const router = useRouter();
|
||||
const erstesMal = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (erstesMal.current) {
|
||||
erstesMal.current = false;
|
||||
return;
|
||||
}
|
||||
const zeitgeber = setTimeout(() => {
|
||||
router.replace(wert.trim() ? `/glossar?suche=${encodeURIComponent(wert.trim())}` : '/glossar', { scroll: false });
|
||||
}, 200);
|
||||
return () => clearTimeout(zeitgeber);
|
||||
}, [wert, router]);
|
||||
|
||||
return (
|
||||
<form action="/glossar" method="get" role="search" className="nicht-drucken">
|
||||
<label htmlFor="glossar-suche" className="nur-lesehilfe">
|
||||
Glossar durchsuchen
|
||||
</label>
|
||||
<input
|
||||
id="glossar-suche"
|
||||
name="suche"
|
||||
type="search"
|
||||
placeholder="Begriff suchen – z. B. Subnetzmaske"
|
||||
value={wert}
|
||||
onChange={(e) => setzeWert(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { glossarAnker } from '@/lib/inhalte/glossarverweise';
|
||||
import { normalisiere } from '@/lib/inhalte/laden';
|
||||
import { GlossarSuche } from './GlossarSuche';
|
||||
|
||||
export const metadata: Metadata = { title: 'Glossar' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function GlossarSeite({ searchParams }: { searchParams: Promise<{ suche?: string }> }) {
|
||||
await erfordereBenutzer();
|
||||
const parameter = await searchParams;
|
||||
const alle = inhalte();
|
||||
|
||||
const suchbegriff = (parameter.suche ?? '').trim();
|
||||
const gesucht = normalisiere(suchbegriff);
|
||||
|
||||
const eintraege = [...alle.glossar].sort((a, b) => a.begriff.localeCompare(b.begriff, 'de-DE'));
|
||||
const gefiltert = gesucht
|
||||
? eintraege.filter(
|
||||
(e) =>
|
||||
normalisiere(e.begriff).includes(gesucht) ||
|
||||
normalisiere(e.steht_fuer ?? '').includes(gesucht) ||
|
||||
normalisiere(e.erklaerung).includes(gesucht) ||
|
||||
e.formen.some((f) => normalisiere(f).includes(gesucht)),
|
||||
)
|
||||
: eintraege;
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Glossar</h1>
|
||||
<p className="leise">
|
||||
{alle.glossar.length} Begriffe. In den Handouts sind sie verlinkt – ein Klick führt hierher.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GlossarSuche anfangswert={suchbegriff} />
|
||||
|
||||
{gefiltert.length === 0 ? (
|
||||
<p className="karte">Kein Begriff passt zu „{suchbegriff}“.</p>
|
||||
) : (
|
||||
<dl className="glossar">
|
||||
{gefiltert.map((eintrag) => (
|
||||
<div key={eintrag.begriff} id={glossarAnker(eintrag.begriff)} className="glossar-eintrag karte">
|
||||
<dt>
|
||||
{eintrag.begriff}
|
||||
{eintrag.steht_fuer ? <span className="glossar-langform">{eintrag.steht_fuer}</span> : null}
|
||||
</dt>
|
||||
<dd>
|
||||
<p style={{ marginBottom: eintrag.siehe_auch.length > 0 ? '0.5rem' : 0 }}>{eintrag.erklaerung}</p>
|
||||
{eintrag.siehe_auch.length > 0 ? (
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
Siehe auch:{' '}
|
||||
{eintrag.siehe_auch.map((verweis, index) => (
|
||||
<span key={verweis}>
|
||||
{index > 0 ? ', ' : ''}
|
||||
<Link href={`#${glossarAnker(verweis)}`}>{verweis}</Link>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Suchfeld für die Handouts. Sucht beim Tippen mit kurzer Verzögerung, damit
|
||||
* nicht bei jedem Buchstaben eine Anfrage losgeht, und funktioniert auch ohne
|
||||
* JavaScript – dann eben erst beim Absenden.
|
||||
*/
|
||||
export function Suchfeld({ anfangswert }: { anfangswert: string }) {
|
||||
const [wert, setzeWert] = useState(anfangswert);
|
||||
const router = useRouter();
|
||||
const erstesMal = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (erstesMal.current) {
|
||||
erstesMal.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const zeitgeber = setTimeout(() => {
|
||||
const ziel = wert.trim() ? `/handouts?suche=${encodeURIComponent(wert.trim())}` : '/handouts';
|
||||
router.replace(ziel, { scroll: false });
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(zeitgeber);
|
||||
}, [wert, router]);
|
||||
|
||||
return (
|
||||
<form action="/handouts" method="get" role="search" className="nicht-drucken">
|
||||
<label htmlFor="suche" className="nur-lesehilfe">
|
||||
Handouts durchsuchen
|
||||
</label>
|
||||
<div className="reihe" style={{ gap: '0.5rem', flexWrap: 'nowrap' }}>
|
||||
<input
|
||||
id="suche"
|
||||
name="suche"
|
||||
type="search"
|
||||
placeholder="Suchen – z. B. Subnetting, SMART, Vererbung"
|
||||
value={wert}
|
||||
onChange={(e) => setzeWert(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="submit" className="knopf knopf-zweit" style={{ flexShrink: 0 }}>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { alsGelesenMerken, alsUngelesenMerken } from '@/lib/repos/fortschritt';
|
||||
|
||||
/**
|
||||
* Gelesen-Markierung umschalten. Nur der Azubi führt seinen eigenen Fortschritt –
|
||||
* der Ausbilder liest mit, hakt aber nichts für ihn ab.
|
||||
*/
|
||||
export async function gelesenUmschalten(formular: FormData): Promise<void> {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
if (benutzer.rolle !== 'azubi') return;
|
||||
|
||||
const handoutId = String(formular.get('handoutId') ?? '');
|
||||
if (!inhalte().handoutNach.has(handoutId)) return;
|
||||
|
||||
const gelesen = String(formular.get('gelesen') ?? '') === '1';
|
||||
if (gelesen) {
|
||||
alsGelesenMerken(benutzer.id, handoutId);
|
||||
} else {
|
||||
alsUngelesenMerken(benutzer.id, handoutId);
|
||||
}
|
||||
|
||||
revalidatePath(`/handouts/${handoutId}`);
|
||||
revalidatePath('/handouts');
|
||||
revalidatePath('/');
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { verlinkeGlossar } from '@/lib/inhalte/glossarverweise';
|
||||
import { gelesenAm } from '@/lib/repos/fortschritt';
|
||||
import { alsZeitpunktText } from '@/lib/zeit';
|
||||
import { gelesenUmschalten } from './aktionen';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const handout = inhalte().handoutNach.get(id.toUpperCase());
|
||||
return { title: handout ? `${handout.id} – ${handout.titel}` : 'Handout' };
|
||||
}
|
||||
|
||||
export default async function HandoutSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const { id } = await params;
|
||||
const alle = inhalte();
|
||||
const handout = alle.handoutNach.get(id.toUpperCase());
|
||||
|
||||
if (!handout) notFound();
|
||||
|
||||
const block = alle.blockNach.get(handout.block);
|
||||
const lernfeld = alle.lernfeldNach.get(handout.lernfeld);
|
||||
const positionen = handout.rahmenplan.map((p) => alle.positionNach.get(p)).filter((p) => p !== undefined);
|
||||
const woche = alle.wocheNach.get(handout.woche);
|
||||
const istAzubi = benutzer.rolle === 'azubi';
|
||||
const gelesen = istAzubi ? gelesenAm(benutzer.id, handout.id) : null;
|
||||
|
||||
const desBlocks = alle.handoutsNachBlock.get(handout.block) ?? [];
|
||||
const stelle = desBlocks.findIndex((h) => h.id === handout.id);
|
||||
const vorher = stelle > 0 ? desBlocks[stelle - 1] : undefined;
|
||||
const nachher = stelle < desBlocks.length - 1 ? desBlocks[stelle + 1] : undefined;
|
||||
|
||||
const wesentlichHtml = handout.wesentlich ? verlinkeGlossar(handout.wesentlich.html, alle.glossar) : '';
|
||||
|
||||
return (
|
||||
<article className="stapel handout">
|
||||
<nav className="leise nicht-drucken" aria-label="Wo du bist">
|
||||
<Link href="/handouts">Handouts</Link>
|
||||
{block ? (
|
||||
<>
|
||||
{' › '}
|
||||
<Link href={`/handouts?block=${block.id}`}>{block.titel}</Link>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
<header className="stapel-eng">
|
||||
<div className="reihe" style={{ gap: '0.6rem' }}>
|
||||
<span className="handout-kuerzel handout-kuerzel-gross">{handout.id}</span>
|
||||
{gelesen ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
✓ gelesen am {alsZeitpunktText(gelesen)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 style={{ marginBottom: '0.2rem' }}>{handout.titel}</h1>
|
||||
<p className="handout-worum">{handout.worum}</p>
|
||||
</header>
|
||||
|
||||
<div className="handout-zuordnung karte">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Ausbildungsrahmenplan</dt>
|
||||
<dd>
|
||||
{positionen.length === 0
|
||||
? '–'
|
||||
: positionen.map((p) => (
|
||||
<span key={p.id} title={`${p.titel} (${p.verordnung})`} className="zuordnung-marke">
|
||||
<strong>{p.id}</strong> {p.kurz}
|
||||
</span>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Lernfeld</dt>
|
||||
<dd>
|
||||
{lernfeld ? (
|
||||
<span className="zuordnung-marke" title={`${lernfeld.stunden} Stunden im ${lernfeld.jahr}. Jahr`}>
|
||||
<strong>{lernfeld.id}</strong> {lernfeld.titel}
|
||||
</span>
|
||||
) : (
|
||||
'–'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Woche</dt>
|
||||
<dd>
|
||||
{woche ? (
|
||||
<span className="zuordnung-marke">
|
||||
<strong>{woche.nummer}</strong> {woche.titel}
|
||||
</span>
|
||||
) : (
|
||||
'–'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{handout.wesentlich ? (
|
||||
<section aria-labelledby="wesentlich">
|
||||
<h2 id="wesentlich">Das Wesentliche</h2>
|
||||
<div className="fliesstext" dangerouslySetInnerHTML={{ __html: wesentlichHtml }} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{handout.uebersicht ? (
|
||||
<section aria-labelledby="uebersicht">
|
||||
<h2 id="uebersicht">Übersicht</h2>
|
||||
<div className="tabelle-huelle fliesstext" dangerouslySetInnerHTML={{ __html: handout.uebersicht.html }} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<aside className="merksatz" aria-label="Merksatz">
|
||||
<span className="merksatz-kennzeichnung">Merksatz</span>
|
||||
<p>{handout.merksatz}</p>
|
||||
</aside>
|
||||
|
||||
{handout.pruefungsfrage ? (
|
||||
<details className="pruefungsfrage">
|
||||
<summary>Prüfungsfrage anzeigen</summary>
|
||||
<div className="fliesstext" dangerouslySetInnerHTML={{ __html: handout.pruefungsfrage.html }} />
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<section aria-labelledby="karten" className="karte stapel-eng">
|
||||
<h2 id="karten" style={{ marginBottom: '0.3rem' }}>
|
||||
Merkkarten zu diesem Handout
|
||||
</h2>
|
||||
<p className="leise" style={{ margin: 0 }}>
|
||||
{handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie
|
||||
nach Fälligkeit auf.
|
||||
</p>
|
||||
<ol className="stapel-eng" style={{ margin: '0.5rem 0 0', paddingLeft: '1.2rem' }}>
|
||||
{handout.karten.map((karte, index) => (
|
||||
<li key={index}>
|
||||
<strong>{karte.frage}</strong>
|
||||
<br />
|
||||
<span className="leise">{karte.antwort}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{istAzubi ? (
|
||||
<form action={gelesenUmschalten} className="nicht-drucken">
|
||||
<input type="hidden" name="handoutId" value={handout.id} />
|
||||
<input type="hidden" name="gelesen" value={gelesen ? '0' : '1'} />
|
||||
<button type="submit" className={`knopf ${gelesen ? 'knopf-zweit' : 'knopf-akzent'}`}>
|
||||
{gelesen ? 'Doch noch nicht gelesen' : 'Als gelesen markieren'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<nav className="reihe nicht-drucken" style={{ justifyContent: 'space-between' }} aria-label="Weitere Handouts">
|
||||
{vorher ? (
|
||||
<Link href={`/handouts/${vorher.id}`} className="knopf knopf-still">
|
||||
← {vorher.id} {vorher.titel}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{nachher ? (
|
||||
<Link href={`/handouts/${nachher.id}`} className="knopf knopf-still">
|
||||
{nachher.id} {nachher.titel} →
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<p className="leise nicht-drucken" style={{ marginBottom: 0 }}>
|
||||
Quelle: <code>{handout.datei}</code>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { erfordereBenutzer } from '@/lib/auth/rechte';
|
||||
import { inhalte } from '@/lib/inhalte';
|
||||
import { sucheHandouts } from '@/lib/inhalte/suche';
|
||||
import { gelesenSet } from '@/lib/repos/fortschritt';
|
||||
import { Suchfeld } from './Suchfeld';
|
||||
|
||||
export const metadata: Metadata = { title: 'Handouts' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function HandoutListe({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ suche?: string; block?: string }>;
|
||||
}) {
|
||||
const benutzer = await erfordereBenutzer();
|
||||
const parameter = await searchParams;
|
||||
const alle = inhalte();
|
||||
const gelesen = benutzer.rolle === 'azubi' ? gelesenSet(benutzer.id) : new Set<string>();
|
||||
|
||||
const suchbegriff = (parameter.suche ?? '').trim();
|
||||
const treffer = suchbegriff ? sucheHandouts(alle, suchbegriff) : null;
|
||||
|
||||
const gefilterterBlock = parameter.block && alle.blockNach.has(parameter.block) ? parameter.block : null;
|
||||
|
||||
return (
|
||||
<div className="stapel">
|
||||
<div>
|
||||
<h1>Handouts</h1>
|
||||
<p className="leise">
|
||||
{alle.handouts.length} Lerneinheiten in {alle.bloecke.length} Themenblöcken
|
||||
{benutzer.rolle === 'azubi' ? ` · ${gelesen.size} gelesen` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Suchfeld anfangswert={suchbegriff} />
|
||||
|
||||
{treffer ? (
|
||||
<section className="stapel" aria-labelledby="treffer-ueberschrift">
|
||||
<h2 id="treffer-ueberschrift">
|
||||
{treffer.length === 0
|
||||
? `Nichts gefunden für „${suchbegriff}"`
|
||||
: `${treffer.length} ${treffer.length === 1 ? 'Treffer' : 'Treffer'} für „${suchbegriff}"`}
|
||||
</h2>
|
||||
|
||||
{treffer.length === 0 ? (
|
||||
<p className="karte" style={{ marginBottom: 0 }}>
|
||||
Versuch es mit einem einzelnen Wort. Gesucht wird in Titel, Merksatz, Merkkarten und im gesamten Text.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="stapel-eng" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{treffer.map(({ handout, ausschnitt }) => (
|
||||
<li key={handout.id}>
|
||||
<Link href={`/handouts/${handout.id}`} className="handout-treffer">
|
||||
<span className="handout-kuerzel">{handout.id}</span>
|
||||
<span>
|
||||
<strong>{handout.titel}</strong>
|
||||
<br />
|
||||
<span className="leise">{ausschnitt}</span>
|
||||
</span>
|
||||
{gelesen.has(handout.id) ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
✓ gelesen
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<nav className="reihe nicht-drucken" aria-label="Themenblöcke">
|
||||
<Link href="/handouts" className={`knopf ${gefilterterBlock ? 'knopf-zweit' : ''}`} scroll={false}>
|
||||
Alle
|
||||
</Link>
|
||||
{alle.bloecke.map((block) => (
|
||||
<Link
|
||||
key={block.id}
|
||||
href={`/handouts?block=${block.id}`}
|
||||
className={`knopf ${gefilterterBlock === block.id ? '' : 'knopf-zweit'}`}
|
||||
scroll={false}
|
||||
>
|
||||
{block.titel}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{alle.bloecke
|
||||
.filter((block) => !gefilterterBlock || block.id === gefilterterBlock)
|
||||
.map((block) => {
|
||||
const desBlocks = alle.handoutsNachBlock.get(block.id) ?? [];
|
||||
if (desBlocks.length === 0) return null;
|
||||
const gelesenImBlock = desBlocks.filter((h) => gelesen.has(h.id)).length;
|
||||
|
||||
return (
|
||||
<section key={block.id} className="stapel" aria-labelledby={`block-${block.id}`}>
|
||||
<div className="reihe" style={{ justifyContent: 'space-between' }}>
|
||||
<h2 id={`block-${block.id}`} style={{ margin: 0 }}>
|
||||
{block.titel}
|
||||
</h2>
|
||||
{benutzer.rolle === 'azubi' ? (
|
||||
<span className="leise">
|
||||
{gelesenImBlock} von {desBlocks.length} gelesen
|
||||
</span>
|
||||
) : (
|
||||
<span className="leise">{desBlocks.length} Handouts</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="raster" style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
||||
{desBlocks.map((handout) => (
|
||||
<li key={handout.id}>
|
||||
<Link href={`/handouts/${handout.id}`} className="handout-kachel">
|
||||
<span className="reihe" style={{ justifyContent: 'space-between', gap: '0.5rem' }}>
|
||||
<span className="handout-kuerzel">{handout.id}</span>
|
||||
{gelesen.has(handout.id) ? (
|
||||
<span className="marke-status" data-ton="gut">
|
||||
✓ gelesen
|
||||
</span>
|
||||
) : (
|
||||
<span className="leise" style={{ fontSize: '0.8rem' }}>
|
||||
Woche {handout.woche}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<strong style={{ display: 'block', marginTop: '0.4rem' }}>{handout.titel}</strong>
|
||||
<span className="leise" style={{ display: 'block', marginTop: '0.2rem' }}>
|
||||
{handout.worum}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,16 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
// Die Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele,
|
||||
// die es auch wirklich gibt.
|
||||
const fuerAzubi: Navigationseintrag[] = [{ pfad: '/', titel: 'Woche' }];
|
||||
const fuerAzubi: Navigationseintrag[] = [
|
||||
{ pfad: '/', titel: 'Woche' },
|
||||
{ pfad: '/handouts', titel: 'Handouts' },
|
||||
{ pfad: '/glossar', titel: 'Glossar' },
|
||||
];
|
||||
|
||||
const fuerAusbilder: Navigationseintrag[] = [
|
||||
{ pfad: '/', titel: 'Überblick' },
|
||||
{ pfad: '/handouts', titel: 'Handouts' },
|
||||
{ pfad: '/glossar', titel: 'Glossar' },
|
||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||
];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { erfordereAusbilder } from '@/lib/auth/rechte';
|
||||
import { passwortMangel } from '@/lib/auth/passwort';
|
||||
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
|
||||
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
|
||||
import { alsDatumText } from '@/lib/zeit';
|
||||
|
||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||
|
||||
@@ -39,7 +40,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
|
||||
});
|
||||
|
||||
revalidatePath('/verwaltung');
|
||||
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${beginn} eingerichtet.` };
|
||||
return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` };
|
||||
}
|
||||
|
||||
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||
|
||||
+211
-3
@@ -323,9 +323,7 @@ select:disabled {
|
||||
color: #f2f8fa;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.marke:hover { color: #fff; }
|
||||
.marke .marke-akzent { color: #e5a552; }
|
||||
@@ -488,3 +486,213 @@ table.tabelle th {
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* --- Handouts --- */
|
||||
|
||||
.handout-kuerzel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.6rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-klein);
|
||||
background: var(--petrol);
|
||||
color: var(--text-auf-petrol);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.handout-kuerzel-gross {
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
}
|
||||
|
||||
.handout-kachel {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--flaeche);
|
||||
border: 1px solid var(--rand-zart);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.handout-kachel:hover {
|
||||
background: var(--flaeche-kraeftig);
|
||||
border-color: var(--rand);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.handout-treffer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
background: var(--flaeche);
|
||||
border: 1px solid var(--rand-zart);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.8rem 1rem;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.handout-treffer:hover {
|
||||
background: var(--flaeche-kraeftig);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.handout-worum {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-leise);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.handout-zuordnung dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.handout-zuordnung dt {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-leise);
|
||||
font-weight: 650;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.handout-zuordnung dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.zuordnung-marke {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
background: var(--grund);
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius-klein);
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.87rem;
|
||||
}
|
||||
.zuordnung-marke strong {
|
||||
color: var(--petrol);
|
||||
}
|
||||
|
||||
.fliesstext > *:first-child { margin-top: 0; }
|
||||
.fliesstext > *:last-child { margin-bottom: 0; }
|
||||
.fliesstext ul,
|
||||
.fliesstext ol {
|
||||
padding-left: 1.3rem;
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
.fliesstext li + li { margin-top: 0.4rem; }
|
||||
.fliesstext table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
.fliesstext th,
|
||||
.fliesstext td {
|
||||
border: 1px solid var(--rand-zart);
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.fliesstext th {
|
||||
background: var(--flaeche);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.merksatz {
|
||||
border-left: 5px solid var(--bernstein);
|
||||
background: var(--warnung-flaeche);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
.merksatz-kennzeichnung {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
color: var(--warnung);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.merksatz p {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pruefungsfrage {
|
||||
border: 1px solid var(--rand);
|
||||
border-radius: var(--radius);
|
||||
background: var(--grund);
|
||||
}
|
||||
.pruefungsfrage > summary {
|
||||
cursor: pointer;
|
||||
padding: 0.7rem 1rem;
|
||||
font-weight: 650;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
.pruefungsfrage > summary::-webkit-details-marker { display: none; }
|
||||
.pruefungsfrage > summary::before {
|
||||
content: "▸";
|
||||
color: var(--petrol);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.pruefungsfrage[open] > summary::before { content: "▾"; }
|
||||
.pruefungsfrage[open] > summary { border-bottom: 1px solid var(--rand-zart); }
|
||||
.pruefungsfrage > div { padding: 0.9rem 1rem; }
|
||||
|
||||
a.glossarbegriff {
|
||||
color: inherit;
|
||||
text-decoration: underline dotted var(--petrol);
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
cursor: help;
|
||||
}
|
||||
a.glossarbegriff:hover {
|
||||
color: var(--petrol);
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
/* --- Glossar --- */
|
||||
|
||||
.glossar {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
.glossar-eintrag {
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
.glossar-eintrag dt {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.glossar-langform {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-leise);
|
||||
}
|
||||
.glossar-eintrag dd { margin: 0; }
|
||||
.glossar-eintrag:target {
|
||||
outline: 3px solid var(--bernstein);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.pruefungsfrage > div { display: block !important; }
|
||||
.handout-zuordnung { border: 1px solid #999; }
|
||||
a.glossarbegriff { text-decoration: none; }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export const metadata: Metadata = {
|
||||
title: { default: 'Lern-Bar', template: '%s · Lern-Bar' },
|
||||
description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration',
|
||||
applicationName: 'Lern-Bar',
|
||||
// Sonst macht der Browser aus Zahlenkolonnen in Tabellen Telefonnummern.
|
||||
formatDetection: { telephone: false, date: false, address: false, email: false },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
||||
@@ -8,6 +8,13 @@ export async function register() {
|
||||
|
||||
const { rohVerbindung } = await import('./lib/db');
|
||||
rohVerbindung(); // öffnet die Datenbank und wendet Migrationen an
|
||||
|
||||
console.log('[lernbar] Datenbank bereit.');
|
||||
|
||||
const { inhalteEinlesen } = await import('./lib/inhalte');
|
||||
const alle = inhalteEinlesen();
|
||||
console.log(
|
||||
`[lernbar] Inhalte gelesen: ${alle.wochen.length} Wochen, ${alle.handouts.length} Handouts, ` +
|
||||
`${alle.karten.length} Merkkarten, ${alle.aufgaben.length} Aufgaben, ${alle.checklisten.length} Checklisten, ` +
|
||||
`${alle.kontrollen.length} Lernzielkontrollen, ${alle.glossar.length} Glossareinträge.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Baustein 2: gelesene Handouts.
|
||||
-- Der Inhalt selbst liegt als Datei im Repository; hier steht nur, wer was
|
||||
-- gelesen hat. Die Handout-Kennung ist deshalb ein freier Text ohne
|
||||
-- Fremdschlüssel – die Prüfung, dass es sie gibt, macht der Inhaltslader.
|
||||
|
||||
CREATE TABLE handout_gelesen (
|
||||
benutzer_id TEXT NOT NULL REFERENCES benutzer (id) ON DELETE CASCADE,
|
||||
handout_id TEXT NOT NULL,
|
||||
gelesen_am TEXT NOT NULL,
|
||||
PRIMARY KEY (benutzer_id, handout_id)
|
||||
);
|
||||
|
||||
CREATE INDEX handout_gelesen_benutzer_idx ON handout_gelesen (benutzer_id);
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
/** Rollen – bewusst nur zwei. */
|
||||
export const rollen = ['ausbilder', 'azubi'] as const;
|
||||
@@ -51,9 +51,22 @@ export const ausbildung = sqliteTable(
|
||||
(t) => [uniqueIndex('ausbildung_azubi_idx').on(t.azubiId), index('ausbildung_ausbilder_idx').on(t.ausbilderId)],
|
||||
);
|
||||
|
||||
export const handoutGelesen = sqliteTable(
|
||||
'handout_gelesen',
|
||||
{
|
||||
benutzerId: text('benutzer_id')
|
||||
.notNull()
|
||||
.references(() => benutzer.id, { onDelete: 'cascade' }),
|
||||
handoutId: text('handout_id').notNull(),
|
||||
gelesenAm: text('gelesen_am').notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.benutzerId, t.handoutId] }), index('handout_gelesen_benutzer_idx').on(t.benutzerId)],
|
||||
);
|
||||
|
||||
export type Benutzer = typeof benutzer.$inferSelect;
|
||||
export type Sitzung = typeof sitzung.$inferSelect;
|
||||
export type Ausbildung = typeof ausbildung.$inferSelect;
|
||||
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
||||
|
||||
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
||||
export const alleTabellen = { benutzer, sitzung, ausbildung };
|
||||
export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen };
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Fehler beim Einlesen der Inhalte.
|
||||
*
|
||||
* Der Anspruch: Wer eine Inhaltsdatei kaputt macht, bekommt Datei, Zeile und
|
||||
* einen Satz, der sagt, was zu tun ist – nicht einen Stapelabzug.
|
||||
*/
|
||||
|
||||
export type Fundstelle = {
|
||||
datei: string;
|
||||
zeile?: number;
|
||||
spalte?: number;
|
||||
feld?: string;
|
||||
};
|
||||
|
||||
export type Einzelfehler = Fundstelle & { meldung: string };
|
||||
|
||||
export class InhaltsFehler extends Error {
|
||||
readonly fehler: Einzelfehler[];
|
||||
|
||||
constructor(fehler: Einzelfehler[]) {
|
||||
super(InhaltsFehler.formatiere(fehler));
|
||||
this.name = 'InhaltsFehler';
|
||||
this.fehler = fehler;
|
||||
}
|
||||
|
||||
static formatiere(fehler: Einzelfehler[]): string {
|
||||
const zeilen = fehler.map((f) => {
|
||||
const ort = [f.datei, f.zeile, f.spalte].filter((t) => t !== undefined).join(':');
|
||||
const feld = f.feld ? ` (${f.feld})` : '';
|
||||
return ` ${ort}${feld}\n ${f.meldung}`;
|
||||
});
|
||||
|
||||
const anzahl = fehler.length === 1 ? '1 Fehler' : `${fehler.length} Fehler`;
|
||||
return [
|
||||
'',
|
||||
`Die Inhalte unter content/ sind nicht in Ordnung – ${anzahl}:`,
|
||||
'',
|
||||
...zeilen,
|
||||
'',
|
||||
'Die Anwendung startet erst, wenn alle Inhalte gültig sind.',
|
||||
'Zum Prüfen ohne Start: npm run inhalte:pruefen',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/** Sammelt Fehler, statt beim ersten abzubrechen – so sieht man alle auf einmal. */
|
||||
export class Fehlersammler {
|
||||
private readonly gesammelt: Einzelfehler[] = [];
|
||||
|
||||
melde(fehler: Einzelfehler): void {
|
||||
this.gesammelt.push(fehler);
|
||||
}
|
||||
|
||||
get anzahl(): number {
|
||||
return this.gesammelt.length;
|
||||
}
|
||||
|
||||
get leer(): boolean {
|
||||
return this.gesammelt.length === 0;
|
||||
}
|
||||
|
||||
alle(): Einzelfehler[] {
|
||||
return [...this.gesammelt];
|
||||
}
|
||||
|
||||
/** Wirft, sobald mindestens ein Fehler gesammelt wurde. */
|
||||
wirfWennNoetig(): void {
|
||||
if (this.gesammelt.length > 0) {
|
||||
throw new InhaltsFehler(this.gesammelt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { GlossarEintrag } from './schema';
|
||||
|
||||
/**
|
||||
* Verlinkt Glossarbegriffe im fertigen HTML eines Handouts.
|
||||
*
|
||||
* Bewusst zurückhaltend: jeder Begriff höchstens einmal je Seite, nie innerhalb
|
||||
* von Code, Überschriften oder bestehenden Verweisen. Ein Text, in dem jedes
|
||||
* zweite Wort unterstrichen ist, liest sich schlechter als einer ganz ohne.
|
||||
*
|
||||
* Der Verweis ist ein echter Link auf das Glossar und trägt die Erklärung
|
||||
* zusätzlich im title-Attribut – reines Überfahren mit der Maus wäre auf dem
|
||||
* Handy und mit der Tastatur nicht bedienbar.
|
||||
*/
|
||||
|
||||
const gemiedeneElemente = new Set(['code', 'pre', 'a', 'h1', 'h2', 'h3', 'h4', 'th']);
|
||||
|
||||
export function glossarAnker(begriff: string): string {
|
||||
return begriff
|
||||
.toLocaleLowerCase('de-DE')
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function maskiere(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fuerRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
type Suchmuster = { muster: RegExp; eintrag: GlossarEintrag };
|
||||
|
||||
/** Baut die Suchmuster einmal auf – Begriff und alle angegebenen Formen. */
|
||||
export function musterFuer(glossar: GlossarEintrag[]): Suchmuster[] {
|
||||
const muster: Suchmuster[] = [];
|
||||
|
||||
for (const eintrag of glossar) {
|
||||
const formen = [eintrag.begriff, ...eintrag.formen];
|
||||
for (const form of formen) {
|
||||
muster.push({
|
||||
// Wortgrenzen von Hand, weil \b bei Umlauten und Bindestrichen daneben liegt.
|
||||
muster: new RegExp(`(^|[^\\p{L}\\p{N}_-])(${fuerRegex(form)})(?=$|[^\\p{L}\\p{N}_-])`, 'iu'),
|
||||
eintrag,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Längere Formen zuerst, damit „MAC-Adresse" nicht als „Adresse" endet.
|
||||
return muster.sort((a, b) => b.muster.source.length - a.muster.source.length);
|
||||
}
|
||||
|
||||
export function verlinkeGlossar(html: string, glossar: GlossarEintrag[]): string {
|
||||
if (glossar.length === 0) return html;
|
||||
|
||||
const muster = musterFuer(glossar);
|
||||
const bereitsVerlinkt = new Set<string>();
|
||||
const teile = html.split(/(<[^>]+>)/);
|
||||
const offen: string[] = [];
|
||||
|
||||
for (let i = 0; i < teile.length; i += 1) {
|
||||
const teil = teile[i]!;
|
||||
|
||||
if (teil.startsWith('<')) {
|
||||
const treffer = /^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)/.exec(teil);
|
||||
if (treffer) {
|
||||
const [, schrägstrich, name] = treffer;
|
||||
const kleinName = name!.toLowerCase();
|
||||
if (schrägstrich) {
|
||||
const stelle = offen.lastIndexOf(kleinName);
|
||||
if (stelle !== -1) offen.splice(stelle, 1);
|
||||
} else if (!teil.endsWith('/>')) {
|
||||
offen.push(kleinName);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (teil.trim() === '') continue;
|
||||
if (offen.some((element) => gemiedeneElemente.has(element))) continue;
|
||||
|
||||
let text = teil;
|
||||
for (const { muster: regex, eintrag } of muster) {
|
||||
if (bereitsVerlinkt.has(eintrag.begriff)) continue;
|
||||
|
||||
const gefunden = regex.exec(text);
|
||||
if (!gefunden) continue;
|
||||
|
||||
const erklaerung = eintrag.steht_fuer
|
||||
? `${eintrag.steht_fuer} – ${eintrag.erklaerung}`
|
||||
: eintrag.erklaerung;
|
||||
|
||||
text =
|
||||
text.slice(0, gefunden.index) +
|
||||
gefunden[1] +
|
||||
`<a class="glossarbegriff" href="/glossar#${glossarAnker(eintrag.begriff)}" title="${maskiere(erklaerung)}">${gefunden[2]}</a>` +
|
||||
text.slice(gefunden.index + gefunden[0].length);
|
||||
|
||||
bereitsVerlinkt.add(eintrag.begriff);
|
||||
}
|
||||
teile[i] = text;
|
||||
}
|
||||
|
||||
return teile.join('');
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from 'node:fs';
|
||||
import { inhaltWurzel } from '../pfade';
|
||||
import { ladeInhalte, type Inhalte } from './laden';
|
||||
|
||||
/**
|
||||
* Die Inhalte werden einmal eingelesen und liegen danach im Speicher.
|
||||
*
|
||||
* Im Entwicklungsbetrieb wird das Verzeichnis beobachtet: Ändert sich eine
|
||||
* Datei, wird beim nächsten Zugriff neu eingelesen. Schlägt das Einlesen dabei
|
||||
* fehl, bleibt der bisherige Stand stehen und der Fehler wird ins Protokoll
|
||||
* geschrieben – im laufenden Entwicklungsbetrieb ist ein Absturz beim Tippen
|
||||
* nicht hilfreich. Beim Start dagegen bricht die Anwendung ab.
|
||||
*/
|
||||
|
||||
type Halter = {
|
||||
inhalte?: Inhalte;
|
||||
beobachter?: fs.FSWatcher;
|
||||
veraltet?: boolean;
|
||||
};
|
||||
|
||||
const halter = globalThis as unknown as { __lernbarInhalte?: Halter };
|
||||
halter.__lernbarInhalte ??= {};
|
||||
|
||||
const imEntwicklungsbetrieb = process.env.NODE_ENV !== 'production';
|
||||
|
||||
function beobachte(): void {
|
||||
const zustand = halter.__lernbarInhalte!;
|
||||
if (zustand.beobachter || !imEntwicklungsbetrieb) return;
|
||||
if (!fs.existsSync(inhaltWurzel)) return;
|
||||
|
||||
try {
|
||||
zustand.beobachter = fs.watch(inhaltWurzel, { recursive: true }, () => {
|
||||
zustand.veraltet = true;
|
||||
});
|
||||
zustand.beobachter.unref?.();
|
||||
} catch {
|
||||
// Auf manchen Dateisystemen gibt es kein rekursives Beobachten. Dann eben nicht.
|
||||
}
|
||||
}
|
||||
|
||||
/** Die eingelesenen Inhalte. Liest beim ersten Aufruf ein. */
|
||||
export function inhalte(): Inhalte {
|
||||
const zustand = halter.__lernbarInhalte!;
|
||||
|
||||
if (!zustand.inhalte) {
|
||||
zustand.inhalte = ladeInhalte(inhaltWurzel);
|
||||
beobachte();
|
||||
return zustand.inhalte;
|
||||
}
|
||||
|
||||
if (zustand.veraltet) {
|
||||
zustand.veraltet = false;
|
||||
try {
|
||||
zustand.inhalte = ladeInhalte(inhaltWurzel);
|
||||
console.log('[lernbar] Inhalte neu eingelesen.');
|
||||
} catch (fehler) {
|
||||
console.error(`[lernbar] Inhalte konnten nicht neu eingelesen werden – der bisherige Stand bleibt aktiv.`);
|
||||
console.error((fehler as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return zustand.inhalte;
|
||||
}
|
||||
|
||||
/** Erzwingt das Einlesen – beim Start, damit Fehler sofort auffallen. */
|
||||
export function inhalteEinlesen(): Inhalte {
|
||||
const zustand = halter.__lernbarInhalte!;
|
||||
zustand.inhalte = ladeInhalte(inhaltWurzel);
|
||||
zustand.veraltet = false;
|
||||
beobachte();
|
||||
return zustand.inhalte;
|
||||
}
|
||||
|
||||
export { ladeInhalte };
|
||||
export type { Inhalte, Handout, Aufgabe, Merkkarte } from './laden';
|
||||
export * from './schema';
|
||||
export { InhaltsFehler } from './fehler';
|
||||
@@ -0,0 +1,251 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { ladeInhalte } from './laden';
|
||||
import { InhaltsFehler } from './fehler';
|
||||
|
||||
/**
|
||||
* Der Inhaltslader wird gegen ein winziges, vollständiges Beispielverzeichnis
|
||||
* geprüft, das je Test gezielt kaputt gemacht wird. So lässt sich prüfen, ob
|
||||
* die Meldung wirklich auf Datei und Zeile zeigt.
|
||||
*/
|
||||
|
||||
const aufraeumen: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (aufraeumen.length > 0) {
|
||||
fs.rmSync(aufraeumen.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const RAHMENPLAN = `quellen:
|
||||
betrieb: { titel: FIAusbV, kurz: FIAusbV, vom: "2020-02-28", fundstelle: Anlage }
|
||||
schule: { titel: Rahmenlehrplan, kurz: KMK, vom: "2019-12-13" }
|
||||
berufsbildpositionen:
|
||||
- { id: A1, gruppe: allgemein, titel: Arbeitsaufgaben planen, kurz: Planen, verordnung: "A, Nr. 1" }
|
||||
- { id: B2, gruppe: si, titel: Netzwerke installieren, kurz: Netzwerke, verordnung: "C, Nr. 2" }
|
||||
gruppen:
|
||||
allgemein: Allgemein
|
||||
si: Systemintegration
|
||||
lernfelder:
|
||||
- { id: LF3, titel: Clients in Netzwerke einbinden, jahr: 1, stunden: 80 }
|
||||
bloecke:
|
||||
- { id: netzwerk, kuerzel: N, titel: Netzwerk }
|
||||
`;
|
||||
|
||||
const WOCHE = `nummer: 1
|
||||
titel: Netzwerkgrundlagen
|
||||
block: netzwerk
|
||||
leitfrage: Wo suchst du zuerst?
|
||||
ziel: Du kannst Netze bestimmen.
|
||||
handouts: [N1]
|
||||
aufgaben: []
|
||||
checklisten: []
|
||||
lernzielkontrolle: null
|
||||
tage:
|
||||
- { tag: Montag, theorie: [N1], praxis: Netzplan zeichnen }
|
||||
- { tag: Dienstag, theorie: [], praxis: Adressen aufnehmen }
|
||||
- { tag: Mittwoch, theorie: [], praxis: Rechnen }
|
||||
- { tag: Donnerstag, theorie: [], praxis: Fehlersuche }
|
||||
- { tag: Freitag, theorie: [], praxis: Rückblick }
|
||||
`;
|
||||
|
||||
const HANDOUT = `---
|
||||
id: N1
|
||||
titel: Subnetting rechnen
|
||||
block: netzwerk
|
||||
woche: 1
|
||||
worum: Adresse und Maske ergeben Netz und Broadcast.
|
||||
rahmenplan: [B2]
|
||||
lernfeld: LF3
|
||||
merksatz: Blockgröße finden.
|
||||
karten:
|
||||
- frage: Was ist die Blockgröße bei /27?
|
||||
antwort: 32 Adressen, davon 30 nutzbar.
|
||||
---
|
||||
|
||||
## Das Wesentliche
|
||||
|
||||
- Blockgröße bestimmen
|
||||
|
||||
## Übersicht
|
||||
|
||||
| CIDR | Maske |
|
||||
|---|---|
|
||||
| /27 | 255.255.255.224 |
|
||||
|
||||
## Prüfungsfrage
|
||||
|
||||
**Frage:** Und?
|
||||
|
||||
**Antwort:** So.
|
||||
`;
|
||||
|
||||
const GLOSSAR = `- begriff: Subnetzmaske
|
||||
formen: [Maske]
|
||||
erklaerung: Trennt Netz und Rechner.
|
||||
siehe_auch: []
|
||||
`;
|
||||
|
||||
function baueBeispiel(aenderungen: Record<string, string | null> = {}): string {
|
||||
const wurzel = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-inhalte-'));
|
||||
aufraeumen.push(wurzel);
|
||||
|
||||
const dateien: Record<string, string> = {
|
||||
'rahmenplan.yaml': RAHMENPLAN,
|
||||
'glossar.yaml': GLOSSAR,
|
||||
'wochen/01-netzwerk.yaml': WOCHE,
|
||||
'handouts/N1-subnetting.md': HANDOUT,
|
||||
};
|
||||
|
||||
for (const [relativ, inhalt] of Object.entries({ ...dateien, ...aenderungen })) {
|
||||
const ziel = path.join(wurzel, relativ);
|
||||
if (inhalt === null) {
|
||||
fs.rmSync(ziel, { force: true });
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(ziel), { recursive: true });
|
||||
fs.writeFileSync(ziel, inhalt, 'utf8');
|
||||
}
|
||||
|
||||
return wurzel;
|
||||
}
|
||||
|
||||
function fehlerVon(wurzel: string) {
|
||||
try {
|
||||
ladeInhalte(wurzel);
|
||||
return null;
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof InhaltsFehler) return fehler;
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Inhalte einlesen', () => {
|
||||
it('liest ein vollständiges Beispiel ohne Beanstandung', () => {
|
||||
const alle = ladeInhalte(baueBeispiel());
|
||||
|
||||
expect(alle.handouts).toHaveLength(1);
|
||||
expect(alle.handouts[0]!.id).toBe('N1');
|
||||
expect(alle.wochen).toHaveLength(1);
|
||||
expect(alle.karten).toHaveLength(1);
|
||||
expect(alle.karten[0]!.id).toBe('N1-1');
|
||||
});
|
||||
|
||||
it('zerlegt das Handout in seine festen Abschnitte', () => {
|
||||
const alle = ladeInhalte(baueBeispiel());
|
||||
const handout = alle.handouts[0]!;
|
||||
|
||||
expect(handout.wesentlich?.titel).toBe('Das Wesentliche');
|
||||
expect(handout.uebersicht?.html).toContain('<table>');
|
||||
expect(handout.pruefungsfrage?.html).toContain('Antwort');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verweise werden geprüft', () => {
|
||||
it('meldet eine unbekannte Berufsbildposition mit Datei und Zeile', () => {
|
||||
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('[B2]', '[B9]') }));
|
||||
|
||||
expect(fehler).not.toBeNull();
|
||||
const treffer = fehler!.fehler.find((f) => f.meldung.includes('B9'));
|
||||
expect(treffer).toBeDefined();
|
||||
expect(treffer!.datei).toMatch(/\/handouts\/N1-subnetting\.md$/);
|
||||
expect(treffer!.zeile).toBe(7); // Zeile mit rahmenplan:
|
||||
expect(treffer!.meldung).toContain('rahmenplan.yaml');
|
||||
});
|
||||
|
||||
it('meldet ein unbekanntes Lernfeld', () => {
|
||||
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('LF3', 'LF9') }));
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('LF9'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet ein Handout, dessen Woche es nicht gibt', () => {
|
||||
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('woche: 1', 'woche: 7') }));
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('keine Woche 7'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet ein Handout, das in seiner Woche nicht aufgeführt ist', () => {
|
||||
const fehler = fehlerVon(baueBeispiel({ 'wochen/01-netzwerk.yaml': WOCHE.replace('handouts: [N1]', 'handouts: []') }));
|
||||
// Sowohl das Handout beschwert sich als auch der Wochenplan über den Theorietag.
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('nicht unter handouts: aufgeführt'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet ein Kürzel, das nicht zum Themenblock passt', () => {
|
||||
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('id: N1', 'id: H1') }));
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('Anfangsbuchstaben N'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet doppelte Kürzel und nennt die andere Datei', () => {
|
||||
const fehler = fehlerVon(
|
||||
baueBeispiel({ 'handouts/N1-zweitfassung.md': HANDOUT.replace('titel: Subnetting rechnen', 'titel: Noch mal') }),
|
||||
);
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('gibt es schon in'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet einen Glossarverweis ins Leere', () => {
|
||||
const fehler = fehlerVon(baueBeispiel({ 'glossar.yaml': GLOSSAR.replace('siehe_auch: []', 'siehe_auch: [Gateway]') }));
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('Gateway'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet eine Woche ohne fünf Tage', () => {
|
||||
const gekuerzt = WOCHE.split('\n').slice(0, -3).join('\n');
|
||||
const fehler = fehlerVon(baueBeispiel({ 'wochen/01-netzwerk.yaml': gekuerzt }));
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('genau fünf Tage'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet ein Handout ohne Merkkarte', () => {
|
||||
const ohneKarten = HANDOUT.replace(/karten:\n( - .*\n| .*\n)+/, 'karten: []\n');
|
||||
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': ohneKarten }));
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('mindestens eine Merkkarte'))).toBe(true);
|
||||
});
|
||||
|
||||
it('meldet ein Handout ohne den Abschnitt „Das Wesentliche"', () => {
|
||||
const ohne = HANDOUT.replace('## Das Wesentliche', '## Irgendwas anderes');
|
||||
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': ohne }));
|
||||
expect(fehler!.fehler.some((f) => f.meldung.includes('Das Wesentliche'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fehlerdarstellung', () => {
|
||||
it('sammelt alle Fehler statt beim ersten abzubrechen', () => {
|
||||
const kaputt = HANDOUT.replace('[B2]', '[B9]').replace('LF3', 'LF9');
|
||||
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': kaputt }));
|
||||
expect(fehler!.fehler.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('nennt in der Meldung Datei und Zeile', () => {
|
||||
const fehler = fehlerVon(baueBeispiel({ 'handouts/N1-subnetting.md': HANDOUT.replace('[B2]', '[B9]') }));
|
||||
expect(fehler!.message).toMatch(/handouts\/N1-subnetting\.md:7/);
|
||||
expect(fehler!.message).toContain('npm run inhalte:pruefen');
|
||||
});
|
||||
|
||||
it('meldet kaputtes YAML mit der Zeile, statt einen Stapelabzug zu werfen', () => {
|
||||
const kaputt = RAHMENPLAN.replace('lernfelder:', 'lernfelder:\n - [ das ist kein gültiges YAML');
|
||||
const fehler = fehlerVon(baueBeispiel({ 'rahmenplan.yaml': kaputt }));
|
||||
expect(fehler).not.toBeNull();
|
||||
expect(fehler!.fehler[0]!.datei).toMatch(/rahmenplan\.yaml$/);
|
||||
expect(fehler!.fehler[0]!.zeile).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Die echten Inhalte des Repositorys', () => {
|
||||
it('lassen sich einlesen und sind in sich stimmig', () => {
|
||||
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
|
||||
|
||||
expect(alle.wochen.length).toBeGreaterThanOrEqual(4);
|
||||
expect(alle.handouts.length).toBeGreaterThanOrEqual(10);
|
||||
expect(alle.karten.length).toBeGreaterThanOrEqual(10);
|
||||
expect(alle.glossar.length).toBeGreaterThanOrEqual(20);
|
||||
expect(alle.positionen.map((p) => p.id)).toContain('A1');
|
||||
expect(alle.positionen.map((p) => p.id)).toContain('B2');
|
||||
expect(alle.positionen.map((p) => p.id)).toContain('C4');
|
||||
expect(alle.lernfelder).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('vergibt jeder Merkkarte eine eindeutige Kennung', () => {
|
||||
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
|
||||
const ids = alle.karten.map((k) => k.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { Fehlersammler } from './fehler';
|
||||
import { dateienIn, leseMarkdown, leseYaml, type GeleseneDatei } from './lesen';
|
||||
import { abschnitt, inAbschnitte, nurText, type Abschnitt } from './markdown';
|
||||
import {
|
||||
aufgabeKopfSchema,
|
||||
checklisteSchema,
|
||||
glossarSchema,
|
||||
handoutKopfSchema,
|
||||
kontrolleSchema,
|
||||
rahmenplanSchema,
|
||||
wocheSchema,
|
||||
type AufgabeKopf,
|
||||
type Berufsbildposition,
|
||||
type Checkliste,
|
||||
type GlossarEintrag,
|
||||
type HandoutKopf,
|
||||
type Kontrolle,
|
||||
type Lernfeld,
|
||||
type Rahmenplan,
|
||||
type Themenblock,
|
||||
type Woche,
|
||||
} from './schema';
|
||||
|
||||
/**
|
||||
* Liest alles unter content/ ein, prüft es und baut die Verweise auf.
|
||||
*
|
||||
* Grundsatz: Fehler werden gesammelt, nicht beim ersten geworfen. Wer zehn
|
||||
* Tippfehler eingebaut hat, will sie in einem Durchgang sehen.
|
||||
*/
|
||||
|
||||
export type Handout = HandoutKopf & {
|
||||
datei: string;
|
||||
rumpf: string;
|
||||
abschnitte: Abschnitt[];
|
||||
wesentlich: Abschnitt | undefined;
|
||||
uebersicht: Abschnitt | undefined;
|
||||
pruefungsfrage: Abschnitt | undefined;
|
||||
suchtext: string;
|
||||
};
|
||||
|
||||
export type Aufgabe = AufgabeKopf & {
|
||||
datei: string;
|
||||
rumpf: string;
|
||||
abschnitte: Abschnitt[];
|
||||
suchtext: string;
|
||||
};
|
||||
|
||||
export type Merkkarte = {
|
||||
id: string;
|
||||
handoutId: string;
|
||||
handoutTitel: string;
|
||||
block: string;
|
||||
woche: number;
|
||||
frage: string;
|
||||
antwort: string;
|
||||
};
|
||||
|
||||
export type Inhalte = {
|
||||
rahmenplan: Rahmenplan;
|
||||
positionen: Berufsbildposition[];
|
||||
positionNach: Map<string, Berufsbildposition>;
|
||||
lernfelder: Lernfeld[];
|
||||
lernfeldNach: Map<string, Lernfeld>;
|
||||
bloecke: Themenblock[];
|
||||
blockNach: Map<string, Themenblock>;
|
||||
|
||||
wochen: Woche[];
|
||||
wocheNach: Map<number, Woche>;
|
||||
|
||||
handouts: Handout[];
|
||||
handoutNach: Map<string, Handout>;
|
||||
handoutsNachBlock: Map<string, Handout[]>;
|
||||
|
||||
karten: Merkkarte[];
|
||||
karteNach: Map<string, Merkkarte>;
|
||||
|
||||
aufgaben: Aufgabe[];
|
||||
aufgabeNach: Map<string, Aufgabe>;
|
||||
|
||||
checklisten: Checkliste[];
|
||||
checklisteNach: Map<string, Checkliste>;
|
||||
|
||||
kontrollen: Kontrolle[];
|
||||
kontrolleNach: Map<string, Kontrolle>;
|
||||
|
||||
glossar: GlossarEintrag[];
|
||||
glossarNach: Map<string, GlossarEintrag>;
|
||||
|
||||
/** Alle Dateien, die eingelesen wurden – für das Neuladen im Entwicklungsbetrieb. */
|
||||
dateien: string[];
|
||||
gelesenAm: string;
|
||||
};
|
||||
|
||||
function anzeige(wurzel: string, absolut: string): string {
|
||||
return path.relative(path.dirname(wurzel), absolut).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export function ladeInhalte(wurzel: string): Inhalte {
|
||||
const sammler = new Fehlersammler();
|
||||
const dateien: string[] = [];
|
||||
|
||||
if (!fs.existsSync(wurzel)) {
|
||||
sammler.melde({
|
||||
datei: wurzel,
|
||||
meldung: 'Das Inhaltsverzeichnis gibt es nicht. Erwartet wird ein Ordner content/ neben package.json.',
|
||||
});
|
||||
sammler.wirfWennNoetig();
|
||||
}
|
||||
|
||||
const zeigeName = (absolut: string) => anzeige(wurzel, absolut);
|
||||
|
||||
// --- rahmenplan.yaml ----------------------------------------------------
|
||||
const rahmenplanDatei = path.join(wurzel, 'rahmenplan.yaml');
|
||||
dateien.push(rahmenplanDatei);
|
||||
const rahmenplanGelesen = leseYaml(rahmenplanDatei, zeigeName(rahmenplanDatei), rahmenplanSchema, sammler);
|
||||
|
||||
if (!rahmenplanGelesen) {
|
||||
// Ohne Rahmenplan lässt sich nichts weiter prüfen.
|
||||
sammler.wirfWennNoetig();
|
||||
throw new Error('unerreichbar');
|
||||
}
|
||||
|
||||
const rahmenplan = rahmenplanGelesen.werte;
|
||||
const positionNach = new Map(rahmenplan.berufsbildpositionen.map((p) => [p.id, p]));
|
||||
const lernfeldNach = new Map(rahmenplan.lernfelder.map((l) => [l.id, l]));
|
||||
const blockNach = new Map(rahmenplan.bloecke.map((b) => [b.id, b]));
|
||||
|
||||
pruefeDoppelte(rahmenplan.berufsbildpositionen.map((p) => p.id), rahmenplanGelesen, 'berufsbildpositionen', 'Berufsbildposition', sammler);
|
||||
pruefeDoppelte(rahmenplan.lernfelder.map((l) => l.id), rahmenplanGelesen, 'lernfelder', 'Lernfeld', sammler);
|
||||
pruefeDoppelte(rahmenplan.bloecke.map((b) => b.id), rahmenplanGelesen, 'bloecke', 'Themenblock', sammler);
|
||||
|
||||
for (const [index, position] of rahmenplan.berufsbildpositionen.entries()) {
|
||||
if (!rahmenplan.gruppen[position.gruppe]) {
|
||||
sammler.melde({
|
||||
datei: rahmenplanGelesen.datei,
|
||||
zeile: rahmenplanGelesen.zeileVon(['berufsbildpositionen', index, 'gruppe']),
|
||||
feld: `berufsbildpositionen[${index}].gruppe`,
|
||||
meldung: `Die Gruppe „${position.gruppe}" steht nicht unter gruppen:. Bekannt sind: ${Object.keys(rahmenplan.gruppen).join(', ')}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- wochen/*.yaml ------------------------------------------------------
|
||||
const wochen: Woche[] = [];
|
||||
const wochenQuelle = new Map<number, GeleseneDatei<Woche>>();
|
||||
|
||||
for (const absolut of dateienIn(path.join(wurzel, 'wochen'), ['.yaml', '.yml'])) {
|
||||
dateien.push(absolut);
|
||||
const gelesen = leseYaml(absolut, zeigeName(absolut), wocheSchema, sammler);
|
||||
if (!gelesen) continue;
|
||||
|
||||
if (wochenQuelle.has(gelesen.werte.nummer)) {
|
||||
sammler.melde({
|
||||
datei: gelesen.datei,
|
||||
zeile: gelesen.zeileVon(['nummer']),
|
||||
feld: 'nummer',
|
||||
meldung: `Die Wochennummer ${gelesen.werte.nummer} gibt es schon in ${wochenQuelle.get(gelesen.werte.nummer)!.datei}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
wochenQuelle.set(gelesen.werte.nummer, gelesen);
|
||||
wochen.push(gelesen.werte);
|
||||
}
|
||||
|
||||
wochen.sort((a, b) => a.nummer - b.nummer);
|
||||
const wocheNach = new Map(wochen.map((w) => [w.nummer, w]));
|
||||
|
||||
// --- handouts/*.md ------------------------------------------------------
|
||||
const handouts: Handout[] = [];
|
||||
const handoutQuelle = new Map<string, GeleseneDatei<HandoutKopf>>();
|
||||
|
||||
for (const absolut of dateienIn(path.join(wurzel, 'handouts'), '.md')) {
|
||||
dateien.push(absolut);
|
||||
const gelesen = leseMarkdown(absolut, zeigeName(absolut), handoutKopfSchema, sammler);
|
||||
if (!gelesen) continue;
|
||||
|
||||
const kopf = gelesen.werte;
|
||||
if (handoutQuelle.has(kopf.id)) {
|
||||
sammler.melde({
|
||||
datei: gelesen.datei,
|
||||
zeile: gelesen.zeileVon(['id']),
|
||||
feld: 'id',
|
||||
meldung: `Das Kürzel ${kopf.id} gibt es schon in ${handoutQuelle.get(kopf.id)!.datei}. Kürzel müssen eindeutig sein.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
handoutQuelle.set(kopf.id, gelesen);
|
||||
|
||||
const abschnitte = inAbschnitte(gelesen.rumpf);
|
||||
const wesentlich = abschnitt(abschnitte, 'Das Wesentliche');
|
||||
|
||||
if (!wesentlich) {
|
||||
sammler.melde({
|
||||
datei: gelesen.datei,
|
||||
meldung: 'Es fehlt der Abschnitt „## Das Wesentliche". Jedes Handout hat diesen festen Aufbau.',
|
||||
});
|
||||
}
|
||||
|
||||
handouts.push({
|
||||
...kopf,
|
||||
datei: gelesen.datei,
|
||||
rumpf: gelesen.rumpf,
|
||||
abschnitte,
|
||||
wesentlich,
|
||||
uebersicht: abschnitt(abschnitte, 'Übersicht'),
|
||||
pruefungsfrage: abschnitt(abschnitte, 'Prüfungsfrage'),
|
||||
suchtext: normalisiere(
|
||||
[kopf.id, kopf.titel, kopf.worum, kopf.merksatz, ...kopf.karten.flatMap((k) => [k.frage, k.antwort]), nurText(gelesen.rumpf)].join(
|
||||
' \n ',
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
handouts.sort((a, b) => vergleicheKuerzel(a.id, b.id));
|
||||
const handoutNach = new Map(handouts.map((h) => [h.id, h]));
|
||||
|
||||
// --- aufgaben/*.md ------------------------------------------------------
|
||||
const aufgaben: Aufgabe[] = [];
|
||||
const aufgabeQuelle = new Map<string, GeleseneDatei<AufgabeKopf>>();
|
||||
|
||||
for (const absolut of dateienIn(path.join(wurzel, 'aufgaben'), '.md')) {
|
||||
dateien.push(absolut);
|
||||
const gelesen = leseMarkdown(absolut, zeigeName(absolut), aufgabeKopfSchema, sammler);
|
||||
if (!gelesen) continue;
|
||||
|
||||
const kopf = gelesen.werte;
|
||||
if (aufgabeQuelle.has(kopf.id)) {
|
||||
sammler.melde({
|
||||
datei: gelesen.datei,
|
||||
zeile: gelesen.zeileVon(['id']),
|
||||
feld: 'id',
|
||||
meldung: `Die Aufgabenkennung ${kopf.id} gibt es schon in ${aufgabeQuelle.get(kopf.id)!.datei}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
aufgabeQuelle.set(kopf.id, gelesen);
|
||||
|
||||
const abschnitte = inAbschnitte(gelesen.rumpf);
|
||||
aufgaben.push({
|
||||
...kopf,
|
||||
datei: gelesen.datei,
|
||||
rumpf: gelesen.rumpf,
|
||||
abschnitte,
|
||||
suchtext: normalisiere([kopf.id, kopf.titel, kopf.ziel, ...kopf.kriterien, nurText(gelesen.rumpf)].join(' \n ')),
|
||||
});
|
||||
}
|
||||
|
||||
aufgaben.sort((a, b) => a.id.localeCompare(b.id, 'de-DE', { numeric: true }));
|
||||
const aufgabeNach = new Map(aufgaben.map((a) => [a.id, a]));
|
||||
|
||||
// --- checklisten/*.yaml -------------------------------------------------
|
||||
const checklisten: Checkliste[] = [];
|
||||
const checklisteQuelle = new Map<string, GeleseneDatei<Checkliste>>();
|
||||
|
||||
for (const absolut of dateienIn(path.join(wurzel, 'checklisten'), ['.yaml', '.yml'])) {
|
||||
dateien.push(absolut);
|
||||
const gelesen = leseYaml(absolut, zeigeName(absolut), checklisteSchema, sammler);
|
||||
if (!gelesen) continue;
|
||||
|
||||
const liste = gelesen.werte;
|
||||
if (checklisteQuelle.has(liste.id)) {
|
||||
sammler.melde({
|
||||
datei: gelesen.datei,
|
||||
zeile: gelesen.zeileVon(['id']),
|
||||
feld: 'id',
|
||||
meldung: `Die Checklistenkennung ${liste.id} gibt es schon in ${checklisteQuelle.get(liste.id)!.datei}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
checklisteQuelle.set(liste.id, gelesen);
|
||||
|
||||
const schrittIds = liste.gruppen.flatMap((g) => g.schritte.map((s) => s.id));
|
||||
pruefeDoppelte(schrittIds, gelesen, 'gruppen', 'Schrittkennung', sammler);
|
||||
|
||||
checklisten.push(liste);
|
||||
}
|
||||
|
||||
checklisten.sort((a, b) => a.id.localeCompare(b.id, 'de-DE', { numeric: true }));
|
||||
const checklisteNach = new Map(checklisten.map((c) => [c.id, c]));
|
||||
|
||||
// --- lernzielkontrollen/*.yaml ------------------------------------------
|
||||
const kontrollen: Kontrolle[] = [];
|
||||
const kontrolleQuelle = new Map<string, GeleseneDatei<Kontrolle>>();
|
||||
|
||||
for (const absolut of dateienIn(path.join(wurzel, 'lernzielkontrollen'), ['.yaml', '.yml'])) {
|
||||
dateien.push(absolut);
|
||||
const gelesen = leseYaml(absolut, zeigeName(absolut), kontrolleSchema, sammler);
|
||||
if (!gelesen) continue;
|
||||
|
||||
const kontrolle = gelesen.werte;
|
||||
if (kontrolleQuelle.has(kontrolle.id)) {
|
||||
sammler.melde({
|
||||
datei: gelesen.datei,
|
||||
zeile: gelesen.zeileVon(['id']),
|
||||
feld: 'id',
|
||||
meldung: `Die Kennung ${kontrolle.id} gibt es schon in ${kontrolleQuelle.get(kontrolle.id)!.datei}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
kontrolleQuelle.set(kontrolle.id, gelesen);
|
||||
pruefeDoppelte(kontrolle.fragen.map((f) => f.id), gelesen, 'fragen', 'Fragenkennung', sammler);
|
||||
kontrollen.push(kontrolle);
|
||||
}
|
||||
|
||||
kontrollen.sort((a, b) => a.woche - b.woche);
|
||||
const kontrolleNach = new Map(kontrollen.map((k) => [k.id, k]));
|
||||
|
||||
// --- glossar.yaml -------------------------------------------------------
|
||||
const glossarDatei = path.join(wurzel, 'glossar.yaml');
|
||||
dateien.push(glossarDatei);
|
||||
const glossarGelesen = leseYaml(glossarDatei, zeigeName(glossarDatei), glossarSchema, sammler);
|
||||
const glossar = glossarGelesen?.werte ?? [];
|
||||
const glossarNach = new Map(glossar.map((g) => [g.begriff.toLocaleLowerCase('de-DE'), g]));
|
||||
|
||||
if (glossarGelesen) {
|
||||
pruefeDoppelte(glossar.map((g) => g.begriff.toLocaleLowerCase('de-DE')), glossarGelesen, '', 'Glossarbegriff', sammler);
|
||||
|
||||
for (const [index, eintrag] of glossar.entries()) {
|
||||
for (const [j, verweis] of eintrag.siehe_auch.entries()) {
|
||||
if (!glossarNach.has(verweis.toLocaleLowerCase('de-DE'))) {
|
||||
sammler.melde({
|
||||
datei: glossarGelesen.datei,
|
||||
zeile: glossarGelesen.zeileVon([index, 'siehe_auch', j]),
|
||||
feld: `${eintrag.begriff}.siehe_auch`,
|
||||
meldung: `„${verweis}" steht nicht im Glossar.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Verweise prüfen ----------------------------------------------------
|
||||
pruefeVerweise({
|
||||
sammler,
|
||||
rahmenplan,
|
||||
positionNach,
|
||||
lernfeldNach,
|
||||
blockNach,
|
||||
wochen,
|
||||
wochenQuelle,
|
||||
handouts,
|
||||
handoutQuelle,
|
||||
handoutNach,
|
||||
aufgaben,
|
||||
aufgabeQuelle,
|
||||
aufgabeNach,
|
||||
checklisteNach,
|
||||
kontrolleNach,
|
||||
kontrollen,
|
||||
kontrolleQuelle,
|
||||
wocheNach,
|
||||
});
|
||||
|
||||
sammler.wirfWennNoetig();
|
||||
|
||||
// --- Ableitungen --------------------------------------------------------
|
||||
const handoutsNachBlock = new Map<string, Handout[]>();
|
||||
for (const block of rahmenplan.bloecke) handoutsNachBlock.set(block.id, []);
|
||||
for (const handout of handouts) {
|
||||
handoutsNachBlock.get(handout.block)!.push(handout);
|
||||
}
|
||||
|
||||
const karten: Merkkarte[] = handouts.flatMap((handout) =>
|
||||
handout.karten.map((karte, index) => ({
|
||||
id: `${handout.id}-${index + 1}`,
|
||||
handoutId: handout.id,
|
||||
handoutTitel: handout.titel,
|
||||
block: handout.block,
|
||||
woche: handout.woche,
|
||||
frage: karte.frage,
|
||||
antwort: karte.antwort,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
rahmenplan,
|
||||
positionen: rahmenplan.berufsbildpositionen,
|
||||
positionNach,
|
||||
lernfelder: rahmenplan.lernfelder,
|
||||
lernfeldNach,
|
||||
bloecke: rahmenplan.bloecke,
|
||||
blockNach,
|
||||
wochen,
|
||||
wocheNach,
|
||||
handouts,
|
||||
handoutNach,
|
||||
handoutsNachBlock,
|
||||
karten,
|
||||
karteNach: new Map(karten.map((k) => [k.id, k])),
|
||||
aufgaben,
|
||||
aufgabeNach,
|
||||
checklisten,
|
||||
checklisteNach,
|
||||
kontrollen,
|
||||
kontrolleNach,
|
||||
glossar,
|
||||
glossarNach,
|
||||
dateien,
|
||||
gelesenAm: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Hilfsmittel ----------------------------------------------------------
|
||||
|
||||
function pruefeDoppelte(
|
||||
werte: string[],
|
||||
quelle: GeleseneDatei<unknown>,
|
||||
feld: string,
|
||||
bezeichnung: string,
|
||||
sammler: Fehlersammler,
|
||||
): void {
|
||||
const gesehen = new Set<string>();
|
||||
for (const [index, wert] of werte.entries()) {
|
||||
if (gesehen.has(wert)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(feld ? [feld, index] : [index]),
|
||||
feld: feld || undefined,
|
||||
meldung: `${bezeichnung} „${wert}" kommt mehrfach vor.`,
|
||||
});
|
||||
}
|
||||
gesehen.add(wert);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sortiert N2 vor N10 – reine Zeichenkettensortierung tut das nicht. */
|
||||
function vergleicheKuerzel(a: string, b: string): number {
|
||||
const zerlege = (k: string) => [k.slice(0, 1), Number(k.slice(1))] as const;
|
||||
const [buchstabeA, zahlA] = zerlege(a);
|
||||
const [buchstabeB, zahlB] = zerlege(b);
|
||||
if (buchstabeA !== buchstabeB) return buchstabeA.localeCompare(buchstabeB, 'de-DE');
|
||||
return zahlA - zahlB;
|
||||
}
|
||||
|
||||
export function normalisiere(text: string): string {
|
||||
return text
|
||||
.toLocaleLowerCase('de-DE')
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
type Pruefkontext = {
|
||||
sammler: Fehlersammler;
|
||||
rahmenplan: Rahmenplan;
|
||||
positionNach: Map<string, Berufsbildposition>;
|
||||
lernfeldNach: Map<string, Lernfeld>;
|
||||
blockNach: Map<string, Themenblock>;
|
||||
wochen: Woche[];
|
||||
wocheNach: Map<number, Woche>;
|
||||
wochenQuelle: Map<number, GeleseneDatei<Woche>>;
|
||||
handouts: Handout[];
|
||||
handoutQuelle: Map<string, GeleseneDatei<HandoutKopf>>;
|
||||
handoutNach: Map<string, Handout>;
|
||||
aufgaben: Aufgabe[];
|
||||
aufgabeQuelle: Map<string, GeleseneDatei<AufgabeKopf>>;
|
||||
aufgabeNach: Map<string, Aufgabe>;
|
||||
checklisteNach: Map<string, Checkliste>;
|
||||
kontrollen: Kontrolle[];
|
||||
kontrolleQuelle: Map<string, GeleseneDatei<Kontrolle>>;
|
||||
kontrolleNach: Map<string, Kontrolle>;
|
||||
};
|
||||
|
||||
function pruefeVerweise(k: Pruefkontext): void {
|
||||
const { sammler } = k;
|
||||
|
||||
const bekanntePositionen = [...k.positionNach.keys()].join(', ');
|
||||
const bekannteLernfelder = [...k.lernfeldNach.keys()].join(', ');
|
||||
const bekannteBloecke = [...k.blockNach.keys()].join(', ');
|
||||
|
||||
// --- Handouts ---
|
||||
for (const handout of k.handouts) {
|
||||
const quelle = k.handoutQuelle.get(handout.id)!;
|
||||
|
||||
const block = k.blockNach.get(handout.block);
|
||||
if (!block) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['block']),
|
||||
feld: 'block',
|
||||
meldung: `Der Themenblock „${handout.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
|
||||
});
|
||||
} else if (!handout.id.startsWith(block.kuerzel)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['id']),
|
||||
feld: 'id',
|
||||
meldung: `Das Kürzel ${handout.id} passt nicht zum Block „${handout.block}" – dieser verlangt den Anfangsbuchstaben ${block.kuerzel}.`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, position] of handout.rahmenplan.entries()) {
|
||||
if (!k.positionNach.has(position)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['rahmenplan', index]),
|
||||
feld: 'rahmenplan',
|
||||
meldung: `Die Berufsbildposition „${position}" steht nicht in rahmenplan.yaml. Bekannt: ${bekanntePositionen}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!k.lernfeldNach.has(handout.lernfeld)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['lernfeld']),
|
||||
feld: 'lernfeld',
|
||||
meldung: `Das Lernfeld „${handout.lernfeld}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteLernfelder}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const woche = k.wocheNach.get(handout.woche);
|
||||
if (!woche) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['woche']),
|
||||
feld: 'woche',
|
||||
meldung: `Es gibt keine Woche ${handout.woche}. Jedes Handout muss zu einer Woche gehören.`,
|
||||
});
|
||||
} else if (!woche.handouts.includes(handout.id)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['woche']),
|
||||
feld: 'woche',
|
||||
meldung: `Das Handout nennt Woche ${handout.woche}, wird dort aber nicht unter handouts: aufgeführt. Trage ${handout.id} in ${k.wochenQuelle.get(handout.woche)!.datei} nach.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Aufgaben ---
|
||||
for (const aufgabe of k.aufgaben) {
|
||||
const quelle = k.aufgabeQuelle.get(aufgabe.id)!;
|
||||
|
||||
if (!k.blockNach.has(aufgabe.block)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['block']),
|
||||
feld: 'block',
|
||||
meldung: `Der Themenblock „${aufgabe.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, position] of aufgabe.rahmenplan.entries()) {
|
||||
if (!k.positionNach.has(position)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['rahmenplan', index]),
|
||||
feld: 'rahmenplan',
|
||||
meldung: `Die Berufsbildposition „${position}" steht nicht in rahmenplan.yaml. Bekannt: ${bekanntePositionen}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!k.lernfeldNach.has(aufgabe.lernfeld)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['lernfeld']),
|
||||
feld: 'lernfeld',
|
||||
meldung: `Das Lernfeld „${aufgabe.lernfeld}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteLernfelder}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const woche = k.wocheNach.get(aufgabe.woche);
|
||||
if (!woche) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['woche']),
|
||||
feld: 'woche',
|
||||
meldung: `Es gibt keine Woche ${aufgabe.woche}.`,
|
||||
});
|
||||
} else if (!woche.aufgaben.includes(aufgabe.id)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['woche']),
|
||||
feld: 'woche',
|
||||
meldung: `Die Aufgabe nennt Woche ${aufgabe.woche}, wird dort aber nicht unter aufgaben: aufgeführt.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lernzielkontrollen ---
|
||||
for (const kontrolle of k.kontrollen) {
|
||||
const quelle = k.kontrolleQuelle.get(kontrolle.id)!;
|
||||
if (!k.wocheNach.has(kontrolle.woche)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['woche']),
|
||||
feld: 'woche',
|
||||
meldung: `Es gibt keine Woche ${kontrolle.woche}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wochen ---
|
||||
for (const woche of k.wochen) {
|
||||
const quelle = k.wochenQuelle.get(woche.nummer)!;
|
||||
|
||||
if (!k.blockNach.has(woche.block)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['block']),
|
||||
feld: 'block',
|
||||
meldung: `Der Themenblock „${woche.block}" steht nicht in rahmenplan.yaml. Bekannt: ${bekannteBloecke}.`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, id] of woche.handouts.entries()) {
|
||||
if (!k.handoutNach.has(id)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['handouts', index]),
|
||||
feld: 'handouts',
|
||||
meldung: `Es gibt kein Handout mit dem Kürzel ${id}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, id] of woche.aufgaben.entries()) {
|
||||
if (!k.aufgabeNach.has(id)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['aufgaben', index]),
|
||||
feld: 'aufgaben',
|
||||
meldung: `Es gibt keine Aufgabe mit der Kennung ${id}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, id] of woche.checklisten.entries()) {
|
||||
if (!k.checklisteNach.has(id)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['checklisten', index]),
|
||||
feld: 'checklisten',
|
||||
meldung: `Es gibt keine Checkliste mit der Kennung ${id}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (woche.lernzielkontrolle && !k.kontrolleNach.has(woche.lernzielkontrolle)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['lernzielkontrolle']),
|
||||
feld: 'lernzielkontrolle',
|
||||
meldung: `Es gibt keine Lernzielkontrolle mit der Kennung „${woche.lernzielkontrolle}".`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [tagIndex, tag] of woche.tage.entries()) {
|
||||
for (const [index, id] of tag.theorie.entries()) {
|
||||
if (!k.handoutNach.has(id)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['tage', tagIndex, 'theorie', index]),
|
||||
feld: `tage[${tagIndex}].theorie`,
|
||||
meldung: `Es gibt kein Handout mit dem Kürzel ${id}.`,
|
||||
});
|
||||
} else if (!woche.handouts.includes(id)) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['tage', tagIndex, 'theorie', index]),
|
||||
feld: `tage[${tagIndex}].theorie`,
|
||||
meldung: `${id} steht am ${tag.tag} im Plan, fehlt aber in der Liste handouts: dieser Woche.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tage = woche.tage.map((t) => t.tag);
|
||||
const erwartet = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'];
|
||||
if (tage.join(',') !== erwartet.join(',')) {
|
||||
sammler.melde({
|
||||
datei: quelle.datei,
|
||||
zeile: quelle.zeileVon(['tage']),
|
||||
feld: 'tage',
|
||||
meldung: `Die Tage müssen in der Reihenfolge ${erwartet.join(', ')} stehen – gefunden: ${tage.join(', ')}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lücken in der Wochennummerierung ---
|
||||
for (let nummer = 1; nummer <= (k.wochen.at(-1)?.nummer ?? 0); nummer += 1) {
|
||||
if (!k.wocheNach.has(nummer)) {
|
||||
sammler.melde({
|
||||
datei: 'content/wochen/',
|
||||
meldung: `Woche ${nummer} fehlt, obwohl es spätere Wochen gibt. Die Nummerierung muss lückenlos sein.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { LineCounter, parseDocument, type Document } from 'yaml';
|
||||
import type { z } from 'zod';
|
||||
import type { Einzelfehler, Fehlersammler } from './fehler';
|
||||
|
||||
/**
|
||||
* Liest YAML- und Markdown-Dateien so ein, dass jeder Prüffehler auf eine Zeile
|
||||
* in der Ursprungsdatei zeigt. Dafür wird nicht `YAML.parse` benutzt, sondern
|
||||
* das Dokumentmodell – nur das kennt die Positionen der einzelnen Knoten.
|
||||
*/
|
||||
|
||||
export type GeleseneDatei<T> = {
|
||||
datei: string;
|
||||
werte: T;
|
||||
/** Findet die Zeile zu einem Pfad wie ['karten', 0, 'frage']. */
|
||||
zeileVon: (pfad: (string | number)[]) => number | undefined;
|
||||
};
|
||||
|
||||
type Leseergebnis = {
|
||||
dokument: Document.Parsed;
|
||||
zaehler: LineCounter;
|
||||
/** Anzahl der Zeilen, die vor dem YAML stehen (bei Markdown der `---`-Anfang). */
|
||||
versatz: number;
|
||||
};
|
||||
|
||||
function leseYamlDokument(text: string, versatz: number): Leseergebnis {
|
||||
const zaehler = new LineCounter();
|
||||
const dokument = parseDocument(text, { lineCounter: zaehler, keepSourceTokens: false });
|
||||
return { dokument, zaehler, versatz };
|
||||
}
|
||||
|
||||
function zeileFuerPfad(ergebnis: Leseergebnis, pfad: (string | number)[]): number | undefined {
|
||||
const { dokument, zaehler, versatz } = ergebnis;
|
||||
|
||||
// Vom vollständigen Pfad rückwärts kürzen, bis ein Knoten gefunden wird.
|
||||
// So landet man bei einem Tippfehler im Feldnamen wenigstens beim Elternknoten.
|
||||
for (let laenge = pfad.length; laenge >= 0; laenge -= 1) {
|
||||
const teil = pfad.slice(0, laenge);
|
||||
let knoten: unknown;
|
||||
try {
|
||||
knoten = teil.length === 0 ? dokument.contents : dokument.getIn(teil, true);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const bereich = (knoten as { range?: [number, number, number] } | undefined)?.range;
|
||||
if (bereich) {
|
||||
const stelle = zaehler.linePos(bereich[0]);
|
||||
return stelle.line + versatz;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Liest eine reine YAML-Datei und prüft sie gegen ein Zod-Schema. */
|
||||
export function leseYaml<S extends z.ZodTypeAny>(
|
||||
absolut: string,
|
||||
anzeigepfad: string,
|
||||
schema: S,
|
||||
sammler: Fehlersammler,
|
||||
): GeleseneDatei<z.output<S>> | null {
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(absolut, 'utf8');
|
||||
} catch (fehler) {
|
||||
sammler.melde({ datei: anzeigepfad, meldung: `Datei nicht lesbar: ${(fehler as Error).message}` });
|
||||
return null;
|
||||
}
|
||||
|
||||
let ergebnis: Leseergebnis;
|
||||
try {
|
||||
ergebnis = leseYamlDokument(text, 0);
|
||||
} catch (fehler) {
|
||||
sammler.melde({ datei: anzeigepfad, meldung: `YAML nicht lesbar: ${(fehler as Error).message}` });
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const fehler of ergebnis.dokument.errors) {
|
||||
const stelle = ergebnis.zaehler.linePos(fehler.pos[0]);
|
||||
sammler.melde({
|
||||
datei: anzeigepfad,
|
||||
zeile: stelle.line,
|
||||
spalte: stelle.col,
|
||||
meldung: fehler.message,
|
||||
});
|
||||
}
|
||||
if (ergebnis.dokument.errors.length > 0) return null;
|
||||
|
||||
return pruefe(ergebnis.dokument.toJS(), ergebnis, anzeigepfad, schema, sammler);
|
||||
}
|
||||
|
||||
/** Liest eine Markdown-Datei mit YAML-Kopf. Der Rumpf landet als `rumpf` im Ergebnis. */
|
||||
export function leseMarkdown<S extends z.ZodTypeAny>(
|
||||
absolut: string,
|
||||
anzeigepfad: string,
|
||||
schema: S,
|
||||
sammler: Fehlersammler,
|
||||
): (GeleseneDatei<z.output<S>> & { rumpf: string }) | null {
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(absolut, 'utf8');
|
||||
} catch (fehler) {
|
||||
sammler.melde({ datei: anzeigepfad, meldung: `Datei nicht lesbar: ${(fehler as Error).message}` });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!text.startsWith('---')) {
|
||||
sammler.melde({
|
||||
datei: anzeigepfad,
|
||||
zeile: 1,
|
||||
meldung: 'Die Datei beginnt nicht mit einem YAML-Kopf. Erwartet wird eine Zeile mit genau ---.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const getrennt = trenneKopfUndRumpf(text);
|
||||
if (!getrennt) {
|
||||
sammler.melde({
|
||||
datei: anzeigepfad,
|
||||
zeile: 1,
|
||||
meldung: 'Der YAML-Kopf wird nicht abgeschlossen. Erwartet wird eine weitere Zeile mit genau ---.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Der YAML-Kopf beginnt in Zeile 2 – die erste Zeile ist das ---.
|
||||
const ergebnis = leseYamlDokument(getrennt.kopf, 1);
|
||||
|
||||
for (const fehler of ergebnis.dokument.errors) {
|
||||
const stelle = ergebnis.zaehler.linePos(fehler.pos[0]);
|
||||
sammler.melde({
|
||||
datei: anzeigepfad,
|
||||
zeile: stelle.line + 1,
|
||||
spalte: stelle.col,
|
||||
meldung: fehler.message,
|
||||
});
|
||||
}
|
||||
if (ergebnis.dokument.errors.length > 0) return null;
|
||||
|
||||
const geprueft = pruefe(ergebnis.dokument.toJS(), ergebnis, anzeigepfad, schema, sammler);
|
||||
if (!geprueft) return null;
|
||||
|
||||
return { ...geprueft, rumpf: getrennt.rumpf.trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trennt den YAML-Kopf vom Markdown-Rumpf. Bewusst von Hand statt über eine
|
||||
* Bibliothek: Nur so ist sicher, dass der Zeilenversatz für die Fehlermeldungen
|
||||
* stimmt – und es ist eine Abhängigkeit weniger.
|
||||
*/
|
||||
function trenneKopfUndRumpf(text: string): { kopf: string; rumpf: string } | null {
|
||||
const treffer = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
||||
if (!treffer) return null;
|
||||
return { kopf: treffer[1] ?? '', rumpf: text.slice(treffer[0].length) };
|
||||
}
|
||||
|
||||
function pruefe<S extends z.ZodTypeAny>(
|
||||
rohwerte: unknown,
|
||||
ergebnis: Leseergebnis,
|
||||
anzeigepfad: string,
|
||||
schema: S,
|
||||
sammler: Fehlersammler,
|
||||
): GeleseneDatei<z.output<S>> | null {
|
||||
const ausgang = schema.safeParse(rohwerte ?? {});
|
||||
|
||||
if (!ausgang.success) {
|
||||
for (const problem of ausgang.error.issues) {
|
||||
const zeile = zeileFuerPfad(ergebnis, problem.path as (string | number)[]);
|
||||
sammler.melde({
|
||||
datei: anzeigepfad,
|
||||
zeile,
|
||||
feld: problem.path.length > 0 ? problem.path.join('.') : undefined,
|
||||
meldung: problem.message,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
datei: anzeigepfad,
|
||||
werte: ausgang.data,
|
||||
zeileVon: (pfad) => zeileFuerPfad(ergebnis, pfad),
|
||||
};
|
||||
}
|
||||
|
||||
/** Alle Dateien eines Verzeichnisses mit passender Endung, nach Namen sortiert. */
|
||||
export function dateienIn(verzeichnis: string, endung: string | string[]): string[] {
|
||||
if (!fs.existsSync(verzeichnis)) return [];
|
||||
const endungen = Array.isArray(endung) ? endung : [endung];
|
||||
return fs
|
||||
.readdirSync(verzeichnis)
|
||||
.filter((d) => endungen.some((e) => d.endsWith(e)))
|
||||
.sort()
|
||||
.map((d) => path.join(verzeichnis, d));
|
||||
}
|
||||
|
||||
/** Erzeugt aus einer Fundstelle einen Einzelfehler – Kurzform für die Prüfschritte. */
|
||||
export function fehlerAn(datei: string, zeile: number | undefined, feld: string, meldung: string): Einzelfehler {
|
||||
return { datei, zeile, feld, meldung };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
/**
|
||||
* Markdown wird beim Einlesen einmal nach HTML übersetzt und liegt dann fertig
|
||||
* im Speicher. Die Inhalte stammen aus dem Repository, sind also vertrauenswürdig
|
||||
* – trotzdem bleibt rohes HTML abgeschaltet, damit sich niemand daran gewöhnt.
|
||||
*/
|
||||
const uebersetzer = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: false,
|
||||
typographer: false,
|
||||
breaks: false,
|
||||
});
|
||||
|
||||
export function nachHtml(markdown: string): string {
|
||||
return uebersetzer.render(markdown).trim();
|
||||
}
|
||||
|
||||
export function nachHtmlInline(markdown: string): string {
|
||||
return uebersetzer.renderInline(markdown).trim();
|
||||
}
|
||||
|
||||
/** Entfernt Auszeichnung, damit der Text durchsucht werden kann. */
|
||||
export function nurText(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`]*)`/g, '$1')
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/^[#>\-*+|]+\s?/gm, ' ')
|
||||
.replace(/[*_~]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export type Abschnitt = { titel: string; markdown: string; html: string };
|
||||
|
||||
/**
|
||||
* Zerlegt den Rumpf eines Handouts an den Überschriften der zweiten Ebene.
|
||||
* Der feste Aufbau eines Handouts – Das Wesentliche, Übersicht, Prüfungsfrage –
|
||||
* wird dadurch für die Darstellung einzeln greifbar.
|
||||
*/
|
||||
export function inAbschnitte(rumpf: string): Abschnitt[] {
|
||||
const zeilen = rumpf.split('\n');
|
||||
const abschnitte: Abschnitt[] = [];
|
||||
let titel = '';
|
||||
let puffer: string[] = [];
|
||||
|
||||
const ablegen = () => {
|
||||
const markdown = puffer.join('\n').trim();
|
||||
if (titel || markdown) {
|
||||
abschnitte.push({ titel, markdown, html: nachHtml(markdown) });
|
||||
}
|
||||
puffer = [];
|
||||
};
|
||||
|
||||
for (const zeile of zeilen) {
|
||||
const treffer = /^##\s+(.+?)\s*$/.exec(zeile);
|
||||
if (treffer) {
|
||||
ablegen();
|
||||
titel = treffer[1]!;
|
||||
} else {
|
||||
puffer.push(zeile);
|
||||
}
|
||||
}
|
||||
ablegen();
|
||||
|
||||
return abschnitte.filter((a) => a.titel !== '' || a.markdown !== '');
|
||||
}
|
||||
|
||||
/** Sucht einen Abschnitt anhand seiner Überschrift, unabhängig von Groß- und Kleinschreibung. */
|
||||
export function abschnitt(abschnitte: Abschnitt[], titel: string): Abschnitt | undefined {
|
||||
const gesucht = titel.toLocaleLowerCase('de-DE');
|
||||
return abschnitte.find((a) => a.titel.toLocaleLowerCase('de-DE') === gesucht);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Die Gestalt der Inhaltsdateien. Fehlermeldungen sind bewusst so formuliert,
|
||||
* dass sie in der Startmeldung direkt lesbar sind – der Leser sitzt in VS Code
|
||||
* und will wissen, was er tippen soll.
|
||||
*/
|
||||
|
||||
const nichtLeer = (was: string) => z.string({ required_error: `${was} fehlt.` }).trim().min(1, `${was} darf nicht leer sein.`);
|
||||
|
||||
/** Kürzel eines Handouts: ein Buchstabe für den Block, dann eine Zahl. */
|
||||
export const handoutKuerzel = z
|
||||
.string()
|
||||
.regex(/^[A-Z]\d{1,2}$/, 'Kürzel muss aus einem Großbuchstaben und ein bis zwei Ziffern bestehen, z. B. N3.');
|
||||
|
||||
export const rahmenplanKuerzel = z
|
||||
.string()
|
||||
.regex(/^[ABC]\d{1,2}$/, 'Berufsbildposition muss wie A1, B2 oder C4 aussehen.');
|
||||
|
||||
export const lernfeldKuerzel = z.string().regex(/^LF\d{1,2}$/, 'Lernfeld muss wie LF3 aussehen.');
|
||||
|
||||
// --- rahmenplan.yaml ------------------------------------------------------
|
||||
|
||||
export const berufsbildpositionSchema = z.object({
|
||||
id: rahmenplanKuerzel,
|
||||
gruppe: nichtLeer('Die Gruppe'),
|
||||
titel: nichtLeer('Der Titel'),
|
||||
kurz: nichtLeer('Die Kurzform'),
|
||||
verordnung: nichtLeer('Die Fundstelle in der Verordnung'),
|
||||
});
|
||||
|
||||
export const lernfeldSchema = z.object({
|
||||
id: lernfeldKuerzel,
|
||||
titel: nichtLeer('Der Titel'),
|
||||
jahr: z.number().int().min(1).max(3),
|
||||
stunden: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const blockSchema = z.object({
|
||||
id: nichtLeer('Die Kennung des Blocks'),
|
||||
kuerzel: z.string().regex(/^[A-Z]$/, 'Das Blockkürzel ist genau ein Großbuchstabe.'),
|
||||
titel: nichtLeer('Der Titel'),
|
||||
farbe: z.string().optional(),
|
||||
});
|
||||
|
||||
export const rahmenplanSchema = z.object({
|
||||
quellen: z.object({
|
||||
betrieb: z.object({ titel: z.string(), kurz: z.string(), vom: z.coerce.string(), fundstelle: z.string() }),
|
||||
schule: z.object({ titel: z.string(), kurz: z.string(), vom: z.coerce.string() }),
|
||||
}),
|
||||
berufsbildpositionen: z.array(berufsbildpositionSchema).min(1, 'Es muss mindestens eine Berufsbildposition geben.'),
|
||||
gruppen: z.record(z.string()),
|
||||
lernfelder: z.array(lernfeldSchema).min(1, 'Es muss mindestens ein Lernfeld geben.'),
|
||||
bloecke: z.array(blockSchema).min(1, 'Es muss mindestens einen Themenblock geben.'),
|
||||
});
|
||||
|
||||
// --- handouts/*.md --------------------------------------------------------
|
||||
|
||||
export const karteSchema = z.object({
|
||||
frage: nichtLeer('Die Frage der Merkkarte'),
|
||||
antwort: nichtLeer('Die Antwort der Merkkarte'),
|
||||
});
|
||||
|
||||
export const handoutKopfSchema = z.object({
|
||||
id: handoutKuerzel,
|
||||
titel: nichtLeer('Der Titel'),
|
||||
block: nichtLeer('Der Themenblock'),
|
||||
woche: z.number().int().positive('Die Wochennummer muss eine positive ganze Zahl sein.'),
|
||||
worum: nichtLeer('Das Feld „worum" – ein Satz, worum es geht'),
|
||||
rahmenplan: z.array(rahmenplanKuerzel).min(1, 'Mindestens eine Berufsbildposition angeben.'),
|
||||
lernfeld: lernfeldKuerzel,
|
||||
merksatz: nichtLeer('Der Merksatz'),
|
||||
karten: z.array(karteSchema).min(1, 'Zu jedem Handout gehört mindestens eine Merkkarte.'),
|
||||
});
|
||||
|
||||
// --- aufgaben/*.md --------------------------------------------------------
|
||||
|
||||
export const aufgabeKopfSchema = z.object({
|
||||
id: z.coerce.string().regex(/^\d+\.\d+$/, 'Die Aufgabenkennung sieht aus wie 2.2 – Woche, Punkt, laufende Nummer.'),
|
||||
titel: nichtLeer('Der Titel'),
|
||||
woche: z.number().int().positive(),
|
||||
block: nichtLeer('Der Themenblock'),
|
||||
ziel: nichtLeer('Das Ziel der Aufgabe'),
|
||||
zeitrahmen: nichtLeer('Der Zeitrahmen'),
|
||||
rahmenplan: z.array(rahmenplanKuerzel).min(1, 'Mindestens eine Berufsbildposition angeben.'),
|
||||
lernfeld: lernfeldKuerzel,
|
||||
material: z.array(z.string().min(1)).default([]),
|
||||
kriterien: z.array(z.string().min(1)).min(1, 'Ohne Bewertungskriterien lässt sich die Aufgabe nicht abnehmen.'),
|
||||
});
|
||||
|
||||
// --- wochen/*.yaml --------------------------------------------------------
|
||||
|
||||
export const wochentagSchema = z.object({
|
||||
tag: z.enum(['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'], {
|
||||
errorMap: () => ({ message: 'Der Tag muss Montag, Dienstag, Mittwoch, Donnerstag oder Freitag heißen.' }),
|
||||
}),
|
||||
theorie: z.array(handoutKuerzel).default([]),
|
||||
praxis: z.string().default(''),
|
||||
});
|
||||
|
||||
export const wocheSchema = z.object({
|
||||
nummer: z.number().int().positive('Die Wochennummer muss eine positive ganze Zahl sein.'),
|
||||
titel: nichtLeer('Der Titel der Woche'),
|
||||
block: nichtLeer('Der Themenblock'),
|
||||
leitfrage: nichtLeer('Die Leitfrage'),
|
||||
ziel: nichtLeer('Das Wochenziel'),
|
||||
handouts: z.array(handoutKuerzel).default([]),
|
||||
aufgaben: z.array(z.coerce.string()).default([]),
|
||||
checklisten: z.array(z.string()).default([]),
|
||||
lernzielkontrolle: z.string().nullish().default(null),
|
||||
tage: z.array(wochentagSchema).length(5, 'Eine Woche hat genau fünf Tage – Montag bis Freitag.'),
|
||||
});
|
||||
|
||||
// --- checklisten/*.yaml ---------------------------------------------------
|
||||
|
||||
export const schrittSchema = z.object({
|
||||
id: nichtLeer('Die Schrittkennung'),
|
||||
text: nichtLeer('Der Text des Schrittes'),
|
||||
});
|
||||
|
||||
export const gruppenKennungen = ['vorbereiten', 'durchfuehren', 'pruefen', 'abschliessen'] as const;
|
||||
|
||||
export const checklistenGruppeSchema = z.object({
|
||||
id: z.enum(gruppenKennungen, {
|
||||
errorMap: () => ({ message: 'Gruppe muss vorbereiten, durchfuehren, pruefen oder abschliessen heißen.' }),
|
||||
}),
|
||||
titel: nichtLeer('Der Titel der Gruppe'),
|
||||
schritte: z.array(schrittSchema).min(1, 'Eine Gruppe ohne Schritte ergibt keinen Sinn.'),
|
||||
});
|
||||
|
||||
export const checklisteSchema = z.object({
|
||||
id: nichtLeer('Die Kennung der Checkliste'),
|
||||
titel: nichtLeer('Der Titel'),
|
||||
block: nichtLeer('Der Themenblock'),
|
||||
worum: nichtLeer('Ein Satz, worum es geht'),
|
||||
rahmenplan: z.array(rahmenplanKuerzel).default([]),
|
||||
lernfeld: lernfeldKuerzel.optional(),
|
||||
mit_ticket: z.boolean().default(false),
|
||||
gruppen: z.array(checklistenGruppeSchema).min(1, 'Mindestens eine Gruppe angeben.'),
|
||||
});
|
||||
|
||||
// --- lernzielkontrollen/*.yaml -------------------------------------------
|
||||
|
||||
export const kontrollfrageSchema = z.object({
|
||||
id: nichtLeer('Die Fragenkennung'),
|
||||
frage: nichtLeer('Der Fragetext'),
|
||||
punkte: z.number().int().positive('Punkte je Frage müssen eine positive ganze Zahl sein.'),
|
||||
musterloesung: nichtLeer('Die Musterlösung'),
|
||||
});
|
||||
|
||||
export const kontrolleSchema = z.object({
|
||||
id: nichtLeer('Die Kennung der Lernzielkontrolle'),
|
||||
woche: z.number().int().positive(),
|
||||
titel: nichtLeer('Der Titel'),
|
||||
dauer_minuten: z.number().int().positive().optional(),
|
||||
hinweis: z.string().default(''),
|
||||
fragen: z.array(kontrollfrageSchema).min(1, 'Eine Lernzielkontrolle ohne Fragen ergibt keinen Sinn.'),
|
||||
});
|
||||
|
||||
// --- glossar.yaml ---------------------------------------------------------
|
||||
|
||||
export const glossarEintragSchema = z.object({
|
||||
begriff: nichtLeer('Der Begriff'),
|
||||
steht_fuer: z.string().optional(),
|
||||
formen: z.array(z.string().min(1)).default([]),
|
||||
erklaerung: nichtLeer('Die Erklärung'),
|
||||
siehe_auch: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const glossarSchema = z.array(glossarEintragSchema).min(1, 'Das Glossar ist leer.');
|
||||
|
||||
// --- Typen ----------------------------------------------------------------
|
||||
|
||||
export type Berufsbildposition = z.infer<typeof berufsbildpositionSchema>;
|
||||
export type Lernfeld = z.infer<typeof lernfeldSchema>;
|
||||
export type Themenblock = z.infer<typeof blockSchema>;
|
||||
export type Rahmenplan = z.infer<typeof rahmenplanSchema>;
|
||||
export type Karte = z.infer<typeof karteSchema>;
|
||||
export type HandoutKopf = z.infer<typeof handoutKopfSchema>;
|
||||
export type AufgabeKopf = z.infer<typeof aufgabeKopfSchema>;
|
||||
export type Woche = z.infer<typeof wocheSchema>;
|
||||
export type Checkliste = z.infer<typeof checklisteSchema>;
|
||||
export type ChecklistenGruppe = z.infer<typeof checklistenGruppeSchema>;
|
||||
export type Kontrolle = z.infer<typeof kontrolleSchema>;
|
||||
export type Kontrollfrage = z.infer<typeof kontrollfrageSchema>;
|
||||
export type GlossarEintrag = z.infer<typeof glossarEintragSchema>;
|
||||
@@ -0,0 +1,141 @@
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ladeInhalte } from './laden';
|
||||
import { sucheHandouts, suchbegriffe } from './suche';
|
||||
import { musterFuer, verlinkeGlossar, glossarAnker } from './glossarverweise';
|
||||
|
||||
const alle = ladeInhalte(path.join(process.cwd(), 'content'));
|
||||
|
||||
describe('Suchbegriffe zerlegen', () => {
|
||||
it('trennt an Leerzeichen und ignoriert einzelne Buchstaben', () => {
|
||||
expect(suchbegriffe('subnetting rechnen a')).toEqual(['subnetting', 'rechnen']);
|
||||
});
|
||||
|
||||
it('hält eine Wortgruppe in Anführungszeichen zusammen', () => {
|
||||
expect(suchbegriffe('"das wesentliche"')).toEqual(['das wesentliche']);
|
||||
});
|
||||
|
||||
it('macht Umlaute und Großschreibung unerheblich', () => {
|
||||
expect(suchbegriffe('BLOCKGRÖSSE')).toEqual(['blockgroesse']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Handouts durchsuchen', () => {
|
||||
it('findet über den Titel', () => {
|
||||
const treffer = sucheHandouts(alle, 'subnetting');
|
||||
expect(treffer[0]!.handout.id).toBe('N3');
|
||||
});
|
||||
|
||||
it('findet über das Kürzel', () => {
|
||||
const treffer = sucheHandouts(alle, 'N3');
|
||||
expect(treffer.map((t) => t.handout.id)).toContain('N3');
|
||||
});
|
||||
|
||||
it('findet auch Text, der nur im Rumpf steht', () => {
|
||||
const treffer = sucheHandouts(alle, 'Kondensatoren');
|
||||
expect(treffer.map((t) => t.handout.id)).toContain('H1');
|
||||
});
|
||||
|
||||
it('findet über den Text einer Merkkarte', () => {
|
||||
const treffer = sucheHandouts(alle, 'Wearout');
|
||||
expect(treffer.map((t) => t.handout.id)).toContain('H2');
|
||||
});
|
||||
|
||||
it('verlangt, dass alle Begriffe vorkommen', () => {
|
||||
const einer = sucheHandouts(alle, 'netz');
|
||||
const beide = sucheHandouts(alle, 'netz kondensatoren');
|
||||
expect(beide.length).toBeLessThan(einer.length);
|
||||
expect(beide.every((t) => einer.some((e) => e.handout.id === t.handout.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('gewichtet den Titel höher als den Fließtext', () => {
|
||||
const treffer = sucheHandouts(alle, 'subnetting');
|
||||
const punkteN3 = treffer.find((t) => t.handout.id === 'N3')!.punkte;
|
||||
const andere = treffer.filter((t) => t.handout.id !== 'N3');
|
||||
for (const t of andere) expect(punkteN3).toBeGreaterThan(t.punkte);
|
||||
});
|
||||
|
||||
it('gibt bei leerer Eingabe nichts zurück statt alles', () => {
|
||||
expect(sucheHandouts(alle, '')).toEqual([]);
|
||||
expect(sucheHandouts(alle, ' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('ist unempfindlich gegen Umlaute und Großschreibung', () => {
|
||||
const mit = sucheHandouts(alle, 'Blockgröße');
|
||||
const ohne = sucheHandouts(alle, 'blockgroesse');
|
||||
expect(mit.map((t) => t.handout.id)).toEqual(ohne.map((t) => t.handout.id));
|
||||
expect(mit.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('liefert zu jedem Treffer einen Ausschnitt', () => {
|
||||
for (const treffer of sucheHandouts(alle, 'netz')) {
|
||||
expect(treffer.ausschnitt.length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Glossarbegriffe verlinken', () => {
|
||||
const glossar = alle.glossar;
|
||||
|
||||
it('verlinkt einen bekannten Begriff', () => {
|
||||
const html = verlinkeGlossar('<p>Die Subnetzmaske entscheidet.</p>', glossar);
|
||||
expect(html).toContain('href="/glossar#subnetzmaske"');
|
||||
expect(html).toContain('>Subnetzmaske</a>');
|
||||
});
|
||||
|
||||
it('verlinkt jeden Begriff höchstens einmal', () => {
|
||||
const html = verlinkeGlossar('<p>DNS hier, DNS da, DNS überall.</p>', glossar);
|
||||
expect(html.match(/glossarbegriff/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lässt Code, Überschriften und bestehende Verweise in Ruhe', () => {
|
||||
expect(verlinkeGlossar('<code>DNS</code>', glossar)).toBe('<code>DNS</code>');
|
||||
expect(verlinkeGlossar('<h2>DNS</h2>', glossar)).toBe('<h2>DNS</h2>');
|
||||
expect(verlinkeGlossar('<a href="/x">DNS</a>', glossar)).toBe('<a href="/x">DNS</a>');
|
||||
expect(verlinkeGlossar('<pre>DNS</pre>', glossar)).toBe('<pre>DNS</pre>');
|
||||
});
|
||||
|
||||
it('trifft nur ganze Wörter', () => {
|
||||
// „Ports" enthält „Port", ist aber ein anderes Wort mit Endung – hier soll
|
||||
// nichts passieren, weil die Form nicht angegeben ist.
|
||||
const html = verlinkeGlossar('<p>Transportweg und Portweite.</p>', glossar);
|
||||
expect(html).not.toContain('glossarbegriff');
|
||||
});
|
||||
|
||||
it('erkennt zusätzlich angegebene Formen', () => {
|
||||
const html = verlinkeGlossar('<p>Die Maske sagt es.</p>', glossar);
|
||||
expect(html).toContain('href="/glossar#subnetzmaske"');
|
||||
expect(html).toContain('>Maske</a>');
|
||||
});
|
||||
|
||||
it('bevorzugt die längere Form – MAC-Adresse wird nicht zu Adresse', () => {
|
||||
const html = verlinkeGlossar('<p>Die MAC-Adresse steht fest.</p>', glossar);
|
||||
expect(html).toContain('>MAC-Adresse</a>');
|
||||
});
|
||||
|
||||
it('maskiert Anführungszeichen in der Erklärung', () => {
|
||||
const html = verlinkeGlossar('<p>Der Begriff Test.</p>', [
|
||||
{ begriff: 'Test', formen: [], erklaerung: 'Ein "Anführungszeichen" und <spitze> Klammern.', siehe_auch: [] },
|
||||
]);
|
||||
expect(html).toContain('"Anf');
|
||||
expect(html).toContain('<spitze>');
|
||||
expect(html).not.toContain('title="Ein "Anführungszeichen"');
|
||||
});
|
||||
|
||||
it('lässt HTML unverändert, wenn das Glossar leer ist', () => {
|
||||
expect(verlinkeGlossar('<p>DNS</p>', [])).toBe('<p>DNS</p>');
|
||||
});
|
||||
|
||||
it('bildet Anker ohne Umlaute und Sonderzeichen', () => {
|
||||
expect(glossarAnker('Blockgröße')).toBe('blockgroesse');
|
||||
expect(glossarAnker('MAC-Adresse')).toBe('mac-adresse');
|
||||
expect(glossarAnker('Prinzip der geringsten Rechte')).toBe('prinzip-der-geringsten-rechte');
|
||||
});
|
||||
|
||||
it('ordnet die Suchmuster von lang nach kurz', () => {
|
||||
const muster = musterFuer(glossar);
|
||||
for (let i = 1; i < muster.length; i += 1) {
|
||||
expect(muster[i - 1]!.muster.source.length).toBeGreaterThanOrEqual(muster[i]!.muster.source.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { normalisiere, type Handout, type Inhalte } from './laden';
|
||||
|
||||
/**
|
||||
* Volltextsuche über die Handouts.
|
||||
*
|
||||
* Bewusst kein Suchindex und keine Bibliothek: Bei 52 Handouts ist ein
|
||||
* Durchlauf über alle Texte im Mikrosekundenbereich, und ein Suchindex wäre
|
||||
* eine zweite Wahrheit, die man pflegen müsste.
|
||||
*/
|
||||
|
||||
export type Fundstueck = {
|
||||
handout: Handout;
|
||||
punkte: number;
|
||||
ausschnitt: string;
|
||||
};
|
||||
|
||||
/** Zerlegt die Eingabe in Wörter; Anführungszeichen halten eine Wortgruppe zusammen. */
|
||||
export function suchbegriffe(eingabe: string): string[] {
|
||||
const treffer = eingabe.match(/"[^"]+"|\S+/g) ?? [];
|
||||
return treffer.map((t) => normalisiere(t.replace(/^"|"$/g, ''))).filter((t) => t.length > 1);
|
||||
}
|
||||
|
||||
export function sucheHandouts(alle: Inhalte, eingabe: string, grenze = 30): Fundstueck[] {
|
||||
const begriffe = suchbegriffe(eingabe);
|
||||
if (begriffe.length === 0) return [];
|
||||
|
||||
const gefunden: Fundstueck[] = [];
|
||||
|
||||
for (const handout of alle.handouts) {
|
||||
const titel = normalisiere(`${handout.id} ${handout.titel}`);
|
||||
const worum = normalisiere(handout.worum);
|
||||
const merksatz = normalisiere(handout.merksatz);
|
||||
|
||||
let punkte = 0;
|
||||
let alleEnthalten = true;
|
||||
|
||||
for (const begriff of begriffe) {
|
||||
let treffer = 0;
|
||||
if (titel.includes(begriff)) treffer += 10;
|
||||
if (worum.includes(begriff)) treffer += 5;
|
||||
if (merksatz.includes(begriff)) treffer += 4;
|
||||
if (handout.suchtext.includes(begriff)) treffer += 1;
|
||||
|
||||
if (treffer === 0) {
|
||||
alleEnthalten = false;
|
||||
break;
|
||||
}
|
||||
punkte += treffer;
|
||||
}
|
||||
|
||||
if (!alleEnthalten) continue;
|
||||
|
||||
gefunden.push({ handout, punkte, ausschnitt: ausschnittUm(handout, begriffe[0]!) });
|
||||
}
|
||||
|
||||
return gefunden
|
||||
.sort((a, b) => b.punkte - a.punkte || a.handout.id.localeCompare(b.handout.id, 'de-DE'))
|
||||
.slice(0, grenze);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Stück Text rund um den ersten Treffer, damit man in der Trefferliste
|
||||
* sieht, warum etwas gefunden wurde.
|
||||
*/
|
||||
function ausschnittUm(handout: Handout, begriff: string, laenge = 160): string {
|
||||
const stelle = handout.suchtext.indexOf(begriff);
|
||||
if (stelle === -1) return handout.worum;
|
||||
|
||||
// Der normalisierte Text hat dieselbe Länge wie das Original, weil nur
|
||||
// kleingeschrieben und Leerraum vereinheitlicht wird – bis auf Umlaute.
|
||||
// Für einen Ausschnitt reicht das allemal.
|
||||
const roh = `${handout.id} ${handout.titel} ${handout.worum} ${handout.merksatz} ${handout.rumpf}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const mitte = Math.min(stelle, Math.max(0, roh.length - 1));
|
||||
const von = Math.max(0, mitte - Math.floor(laenge / 3));
|
||||
const bis = Math.min(roh.length, von + laenge);
|
||||
|
||||
const teil = roh.slice(von, bis).trim();
|
||||
return `${von > 0 ? '… ' : ''}${teil}${bis < roh.length ? ' …' : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { datenbank, schema } from '../db';
|
||||
import { jetzt } from '../zeit';
|
||||
|
||||
/** Fortschritt beim Lesen der Handouts. */
|
||||
|
||||
export function gelesenSet(benutzerId: string): Set<string> {
|
||||
const zeilen = datenbank()
|
||||
.select({ handoutId: schema.handoutGelesen.handoutId })
|
||||
.from(schema.handoutGelesen)
|
||||
.where(eq(schema.handoutGelesen.benutzerId, benutzerId))
|
||||
.all();
|
||||
return new Set(zeilen.map((z) => z.handoutId));
|
||||
}
|
||||
|
||||
export function gelesenAm(benutzerId: string, handoutId: string): string | null {
|
||||
const zeile = datenbank()
|
||||
.select({ gelesenAm: schema.handoutGelesen.gelesenAm })
|
||||
.from(schema.handoutGelesen)
|
||||
.where(and(eq(schema.handoutGelesen.benutzerId, benutzerId), eq(schema.handoutGelesen.handoutId, handoutId)))
|
||||
.get();
|
||||
return zeile?.gelesenAm ?? null;
|
||||
}
|
||||
|
||||
export function alsGelesenMerken(benutzerId: string, handoutId: string): void {
|
||||
datenbank()
|
||||
.insert(schema.handoutGelesen)
|
||||
.values({ benutzerId, handoutId, gelesenAm: jetzt() })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
|
||||
export function alsUngelesenMerken(benutzerId: string, handoutId: string): void {
|
||||
datenbank()
|
||||
.delete(schema.handoutGelesen)
|
||||
.where(and(eq(schema.handoutGelesen.benutzerId, benutzerId), eq(schema.handoutGelesen.handoutId, handoutId)))
|
||||
.run();
|
||||
}
|
||||
|
||||
/** Anzahl gelesener Handouts – für die Fortschrittsanzeige. */
|
||||
export function anzahlGelesen(benutzerId: string): number {
|
||||
return gelesenSet(benutzerId).size;
|
||||
}
|
||||
Reference in New Issue
Block a user