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>
@@ -26,6 +26,53 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.2.0] – 2026-09-10
|
||||||
|
|
||||||
|
Die Inhalte kommen ins Spiel: Handouts, Suche, Glossar – alles aus Dateien
|
||||||
|
im Repository, beim Start geprüft.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Inhaltslader für `content/`: YAML und Markdown mit YAML-Kopf, Prüfung über
|
||||||
|
Zod-Schemata, Aufbau aller Querverweise
|
||||||
|
- **Fehlermeldungen mit Datei und Zeile.** Fehler werden gesammelt statt beim
|
||||||
|
ersten geworfen – wer zehn Tippfehler eingebaut hat, sieht sie in einem
|
||||||
|
Durchgang. Die Zeilennummer stammt aus dem YAML-Dokumentmodell, nicht aus
|
||||||
|
einer Schätzung
|
||||||
|
- Geprüft wird unter anderem: Existenz jeder referenzierten Kennung, Zugehörigkeit
|
||||||
|
jedes Handouts zu genau einer Woche (in beide Richtungen), Kürzel passend zum
|
||||||
|
Themenblock, Berufsbildpositionen und Lernfelder gegen `rahmenplan.yaml`,
|
||||||
|
lückenlose Wochennummerierung, doppelte Kennungen, Glossarverweise ins Leere
|
||||||
|
- `npm run inhalte:pruefen` prüft ohne Start und meldet zusätzlich, welche
|
||||||
|
Berufsbildpositionen bisher von keinem Inhalt berührt werden
|
||||||
|
- Handout-Ansicht mit dem festen Aufbau: Kürzel, Titel, „Worum es geht",
|
||||||
|
Das Wesentliche, Übersicht, hervorgehobener Merksatz, eingeklappte
|
||||||
|
Prüfungsfrage; dazu die Zuordnung zu Rahmenplan, Lernfeld und Woche
|
||||||
|
- Handout-Übersicht nach Themenblöcken, Filter je Block, Lesestand je Block
|
||||||
|
- Volltextsuche über Titel, Kurzbeschreibung, Merksatz, Merkkarten und
|
||||||
|
Fließtext – mit Gewichtung und Trefferausschnitt, unempfindlich gegen
|
||||||
|
Umlaute und Großschreibung
|
||||||
|
- „Als gelesen markieren" je Handout, gespeichert je Benutzer
|
||||||
|
- Glossar mit Suche; Begriffe werden in den Handouts automatisch verlinkt –
|
||||||
|
höchstens einmal je Seite, nie in Code, Überschriften oder bestehenden
|
||||||
|
Verweisen. Der Verweis ist ein echter Link mit Erklärung im title-Attribut,
|
||||||
|
damit er auch am Handy und mit der Tastatur nutzbar ist
|
||||||
|
- Neu einlesen bei Dateiänderung im Entwicklungsbetrieb; schlägt das fehl,
|
||||||
|
bleibt der bisherige Stand aktiv und der Fehler steht im Protokoll
|
||||||
|
- Beispielinhalte: 4 Wochen, 12 Handouts mit 24 Merkkarten, 4 Praxisaufgaben,
|
||||||
|
4 Checklisten, 2 Lernzielkontrollen mit Musterlösungen, 24 Glossareinträge
|
||||||
|
- `content/rahmenplan.yaml` mit allen Berufsbildpositionen der FIAusbV 2020
|
||||||
|
(Abschnitte A, C und F, hier als A1–A10, B1–B3, C1–C5) samt amtlicher
|
||||||
|
Fundstelle sowie allen zwölf Lernfeldern des KMK-Rahmenlehrplans
|
||||||
|
- `npm run screenshots` nimmt die Bilder für die Anleitung automatisiert auf
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- `gray-matter` entfernt: Der YAML-Kopf wird selbst herausgelöst. Die Bibliothek
|
||||||
|
merkt sich Ergebnisse und lieferte beim zweiten Einlesen desselben Textes ein
|
||||||
|
anderes Objekt; außerdem stimmt der Zeilenversatz für Fehlermeldungen jetzt
|
||||||
|
garantiert
|
||||||
|
|
||||||
## [0.1.0] – 2026-09-10
|
## [0.1.0] – 2026-09-10
|
||||||
|
|
||||||
Erster Baustein: das Fundament steht, die Anwendung lässt sich starten,
|
Erster Baustein: das Fundament steht, die Anwendung lässt sich starten,
|
||||||
@@ -58,4 +105,5 @@ anmelden und verwalten.
|
|||||||
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
- `npm run konto:anlegen` für das erste Ausbilderkonto
|
||||||
|
|
||||||
[Unveröffentlicht]: #unveröffentlicht
|
[Unveröffentlicht]: #unveröffentlicht
|
||||||
|
[0.2.0]: #020--2026-09-10
|
||||||
[0.1.0]: #010--2026-09-10
|
[0.1.0]: #010--2026-09-10
|
||||||
|
|||||||
@@ -7,6 +7,40 @@ Format: `HH:MM–HH:MM (Dauer) · was gemacht wurde`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 · Baustein 2 – Inhalte
|
||||||
|
|
||||||
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 07:05 | 07:15 | 0:10 | Remote eingetragen, Push scheitert an fehlenden Zugangsdaten – zurückgestellt, Arbeit läuft weiter |
|
||||||
|
| 07:15 | 07:25 | 0:10 | Ausbildungsrahmenplan und Lernfelder aus den Quellen geholt statt geraten (FIAusbV Anlage, KMK-Rahmenlehrplan) |
|
||||||
|
| 07:25 | 07:50 | 0:25 | Beispielinhalte geschrieben: 12 Handouts mit Merkkarten, 4 Wochenpläne, 4 Aufgaben, 4 Checklisten, 2 Lernzielkontrollen, 24 Glossareinträge |
|
||||||
|
| 07:50 | 08:20 | 0:30 | Inhaltslader: YAML-Dokumentmodell für Zeilennummern, Zod-Schemata, Fehlersammler, Querverweisprüfung |
|
||||||
|
| 08:20 | 08:35 | 0:15 | Handout-Ansicht, Übersicht nach Blöcken, Volltextsuche, Glossar mit automatischer Verlinkung |
|
||||||
|
| 08:35 | 08:50 | 0:15 | 39 neue Tests; zwei echte Fehler dabei gefunden (siehe unten) |
|
||||||
|
| 08:50 | 09:00 | 0:10 | Screenshot-Skript, Bilder aufgenommen, Fahrplan und Änderungsprotokoll nachgezogen |
|
||||||
|
|
||||||
|
**Summe Baustein 2: rund 1 h 55 min**
|
||||||
|
|
||||||
|
### Was aufgefallen ist
|
||||||
|
|
||||||
|
- Die Zeilennummer in der Fehlermeldung war den Aufwand wert. Beim ersten Lauf
|
||||||
|
gegen die echten Inhalte kam sofort `content/glossar.yaml:8 – Expected string,
|
||||||
|
received number`: `formen: [169.254]` hatte YAML als Gleitkommazahl gelesen.
|
||||||
|
Ohne Zeilenangabe hätte ich das in 24 Einträgen gesucht.
|
||||||
|
- `gray-matter` liefert beim zweiten Einlesen desselben Textes ein Objekt ohne
|
||||||
|
das Feld `matter` – im Betrieb fällt das nie auf, im Test sofort. Den YAML-Kopf
|
||||||
|
selbst herauszulösen ist zehn Zeilen Arbeit und beseitigt gleich zwei Probleme.
|
||||||
|
- Zod-Schemata mit `.default()` haben verschiedene Ein- und Ausgabetypen. Mit
|
||||||
|
`z.ZodType<T>` erzwingt TypeScript Gleichheit und leitet den falschen Typ ab;
|
||||||
|
`<S extends z.ZodTypeAny>` mit `z.output<S>` ist die richtige Form.
|
||||||
|
- Der Auftrag nennt für den Rahmenplan `C1` bis `C4`. Die Verordnung führt in
|
||||||
|
Abschnitt F aber **fünf** integrativ zu vermittelnde Positionen. Ich habe alle
|
||||||
|
fünf aufgenommen – sonst fehlt in der Abdeckung ausgerechnet die Position, die
|
||||||
|
2020 neu dazugekommen ist („Vernetztes Zusammenarbeiten unter Nutzung digitaler
|
||||||
|
Medien"). Siehe ENTSCHEIDUNGEN.md, E10.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-10 · Baustein 1 – Fundament
|
## 2026-09-10 · Baustein 1 – Fundament
|
||||||
|
|
||||||
| Von | Bis | Dauer | Tätigkeit |
|
| Von | Bis | Dauer | Tätigkeit |
|
||||||
|
|||||||
@@ -82,3 +82,56 @@ Erstfassung entsteht deshalb vollständig auf `feat/lernbar-erstfassung`.
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!-- Weitere Entscheidungen werden unten angefügt und fortlaufend nummeriert. -->
|
<!-- Weitere Entscheidungen werden unten angefügt und fortlaufend nummeriert. -->
|
||||||
|
|
||||||
|
## E10 · Rahmenplan: A1–A10, B1–B3, C1–**C5**
|
||||||
|
|
||||||
|
Der Auftrag nennt die Berufsbildpositionen `A 1 bis A 10`, `B 1 bis B 3` und
|
||||||
|
`C 1 bis C 4`. Die Verordnung selbst gliedert anders: Sie deckt alle vier
|
||||||
|
Fachrichtungen ab und hat deshalb sechs Abschnitte. Übersetzt:
|
||||||
|
|
||||||
|
| Kürzel hier | Abschnitt der FIAusbV | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 – A10 | Abschnitt A | fachrichtungsübergreifend |
|
||||||
|
| B1 – B3 | Abschnitt C | Fachrichtung Systemintegration |
|
||||||
|
| C1 – C5 | Abschnitt F | integrativ zu vermitteln |
|
||||||
|
|
||||||
|
Abweichung: Abschnitt F hat **fünf** Positionen, nicht vier. Die fünfte
|
||||||
|
(„Vernetztes Zusammenarbeiten unter Nutzung digitaler Medien") ist 2020 neu
|
||||||
|
dazugekommen. Sie wegzulassen würde ausgerechnet in der Abdeckungsansicht eine
|
||||||
|
Lücke erzeugen, die gegenüber der IHK zählt. Deshalb sind alle fünf drin.
|
||||||
|
|
||||||
|
Jede Position trägt im Feld `verordnung` ihre amtliche Fundstelle, damit die
|
||||||
|
Übersetzung nachvollziehbar bleibt.
|
||||||
|
|
||||||
|
## E11 · Kennung einer Merkkarte: Handout-Kürzel plus laufende Nummer
|
||||||
|
|
||||||
|
Eine Karte heißt `N3-1`, `N3-2` und so weiter – abgeleitet aus dem Handout und
|
||||||
|
der Reihenfolge im YAML-Kopf. Damit steht die Kennung fest, ohne dass sie in der
|
||||||
|
Datei gepflegt werden muss.
|
||||||
|
|
||||||
|
Der Preis: Wird eine Karte **mittendrin** eingefügt oder gelöscht, verschieben
|
||||||
|
sich die Kennungen der folgenden, und ihr Lernstand rutscht mit. Neue Karten
|
||||||
|
gehören deshalb ans Ende der Liste. Bei einem Handout mit ein bis drei Karten
|
||||||
|
ist das zumutbar; eine gepflegte Kennung je Karte wäre mehr Aufwand für die
|
||||||
|
Person, die Inhalte schreibt, als sie einbringt.
|
||||||
|
|
||||||
|
## E12 · Glossarverweise: Link statt Überfahren
|
||||||
|
|
||||||
|
Der Auftrag lässt die Wahl zwischen automatischer Verlinkung und einer
|
||||||
|
Erklärung beim Überfahren mit der Maus. Es wird beides, aber der Link führt:
|
||||||
|
Ein reiner `title`-Hinweis ist am Handy nicht erreichbar und mit der Tastatur
|
||||||
|
nur mit Mühe. Der Verweis zeigt auf die Stelle im Glossar und trägt die
|
||||||
|
Erklärung zusätzlich im `title`.
|
||||||
|
|
||||||
|
Zurückhaltung ist eingebaut: Jeder Begriff wird höchstens einmal je Seite
|
||||||
|
verlinkt, und nie innerhalb von Code, Überschriften, Tabellenköpfen oder
|
||||||
|
bestehenden Verweisen. Ein Text, in dem jedes zweite Wort unterstrichen ist,
|
||||||
|
liest sich schlechter als einer ganz ohne.
|
||||||
|
|
||||||
|
## E13 · Kein `gray-matter`
|
||||||
|
|
||||||
|
Der YAML-Kopf einer Markdown-Datei wird selbst herausgelöst (zehn Zeilen).
|
||||||
|
Gründe: `gray-matter` merkt sich Ergebnisse und liefert beim zweiten Einlesen
|
||||||
|
desselben Textes ein Objekt ohne das Feld `matter` – im Betrieb fällt das nie
|
||||||
|
auf, im Test sofort. Und der Zeilenversatz für die Fehlermeldungen ist so
|
||||||
|
nachweislich richtig statt nachgebaut.
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
|||||||
|
|
||||||
## 0.2 – Inhalte
|
## 0.2 – Inhalte
|
||||||
|
|
||||||
- [ ] **B2 · Inhalte einlesen und validieren, Handouts anzeigen, Suche**
|
- [x] **B2 · Inhalte einlesen und validieren, Handouts anzeigen, Suche**
|
||||||
- [ ] Markdown mit YAML-Frontmatter, Zod-Prüfung, Start bricht bei Fehler ab
|
- [x] Markdown mit YAML-Frontmatter, Zod-Prüfung, Start bricht bei Fehler ab
|
||||||
- [ ] Fehlermeldung nennt Datei und Zeile
|
- [x] Fehlermeldung nennt Datei und Zeile
|
||||||
- [ ] Handout-Ansicht mit festem Aufbau, Themenblöcke, „gelesen" merken
|
- [x] Handout-Ansicht mit festem Aufbau, Themenblöcke, „gelesen" merken
|
||||||
- [ ] Volltextsuche über alle Handouts
|
- [x] Volltextsuche über alle Handouts
|
||||||
- [ ] Neu einlesen bei Dateiänderung im Entwicklungsbetrieb
|
- [x] Neu einlesen bei Dateiänderung im Entwicklungsbetrieb
|
||||||
|
- [x] Glossar mit automatischer Verlinkung in den Handouts
|
||||||
|
- [x] Beispielinhalte: 4 Wochen, 12 Handouts, 24 Merkkarten, 4 Aufgaben,
|
||||||
|
4 Checklisten, 2 Lernzielkontrollen, 24 Glossareinträge
|
||||||
|
- [x] Rahmenplan aus FIAusbV und KMK-Rahmenlehrplan als Nachschlagewerk
|
||||||
|
|
||||||
## 0.3 – Woche und Wiederholung
|
## 0.3 – Woche und Wiederholung
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: "1.1"
|
||||||
|
titel: Einen Arbeitsplatz übernehmen und übergeben
|
||||||
|
woche: 1
|
||||||
|
block: arbeitsalltag
|
||||||
|
ziel: Du übernimmst einen frei gewordenen Arbeitsplatz, machst ihn für die Nachfolge einsatzbereit und dokumentierst den Vorgang so, dass er prüffähig ist.
|
||||||
|
zeitrahmen: 3 Stunden
|
||||||
|
rahmenplan: [A1, A7, C3]
|
||||||
|
lernfeld: LF1
|
||||||
|
material:
|
||||||
|
- Der frei gewordene Arbeitsplatzrechner samt Bildschirm, Tastatur und Maus
|
||||||
|
- Zugang zum Ticketsystem und zur Geräteliste
|
||||||
|
- Übergabeprotokoll (Vorlage beim Ausbilder)
|
||||||
|
- Externer Datenträger für die Sicherung
|
||||||
|
kriterien:
|
||||||
|
- Nutzerdaten wurden gesichert und die Sicherung stichprobenartig zurückgespielt
|
||||||
|
- Gerät ist in der Geräteliste mit Seriennummer, Standort und Zustand erfasst
|
||||||
|
- Alle personenbezogenen Daten des Vorgängers sind entfernt oder gesperrt
|
||||||
|
- Das Übergabeprotokoll ist vollständig und von beiden Seiten unterschrieben
|
||||||
|
- Der Vorgang ist als Ticket dokumentiert, Aufwand ist erfasst
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auftrag
|
||||||
|
|
||||||
|
Ein Kollege hat den Betrieb verlassen. Sein Arbeitsplatz soll in zwei Wochen von einer neuen
|
||||||
|
Kollegin übernommen werden. Bis dahin ist das Gerät bei dir.
|
||||||
|
|
||||||
|
Deine Aufgabe:
|
||||||
|
|
||||||
|
1. Nimm das Gerät auf: Seriennummer, Modell, Alter, Zustand, verbaute Ausstattung.
|
||||||
|
Trage alles in die Geräteliste ein und ergänze fehlende Angaben.
|
||||||
|
2. Sichere die Nutzerdaten des Vorgängers auf den bereitgestellten Datenträger und **prüfe
|
||||||
|
die Sicherung**, indem du eine beliebige Datei an anderer Stelle zurückspielst.
|
||||||
|
Kläre mit dem Ausbilder, wie lange diese Sicherung aufbewahrt wird.
|
||||||
|
3. Prüfe den Zustand des Datenträgers (SMART) und dokumentiere das Ergebnis. Bewerte, ob das
|
||||||
|
Gerät weiterverwendet werden kann.
|
||||||
|
4. Entferne beziehungsweise sperre das Konto des Vorgängers. Beachte: sperren, nicht löschen.
|
||||||
|
5. Fülle das Übergabeprotokoll aus und lass es vom Ausbilder gegenzeichnen.
|
||||||
|
6. Dokumentiere den gesamten Vorgang als Ticket – mit Aufwandsschätzung vorher und
|
||||||
|
tatsächlichem Aufwand nachher.
|
||||||
|
|
||||||
|
## Worauf es ankommt
|
||||||
|
|
||||||
|
Der eigentliche Prüfstein dieser Aufgabe ist nicht der technische Teil, sondern die
|
||||||
|
Dokumentation. Nimm an, du wärst in der Woche der Übernahme krank: Könnte ein Kollege
|
||||||
|
allein anhand deiner Einträge weiterarbeiten, ohne dich anzurufen?
|
||||||
|
|
||||||
|
## Zeit
|
||||||
|
|
||||||
|
Rechne mit drei Stunden. Wenn du deutlich darunter oder darüber liegst, notiere im
|
||||||
|
Berichtsheft, woran es lag – das ist Teil der Übung aus Handout A3.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
id: "2.1"
|
||||||
|
titel: Datenträger aus dem Lager beurteilen und aussortieren
|
||||||
|
woche: 2
|
||||||
|
block: hardware
|
||||||
|
ziel: Du beurteilst gebrauchte Datenträger anhand ihrer SMART-Werte, triffst eine begründete Entscheidung über Weiterverwendung und entsorgst die aussortierten datenschutzgerecht.
|
||||||
|
zeitrahmen: 2 Stunden
|
||||||
|
rahmenplan: [A3, A9, C4]
|
||||||
|
lernfeld: LF2
|
||||||
|
material:
|
||||||
|
- Fünf gebrauchte Datenträger aus dem Lager
|
||||||
|
- USB-Adapter für SATA und M.2
|
||||||
|
- Auslesewerkzeug für SMART-Werte
|
||||||
|
- Vernichtungsprotokoll des Betriebs
|
||||||
|
kriterien:
|
||||||
|
- Für jeden Datenträger sind Modell, Seriennummer, Betriebsstunden und die kritischen SMART-Werte erfasst
|
||||||
|
- Die Entscheidung Weiterverwendung oder Aussonderung ist je Datenträger begründet
|
||||||
|
- Datenträger mit Fehlern über null sind ausgesondert, nicht "unter Beobachtung"
|
||||||
|
- Aussonderungen sind im Vernichtungsprotokoll erfasst
|
||||||
|
- Weiterverwendbare Datenträger sind sicher gelöscht und beschriftet
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auftrag
|
||||||
|
|
||||||
|
Im Lager liegen fünf gebrauchte Datenträger ohne Kennzeichnung. Es soll entschieden werden,
|
||||||
|
welche davon noch in Testgeräte eingebaut werden können.
|
||||||
|
|
||||||
|
1. Lies zu jedem Datenträger Modell, Seriennummer, Betriebsstunden und die SMART-Werte aus.
|
||||||
|
Halte insbesondere Reallocated Sector Count, Current Pending Sector und – bei SSDs –
|
||||||
|
Percentage Used fest.
|
||||||
|
2. Entscheide je Datenträger: Weiterverwendung oder Aussonderung. **Begründe jede
|
||||||
|
Entscheidung in einem Satz.** „Sieht in Ordnung aus" ist keine Begründung.
|
||||||
|
3. Lösche die weiterverwendbaren Datenträger sicher und beschrifte sie mit Datum und
|
||||||
|
deinem Kürzel.
|
||||||
|
4. Trage die ausgesonderten Datenträger ins Vernichtungsprotokoll ein und übergib sie dem
|
||||||
|
Ausbilder. Kläre dabei, wie im Betrieb datenschutzgerecht entsorgt wird und warum ein
|
||||||
|
einfaches Formatieren dafür nicht ausreicht.
|
||||||
|
|
||||||
|
## Worauf es ankommt
|
||||||
|
|
||||||
|
Die häufigste Fehlentscheidung an dieser Stelle lautet „ein paar defekte Sektoren sind
|
||||||
|
normal, den beobachten wir". Werden sie nicht. Ein Datenträger mit wachsenden Fehlern
|
||||||
|
kostet später mehr Arbeitszeit, als er an Anschaffung spart – und im schlechten Fall Daten.
|
||||||
|
|
||||||
|
## Zeit
|
||||||
|
|
||||||
|
Zwei Stunden, davon etwa die Hälfte für das sichere Löschen. Das läuft nebenher, plane es
|
||||||
|
aber ein.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
id: "2.2"
|
||||||
|
titel: Drei Notebooks für den Außendienst vergleichen
|
||||||
|
woche: 2
|
||||||
|
block: hardware
|
||||||
|
ziel: Du erstellst eine nachvollziehbare Beschaffungsempfehlung für drei Notebooks anhand gewichteter Kriterien und begründest sie gegenüber dem Ausbilder.
|
||||||
|
zeitrahmen: 4 Stunden
|
||||||
|
rahmenplan: [A3, A2, A7]
|
||||||
|
lernfeld: LF2
|
||||||
|
material:
|
||||||
|
- Anforderungsbeschreibung des Außendienstes (beim Ausbilder)
|
||||||
|
- Zugang zu den Katalogen der beiden Hauslieferanten
|
||||||
|
- Tabellenkalkulation für die Nutzwertanalyse
|
||||||
|
kriterien:
|
||||||
|
- Der Einsatzzweck ist in eigenen Worten beschrieben, bevor Geräte gesucht wurden
|
||||||
|
- Mindestens fünf Kriterien sind festgelegt und begründet gewichtet
|
||||||
|
- Die Gewichtung wurde vor der Gerätesuche festgelegt und nicht nachträglich geändert
|
||||||
|
- Drei Geräte sind vollständig bewertet, Punktvergabe je Kriterium ist nachvollziehbar
|
||||||
|
- Gesamtkosten über vier Jahre sind berücksichtigt, nicht nur der Anschaffungspreis
|
||||||
|
- Die Empfehlung ist schriftlich und umfasst höchstens eine Seite
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auftrag
|
||||||
|
|
||||||
|
Der Außendienst braucht sechs neue Notebooks. Die Kollegen sind ganztägig unterwegs, oft
|
||||||
|
ohne Steckdose, gelegentlich auf Baustellen.
|
||||||
|
|
||||||
|
1. Schreibe zuerst den Einsatzzweck in eigenen Worten auf – **bevor** du dir ein Gerät
|
||||||
|
ansiehst. Kläre offene Punkte mit dem Ausbilder.
|
||||||
|
2. Leite daraus mindestens fünf Kriterien ab und gewichte sie. Lass die Gewichtung vom
|
||||||
|
Ausbilder abzeichnen, bevor du weitermachst.
|
||||||
|
3. Suche drei Geräte, die grundsätzlich in Frage kommen, und bewerte sie je Kriterium mit
|
||||||
|
1 bis 10 Punkten.
|
||||||
|
4. Rechne die Gesamtkosten über vier Jahre: Anschaffung, Garantieverlängerung, Zubehör,
|
||||||
|
absehbare Ersatzteile.
|
||||||
|
5. Schreibe eine Empfehlung von höchstens einer Seite: welches Gerät, warum, und was gegen
|
||||||
|
die beiden anderen spricht.
|
||||||
|
|
||||||
|
## Worauf es ankommt
|
||||||
|
|
||||||
|
Die Empfehlung muss auch dann tragen, wenn jemand die Gewichte anders setzen würde. Schreib
|
||||||
|
deshalb dazu, wie empfindlich das Ergebnis auf die Gewichtung reagiert – wenn ein Gerät
|
||||||
|
schon bei kleiner Verschiebung vorne läge, gehört das erwähnt.
|
||||||
|
|
||||||
|
## Zeit
|
||||||
|
|
||||||
|
Vier Stunden. Der größte Teil geht für Recherche drauf; die Analyse selbst ist in einer
|
||||||
|
Stunde gemacht, wenn die Kriterien stehen.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
id: "3.1"
|
||||||
|
titel: Vier gestörte Clients ins Netz bringen
|
||||||
|
woche: 3
|
||||||
|
block: netzwerk
|
||||||
|
ziel: Du grenzt vier unterschiedlich gestörte Arbeitsplätze schichtweise ein, bringst sie ins Netz und dokumentierst je Fall Ursache, Weg und Nachweis.
|
||||||
|
zeitrahmen: 3 Stunden
|
||||||
|
rahmenplan: [B2, A8, A1]
|
||||||
|
lernfeld: LF3
|
||||||
|
material:
|
||||||
|
- Vier vorbereitete Testrechner im Schulungsnetz
|
||||||
|
- Netzplan des Schulungsnetzes
|
||||||
|
- Kabeltester
|
||||||
|
- Zugang zum DHCP- und DNS-Server (lesend)
|
||||||
|
kriterien:
|
||||||
|
- Für jeden Fall ist die geprüfte Schicht und das verwendete Werkzeug dokumentiert
|
||||||
|
- Die tatsächliche Ursache ist benannt, nicht nur die Maßnahme
|
||||||
|
- Die Fehlersuche verlief von unten nach oben, Abweichungen sind begründet
|
||||||
|
- Alle vier Clients erreichen am Ende den Dateiserver über den Namen
|
||||||
|
- Je Fall liegt ein Nachweis vor (Bildschirmfoto oder abgetippte Ausgabe)
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auftrag
|
||||||
|
|
||||||
|
Im Schulungsnetz stehen vier Rechner, die alle „nicht ins Netz kommen". Die Ursachen sind
|
||||||
|
unterschiedlich. Einer davon hat mehr als einen Fehler.
|
||||||
|
|
||||||
|
Für jeden Rechner:
|
||||||
|
|
||||||
|
1. Nimm das Fehlerbild auf, **bevor** du etwas veränderst. Was genau geht nicht?
|
||||||
|
2. Arbeite dich von unten nach oben durch: Leitung und Verbindungsanzeige, Adresse und
|
||||||
|
Maske, Gateway, Port, Namensauflösung.
|
||||||
|
3. Halte fest, welches Werkzeug du auf welcher Schicht eingesetzt hast und was es ausgegeben
|
||||||
|
hat. Ein Bildschirmfoto oder die abgetippte Ausgabe reicht.
|
||||||
|
4. Benenne die Ursache. „Habe die IP neu geholt" ist die Maßnahme, nicht die Ursache.
|
||||||
|
5. Weise nach, dass der Fehler behoben ist: Zugriff auf den Dateiserver **über den Namen**,
|
||||||
|
nicht über die IP-Adresse.
|
||||||
|
|
||||||
|
## Worauf es ankommt
|
||||||
|
|
||||||
|
Der Rechner mit zwei Fehlern ist der eigentliche Prüfstein. Wer nach dem ersten Erfolg
|
||||||
|
aufhört zu prüfen, meldet ihn als erledigt und bekommt ihn morgen zurück. Prüfe deshalb bei
|
||||||
|
jedem Rechner am Ende den vollständigen Weg, auch wenn du glaubst, fertig zu sein.
|
||||||
|
|
||||||
|
## Zeit
|
||||||
|
|
||||||
|
Drei Stunden für alle vier. Wenn du an einem Rechner länger als 45 Minuten hängst, hol den
|
||||||
|
Ausbilder dazu – festbeißen gehört nicht zur Übung.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
id: 01-arbeitsplatz-uebergeben
|
||||||
|
titel: Arbeitsplatz übernehmen und übergeben
|
||||||
|
block: arbeitsalltag
|
||||||
|
worum: >-
|
||||||
|
Ein Gerät wechselt den Besitzer. Diese Liste stellt sicher, dass weder Daten
|
||||||
|
noch Zuständigkeiten dabei verloren gehen.
|
||||||
|
rahmenplan: [A1, A7, A6]
|
||||||
|
lernfeld: LF1
|
||||||
|
mit_ticket: true
|
||||||
|
|
||||||
|
gruppen:
|
||||||
|
- id: vorbereiten
|
||||||
|
titel: Vorbereiten
|
||||||
|
schritte:
|
||||||
|
- id: v1
|
||||||
|
text: Auftrag geklärt – wer gibt ab, wer übernimmt, bis wann?
|
||||||
|
- id: v2
|
||||||
|
text: Gerät in der Geräteliste gefunden oder neu erfasst (Seriennummer, Modell, Standort)
|
||||||
|
- id: v3
|
||||||
|
text: Externen Datenträger für die Sicherung bereitgelegt und freien Platz geprüft
|
||||||
|
- id: v4
|
||||||
|
text: Mit dem Vorgänger geklärt, welche Daten persönlich und welche dienstlich sind
|
||||||
|
|
||||||
|
- id: durchfuehren
|
||||||
|
titel: Durchführen
|
||||||
|
schritte:
|
||||||
|
- id: d1
|
||||||
|
text: Nutzerdaten gesichert
|
||||||
|
- id: d2
|
||||||
|
text: Sicherung stichprobenartig zurückgespielt und geöffnet
|
||||||
|
- id: d3
|
||||||
|
text: Lizenzen und installierte Fachanwendungen notiert
|
||||||
|
- id: d4
|
||||||
|
text: Konto des Vorgängers gesperrt (nicht gelöscht)
|
||||||
|
- id: d5
|
||||||
|
text: Persönliche Daten vom Gerät entfernt
|
||||||
|
- id: d6
|
||||||
|
text: Gerät gereinigt, Kabel und Zubehör auf Vollständigkeit geprüft
|
||||||
|
|
||||||
|
- id: pruefen
|
||||||
|
titel: Prüfen
|
||||||
|
schritte:
|
||||||
|
- id: p1
|
||||||
|
text: SMART-Werte des Datenträgers gelesen und bewertet
|
||||||
|
- id: p2
|
||||||
|
text: Gerät startet, meldet sich am Netz an, Fachanwendungen starten
|
||||||
|
- id: p3
|
||||||
|
text: Keine Zugriffe des Vorgängers mehr möglich (mit Testkonto geprüft)
|
||||||
|
|
||||||
|
- id: abschliessen
|
||||||
|
titel: Abschließen
|
||||||
|
schritte:
|
||||||
|
- id: a1
|
||||||
|
text: Übergabeprotokoll ausgefüllt und von beiden Seiten unterschrieben
|
||||||
|
- id: a2
|
||||||
|
text: Geräteliste aktualisiert (neuer Nutzer, Datum)
|
||||||
|
- id: a3
|
||||||
|
text: Aufbewahrungsfrist der Sicherung notiert und Wiedervorlage gesetzt
|
||||||
|
- id: a4
|
||||||
|
text: Ticket geschlossen – mit Ursache, Maßnahme und tatsächlichem Aufwand
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
id: 02-windows-installieren
|
||||||
|
titel: Windows 11 installieren und übergeben
|
||||||
|
block: system
|
||||||
|
worum: >-
|
||||||
|
Von der gesicherten Ausgangslage bis zum unterschriebenen Übergabeprotokoll.
|
||||||
|
Die Liste ist bewusst länger, als es sich anfühlt – genau die Schritte am Ende
|
||||||
|
sind es, die sonst vergessen werden.
|
||||||
|
rahmenplan: [B1, B3, A5]
|
||||||
|
lernfeld: LF2
|
||||||
|
mit_ticket: true
|
||||||
|
|
||||||
|
gruppen:
|
||||||
|
- id: vorbereiten
|
||||||
|
titel: Vorbereiten
|
||||||
|
schritte:
|
||||||
|
- id: v1
|
||||||
|
text: Nutzerdaten gesichert und Rückspielen einer Beispieldatei getestet
|
||||||
|
- id: v2
|
||||||
|
text: Lizenz geklärt (Datenträger, Volumenlizenz, im Gerät hinterlegt?)
|
||||||
|
- id: v3
|
||||||
|
text: Installationsmedium auf aktuellem Stand
|
||||||
|
- id: v4
|
||||||
|
text: Treiberpaket des Herstellers heruntergeladen (Chipsatz, Netzwerk, Grafik)
|
||||||
|
- id: v5
|
||||||
|
text: Liste der benötigten Fachanwendungen erstellt und Zugangsdaten geklärt
|
||||||
|
- id: v6
|
||||||
|
text: Rechnername und Netzwerkdaten festgelegt
|
||||||
|
|
||||||
|
- id: durchfuehren
|
||||||
|
titel: Durchführen
|
||||||
|
schritte:
|
||||||
|
- id: d1
|
||||||
|
text: Von Installationsmedium gestartet, Datenträger partitioniert
|
||||||
|
- id: d2
|
||||||
|
text: Windows installiert, lokales Administratorkonto angelegt
|
||||||
|
- id: d3
|
||||||
|
text: Chipsatztreiber zuerst, danach die übrigen Treiber eingespielt
|
||||||
|
- id: d4
|
||||||
|
text: Alle Aktualisierungen eingespielt, bis "Sie sind auf dem neuesten Stand" erscheint
|
||||||
|
- id: d5
|
||||||
|
text: Gerät in die Domäne aufgenommen
|
||||||
|
- id: d6
|
||||||
|
text: Fachanwendungen installiert und einmal gestartet
|
||||||
|
- id: d7
|
||||||
|
text: Datensicherung des Arbeitsplatzes eingerichtet
|
||||||
|
|
||||||
|
- id: pruefen
|
||||||
|
titel: Prüfen
|
||||||
|
schritte:
|
||||||
|
- id: p1
|
||||||
|
text: Geräte-Manager ohne unbekannte Geräte
|
||||||
|
- id: p2
|
||||||
|
text: Anmeldung mit dem echten Nutzerkonto erfolgreich
|
||||||
|
- id: p3
|
||||||
|
text: Netzlaufwerke verbunden, Drucker erreichbar, Testdruck erfolgt
|
||||||
|
- id: p4
|
||||||
|
text: Fachanwendungen mit Nutzerkonto angemeldet getestet
|
||||||
|
- id: p5
|
||||||
|
text: Datensicherung einmal ausgeführt und Ergebnis kontrolliert
|
||||||
|
- id: p6
|
||||||
|
text: Nutzerdaten aus der Sicherung zurückgespielt und auf Vollständigkeit geprüft
|
||||||
|
|
||||||
|
- id: abschliessen
|
||||||
|
titel: Abschließen
|
||||||
|
schritte:
|
||||||
|
- id: a1
|
||||||
|
text: Rechnername, Seriennummer, Lizenz und Datum dokumentiert
|
||||||
|
- id: a2
|
||||||
|
text: Anwender eingewiesen, Anmeldung gemeinsam durchgeführt
|
||||||
|
- id: a3
|
||||||
|
text: Übergabeprotokoll unterschrieben
|
||||||
|
- id: a4
|
||||||
|
text: Altgerät beziehungsweise alter Datenträger nach Vorgabe behandelt
|
||||||
|
- id: a5
|
||||||
|
text: Ticket geschlossen mit Ursache, Maßnahme und tatsächlichem Aufwand
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
id: 03-client-ins-netz-bringen
|
||||||
|
titel: Client ins Netz bringen
|
||||||
|
block: netzwerk
|
||||||
|
worum: >-
|
||||||
|
Die Reihenfolge ist der eigentliche Inhalt dieser Liste: von unten nach oben.
|
||||||
|
Wer bei DNS anfängt, sucht am längsten.
|
||||||
|
rahmenplan: [B2, A8]
|
||||||
|
lernfeld: LF3
|
||||||
|
mit_ticket: true
|
||||||
|
|
||||||
|
gruppen:
|
||||||
|
- id: vorbereiten
|
||||||
|
titel: Vorbereiten
|
||||||
|
schritte:
|
||||||
|
- id: v1
|
||||||
|
text: Fehlerbild aufgenommen, bevor etwas verändert wurde
|
||||||
|
- id: v2
|
||||||
|
text: Geklärt, ob nur dieser Arbeitsplatz betroffen ist oder mehrere
|
||||||
|
- id: v3
|
||||||
|
text: Netzplan und Anschlussdokumentation zur Hand
|
||||||
|
|
||||||
|
- id: durchfuehren
|
||||||
|
titel: Durchführen
|
||||||
|
schritte:
|
||||||
|
- id: d1
|
||||||
|
text: Schicht 1 – Kabel gesteckt, Verbindungsanzeige an Gerät und Anschlussdose leuchtet
|
||||||
|
- id: d2
|
||||||
|
text: Schicht 2 – Anschluss am Switch aktiv, richtiges VLAN zugewiesen
|
||||||
|
- id: d3
|
||||||
|
text: Schicht 3 – gültige IP-Adresse vorhanden (keine 169.254.x.x), Maske und Gateway geprüft
|
||||||
|
- id: d4
|
||||||
|
text: Gateway angepingt
|
||||||
|
- id: d5
|
||||||
|
text: Schicht 4 – benötigte Ports zum Zielsystem erreichbar
|
||||||
|
- id: d6
|
||||||
|
text: Schicht 7 – Namensauflösung geprüft, Zwischenspeicher geleert
|
||||||
|
|
||||||
|
- id: pruefen
|
||||||
|
titel: Prüfen
|
||||||
|
schritte:
|
||||||
|
- id: p1
|
||||||
|
text: Dateiserver über den Namen erreichbar, nicht nur über die IP-Adresse
|
||||||
|
- id: p2
|
||||||
|
text: Drucker erreichbar, Testdruck erfolgt
|
||||||
|
- id: p3
|
||||||
|
text: Nach einem Neustart weiterhin erreichbar
|
||||||
|
|
||||||
|
- id: abschliessen
|
||||||
|
titel: Abschließen
|
||||||
|
schritte:
|
||||||
|
- id: a1
|
||||||
|
text: Ursache benannt, nicht nur die Maßnahme
|
||||||
|
- id: a2
|
||||||
|
text: Anschluss- und Netzdokumentation aktualisiert, falls etwas nicht stimmte
|
||||||
|
- id: a3
|
||||||
|
text: Anwender informiert, dass er wieder arbeiten kann
|
||||||
|
- id: a4
|
||||||
|
text: Ticket geschlossen mit Ursache, Maßnahme und tatsächlichem Aufwand
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
id: 04-rechner-zerlegen
|
||||||
|
titel: Rechner zerlegen und wieder aufbauen
|
||||||
|
block: hardware
|
||||||
|
worum: >-
|
||||||
|
Übung und Wartungsvorgang zugleich. Der wichtigste Teil steht unter
|
||||||
|
"Vorbereiten" – ein statischer Schlag zerstört nicht laut, sondern leise.
|
||||||
|
rahmenplan: [A3, B1, C3]
|
||||||
|
lernfeld: LF2
|
||||||
|
mit_ticket: false
|
||||||
|
|
||||||
|
gruppen:
|
||||||
|
- id: vorbereiten
|
||||||
|
titel: Vorbereiten
|
||||||
|
schritte:
|
||||||
|
- id: v1
|
||||||
|
text: Gerät heruntergefahren, Netzstecker gezogen
|
||||||
|
- id: v2
|
||||||
|
text: Einschaltknopf einige Sekunden gedrückt, um Reststrom abzubauen
|
||||||
|
- id: v3
|
||||||
|
text: Antistatikband angelegt oder Gehäuse geerdet berührt
|
||||||
|
- id: v4
|
||||||
|
text: Arbeitsfläche freigeräumt, Schälchen für Schrauben bereit
|
||||||
|
- id: v5
|
||||||
|
text: Ausgangszustand fotografiert (Kabelführung, Steckplätze)
|
||||||
|
|
||||||
|
- id: durchfuehren
|
||||||
|
titel: Durchführen
|
||||||
|
schritte:
|
||||||
|
- id: d1
|
||||||
|
text: Seitenwand geöffnet, Innenraum entstaubt (Druckluft, nicht Staubsauger)
|
||||||
|
- id: d2
|
||||||
|
text: Erweiterungskarten entnommen und beschriftet abgelegt
|
||||||
|
- id: d3
|
||||||
|
text: Datenträger und Kabel entnommen, Anschlüsse notiert
|
||||||
|
- id: d4
|
||||||
|
text: Arbeitsspeicher entnommen, Bänke notiert
|
||||||
|
- id: d5
|
||||||
|
text: CPU-Kühler abgenommen, Wärmeleitpaste beurteilt
|
||||||
|
- id: d6
|
||||||
|
text: Netzteil ausgebaut und auf Auffälligkeiten geprüft
|
||||||
|
- id: d7
|
||||||
|
text: Alles in umgekehrter Reihenfolge wieder eingebaut
|
||||||
|
|
||||||
|
- id: pruefen
|
||||||
|
titel: Prüfen
|
||||||
|
schritte:
|
||||||
|
- id: p1
|
||||||
|
text: Sichtprüfung – keine losen Kabel, keine übrig gebliebenen Schrauben
|
||||||
|
- id: p2
|
||||||
|
text: Gerät startet, Firmware erkennt alle Datenträger und den vollen Arbeitsspeicher
|
||||||
|
- id: p3
|
||||||
|
text: Arbeitsspeicher läuft im Zweikanalbetrieb
|
||||||
|
- id: p4
|
||||||
|
text: Temperaturen unter Last im Rahmen, Lüfter drehen hoch
|
||||||
|
|
||||||
|
- id: abschliessen
|
||||||
|
titel: Abschließen
|
||||||
|
schritte:
|
||||||
|
- id: a1
|
||||||
|
text: Getauschte Teile mit Datum und Grund dokumentiert
|
||||||
|
- id: a2
|
||||||
|
text: Arbeitsplatz aufgeräumt, Altteile nach Vorgabe entsorgt
|
||||||
|
- id: a3
|
||||||
|
text: Auffälligkeiten dem Ausbilder gemeldet
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Glossar – Begriffe in verständlichem Deutsch erklärt.
|
||||||
|
# Begriffe, die in Handouts vorkommen, werden dort automatisch verlinkt.
|
||||||
|
# `formen` nennt weitere Schreibweisen, unter denen der Begriff im Text erkannt
|
||||||
|
# werden soll (Plural, Abkürzung, ausgeschriebene Fassung).
|
||||||
|
|
||||||
|
- begriff: APIPA
|
||||||
|
steht_fuer: Automatic Private IP Addressing
|
||||||
|
formen: ["169.254"]
|
||||||
|
erklaerung: >-
|
||||||
|
Die Notlösung eines Rechners, wenn kein DHCP-Server antwortet: Er gibt sich
|
||||||
|
selbst eine Adresse aus dem Bereich 169.254.x.x. Damit erreicht er nur andere
|
||||||
|
Geräte, die dasselbe getan haben. Praktisch ist es keine Adresse, sondern
|
||||||
|
eine Fehlermeldung mit vier Zahlen.
|
||||||
|
siehe_auch: [DHCP]
|
||||||
|
|
||||||
|
- begriff: Blockgröße
|
||||||
|
erklaerung: >-
|
||||||
|
Beim Subnetting die Anzahl der Adressen, die ein Netz umfasst – einschließlich
|
||||||
|
Netz- und Broadcastadresse. Sie ergibt sich aus 256 minus dem Maskenwert im
|
||||||
|
betreffenden Oktett und ist der Schlüssel zum Rechnen im Kopf.
|
||||||
|
siehe_auch: [Subnetzmaske, CIDR]
|
||||||
|
|
||||||
|
- begriff: Broadcast
|
||||||
|
erklaerung: >-
|
||||||
|
Eine Nachricht an alle Geräte im selben Netz. Die Broadcastadresse ist die
|
||||||
|
höchste Adresse eines Netzes und kann keinem Gerät zugewiesen werden.
|
||||||
|
siehe_auch: [Netzadresse]
|
||||||
|
|
||||||
|
- begriff: CIDR
|
||||||
|
steht_fuer: Classless Inter-Domain Routing
|
||||||
|
formen: [Präfixschreibweise]
|
||||||
|
erklaerung: >-
|
||||||
|
Die Schreibweise mit Schrägstrich, etwa 192.168.10.0/24. Die Zahl nach dem
|
||||||
|
Schrägstrich sagt, wie viele Bits von links das Netz bezeichnen. Kürzer und
|
||||||
|
weniger fehleranfällig als die Maske in vier Zahlen.
|
||||||
|
siehe_auch: [Subnetzmaske]
|
||||||
|
|
||||||
|
- begriff: DHCP
|
||||||
|
steht_fuer: Dynamic Host Configuration Protocol
|
||||||
|
erklaerung: >-
|
||||||
|
Der Dienst, der Geräten beim Einschalten eine IP-Adresse zuteilt – dazu
|
||||||
|
Maske, Gateway und DNS-Server. Die Adresse gilt auf Zeit und wird
|
||||||
|
selbstständig verlängert. Ablauf in vier Schritten: Discover, Offer, Request,
|
||||||
|
Acknowledge.
|
||||||
|
siehe_auch: [APIPA, DNS, Lease]
|
||||||
|
|
||||||
|
- begriff: DNS
|
||||||
|
steht_fuer: Domain Name System
|
||||||
|
erklaerung: >-
|
||||||
|
Übersetzt Namen in IP-Adressen. Wenn eine IP-Adresse erreichbar ist, der Name
|
||||||
|
aber nicht, liegt der Fehler hier. Gegenprobe mit nslookup.
|
||||||
|
siehe_auch: [DHCP, TTL]
|
||||||
|
|
||||||
|
- begriff: Gateway
|
||||||
|
formen: [Standardgateway]
|
||||||
|
erklaerung: >-
|
||||||
|
Der Ausgang aus dem eigenen Netz. Alles, was nicht im selben Netz liegt,
|
||||||
|
schickt ein Rechner an diese Adresse. Liegt das Gateway selbst nicht im
|
||||||
|
eigenen Netz, kommt der Rechner nirgendwo hin.
|
||||||
|
siehe_auch: [Subnetzmaske, Router]
|
||||||
|
|
||||||
|
- begriff: Lease
|
||||||
|
formen: [Mietdauer]
|
||||||
|
erklaerung: >-
|
||||||
|
Die Zeit, für die ein per DHCP vergebene Adresse gilt. Nach der Hälfte der
|
||||||
|
Zeit fragt der Client von sich aus nach Verlängerung.
|
||||||
|
siehe_auch: [DHCP]
|
||||||
|
|
||||||
|
- begriff: MAC-Adresse
|
||||||
|
steht_fuer: Media Access Control
|
||||||
|
erklaerung: >-
|
||||||
|
Die werkseitig vergebene Kennung einer Netzwerkschnittstelle, sechs
|
||||||
|
Byte lang. Sie gilt nur im selben Netz und wird an jedem Router ersetzt.
|
||||||
|
Grundlage für DHCP-Reservierungen.
|
||||||
|
siehe_auch: [Switch]
|
||||||
|
|
||||||
|
- begriff: Netzadresse
|
||||||
|
erklaerung: >-
|
||||||
|
Die kleinste Adresse eines Netzes, bei der alle Hostbits null sind. Sie
|
||||||
|
bezeichnet das Netz selbst und wird keinem Gerät zugewiesen.
|
||||||
|
siehe_auch: [Broadcast, Subnetzmaske]
|
||||||
|
|
||||||
|
- begriff: NTFS-Berechtigung
|
||||||
|
erklaerung: >-
|
||||||
|
Die Zugriffsrechte, die direkt am Dateisystem hängen. Sie gelten lokal wie
|
||||||
|
über das Netz. Trifft sie auf eine Freigabeberechtigung, setzt sich die
|
||||||
|
jeweils strengere durch.
|
||||||
|
siehe_auch: [Freigabeberechtigung, Vererbung]
|
||||||
|
|
||||||
|
- begriff: Freigabeberechtigung
|
||||||
|
erklaerung: >-
|
||||||
|
Die Rechte, die für den Zugriff über das Netz auf eine Freigabe gelten. In
|
||||||
|
der Praxis wird sie großzügig gesetzt und die eigentliche Steuerung über
|
||||||
|
NTFS-Berechtigungen gemacht – sonst sucht man den Fehler an zwei Stellen.
|
||||||
|
siehe_auch: [NTFS-Berechtigung]
|
||||||
|
|
||||||
|
- begriff: Vererbung
|
||||||
|
erklaerung: >-
|
||||||
|
Berechtigungen eines Ordners gelten für alles darunter, solange die Vererbung
|
||||||
|
nicht unterbrochen wird. Jede Unterbrechung muss dokumentiert werden – sonst
|
||||||
|
versteht sie in zwei Jahren niemand mehr.
|
||||||
|
siehe_auch: [NTFS-Berechtigung]
|
||||||
|
|
||||||
|
- begriff: Prinzip der geringsten Rechte
|
||||||
|
formen: [Least Privilege]
|
||||||
|
erklaerung: >-
|
||||||
|
Jedes Konto bekommt genau die Rechte, die es für seine Aufgabe braucht, und
|
||||||
|
nicht mehr. Auf Zeit vergebene Rechte werden wieder entzogen. Der wirksamste
|
||||||
|
einzelne Schutz gegen Schadsoftware und Fehlbedienung.
|
||||||
|
siehe_auch: [NTFS-Berechtigung]
|
||||||
|
|
||||||
|
- begriff: Port
|
||||||
|
erklaerung: >-
|
||||||
|
Die Nummer, die auf einem Rechner den Dienst bezeichnet – 443 für HTTPS, 445
|
||||||
|
für Dateifreigaben. Ist eine IP-Adresse erreichbar, ein Dienst aber nicht,
|
||||||
|
ist meist ein Port gesperrt.
|
||||||
|
siehe_auch: [Firewall]
|
||||||
|
|
||||||
|
- begriff: Firewall
|
||||||
|
erklaerung: >-
|
||||||
|
Filtert Datenverkehr nach Regeln – nach Adresse, Port und je nach Bauart
|
||||||
|
auch nach Anwendung. Eine Firewall, deren Regeln niemand mehr versteht,
|
||||||
|
schützt weniger, als sie stört.
|
||||||
|
siehe_auch: [Port]
|
||||||
|
|
||||||
|
- begriff: Router
|
||||||
|
erklaerung: >-
|
||||||
|
Verbindet verschiedene Netze und entscheidet anhand der IP-Adresse, wohin ein
|
||||||
|
Paket weitergeht. Arbeitet auf Schicht 3.
|
||||||
|
siehe_auch: [Switch, Gateway]
|
||||||
|
|
||||||
|
- begriff: Switch
|
||||||
|
erklaerung: >-
|
||||||
|
Verteilt Datenpakete innerhalb eines Netzes anhand der MAC-Adresse. Arbeitet
|
||||||
|
auf Schicht 2. Viele Geräte können zusätzlich routen – bei der Fehlersuche
|
||||||
|
muss klar sein, in welcher Rolle das Gerät gerade arbeitet.
|
||||||
|
siehe_auch: [Router, MAC-Adresse, VLAN]
|
||||||
|
|
||||||
|
- begriff: VLAN
|
||||||
|
steht_fuer: Virtual Local Area Network
|
||||||
|
erklaerung: >-
|
||||||
|
Eine logische Aufteilung eines Switches in mehrere getrennte Netze. Ein
|
||||||
|
Arbeitsplatz im falschen VLAN verhält sich wie einer mit gezogenem Kabel –
|
||||||
|
er bekommt keine passende Adresse.
|
||||||
|
siehe_auch: [Switch]
|
||||||
|
|
||||||
|
- begriff: SMART
|
||||||
|
steht_fuer: Self-Monitoring, Analysis and Reporting Technology
|
||||||
|
erklaerung: >-
|
||||||
|
Die Selbstauskunft eines Datenträgers über seinen Zustand. Reallocated und
|
||||||
|
Pending Sectors über null bedeuten Tausch. Ein sauberer Wert ist keine
|
||||||
|
Garantie, ein auffälliger aber eine verlässliche Warnung.
|
||||||
|
siehe_auch: [Datenträger]
|
||||||
|
|
||||||
|
- begriff: Subnetzmaske
|
||||||
|
formen: [Maske, Netzmaske]
|
||||||
|
erklaerung: >-
|
||||||
|
Legt fest, welcher Teil einer IP-Adresse das Netz bezeichnet und welcher den
|
||||||
|
einzelnen Rechner. Sie beantwortet genau eine Frage: Kann ich mein Ziel
|
||||||
|
direkt erreichen oder muss ich über das Gateway?
|
||||||
|
siehe_auch: [CIDR, Gateway, Blockgröße]
|
||||||
|
|
||||||
|
- begriff: TTL
|
||||||
|
steht_fuer: Time to Live
|
||||||
|
erklaerung: >-
|
||||||
|
Die Gültigkeitsdauer eines DNS-Eintrags im Zwischenspeicher. Wer einen Server
|
||||||
|
umzieht, setzt die TTL vorher herunter – sonst zeigen Clients tagelang auf
|
||||||
|
die alte Adresse.
|
||||||
|
siehe_auch: [DNS]
|
||||||
|
|
||||||
|
- begriff: Ticket
|
||||||
|
erklaerung: >-
|
||||||
|
Der dokumentierte Vorgang zu einer Störung oder einem Auftrag. Es ist nicht
|
||||||
|
Verwaltungsaufwand neben der Arbeit, sondern die einzige Dokumentation der
|
||||||
|
Arbeit.
|
||||||
|
siehe_auch: []
|
||||||
|
|
||||||
|
- begriff: Datenträger
|
||||||
|
formen: [Festplatte, SSD]
|
||||||
|
erklaerung: >-
|
||||||
|
Der dauerhafte Speicher eines Rechners. Festplatten verschleißen mechanisch,
|
||||||
|
SSDs durch Schreibvorgänge – die Kennzahlen für den Zustand sind deshalb
|
||||||
|
unterschiedlich.
|
||||||
|
siehe_auch: [SMART]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
id: A1
|
||||||
|
titel: Ein Ticket schreiben, das keine Rückfrage auslöst
|
||||||
|
block: arbeitsalltag
|
||||||
|
woche: 1
|
||||||
|
worum: Ein gutes Ticket beantwortet vorab die drei Fragen, die sonst als Rückfrage zurückkommen.
|
||||||
|
rahmenplan: [A1, A2]
|
||||||
|
lernfeld: LF6
|
||||||
|
merksatz: Wer, was, seit wann – und was schon versucht wurde. Alles andere ist Beiwerk.
|
||||||
|
karten:
|
||||||
|
- frage: Welche vier Angaben gehören in jedes Ticket, bevor du es abschickst?
|
||||||
|
antwort: Betroffene Person und Gerät, beobachtetes Verhalten im Wortlaut, Zeitpunkt des ersten Auftretens, und was bereits versucht wurde.
|
||||||
|
- frage: Warum ist „Drucker geht nicht" ein schlechter Betreff?
|
||||||
|
antwort: Er nennt weder Gerät noch Fehlerbild. Besser ist „Kyocera Flur 2 – Druckaufträge bleiben in der Warteschlange, seit heute 08:00".
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Ein Ticket pro Problem.** Zwei Störungen in einem Ticket führen dazu, dass eine davon
|
||||||
|
beim Schließen untergeht.
|
||||||
|
- **Betreff wie eine Schlagzeile:** Gerät oder Dienst, Fehlerbild, Zeitpunkt. Der Betreff
|
||||||
|
muss für sich allein verständlich sein – in der Übersicht sieht man nichts anderes.
|
||||||
|
- **Fehlermeldungen im Wortlaut**, abgetippt oder als Bildschirmfoto, nicht sinngemäß.
|
||||||
|
„Irgendwas mit Netzwerk" kostet später eine halbe Stunde.
|
||||||
|
- **Was schon versucht wurde**, gehört hinein. Sonst macht es der Nächste noch einmal.
|
||||||
|
- **Erreichbarkeit und Dringlichkeit** notieren: Wann ist die Person am Platz? Blockiert die
|
||||||
|
Störung die Arbeit oder ist sie lästig?
|
||||||
|
- **Beim Schließen** schreibst du auf, was die Ursache war und was du getan hast – nicht
|
||||||
|
„erledigt". Das Ticket ist die Dokumentation, und zwar die einzige.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Feld | Schlecht | Gut |
|
||||||
|
|------|----------|-----|
|
||||||
|
| Betreff | Drucker geht nicht | Kyocera Flur 2 – Aufträge bleiben in Warteschlange, seit 08:00 |
|
||||||
|
| Beschreibung | Fehler beim Drucken | Meldung „Der Spooler-Dienst wird nicht ausgeführt", betrifft alle Nutzer in Halle 2 |
|
||||||
|
| Bereits versucht | *(leer)* | Neustart des Clients, Drucker aus- und eingeschaltet |
|
||||||
|
| Abschluss | erledigt | Warteschlange geleert, Spooler-Dienst neu gestartet; Ursache war ein hängender Auftrag von PC-042 |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Nennen Sie drei Angaben, die eine Störungsmeldung enthalten muss, damit sie ohne
|
||||||
|
Rückfrage bearbeitet werden kann, und begründen Sie jeweils kurz.
|
||||||
|
|
||||||
|
**Antwort:** (1) Betroffenes System oder Gerät – sonst ist unklar, wo gesucht werden muss.
|
||||||
|
(2) Beobachtetes Verhalten samt Fehlermeldung im Wortlaut – erlaubt die Eingrenzung ohne
|
||||||
|
Rückfrage. (3) Zeitpunkt des ersten Auftretens – erlaubt den Abgleich mit Änderungen und
|
||||||
|
Protokolldateien. Zusätzlich sinnvoll: bereits durchgeführte Maßnahmen und die
|
||||||
|
Erreichbarkeit des Melders.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
id: A2
|
||||||
|
titel: Das Gespräch am Telefon annehmen
|
||||||
|
block: arbeitsalltag
|
||||||
|
woche: 1
|
||||||
|
worum: Wer zuerst zuhört und dann in eigenen Worten wiederholt, spart sich die Hälfte der Rückfragen.
|
||||||
|
rahmenplan: [A2]
|
||||||
|
lernfeld: LF6
|
||||||
|
merksatz: Ausreden lassen, zusammenfassen, bestätigen lassen – erst dann den Rechner anfassen.
|
||||||
|
karten:
|
||||||
|
- frage: Was tust du im Kundengespräch, bevor du mit der Lösung beginnst?
|
||||||
|
antwort: Das Problem in eigenen Worten zusammenfassen und bestätigen lassen. So fällt sofort auf, wenn du etwas anderes verstanden hast als gemeint war.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Melden mit Name und Bereich.** „IT-Abteilung, Berger, guten Tag." Der Anrufer weiß
|
||||||
|
dann, mit wem er spricht, und du musst es nicht nachreichen.
|
||||||
|
- **Ausreden lassen.** Auch wenn nach zehn Sekunden klar scheint, worum es geht – die
|
||||||
|
letzten zwanzig enthalten oft die entscheidende Angabe.
|
||||||
|
- **Zurückspiegeln:** „Ich fasse zusammen: Seit heute Morgen kommen Sie nicht mehr ins
|
||||||
|
Warenwirtschaftssystem, am Nachbarplatz geht es. Habe ich das richtig?"
|
||||||
|
- **Keine Schuldzuweisung**, auch nicht scherzhaft. „Da haben Sie was verstellt" schließt
|
||||||
|
die Tür für die nächste Meldung.
|
||||||
|
- **Verbindlich abschließen:** Was passiert als Nächstes, bis wann, und wer meldet sich.
|
||||||
|
Lieber „ich melde mich bis 14 Uhr" als „ich schaue mal".
|
||||||
|
- **Sofort notieren.** Was du nicht innerhalb einer Minute ins Ticket schreibst, ist weg.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Satz, den man sich sparen sollte | Besser |
|
||||||
|
|---|---|
|
||||||
|
| Das kann eigentlich nicht sein. | Das habe ich so noch nicht gesehen – ich schaue es mir an. |
|
||||||
|
| Haben Sie schon neu gestartet? | Damit ich nichts doppelt mache: Was haben Sie bisher probiert? |
|
||||||
|
| Ich melde mich. | Ich melde mich heute bis 14 Uhr, auch wenn ich noch keine Lösung habe. |
|
||||||
|
| Da ist wohl der Server schuld. | Ich prüfe, ob es am Arbeitsplatz oder am Dienst liegt. |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Warum ist es sinnvoll, das geschilderte Problem vor Beginn der Fehlersuche mit
|
||||||
|
eigenen Worten zu wiederholen?
|
||||||
|
|
||||||
|
**Antwort:** Weil Missverständnisse dadurch sofort auffallen, solange sie noch nichts
|
||||||
|
gekostet haben. Der Kunde kann korrigieren, bevor Arbeitszeit in die falsche Richtung
|
||||||
|
fließt. Zusätzlich zeigt die Zusammenfassung, dass zugehört wurde – das senkt die
|
||||||
|
Anspannung und verbessert die Zusammenarbeit.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
id: A3
|
||||||
|
titel: Aufwand schätzen, ohne sich zu verschätzen
|
||||||
|
block: arbeitsalltag
|
||||||
|
woche: 1
|
||||||
|
worum: Eine Schätzung ist kein Versprechen, aber sie muss begründet und nachprüfbar sein.
|
||||||
|
rahmenplan: [A1, A7]
|
||||||
|
lernfeld: LF1
|
||||||
|
merksatz: In Arbeitsschritte zerlegen, jeden Schritt schätzen, den Puffer offen ausweisen.
|
||||||
|
karten:
|
||||||
|
- frage: Warum schätzt man Arbeitsschritte einzeln statt die Aufgabe als Ganzes?
|
||||||
|
antwort: Weil sich Schätzfehler beim Zerlegen ausgleichen statt aufzuaddieren, und weil sichtbar wird, welcher Schritt die Unsicherheit trägt.
|
||||||
|
- frage: Was gehört neben der reinen Arbeitszeit in eine Aufwandsschätzung?
|
||||||
|
antwort: Wege, Rüstzeiten, Wartezeiten auf Dritte, die Dokumentation und ein offen ausgewiesener Puffer für Unvorhergesehenes.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Zerlegen vor Schätzen.** Eine Aufgabe, die du nicht in Schritte zerlegen kannst, hast du
|
||||||
|
noch nicht verstanden – und kannst sie folglich nicht schätzen.
|
||||||
|
- **Drei Werte statt einem:** bester Fall, wahrscheinlicher Fall, schlechtester Fall. Wer nur
|
||||||
|
eine Zahl nennt, nennt immer den besten Fall.
|
||||||
|
- **Wartezeit ist keine Arbeitszeit**, kostet aber Durchlaufzeit. Beides getrennt nennen.
|
||||||
|
- **Der Puffer wird ausgewiesen, nicht versteckt.** Ein versteckter Puffer wird beim ersten
|
||||||
|
Nachverhandeln weggekürzt, ein begründeter nicht.
|
||||||
|
- **Nach der Arbeit vergleichen:** geschätzt gegen gebraucht. Nur so wird die nächste
|
||||||
|
Schätzung besser. Das ist kein Tadel, das ist Handwerk.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Schritt | Bester Fall | Wahrscheinlich | Schlechtester Fall |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Gerät abholen und aufbauen | 15 min | 20 min | 40 min |
|
||||||
|
| Windows installieren | 30 min | 45 min | 90 min |
|
||||||
|
| Treiber und Fachanwendungen | 20 min | 40 min | 120 min |
|
||||||
|
| Ins Netz bringen, Funktionstest | 15 min | 20 min | 45 min |
|
||||||
|
| Dokumentation und Übergabe | 10 min | 15 min | 25 min |
|
||||||
|
| **Summe** | **1:30 h** | **2:20 h** | **5:20 h** |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Ein Kunde fragt, wie lange die Einrichtung von zehn neuen Arbeitsplätzen dauert.
|
||||||
|
Wie gehen Sie bei der Schätzung vor?
|
||||||
|
|
||||||
|
**Antwort:** Zuerst die Arbeit für einen Arbeitsplatz in Schritte zerlegen und jeden Schritt
|
||||||
|
schätzen. Dann prüfen, welche Schritte sich bei zehn Geräten vorbereiten oder
|
||||||
|
parallelisieren lassen – ein einmal erstelltes Abbild ersetzt zehn Installationen, die Zeit
|
||||||
|
steigt also nicht linear. Rüst-, Weg- und Übergabezeiten je Gerät gesondert ansetzen,
|
||||||
|
Wartezeiten auf Zulieferungen als Durchlaufzeit ausweisen und einen begründeten Puffer
|
||||||
|
offenlegen. Das Ergebnis als Spanne nennen, nicht als eine Zahl.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: H1
|
||||||
|
titel: Der Rechner von innen – was wo sitzt und warum
|
||||||
|
block: hardware
|
||||||
|
woche: 2
|
||||||
|
worum: Wer die Baugruppen und ihre Anschlüsse kennt, findet den Fehler durch Hinschauen statt durch Raten.
|
||||||
|
rahmenplan: [A3, B1]
|
||||||
|
lernfeld: LF2
|
||||||
|
merksatz: Strom, Signal, Kühlung – an einem dieser drei hängt fast jeder Hardwarefehler.
|
||||||
|
karten:
|
||||||
|
- frage: Woran erkennst du beim Öffnen eines PCs sofort ein defektes Netzteil oder Mainboard?
|
||||||
|
antwort: An aufgewölbten oder ausgelaufenen Elektrolytkondensatoren, an Brandspuren und an Verfärbungen rund um die Spannungswandler.
|
||||||
|
- frage: In welcher Reihenfolge steckst du RAM-Module bei zwei Modulen und vier Bänken?
|
||||||
|
antwort: In die beiden gleichfarbigen Bänke desselben Kanalpaares, meist Bank 2 und 4. Nur so läuft der Speicher im Zweikanalbetrieb – das Handbuch entscheidet.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Vor dem Öffnen:** Netzstecker ziehen, Reststrom über den Einschaltknopf abbauen,
|
||||||
|
erden. Ein statischer Schlag zerstört nicht sofort, sondern schleichend.
|
||||||
|
- **Mainboard** ist der Verteiler: CPU-Sockel, Speicherbänke, Steckplätze, Anschlüsse.
|
||||||
|
Alles hängt daran, deshalb ist es die letzte Verdächtige, nicht die erste.
|
||||||
|
- **Netzteil** liefert die Spannungen. Zu knapp bemessen oder gealtert äußert es sich als
|
||||||
|
sporadischer Neustart unter Last – nicht als sauberer Ausfall.
|
||||||
|
- **Arbeitsspeicher** paarweise und in die richtigen Bänke. Falsch bestückt läuft der
|
||||||
|
Rechner, aber langsamer, und niemand merkt es.
|
||||||
|
- **Datenträger:** SATA braucht Daten- und Stromkabel, M.2/NVMe steckt direkt auf dem
|
||||||
|
Board und teilt sich Leitungen mit anderen Steckplätzen.
|
||||||
|
- **Kühlung** ist Wartung, nicht Kosmetik. Verstaubte Kühlkörper drosseln die CPU, das
|
||||||
|
Gerät wird „einfach langsam".
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Baugruppe | Typisches Fehlerbild | Erster Handgriff |
|
||||||
|
|---|---|---|
|
||||||
|
| Netzteil | Neustart unter Last, gar kein Start | Ersatznetzteil anschließen |
|
||||||
|
| Arbeitsspeicher | Bluescreens ohne Muster, Startton | Module einzeln testen, Bänke tauschen |
|
||||||
|
| Datenträger | Hängen, lange Ladezeiten, Klicken | SMART-Werte auslesen |
|
||||||
|
| Kühlung | Drosselung, Lüfter dauerhaft laut | Temperaturen auslesen, entstauben |
|
||||||
|
| Mainboard | gar kein Lebenszeichen, keine Anzeige | Sichtprüfung Kondensatoren, Diagnose-Anzeige |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Ein Arbeitsplatzrechner startet mehrmals täglich ohne Fehlermeldung neu, sonst
|
||||||
|
läuft er unauffällig. Wie grenzen Sie die Ursache ein?
|
||||||
|
|
||||||
|
**Antwort:** Zuerst die Protokolle des Betriebssystems auf Kernel-Power-Ereignisse prüfen –
|
||||||
|
sie unterscheiden Absturz von sauberem Neustart. Dann Temperaturen und Lüfterdrehzahlen
|
||||||
|
unter Last beobachten (Drosselung oder Abschaltung durch Überhitzung). Anschließend
|
||||||
|
Arbeitsspeicher mit einem Speichertest prüfen und die Spannungsversorgung bewerten:
|
||||||
|
Neustarts ausschließlich unter Last deuten auf ein zu schwaches oder gealtertes Netzteil.
|
||||||
|
Ein Tausch gegen ein bekannt gutes Netzteil bestätigt oder widerlegt das schnell.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: H2
|
||||||
|
titel: Datenträger beurteilen – SMART lesen statt hoffen
|
||||||
|
block: hardware
|
||||||
|
woche: 2
|
||||||
|
worum: Datenträger sterben selten plötzlich; sie kündigen es an, wenn man die richtigen Werte liest.
|
||||||
|
rahmenplan: [A3, A9]
|
||||||
|
lernfeld: LF2
|
||||||
|
merksatz: Bei „reallocated sectors" und „pending sectors" über null wird getauscht, nicht diskutiert.
|
||||||
|
karten:
|
||||||
|
- frage: Welche zwei SMART-Werte sind bei einer Festplatte das deutlichste Warnzeichen?
|
||||||
|
antwort: Reallocated Sector Count und Current Pending Sector Count. Steigt einer von beiden über null, wird der Datenträger getauscht.
|
||||||
|
- frage: Worin unterscheidet sich der Verschleiß einer SSD von dem einer Festplatte?
|
||||||
|
antwort: Die SSD verschleißt durch Schreibvorgänge – maßgeblich sind Media Wearout Indicator beziehungsweise Percentage Used und die geschriebene Gesamtdatenmenge. Die Festplatte verschleißt mechanisch.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **SMART ist eine Selbstauskunft**, keine Garantie. Ein sauberer SMART-Wert schließt einen
|
||||||
|
Ausfall nicht aus – ein auffälliger Wert sagt aber verlässlich, dass es losgeht.
|
||||||
|
- **Festplatte:** Reallocated und Pending Sectors sind die beiden Werte, die zählen.
|
||||||
|
Alles über null bedeutet Tausch, nicht Beobachtung.
|
||||||
|
- **SSD:** Der Verschleiß hängt an geschriebenen Datenmengen. Percentage Used über
|
||||||
|
etwa 90 Prozent heißt: einplanen, nicht abwarten.
|
||||||
|
- **Betriebsstunden allein sagen wenig.** Eine Platte mit 60 000 Stunden und sauberen Werten
|
||||||
|
ist gesünder als eine mit 8 000 Stunden und wachsenden Fehlern.
|
||||||
|
- **Erst sichern, dann prüfen.** Diagnose auf einem angeschlagenen Datenträger kann ihm den
|
||||||
|
Rest geben.
|
||||||
|
- **Tausch dokumentieren:** Seriennummer, Einbaudatum, Grund. Sonst steht in zwei Jahren
|
||||||
|
niemand mehr fest, welches Gerät schon einmal auffällig war.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Wert | Bedeutung | Handeln ab |
|
||||||
|
|---|---|---|
|
||||||
|
| Reallocated Sector Count | ersetzte defekte Sektoren | > 0 |
|
||||||
|
| Current Pending Sector | Sektoren, die nicht gelesen werden konnten | > 0 |
|
||||||
|
| Uncorrectable Sector Count | endgültig verlorene Sektoren | > 0 |
|
||||||
|
| Percentage Used (SSD) | verbrauchte Schreiblebensdauer | > 90 % |
|
||||||
|
| Power On Hours | Betriebsstunden | nur im Zusammenhang |
|
||||||
|
| Temperature | Betriebstemperatur | dauerhaft > 50 °C prüfen |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Ein Anwender meldet, sein Rechner „hänge manchmal minutenlang". Welche
|
||||||
|
Hardwareursache prüfen Sie zuerst und wie?
|
||||||
|
|
||||||
|
**Antwort:** Zuerst den Datenträger. Minutenlange Hänger ohne CPU-Last sind das typische
|
||||||
|
Bild wiederholter Leseversuche auf schwachen Sektoren. Vorgehen: zunächst eine Sicherung
|
||||||
|
der Nutzerdaten anlegen, dann SMART-Werte auslesen und auf Reallocated und Pending Sectors
|
||||||
|
achten, ergänzend das Ereignisprotokoll auf Datenträgerfehler durchsehen. Bestätigt sich
|
||||||
|
der Verdacht, wird der Datenträger getauscht und nicht repariert – die Daten sind teurer
|
||||||
|
als das Bauteil.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: H3
|
||||||
|
titel: Geräte vergleichen und eine Empfehlung begründen
|
||||||
|
block: hardware
|
||||||
|
woche: 2
|
||||||
|
worum: Ein Gerätevergleich ist erst dann etwas wert, wenn er sich am Einsatzzweck misst und nicht am Datenblatt.
|
||||||
|
rahmenplan: [A3, A2]
|
||||||
|
lernfeld: LF2
|
||||||
|
merksatz: Erst den Einsatzzweck aufschreiben, dann die Kriterien, dann erst die Geräte suchen.
|
||||||
|
karten:
|
||||||
|
- frage: In welcher Reihenfolge gehst du bei einem Gerätevergleich vor?
|
||||||
|
antwort: Einsatzzweck klären, daraus Kriterien mit Gewichtung ableiten, dann Geräte suchen und bewerten. Wer mit den Geräten anfängt, sucht sich die Kriterien passend zum Wunschgerät.
|
||||||
|
- frage: Warum gehören Gesamtkosten statt Anschaffungspreis in einen Gerätevergleich?
|
||||||
|
antwort: Weil Garantieverlängerung, Ersatzteilverfügbarkeit, Energieverbrauch und Wartungsaufwand über die Nutzungsdauer den Preisunterschied oft übersteigen.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Der Einsatzzweck kommt zuerst.** „Notebook für den Außendienst, acht Stunden ohne
|
||||||
|
Steckdose, robust" ist eine Anforderung. „Ein gutes Notebook" ist keine.
|
||||||
|
- **Kriterien gewichten**, bevor Geräte angeschaut werden. Sonst wird das Wunschgerät
|
||||||
|
nachträglich begründet.
|
||||||
|
- **Nutzwertanalyse:** Kriterium, Gewicht, Punktzahl je Gerät, Produkt. Das Ergebnis ist
|
||||||
|
keine Wahrheit, aber es macht die Entscheidung nachvollziehbar.
|
||||||
|
- **Gesamtkosten statt Kaufpreis:** Garantie, Ersatzteile, Zubehör, Strom, Aufwand für die
|
||||||
|
Einrichtung. Über vier Jahre gerechnet dreht sich die Reihenfolge häufig.
|
||||||
|
- **Verfügbarkeit prüfen**, bevor die Empfehlung raus geht. Das beste Gerät nützt nichts,
|
||||||
|
wenn es zwölf Wochen Lieferzeit hat.
|
||||||
|
- **Empfehlung schriftlich** mit einem Satz Begründung je Kriterium. Der Kunde entscheidet,
|
||||||
|
nicht die IT – aber er entscheidet auf Grundlage deiner Aufbereitung.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Kriterium | Gewicht | Gerät A | Gerät B | Gerät C |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Akkulaufzeit | 30 % | 8 | 6 | 9 |
|
||||||
|
| Robustheit | 25 % | 7 | 9 | 5 |
|
||||||
|
| Gewicht | 15 % | 6 | 4 | 9 |
|
||||||
|
| Ersatzteile und Garantie | 20 % | 8 | 9 | 5 |
|
||||||
|
| Preis | 10 % | 6 | 5 | 8 |
|
||||||
|
| **Nutzwert** | **100 %** | **7,25** | **6,95** | **7,05** |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Erläutern Sie, warum eine Nutzwertanalyse einer reinen Preisbetrachtung
|
||||||
|
vorzuziehen ist, und nennen Sie eine Schwäche des Verfahrens.
|
||||||
|
|
||||||
|
**Antwort:** Die Nutzwertanalyse bildet ab, dass ein Gerät mehrere Anforderungen
|
||||||
|
unterschiedlich gut erfüllt, und macht die Gewichtung dieser Anforderungen sichtbar und
|
||||||
|
diskutierbar. Eine reine Preisbetrachtung blendet Folgekosten und Eignung aus. Schwäche:
|
||||||
|
Gewichte und Punktvergabe sind subjektiv; wer das Ergebnis kennen will, kann es durch die
|
||||||
|
Wahl der Gewichte herbeiführen. Deshalb müssen Gewichte vor der Gerätesuche festgelegt und
|
||||||
|
begründet werden.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: N1
|
||||||
|
titel: Das Schichtenmodell als Werkzeug der Fehlersuche
|
||||||
|
block: netzwerk
|
||||||
|
woche: 3
|
||||||
|
worum: Das Schichtenmodell ist keine Theorie zum Auswendiglernen, sondern eine Reihenfolge für die Fehlersuche.
|
||||||
|
rahmenplan: [B2, A8]
|
||||||
|
lernfeld: LF3
|
||||||
|
merksatz: Von unten nach oben prüfen – Kabel vor IP, IP vor Namen, Namen vor Anwendung.
|
||||||
|
karten:
|
||||||
|
- frage: In welcher Reihenfolge suchst du einen Netzwerkfehler und warum?
|
||||||
|
antwort: Von unten nach oben – erst Leitung und Verbindung, dann IP-Adresse und Weg, dann Namensauflösung, zuletzt die Anwendung. Eine untere Schicht trägt alle darüber, also ist jede Prüfung oben wertlos, solange unten etwas fehlt.
|
||||||
|
- frage: Auf welcher Schicht arbeiten Switch, Router und Firewall mit Anwendungsfilter?
|
||||||
|
antwort: Der Switch auf Schicht 2 (MAC-Adressen), der Router auf Schicht 3 (IP-Adressen), eine Firewall mit Anwendungsfilter bis hinauf auf Schicht 7.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Vier Schichten reichen im Alltag:** Leitung, Netz, Transport, Anwendung. Die sieben
|
||||||
|
Schichten des OSI-Modells braucht man für die Prüfung, die vier für die Arbeit.
|
||||||
|
- **Immer von unten prüfen.** Die Verbindungsanzeige am Anschluss zu übersehen und
|
||||||
|
stattdessen DNS zu prüfen, kostet regelmäßig eine Stunde.
|
||||||
|
- **Jede Schicht hat ein eigenes Werkzeug:** Anzeige-LED und Kabeltester unten,
|
||||||
|
`ipconfig` und `ping` in der Mitte, `nslookup` darüber, Browser und Protokolldatei oben.
|
||||||
|
- **Ein Gerät kann auf mehreren Schichten arbeiten.** Ein „Switch" mit Routingfunktion ist
|
||||||
|
beides – bei der Fehlersuche muss man wissen, welche Rolle er gerade spielt.
|
||||||
|
- **Kapselung heißt:** Jede Schicht packt ihre eigene Kopfzeile davor. Deshalb sieht man im
|
||||||
|
Mitschnitt außen Ethernet, dann IP, dann TCP, dann erst die Nutzdaten.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Schicht | Adresse | Gerät | Werkzeug | Typische Störung |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Anwendung | URL, Name | Server, Anwendung | Browser, Protokolle | Dienst gestoppt, Anmeldung falsch |
|
||||||
|
| Transport | Port | Firewall | `Test-NetConnection` | Port gesperrt |
|
||||||
|
| Netz | IP-Adresse | Router | `ping`, `tracert` | falsche Adresse, kein Weg |
|
||||||
|
| Leitung | MAC-Adresse | Switch, Kabel | LED, Kabeltester | Kabelbruch, Anschluss aus |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Ein Anwender kann eine interne Webanwendung nicht erreichen, andere Kollegen im
|
||||||
|
selben Netz schon. Beschreiben Sie Ihr Vorgehen anhand des Schichtenmodells.
|
||||||
|
|
||||||
|
**Antwort:** Schicht 1 und 2: Verbindungsanzeige am Anschluss und am Switch prüfen, Kabel
|
||||||
|
und Anschlussdose kontrollieren. Schicht 3: mit `ipconfig` prüfen, ob eine gültige Adresse
|
||||||
|
und ein Standardgateway vorhanden sind, danach das Gateway und den Server anpingen.
|
||||||
|
Schicht 4: mit `Test-NetConnection` prüfen, ob der Zielport erreichbar ist – schlägt das bei
|
||||||
|
erreichbarer IP fehl, ist eine Firewallregel wahrscheinlich. Erst danach Schicht 7: Name mit
|
||||||
|
`nslookup` auflösen, Anmeldedaten, Browserzwischenspeicher und die Protokolle der Anwendung
|
||||||
|
prüfen. Da andere Kollegen es erreichen, liegt die Ursache erfahrungsgemäß unten oder in
|
||||||
|
der Konfiguration dieses einen Arbeitsplatzes.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
id: N2
|
||||||
|
titel: IP-Adresse und Subnetzmaske – wer gehört zusammen?
|
||||||
|
block: netzwerk
|
||||||
|
woche: 3
|
||||||
|
worum: Die Maske entscheidet, welchen Teil der Adresse das Netz ausmacht und welchen der einzelne Rechner.
|
||||||
|
rahmenplan: [B2]
|
||||||
|
lernfeld: LF3
|
||||||
|
merksatz: Gleiche Netzadresse heißt direkt erreichbar, alles andere geht über das Gateway.
|
||||||
|
karten:
|
||||||
|
- frage: Woran erkennt ein Rechner, ob er ein Ziel direkt erreichen kann oder über das Gateway muss?
|
||||||
|
antwort: Er verknüpft die eigene und die Zieladresse jeweils mit der Subnetzmaske. Kommt dieselbe Netzadresse heraus, liegt das Ziel im selben Netz und wird direkt angesprochen, sonst geht es über das Standardgateway.
|
||||||
|
- frage: Welche Adressbereiche sind privat und werden im Internet nicht geroutet?
|
||||||
|
antwort: 10.0.0.0/8, 172.16.0.0/12 und 192.168.0.0/16. Dazu kommt 169.254.0.0/16 für die Selbstvergabe, wenn kein DHCP antwortet.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Eine IPv4-Adresse hat 32 Bit**, geschrieben als vier Zahlen von 0 bis 255. Die Maske
|
||||||
|
sagt, wie viele Bits von links das Netz bezeichnen – `/24` heißt 24 Bit Netz, 8 Bit Rechner.
|
||||||
|
- **Netzadresse** ist die kleinste Adresse im Netz (alle Rechner-Bits null), **Broadcast**
|
||||||
|
die größte (alle Rechner-Bits eins). Beide sind vergeben, keinem Gerät zuweisbar.
|
||||||
|
- **Nutzbare Adressen** sind Blockgröße minus zwei. Bei `/24`: 256 − 2 = 254.
|
||||||
|
- **Gleiche Netzadresse = direkt erreichbar.** Das ist der einzige Zweck der Maske, alles
|
||||||
|
andere folgt daraus.
|
||||||
|
- **169.254.x.x** bedeutet: kein DHCP-Server geantwortet. Das ist keine Adresse, das ist
|
||||||
|
eine Fehlermeldung mit vier Zahlen.
|
||||||
|
- **Privatbereiche** werden im Internet nicht weitergeleitet und dürfen im Betrieb frei
|
||||||
|
verwendet werden – aber nicht doppelt an zwei Standorten, die sich später verbinden sollen.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| CIDR | Subnetzmaske | Blockgröße | Nutzbar |
|
||||||
|
|------|--------------|-----------|---------|
|
||||||
|
| /24 | 255.255.255.0 | 256 | 254 |
|
||||||
|
| /25 | 255.255.255.128 | 128 | 126 |
|
||||||
|
| /26 | 255.255.255.192 | 64 | 62 |
|
||||||
|
| /27 | 255.255.255.224 | 32 | 30 |
|
||||||
|
| /28 | 255.255.255.240 | 16 | 14 |
|
||||||
|
| /29 | 255.255.255.248 | 8 | 6 |
|
||||||
|
| /30 | 255.255.255.252 | 4 | 2 |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Ein Arbeitsplatz hat die Adresse 192.168.20.130 mit der Maske 255.255.255.128,
|
||||||
|
das Standardgateway ist 192.168.20.1. Warum funktioniert das nicht?
|
||||||
|
|
||||||
|
**Antwort:** Die Maske 255.255.255.128 entspricht `/25`, die Blockgröße ist 128. Es gibt
|
||||||
|
also die Netze 192.168.20.0/25 (Adressen .0 bis .127) und 192.168.20.128/25 (.128 bis .255).
|
||||||
|
Der Arbeitsplatz liegt mit .130 im zweiten Netz, das Gateway mit .1 im ersten. Das Gateway
|
||||||
|
ist damit nicht direkt erreichbar, und der Rechner kommt aus seinem Netz nicht heraus.
|
||||||
|
Richtig wäre ein Gateway aus dem Bereich .129 bis .254, üblicherweise 192.168.20.129.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
id: N3
|
||||||
|
titel: Subnetting rechnen – der Weg in vier Schritten
|
||||||
|
block: netzwerk
|
||||||
|
woche: 3
|
||||||
|
worum: Eine Adresse und eine Maske reichen, um Netz, Broadcast und Hostbereich zu bestimmen.
|
||||||
|
rahmenplan: [B2, A8]
|
||||||
|
lernfeld: LF3
|
||||||
|
merksatz: Blockgröße finden, Vielfache aufschreiben, eigene Adresse einsortieren.
|
||||||
|
karten:
|
||||||
|
- frage: Zu welchem Netz gehört 192.168.10.200 bei der Maske 255.255.255.224?
|
||||||
|
antwort: Blockgröße 32, Netze bei .192 und .224 – die Adresse gehört zu 192.168.10.192/27. Broadcast ist .223, nutzbar sind .193 bis .222.
|
||||||
|
- frage: Wie berechnest du die Blockgröße aus der CIDR-Angabe?
|
||||||
|
antwort: Blockgröße = 256 minus dem letzten von null verschiedenen Oktett der Maske, oder gleichwertig 2 hoch (32 minus Präfixlänge), bezogen auf das betroffene Oktett.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Schritt 1 – Blockgröße bestimmen.** Interessant ist das Oktett, in dem die Maske
|
||||||
|
„bricht". Blockgröße = 256 − Maskenwert in diesem Oktett. Bei `/27` ist die Maske
|
||||||
|
255.255.255.224, also 256 − 224 = **32**.
|
||||||
|
- **Schritt 2 – Vielfache aufschreiben.** 0, 32, 64, 96, 128, 160, 192, 224. Das sind alle
|
||||||
|
Netzadressen in diesem Oktett.
|
||||||
|
- **Schritt 3 – eigene Adresse einsortieren.** Das größte Vielfache, das nicht größer ist als
|
||||||
|
die eigene Adresse, ist die Netzadresse. Für .200 ist das **192**.
|
||||||
|
- **Schritt 4 – Grenzen ablesen.** Broadcast = nächste Netzadresse − 1, hier 224 − 1 = **223**.
|
||||||
|
Nutzbar ist alles dazwischen: **.193 bis .222**.
|
||||||
|
- **Gegenprobe:** nutzbare Adressen = Blockgröße − 2. Hier 30. Von .193 bis .222 sind es
|
||||||
|
genau 30. Passt.
|
||||||
|
- **Nie im Kopf über mehrere Oktette rechnen.** Wenn die Maske im dritten Oktett bricht,
|
||||||
|
gilt dasselbe Verfahren dort, und das vierte Oktett läuft von 0 bis 255 durch.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| CIDR | Maske | Blockgröße | Netze je Oktett | Nutzbar |
|
||||||
|
|------|-------|-----------|-----------------|---------|
|
||||||
|
| /25 | 255.255.255.128 | 128 | 2 | 126 |
|
||||||
|
| /26 | 255.255.255.192 | 64 | 4 | 62 |
|
||||||
|
| /27 | 255.255.255.224 | 32 | 8 | 30 |
|
||||||
|
| /28 | 255.255.255.240 | 16 | 16 | 14 |
|
||||||
|
| /29 | 255.255.255.248 | 8 | 32 | 6 |
|
||||||
|
| /30 | 255.255.255.252 | 4 | 64 | 2 |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Ein Standort benötigt vier getrennte Netze mit jeweils mindestens 25 Geräten. Zur
|
||||||
|
Verfügung steht 192.168.50.0/24. Wie teilen Sie auf?
|
||||||
|
|
||||||
|
**Antwort:** 25 Geräte brauchen mindestens 27 Adressen (25 Hosts, Netzadresse, Broadcast),
|
||||||
|
also eine Blockgröße von 32 – das entspricht `/27` mit 30 nutzbaren Adressen. Aus einem
|
||||||
|
`/24` entstehen acht `/27`-Netze, vier davon werden vergeben, vier bleiben in Reserve:
|
||||||
|
192.168.50.0/27 (nutzbar .1–.30), 192.168.50.32/27 (.33–.62), 192.168.50.64/27 (.65–.94),
|
||||||
|
192.168.50.96/27 (.97–.126). Eine Aufteilung in `/26` wäre ebenfalls möglich und ließe mehr
|
||||||
|
Luft nach oben, aber nur vier Netze insgesamt – bei absehbarem Wachstum die schlechtere Wahl.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: N4
|
||||||
|
titel: DHCP und DNS – Adresse bekommen, Namen auflösen
|
||||||
|
block: netzwerk
|
||||||
|
woche: 3
|
||||||
|
worum: DHCP verteilt Adressen, DNS übersetzt Namen – zwei Dienste, zwei ganz verschiedene Fehlerbilder.
|
||||||
|
rahmenplan: [B2, B3]
|
||||||
|
lernfeld: LF3
|
||||||
|
merksatz: Keine Adresse heißt DHCP, keine Auflösung heißt DNS – und 169.254 heißt beides nicht erreicht.
|
||||||
|
karten:
|
||||||
|
- frage: Welche vier Schritte umfasst der DHCP-Ablauf und wie heißen sie?
|
||||||
|
antwort: Discover, Offer, Request, Acknowledge – der Client sucht, der Server bietet an, der Client fordert das Angebot an, der Server bestätigt.
|
||||||
|
- frage: Ein Rechner kann 8.8.8.8 anpingen, aber keine Internetseite öffnen. Wo liegt der Fehler?
|
||||||
|
antwort: Bei der Namensauflösung. Die IP-Verbindung steht, also ist DNS falsch konfiguriert oder der DNS-Server antwortet nicht.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **DHCP vergibt Adressen auf Zeit.** Der Mietzeitraum („Lease") läuft ab, der Client
|
||||||
|
verlängert nach der Hälfte der Zeit selbstständig.
|
||||||
|
- **Vier Schritte:** Discover, Offer, Request, Acknowledge. Merkhilfe: **D-O-R-A**.
|
||||||
|
- **Reservierung statt fester Adresse.** Drucker und Server bekommen im DHCP eine feste
|
||||||
|
Zuordnung über die MAC-Adresse – so bleibt die Verwaltung an einer Stelle.
|
||||||
|
- **DNS übersetzt Namen in Adressen**, in Stufen: eigener Zwischenspeicher, hosts-Datei,
|
||||||
|
konfigurierter Server, dessen Weiterleitung.
|
||||||
|
- **Die beiden Fehlerbilder unterscheiden sich klar.** Keine oder eine 169.254-Adresse:
|
||||||
|
DHCP. Adresse vorhanden, `ping` auf eine IP funktioniert, Namen nicht: DNS.
|
||||||
|
- **Zwischenspeicher leeren, bevor man verzweifelt:** `ipconfig /flushdns` auf dem Client,
|
||||||
|
und daran denken, dass Browser einen eigenen Speicher haben.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Beobachtung | Wahrscheinliche Ursache | Prüfung |
|
||||||
|
|---|---|---|
|
||||||
|
| Adresse 169.254.x.x | kein DHCP-Server erreichbar | `ipconfig /all`, Anschluss, VLAN |
|
||||||
|
| Adresse da, Gateway nicht erreichbar | falsches Netz oder Maske | `ping` Gateway, Maske prüfen |
|
||||||
|
| IP erreichbar, Name nicht | DNS | `nslookup name`, DNS-Server prüfen |
|
||||||
|
| Name löst auf alte Adresse auf | Zwischenspeicher | `ipconfig /flushdns` |
|
||||||
|
| Nur eine Anwendung geht nicht | Port oder Dienst | `Test-NetConnection -Port` |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Nach dem Austausch eines Servers erreichen einige Arbeitsplätze den Dateidienst
|
||||||
|
weiterhin nicht, andere sofort. Woran liegt das und was tun Sie?
|
||||||
|
|
||||||
|
**Antwort:** Die betroffenen Arbeitsplätze haben den alten DNS-Eintrag noch im
|
||||||
|
Zwischenspeicher; die Gültigkeitsdauer des Eintrags ist noch nicht abgelaufen. Kurzfristig
|
||||||
|
hilft `ipconfig /flushdns` auf den betroffenen Rechnern beziehungsweise ein Neustart.
|
||||||
|
Grundsätzlich: vor geplanten Umstellungen die Gültigkeitsdauer des betroffenen
|
||||||
|
DNS-Eintrags rechtzeitig herabsetzen, umstellen, und sie danach wieder anheben. Zusätzlich
|
||||||
|
prüfen, ob auf dem DNS-Server noch ein veralteter Eintrag steht.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
id: S1
|
||||||
|
titel: Windows sauber installieren und übergabefertig machen
|
||||||
|
block: system
|
||||||
|
woche: 4
|
||||||
|
worum: Eine Installation ist erst fertig, wenn Treiber, Updates, Sicherung und Dokumentation stehen – nicht wenn der Anmeldebildschirm erscheint.
|
||||||
|
rahmenplan: [B1, B3, A5]
|
||||||
|
lernfeld: LF2
|
||||||
|
merksatz: Erst Daten sichern, dann installieren – die Reihenfolge ist nicht verhandelbar.
|
||||||
|
karten:
|
||||||
|
- frage: Was prüfst du vor jeder Neuinstallation zuerst?
|
||||||
|
antwort: Ob die Nutzerdaten gesichert sind und die Sicherung nachweislich zurückspielbar ist. Danach Lizenz, Treiberverfügbarkeit und benötigte Fachanwendungen.
|
||||||
|
- frage: Warum trennt man Betriebssystem und Nutzerdaten auf getrennte Ablagen?
|
||||||
|
antwort: Weil das Betriebssystem jederzeit neu aufgesetzt werden können muss, ohne dass Nutzerdaten davon betroffen sind.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Vorher sichern und die Sicherung prüfen.** Eine ungeprüfte Sicherung ist keine.
|
||||||
|
- **Installationsmedium aktuell halten.** Ein zwei Jahre altes Abbild bedeutet 300 Updates
|
||||||
|
nach der Installation und einen halben Tag Wartezeit.
|
||||||
|
- **Treiber vom Hersteller**, nicht aus der Suchmaschine. Chipsatz zuerst, dann Netzwerk,
|
||||||
|
dann der Rest.
|
||||||
|
- **Updates in Ruhe durchlaufen lassen**, bevor Fachanwendungen installiert werden. Sonst
|
||||||
|
reißt ein Neustart eine halb installierte Anwendung auseinander.
|
||||||
|
- **Konten sauber trennen:** ein lokales Administratorkonto für die Verwaltung, ein
|
||||||
|
normales Konto für die tägliche Arbeit. Nicht als Administrator arbeiten.
|
||||||
|
- **Übergabe ist Teil der Arbeit:** Rechnername, Adresse, Lizenz, installierte Software und
|
||||||
|
Datum in die Dokumentation. Danach eine kurze Einweisung des Anwenders.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Schritt | Fertig, wenn … |
|
||||||
|
|---|---|
|
||||||
|
| Sicherung | Rückspielen einer Beispieldatei erfolgreich getestet |
|
||||||
|
| Installation | Windows startet, Datenträger sinnvoll aufgeteilt |
|
||||||
|
| Treiber | Geräte-Manager ohne gelbe Ausrufezeichen |
|
||||||
|
| Updates | Windows Update meldet „Sie sind auf dem neuesten Stand" |
|
||||||
|
| Software | alle Fachanwendungen gestartet und angemeldet getestet |
|
||||||
|
| Netz und Konten | Domänenbeitritt erfolgt, Anmeldung mit Nutzerkonto geprüft |
|
||||||
|
| Dokumentation | Rechnername, Seriennummer, Lizenz, Datum eingetragen |
|
||||||
|
| Übergabe | Anwender hat sich selbst angemeldet und quittiert |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Begründen Sie, warum Fachanwendungen erst nach dem vollständigen Einspielen der
|
||||||
|
Betriebssystemaktualisierungen installiert werden sollten.
|
||||||
|
|
||||||
|
**Antwort:** Aktualisierungen tauschen Systemkomponenten und Laufzeitbibliotheken aus und
|
||||||
|
erzwingen Neustarts. Werden Fachanwendungen vorher installiert, können sie gegen
|
||||||
|
Komponenten gebunden sein, die anschließend ersetzt werden; außerdem kann ein erzwungener
|
||||||
|
Neustart eine laufende Installation abbrechen und einen inkonsistenten Zustand
|
||||||
|
hinterlassen. Erst ein vollständig aktualisiertes System bietet eine stabile Grundlage –
|
||||||
|
und Fehler bei der Anwendungsinstallation sind dann eindeutig der Anwendung zuzuordnen.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
id: S2
|
||||||
|
titel: Benutzer, Gruppen und Berechtigungen
|
||||||
|
block: system
|
||||||
|
woche: 4
|
||||||
|
worum: Rechte werden an Gruppen vergeben, nie an einzelne Personen – sonst weiß nach zwei Jahren niemand mehr, warum jemand irgendwo hineinkommt.
|
||||||
|
rahmenplan: [B3, A6]
|
||||||
|
lernfeld: LF4
|
||||||
|
merksatz: Rechte an Gruppen, Personen in Gruppen. So wenig wie möglich, so viel wie nötig.
|
||||||
|
karten:
|
||||||
|
- frage: Warum vergibt man Berechtigungen an Gruppen statt an einzelne Benutzer?
|
||||||
|
antwort: Weil beim Wechsel einer Person nur die Gruppenzugehörigkeit geändert werden muss und jederzeit nachvollziehbar bleibt, warum jemand ein Recht hat.
|
||||||
|
- frage: Was setzt sich durch, wenn Freigabe- und NTFS-Berechtigungen sich widersprechen?
|
||||||
|
antwort: Die jeweils restriktivere Berechtigung. Über das Netz gilt der Schnitt aus beiden, lokal nur die NTFS-Berechtigung.
|
||||||
|
- frage: Was besagt das Prinzip der geringsten Rechte?
|
||||||
|
antwort: Jedes Konto bekommt genau die Rechte, die es für seine Aufgabe braucht, und nicht mehr – auf Zeit vergebene Rechte werden wieder entzogen.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Das Wesentliche
|
||||||
|
|
||||||
|
- **Rechte an Gruppen, Personen in Gruppen.** Die Gruppe trägt die Begründung im Namen:
|
||||||
|
`FS-Buchhaltung-Aendern` ist selbsterklärend, `Rechte_Meier` nicht.
|
||||||
|
- **Prinzip der geringsten Rechte.** Kein Tagesgeschäft mit Administratorrechten. Erhöhte
|
||||||
|
Rechte werden angefordert, benutzt und wieder abgelegt.
|
||||||
|
- **Vererbung verstehen**, bevor man sie unterbricht. Ein unterbrochener Vererbungspfad ist
|
||||||
|
in zwei Jahren nicht mehr nachvollziehbar.
|
||||||
|
- **Verweigern schlägt Erlauben.** Eine explizite Verweigerung setzt sich gegen jede
|
||||||
|
Erlaubnis durch – deshalb sparsam einsetzen, sonst sucht man sich später tot.
|
||||||
|
- **Freigabe und NTFS greifen zusammen:** Über das Netz gilt die restriktivere der beiden.
|
||||||
|
Übliche Praxis: Freigabe auf „Jeder – Vollzugriff", die eigentliche Steuerung über NTFS.
|
||||||
|
- **Austritt heißt sperren, nicht löschen.** Gelöschte Konten reißen Berechtigungen und
|
||||||
|
Dateibesitz auseinander.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Berechtigung | Erlaubt | Typisch für |
|
||||||
|
|---|---|---|
|
||||||
|
| Lesen | Öffnen, Auflisten | Nachschlagewerke, Vorlagen |
|
||||||
|
| Lesen und Ausführen | zusätzlich Programme starten | Softwareablagen |
|
||||||
|
| Ändern | zusätzlich Anlegen, Ändern, Löschen | Arbeitsverzeichnisse einer Abteilung |
|
||||||
|
| Vollzugriff | zusätzlich Berechtigungen ändern | ausschließlich Administratoren |
|
||||||
|
|
||||||
|
## Prüfungsfrage
|
||||||
|
|
||||||
|
**Frage:** Eine Mitarbeiterin wechselt von der Buchhaltung in den Einkauf. Wie gehen Sie mit
|
||||||
|
ihren Berechtigungen um?
|
||||||
|
|
||||||
|
**Antwort:** Das Konto bleibt bestehen; geändert wird ausschließlich die
|
||||||
|
Gruppenzugehörigkeit: Entfernen aus den Gruppen der Buchhaltung, Aufnahme in die Gruppen
|
||||||
|
des Einkaufs. Direkt am Konto vergebene Einzelberechtigungen sind zu suchen und zu
|
||||||
|
entfernen – sie sind der Grund, warum Rechte über Jahre mitwandern. Die Änderung wird mit
|
||||||
|
Datum und Anlass dokumentiert, und nach dem Wechsel wird stichprobenartig geprüft, dass der
|
||||||
|
Zugriff auf die Buchhaltungsablagen tatsächlich nicht mehr besteht.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
id: woche-01
|
||||||
|
woche: 1
|
||||||
|
titel: Lernzielkontrolle Woche 1 – Arbeitsalltag und Auftrag
|
||||||
|
dauer_minuten: 30
|
||||||
|
hinweis: >-
|
||||||
|
Offene Fragen, keine Hilfsmittel. Antworte in ganzen Sätzen – bei der
|
||||||
|
Abschlussprüfung wird ebenfalls nicht in Stichworten bewertet.
|
||||||
|
|
||||||
|
fragen:
|
||||||
|
- id: f1
|
||||||
|
frage: >-
|
||||||
|
Nennen Sie vier Angaben, die in jede Störungsmeldung gehören, und
|
||||||
|
begründen Sie bei zweien davon, warum sie ohne Rückfrage nötig sind.
|
||||||
|
punkte: 8
|
||||||
|
musterloesung: >-
|
||||||
|
Betroffene Person und Gerät oder Dienst; das beobachtete Verhalten
|
||||||
|
einschließlich Fehlermeldung im Wortlaut; der Zeitpunkt des ersten
|
||||||
|
Auftretens; die bereits durchgeführten Maßnahmen. Begründungen: Der
|
||||||
|
Zeitpunkt erlaubt den Abgleich mit Änderungen und Protokolldateien und
|
||||||
|
grenzt damit den Ursachenraum ein. Die bereits durchgeführten Maßnahmen
|
||||||
|
verhindern, dass der Bearbeiter dieselben Schritte wiederholt und Zeit
|
||||||
|
verliert. (Je Angabe 1 Punkt, je Begründung 2 Punkte.)
|
||||||
|
|
||||||
|
- id: f2
|
||||||
|
frage: >-
|
||||||
|
Ein Kollege schreibt als Betreff "Rechner spinnt". Formulieren Sie einen
|
||||||
|
brauchbaren Betreff und erklären Sie in einem Satz, woran man einen guten
|
||||||
|
Betreff erkennt.
|
||||||
|
punkte: 4
|
||||||
|
musterloesung: >-
|
||||||
|
Beispiel: "PC-042 Buchhaltung – Bluescreen beim Start von Word, seit
|
||||||
|
heute 09:15". Ein guter Betreff nennt Gerät oder Dienst, das Fehlerbild
|
||||||
|
und den Zeitpunkt und ist damit in der Ticketübersicht allein verständlich,
|
||||||
|
ohne dass das Ticket geöffnet werden muss.
|
||||||
|
|
||||||
|
- id: f3
|
||||||
|
frage: >-
|
||||||
|
Warum wiederholt man im Kundengespräch das geschilderte Problem in eigenen
|
||||||
|
Worten, bevor man mit der Fehlersuche beginnt?
|
||||||
|
punkte: 4
|
||||||
|
musterloesung: >-
|
||||||
|
Weil Missverständnisse dadurch auffallen, bevor Arbeitszeit in die falsche
|
||||||
|
Richtung fließt. Der Kunde kann sofort korrigieren. Zusätzlich zeigt die
|
||||||
|
Zusammenfassung, dass zugehört wurde, was die Anspannung senkt und die
|
||||||
|
weitere Zusammenarbeit erleichtert.
|
||||||
|
|
||||||
|
- id: f4
|
||||||
|
frage: >-
|
||||||
|
Sie sollen den Aufwand für die Einrichtung von zehn Arbeitsplätzen
|
||||||
|
schätzen. Beschreiben Sie Ihr Vorgehen in vier Schritten.
|
||||||
|
punkte: 8
|
||||||
|
musterloesung: >-
|
||||||
|
(1) Die Arbeit für einen Arbeitsplatz in einzelne Schritte zerlegen.
|
||||||
|
(2) Jeden Schritt getrennt schätzen, jeweils mit bestem, wahrscheinlichem
|
||||||
|
und schlechtestem Fall. (3) Prüfen, welche Schritte sich für zehn Geräte
|
||||||
|
vorbereiten oder parallelisieren lassen – ein einmal erstelltes Abbild
|
||||||
|
ersetzt zehn Installationen, die Zeit steigt also nicht linear. (4) Weg-,
|
||||||
|
Rüst- und Übergabezeiten je Gerät ergänzen, Wartezeiten als Durchlaufzeit
|
||||||
|
getrennt ausweisen und einen begründeten Puffer offenlegen; das Ergebnis
|
||||||
|
als Spanne nennen.
|
||||||
|
|
||||||
|
- id: f5
|
||||||
|
frage: >-
|
||||||
|
Was gehört in ein Ticket, wenn Sie es schließen? Nennen Sie zwei Dinge und
|
||||||
|
erklären Sie, warum "erledigt" nicht genügt.
|
||||||
|
punkte: 6
|
||||||
|
musterloesung: >-
|
||||||
|
Die festgestellte Ursache und die durchgeführte Maßnahme, idealerweise
|
||||||
|
ergänzt um den tatsächlichen Aufwand. "Erledigt" genügt nicht, weil das
|
||||||
|
Ticket die einzige Dokumentation des Vorgangs ist: Tritt der Fehler erneut
|
||||||
|
auf oder bearbeitet ihn jemand anderes, ist ohne Ursache und Maßnahme die
|
||||||
|
gesamte Fehlersuche zu wiederholen. Außerdem lässt sich ohne diese Angaben
|
||||||
|
nicht erkennen, ob ein Fehler häufig auftritt und eine dauerhafte Lösung
|
||||||
|
braucht.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
id: woche-03
|
||||||
|
woche: 3
|
||||||
|
titel: Lernzielkontrolle Woche 3 – Netzwerkgrundlagen
|
||||||
|
dauer_minuten: 45
|
||||||
|
hinweis: >-
|
||||||
|
Offene Fragen. Bei Rechenaufgaben zählt der Rechenweg – ein richtiges Ergebnis
|
||||||
|
ohne Weg gibt die halbe Punktzahl.
|
||||||
|
|
||||||
|
fragen:
|
||||||
|
- id: f1
|
||||||
|
frage: >-
|
||||||
|
Bestimmen Sie zu 192.168.10.200 mit der Maske 255.255.255.224 die
|
||||||
|
Netzadresse, die Broadcastadresse und den nutzbaren Hostbereich. Zeigen Sie
|
||||||
|
den Rechenweg.
|
||||||
|
punkte: 8
|
||||||
|
musterloesung: >-
|
||||||
|
Maske 255.255.255.224 entspricht /27. Blockgröße = 256 − 224 = 32.
|
||||||
|
Vielfache von 32: 0, 32, 64, 96, 128, 160, 192, 224. Das größte Vielfache,
|
||||||
|
das 200 nicht übersteigt, ist 192 – Netzadresse also 192.168.10.192/27.
|
||||||
|
Broadcast = nächste Netzadresse (224) minus 1 = 192.168.10.223. Nutzbar:
|
||||||
|
192.168.10.193 bis 192.168.10.222, das sind 30 Adressen (Blockgröße minus
|
||||||
|
zwei). (Blockgröße 2 P., Netzadresse 2 P., Broadcast 2 P., Hostbereich 2 P.)
|
||||||
|
|
||||||
|
- id: f2
|
||||||
|
frage: >-
|
||||||
|
Ein Standort braucht vier getrennte Netze mit je mindestens 25 Geräten.
|
||||||
|
Zur Verfügung steht 192.168.50.0/24. Wie teilen Sie auf und warum?
|
||||||
|
punkte: 8
|
||||||
|
musterloesung: >-
|
||||||
|
25 Geräte brauchen 27 Adressen (25 Hosts plus Netzadresse plus Broadcast),
|
||||||
|
also Blockgröße 32 und damit /27 mit 30 nutzbaren Adressen. Aus einem /24
|
||||||
|
entstehen acht /27-Netze; vier werden vergeben, vier bleiben Reserve:
|
||||||
|
192.168.50.0/27, .32/27, .64/27, .96/27. Eine Aufteilung in /26 wäre
|
||||||
|
ebenfalls ausreichend, ergäbe aber nur vier Netze insgesamt und damit keine
|
||||||
|
Reserve – bei absehbarem Wachstum die schlechtere Wahl.
|
||||||
|
|
||||||
|
- id: f3
|
||||||
|
frage: >-
|
||||||
|
Ein Arbeitsplatz meldet die Adresse 169.254.13.7. Was bedeutet das, und
|
||||||
|
welche drei Prüfungen führen Sie in welcher Reihenfolge durch?
|
||||||
|
punkte: 6
|
||||||
|
musterloesung: >-
|
||||||
|
169.254.x.x ist eine Selbstvergabe des Clients (APIPA); sie bedeutet, dass
|
||||||
|
kein DHCP-Server geantwortet hat. Reihenfolge von unten nach oben:
|
||||||
|
(1) Schicht 1 – Kabel und Verbindungsanzeige an Gerät, Dose und Switch.
|
||||||
|
(2) Schicht 2 – ist der Switchport aktiv und im richtigen VLAN?
|
||||||
|
(3) DHCP – antwortet der Server, ist der Adressbereich erschöpft, ist die
|
||||||
|
DHCP-Weiterleitung im betreffenden Netz eingerichtet?
|
||||||
|
|
||||||
|
- id: f4
|
||||||
|
frage: >-
|
||||||
|
Ein Rechner kann 8.8.8.8 anpingen, aber keine Internetseite öffnen. Wo
|
||||||
|
liegt der Fehler, und wie weisen Sie ihn nach?
|
||||||
|
punkte: 5
|
||||||
|
musterloesung: >-
|
||||||
|
Bei der Namensauflösung. Die IP-Verbindung nach außen steht, also ist
|
||||||
|
entweder kein oder ein falscher DNS-Server eingetragen, oder der
|
||||||
|
eingetragene Server antwortet nicht. Nachweis: "nslookup www.example.com"
|
||||||
|
schlägt fehl, während der Ping auf die IP-Adresse funktioniert; ergänzend
|
||||||
|
"ipconfig /all" auf den eingetragenen DNS-Server prüfen und testweise einen
|
||||||
|
bekannt funktionierenden Server abfragen.
|
||||||
|
|
||||||
|
- id: f5
|
||||||
|
frage: >-
|
||||||
|
Ordnen Sie Switch, Router und Kabel den Schichten des Schichtenmodells zu
|
||||||
|
und erklären Sie, warum man bei der Fehlersuche unten anfängt.
|
||||||
|
punkte: 6
|
||||||
|
musterloesung: >-
|
||||||
|
Kabel: Schicht 1 (Bitübertragung). Switch: Schicht 2 (MAC-Adressen).
|
||||||
|
Router: Schicht 3 (IP-Adressen). Man beginnt unten, weil jede Schicht auf
|
||||||
|
der darunterliegenden aufsetzt: Fehlt die physische Verbindung, ist jede
|
||||||
|
Prüfung auf IP- oder Namensebene ohne Aussagekraft. Von unten nach oben zu
|
||||||
|
arbeiten schließt außerdem mit jedem Schritt einen ganzen Bereich
|
||||||
|
möglicher Ursachen aus.
|
||||||
|
|
||||||
|
- id: f6
|
||||||
|
frage: >-
|
||||||
|
Nennen Sie die vier Schritte des DHCP-Ablaufs in der richtigen Reihenfolge
|
||||||
|
und erklären Sie einen davon.
|
||||||
|
punkte: 5
|
||||||
|
musterloesung: >-
|
||||||
|
Discover, Offer, Request, Acknowledge. Beispiel Offer: Der DHCP-Server
|
||||||
|
antwortet auf die Suchanfrage des Clients mit einem konkreten
|
||||||
|
Adressangebot samt Maske, Gateway, DNS-Servern und Mietdauer; die Adresse
|
||||||
|
ist damit noch nicht vergeben, sondern nur vorgemerkt, bis der Client sie
|
||||||
|
mit Request anfordert.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nachschlagewerk für die beiden verbindlichen Dokumente der Ausbildung.
|
||||||
|
# Diese Datei wird nicht bearbeitet, um Inhalte zu planen – sie bildet ab, was
|
||||||
|
# in Verordnung und Rahmenlehrplan steht. Alles andere verweist nur darauf.
|
||||||
|
#
|
||||||
|
# Quellen:
|
||||||
|
# Betrieb: Verordnung über die Berufsausbildung zum Fachinformatiker und zur
|
||||||
|
# Fachinformatikerin (FIAusbV) vom 28.02.2020, Anlage zu § 3 Abs. 1
|
||||||
|
# Schule: Rahmenlehrplan der Kultusministerkonferenz für die Ausbildungs-
|
||||||
|
# berufe der IT-Berufe, Beschluss vom 13.12.2019
|
||||||
|
#
|
||||||
|
# Zur Nummerierung: Die Verordnung führt sechs Abschnitte (A bis F), weil sie
|
||||||
|
# alle vier Fachrichtungen abdeckt. Für die Systemintegration sind davon drei
|
||||||
|
# einschlägig. Um die Kürzel kurz zu halten, werden sie hier so benannt:
|
||||||
|
#
|
||||||
|
# A1 – A10 = Abschnitt A der Verordnung (fachrichtungsübergreifend)
|
||||||
|
# B1 – B3 = Abschnitt C der Verordnung (Fachrichtung Systemintegration)
|
||||||
|
# C1 – C5 = Abschnitt F der Verordnung (integrativ zu vermitteln)
|
||||||
|
#
|
||||||
|
# Das Feld `verordnung` nennt bei jeder Position die amtliche Fundstelle,
|
||||||
|
# damit gegenüber der IHK nachvollziehbar bleibt, was gemeint ist.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
quellen:
|
||||||
|
betrieb:
|
||||||
|
titel: Verordnung über die Berufsausbildung zum Fachinformatiker und zur Fachinformatikerin
|
||||||
|
kurz: FIAusbV
|
||||||
|
vom: 2020-02-28
|
||||||
|
fundstelle: BGBl. I S. 250, Anlage zu § 3 Absatz 1
|
||||||
|
schule:
|
||||||
|
titel: Rahmenlehrplan für die Ausbildungsberufe der Informations- und Telekommunikationstechnik
|
||||||
|
kurz: KMK-Rahmenlehrplan
|
||||||
|
vom: 2019-12-13
|
||||||
|
|
||||||
|
berufsbildpositionen:
|
||||||
|
# --- Abschnitt A der Verordnung: fachrichtungsübergreifend ---------------
|
||||||
|
- id: A1
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Planen, Vorbereiten und Durchführen von Arbeitsaufgaben in Abstimmung mit kundenspezifischen Geschäfts- und Leistungsprozessen
|
||||||
|
kurz: Arbeitsaufgaben planen und durchführen
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 1
|
||||||
|
- id: A2
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Informieren und Beraten von Kunden und Kundinnen
|
||||||
|
kurz: Kunden informieren und beraten
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 2
|
||||||
|
- id: A3
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Beurteilen marktgängiger IT-Systeme und kundenspezifischer Lösungen
|
||||||
|
kurz: IT-Systeme beurteilen
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 3
|
||||||
|
- id: A4
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Entwickeln, Erstellen und Betreuen von IT-Lösungen
|
||||||
|
kurz: IT-Lösungen entwickeln und betreuen
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 4
|
||||||
|
- id: A5
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Durchführen und Dokumentieren von qualitätssichernden Maßnahmen
|
||||||
|
kurz: Qualität sichern und dokumentieren
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 5
|
||||||
|
- id: A6
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Umsetzen, Integrieren und Prüfen von Maßnahmen zur IT-Sicherheit und zum Datenschutz
|
||||||
|
kurz: IT-Sicherheit und Datenschutz
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 6
|
||||||
|
- id: A7
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Erbringen der Leistungen und Auftragsabschluss
|
||||||
|
kurz: Leistung erbringen und Auftrag abschließen
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 7
|
||||||
|
- id: A8
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Betreiben von IT-Systemen
|
||||||
|
kurz: IT-Systeme betreiben
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 8
|
||||||
|
- id: A9
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Inbetriebnehmen von Speicherlösungen
|
||||||
|
kurz: Speicherlösungen in Betrieb nehmen
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 9
|
||||||
|
- id: A10
|
||||||
|
gruppe: fachrichtungsuebergreifend
|
||||||
|
titel: Programmieren von Softwarelösungen
|
||||||
|
kurz: Softwarelösungen programmieren
|
||||||
|
verordnung: Abschnitt A, lfd. Nr. 10
|
||||||
|
|
||||||
|
# --- Abschnitt C der Verordnung: Fachrichtung Systemintegration ----------
|
||||||
|
- id: B1
|
||||||
|
gruppe: systemintegration
|
||||||
|
titel: Konzipieren und Realisieren von IT-Systemen
|
||||||
|
kurz: IT-Systeme konzipieren und realisieren
|
||||||
|
verordnung: Abschnitt C, lfd. Nr. 1
|
||||||
|
- id: B2
|
||||||
|
gruppe: systemintegration
|
||||||
|
titel: Installieren und Konfigurieren von Netzwerken
|
||||||
|
kurz: Netzwerke installieren und konfigurieren
|
||||||
|
verordnung: Abschnitt C, lfd. Nr. 2
|
||||||
|
- id: B3
|
||||||
|
gruppe: systemintegration
|
||||||
|
titel: Administrieren von IT-Systemen
|
||||||
|
kurz: IT-Systeme administrieren
|
||||||
|
verordnung: Abschnitt C, lfd. Nr. 3
|
||||||
|
|
||||||
|
# --- Abschnitt F der Verordnung: integrativ zu vermitteln ----------------
|
||||||
|
- id: C1
|
||||||
|
gruppe: integrativ
|
||||||
|
titel: Berufsbildung sowie Arbeits- und Tarifrecht
|
||||||
|
kurz: Berufsbildung, Arbeits- und Tarifrecht
|
||||||
|
verordnung: Abschnitt F, lfd. Nr. 1
|
||||||
|
- id: C2
|
||||||
|
gruppe: integrativ
|
||||||
|
titel: Aufbau und Organisation des Ausbildungsbetriebes
|
||||||
|
kurz: Aufbau und Organisation des Betriebes
|
||||||
|
verordnung: Abschnitt F, lfd. Nr. 2
|
||||||
|
- id: C3
|
||||||
|
gruppe: integrativ
|
||||||
|
titel: Sicherheit und Gesundheitsschutz bei der Arbeit
|
||||||
|
kurz: Sicherheit und Gesundheitsschutz
|
||||||
|
verordnung: Abschnitt F, lfd. Nr. 3
|
||||||
|
- id: C4
|
||||||
|
gruppe: integrativ
|
||||||
|
titel: Umweltschutz und Nachhaltigkeit
|
||||||
|
kurz: Umweltschutz und Nachhaltigkeit
|
||||||
|
verordnung: Abschnitt F, lfd. Nr. 4
|
||||||
|
- id: C5
|
||||||
|
gruppe: integrativ
|
||||||
|
titel: Vernetztes Zusammenarbeiten unter Nutzung digitaler Medien
|
||||||
|
kurz: Vernetztes Zusammenarbeiten
|
||||||
|
verordnung: Abschnitt F, lfd. Nr. 5
|
||||||
|
|
||||||
|
gruppen:
|
||||||
|
fachrichtungsuebergreifend: Fachrichtungsübergreifende berufsprofilgebende Fertigkeiten
|
||||||
|
systemintegration: Fachrichtung Systemintegration
|
||||||
|
integrativ: Integrativ zu vermittelnde Fertigkeiten
|
||||||
|
|
||||||
|
lernfelder:
|
||||||
|
- id: LF1
|
||||||
|
titel: Das Unternehmen und die eigene Rolle im Betrieb beschreiben
|
||||||
|
jahr: 1
|
||||||
|
stunden: 40
|
||||||
|
- id: LF2
|
||||||
|
titel: Arbeitsplätze nach Kundenwunsch ausstatten
|
||||||
|
jahr: 1
|
||||||
|
stunden: 80
|
||||||
|
- id: LF3
|
||||||
|
titel: Clients in Netzwerke einbinden
|
||||||
|
jahr: 1
|
||||||
|
stunden: 80
|
||||||
|
- id: LF4
|
||||||
|
titel: Schutzbedarfsanalyse im eigenen Arbeitsbereich durchführen
|
||||||
|
jahr: 1
|
||||||
|
stunden: 40
|
||||||
|
- id: LF5
|
||||||
|
titel: Software zur Verwaltung von Daten anpassen
|
||||||
|
jahr: 1
|
||||||
|
stunden: 80
|
||||||
|
- id: LF6
|
||||||
|
titel: Serviceanfragen bearbeiten
|
||||||
|
jahr: 2
|
||||||
|
stunden: 40
|
||||||
|
- id: LF7
|
||||||
|
titel: Cyber-physische Systeme ergänzen
|
||||||
|
jahr: 2
|
||||||
|
stunden: 80
|
||||||
|
- id: LF8
|
||||||
|
titel: Daten systemübergreifend bereitstellen
|
||||||
|
jahr: 2
|
||||||
|
stunden: 80
|
||||||
|
- id: LF9
|
||||||
|
titel: Netzwerke und Dienste bereitstellen
|
||||||
|
jahr: 2
|
||||||
|
stunden: 80
|
||||||
|
- id: LF10
|
||||||
|
titel: Serverdienste bereitstellen und Administrationsaufgaben automatisieren
|
||||||
|
jahr: 3
|
||||||
|
stunden: 80
|
||||||
|
- id: LF11
|
||||||
|
titel: Betrieb und Sicherheit vernetzter Systeme gewährleisten
|
||||||
|
jahr: 3
|
||||||
|
stunden: 80
|
||||||
|
- id: LF12
|
||||||
|
titel: Kundenspezifische Systemintegration durchführen
|
||||||
|
jahr: 3
|
||||||
|
stunden: 120
|
||||||
|
|
||||||
|
# Themenblöcke – die Klammer, unter der Handouts und Merkkarten gruppiert
|
||||||
|
# werden. Das Kürzel eines Handouts beginnt mit dem Buchstaben des Blocks.
|
||||||
|
bloecke:
|
||||||
|
- id: arbeitsalltag
|
||||||
|
kuerzel: A
|
||||||
|
titel: Arbeitsalltag und Auftrag
|
||||||
|
farbe: petrol
|
||||||
|
- id: hardware
|
||||||
|
kuerzel: H
|
||||||
|
titel: Hardware
|
||||||
|
farbe: petrol
|
||||||
|
- id: netzwerk
|
||||||
|
kuerzel: N
|
||||||
|
titel: Netzwerk
|
||||||
|
farbe: petrol
|
||||||
|
- id: system
|
||||||
|
kuerzel: S
|
||||||
|
titel: Betriebssysteme und Dienste
|
||||||
|
farbe: petrol
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
nummer: 1
|
||||||
|
titel: Arbeitsalltag und Auftrag
|
||||||
|
block: arbeitsalltag
|
||||||
|
leitfrage: Woran erkennst du, dass ein Auftrag verstanden ist – und nicht nur angenommen?
|
||||||
|
ziel: >-
|
||||||
|
Du kannst eine Störungsmeldung so aufnehmen und dokumentieren, dass ein
|
||||||
|
Kollege sie ohne Rückfrage übernehmen könnte.
|
||||||
|
|
||||||
|
handouts: [A1, A2, A3]
|
||||||
|
aufgaben: ["1.1"]
|
||||||
|
checklisten: [01-arbeitsplatz-uebergeben]
|
||||||
|
lernzielkontrolle: woche-01
|
||||||
|
|
||||||
|
tage:
|
||||||
|
- tag: Montag
|
||||||
|
theorie: [A1]
|
||||||
|
praxis: Ticketsystem einrichten, die letzten zwanzig geschlossenen Tickets lesen und drei davon nach Qualität bewerten.
|
||||||
|
- tag: Dienstag
|
||||||
|
theorie: [A2]
|
||||||
|
praxis: Beim Ausbilder in zwei Anrufen mithören, danach einen selbst annehmen und das Ticket schreiben.
|
||||||
|
- tag: Mittwoch
|
||||||
|
theorie: [A3]
|
||||||
|
praxis: Für drei offene Tickets eine Aufwandsschätzung erstellen und mit dem Ausbilder abgleichen.
|
||||||
|
- tag: Donnerstag
|
||||||
|
theorie: []
|
||||||
|
praxis: Praxisaufgabe 1.1 – einen Arbeitsplatz übernehmen, dokumentieren und übergeben.
|
||||||
|
- tag: Freitag
|
||||||
|
theorie: []
|
||||||
|
praxis: Lernzielkontrolle, danach Wochenrückblick und Berichtsheft schreiben.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
nummer: 2
|
||||||
|
titel: Hardware beurteilen und beschaffen
|
||||||
|
block: hardware
|
||||||
|
leitfrage: Warum ist das billigste Gerät selten das günstigste?
|
||||||
|
ziel: >-
|
||||||
|
Du kannst einen Rechner öffnen, die Baugruppen benennen, den Zustand eines
|
||||||
|
Datenträgers beurteilen und eine Beschaffungsempfehlung begründen.
|
||||||
|
|
||||||
|
handouts: [H1, H2, H3]
|
||||||
|
aufgaben: ["2.1", "2.2"]
|
||||||
|
checklisten: [04-rechner-zerlegen]
|
||||||
|
lernzielkontrolle: null
|
||||||
|
|
||||||
|
tage:
|
||||||
|
- tag: Montag
|
||||||
|
theorie: [H1]
|
||||||
|
praxis: Zwei ausgemusterte Rechner zerlegen und wieder zusammenbauen, Baugruppen benennen.
|
||||||
|
- tag: Dienstag
|
||||||
|
theorie: [H2]
|
||||||
|
praxis: SMART-Werte von fünf Datenträgern aus dem Lager auslesen und bewerten.
|
||||||
|
- tag: Mittwoch
|
||||||
|
theorie: [H3]
|
||||||
|
praxis: Kriterien für die Notebookbeschaffung im Außendienst festlegen und gewichten.
|
||||||
|
- tag: Donnerstag
|
||||||
|
theorie: []
|
||||||
|
praxis: Praxisaufgabe 2.2 – drei Notebooks vergleichen und eine Empfehlung schreiben.
|
||||||
|
- tag: Freitag
|
||||||
|
theorie: []
|
||||||
|
praxis: Empfehlung dem Ausbilder vorstellen, Rückfragen beantworten, Berichtsheft schreiben.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
nummer: 3
|
||||||
|
titel: Netzwerkgrundlagen
|
||||||
|
block: netzwerk
|
||||||
|
leitfrage: Wo im Netz suchst du zuerst – und warum nie oben?
|
||||||
|
ziel: >-
|
||||||
|
Du kannst Netz, Broadcast und Hostbereich zu einer Adresse bestimmen, einen
|
||||||
|
Netzwerkfehler schichtweise eingrenzen und DHCP- von DNS-Problemen unterscheiden.
|
||||||
|
|
||||||
|
handouts: [N1, N2, N3, N4]
|
||||||
|
aufgaben: ["3.1"]
|
||||||
|
checklisten: [03-client-ins-netz-bringen]
|
||||||
|
lernzielkontrolle: woche-03
|
||||||
|
|
||||||
|
tage:
|
||||||
|
- tag: Montag
|
||||||
|
theorie: [N1]
|
||||||
|
praxis: Netzplan des Betriebs abzeichnen, Geräte den Schichten zuordnen.
|
||||||
|
- tag: Dienstag
|
||||||
|
theorie: [N2]
|
||||||
|
praxis: Adressen und Masken der eigenen Netze aufnehmen, Netzadressen bestimmen.
|
||||||
|
- tag: Mittwoch
|
||||||
|
theorie: [N3]
|
||||||
|
praxis: Zwanzig Subnetting-Aufgaben rechnen, davon zehn ohne Tabelle.
|
||||||
|
- tag: Donnerstag
|
||||||
|
theorie: [N4]
|
||||||
|
praxis: Praxisaufgabe 3.1 – vier gestörte Clients ins Netz bringen und den Weg dokumentieren.
|
||||||
|
- tag: Freitag
|
||||||
|
theorie: []
|
||||||
|
praxis: Lernzielkontrolle, danach Berichtsheft schreiben.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
nummer: 4
|
||||||
|
titel: Betriebssystem, Konten und Rechte
|
||||||
|
block: system
|
||||||
|
leitfrage: Wer darf was – und woran sieht man das in zwei Jahren noch?
|
||||||
|
ziel: >-
|
||||||
|
Du kannst Windows übergabefertig installieren und Berechtigungen so vergeben,
|
||||||
|
dass sie nachvollziehbar und pflegbar bleiben.
|
||||||
|
|
||||||
|
handouts: [S1, S2]
|
||||||
|
aufgaben: []
|
||||||
|
checklisten: [02-windows-installieren]
|
||||||
|
lernzielkontrolle: null
|
||||||
|
|
||||||
|
tage:
|
||||||
|
- tag: Montag
|
||||||
|
theorie: [S1]
|
||||||
|
praxis: Installationsmedium erstellen, Testrechner nach Checkliste neu aufsetzen.
|
||||||
|
- tag: Dienstag
|
||||||
|
theorie: []
|
||||||
|
praxis: Treiber, Updates und Fachanwendungen einspielen, Übergabeprotokoll ausfüllen.
|
||||||
|
- tag: Mittwoch
|
||||||
|
theorie: [S2]
|
||||||
|
praxis: Gruppenstruktur einer Beispielabteilung entwerfen und im Testsystem anlegen.
|
||||||
|
- tag: Donnerstag
|
||||||
|
theorie: []
|
||||||
|
praxis: Berechtigungen auf drei Testablagen setzen und mit zwei Testkonten prüfen.
|
||||||
|
- tag: Freitag
|
||||||
|
theorie: []
|
||||||
|
praxis: Monatliches Feedbackgespräch, danach Berichtsheft schreiben.
|
||||||
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 503 KiB |
@@ -11,7 +11,6 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"gray-matter": "^4.0.3",
|
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"pdfkit": "^0.17.1",
|
"pdfkit": "^0.17.1",
|
||||||
@@ -4683,19 +4682,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esprima": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"bin": {
|
|
||||||
"esparse": "bin/esparse.js",
|
|
||||||
"esvalidate": "bin/esvalidate.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esquery": {
|
"node_modules/esquery": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||||
@@ -4771,18 +4757,6 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/extend-shallow": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-extendable": "^0.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -5153,43 +5127,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/gray-matter": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"js-yaml": "^3.13.1",
|
|
||||||
"kind-of": "^6.0.2",
|
|
||||||
"section-matter": "^1.0.0",
|
|
||||||
"strip-bom-string": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/gray-matter/node_modules/argparse": {
|
|
||||||
"version": "1.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
|
||||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"sprintf-js": "~1.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
|
||||||
"version": "3.15.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
|
||||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"argparse": "^1.0.7",
|
|
||||||
"esprima": "^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"js-yaml": "bin/js-yaml.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-bigints": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -5529,15 +5466,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-extendable": {
|
|
||||||
"version": "0.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
|
||||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -5924,15 +5852,6 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/kind-of": {
|
|
||||||
"version": "6.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
|
||||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/language-subtag-registry": {
|
"node_modules/language-subtag-registry": {
|
||||||
"version": "0.3.23",
|
"version": "0.3.23",
|
||||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
||||||
@@ -7104,19 +7023,6 @@
|
|||||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/section-matter": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"extend-shallow": "^2.0.1",
|
|
||||||
"kind-of": "^6.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.8.5",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
@@ -7394,12 +7300,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sprintf-js": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -7568,15 +7468,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-bom-string": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"gray-matter": "^4.0.3",
|
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"pdfkit": "^0.17.1",
|
"pdfkit": "^0.17.1",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Prüft alle Dateien unter content/, ohne die Anwendung zu starten.
|
||||||
|
*
|
||||||
|
* npm run inhalte:pruefen
|
||||||
|
*
|
||||||
|
* Endet mit 0, wenn alles in Ordnung ist, sonst mit 1 – so lässt sich das
|
||||||
|
* Skript in einen Prüflauf oder einen Git-Haken einhängen.
|
||||||
|
*/
|
||||||
|
import { ladeInhalte } from '../src/lib/inhalte/laden';
|
||||||
|
import { InhaltsFehler } from '../src/lib/inhalte/fehler';
|
||||||
|
import { inhaltWurzel } from '../src/lib/pfade';
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
try {
|
||||||
|
const alle = ladeInhalte(inhaltWurzel);
|
||||||
|
|
||||||
|
const kartenJeBlock = alle.bloecke.map((b) => {
|
||||||
|
const anzahl = alle.karten.filter((k) => k.block === b.id).length;
|
||||||
|
return `${b.titel}: ${anzahl}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Inhalte in Ordnung.');
|
||||||
|
console.log('');
|
||||||
|
console.log(` Wochen ${alle.wochen.length}`);
|
||||||
|
console.log(` Handouts ${alle.handouts.length}`);
|
||||||
|
console.log(` Merkkarten ${alle.karten.length} (${kartenJeBlock.join(', ')})`);
|
||||||
|
console.log(` Praxisaufgaben ${alle.aufgaben.length}`);
|
||||||
|
console.log(` Checklisten ${alle.checklisten.length}`);
|
||||||
|
console.log(` Lernzielkontrollen ${alle.kontrollen.length}`);
|
||||||
|
console.log(` Glossareinträge ${alle.glossar.length}`);
|
||||||
|
console.log(` Berufsbildpositionen ${alle.positionen.length}`);
|
||||||
|
console.log(` Lernfelder ${alle.lernfelder.length}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const ohneAbdeckung = alle.positionen.filter(
|
||||||
|
(p) =>
|
||||||
|
!alle.handouts.some((h) => h.rahmenplan.includes(p.id)) &&
|
||||||
|
!alle.aufgaben.some((a) => a.rahmenplan.includes(p.id)),
|
||||||
|
);
|
||||||
|
if (ohneAbdeckung.length > 0) {
|
||||||
|
console.log(` Hinweis: ${ohneAbdeckung.length} Berufsbildpositionen sind von keinem Inhalt berührt:`);
|
||||||
|
console.log(` ${ohneAbdeckung.map((p) => p.id).join(', ')}`);
|
||||||
|
console.log(' Das ist kein Fehler – nur etwas, das im Laufe der Ausbildung geschlossen werden sollte.');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof InhaltsFehler) {
|
||||||
|
console.error(fehler.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Nimmt die Bildschirmfotos für die Anleitung auf.
|
||||||
|
*
|
||||||
|
* npm run build && npm run start (in einer zweiten Konsole)
|
||||||
|
* npm run screenshots
|
||||||
|
*
|
||||||
|
* Die Bilder landen unter docs/bilder/ und werden bei jedem Lauf überschrieben.
|
||||||
|
* So veralten sie nicht – wer die Oberfläche ändert, lässt das Skript neu laufen.
|
||||||
|
*
|
||||||
|
* Umgebungsvariablen:
|
||||||
|
* LERNBAR_BASIS Adresse der laufenden Anwendung (Vorgabe http://localhost:3000)
|
||||||
|
* LERNBAR_AUSBILDER E-Mail des Ausbilderkontos
|
||||||
|
* LERNBAR_AZUBI E-Mail des Azubikontos
|
||||||
|
* LERNBAR_PASSWORT_* zugehörige Passwörter
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { chromium, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000';
|
||||||
|
const ZIEL = path.join(process.cwd(), 'docs', 'bilder');
|
||||||
|
|
||||||
|
const AUSBILDER = {
|
||||||
|
email: process.env.LERNBAR_AUSBILDER ?? 'ausbilder@betrieb.de',
|
||||||
|
passwort: process.env.LERNBAR_PASSWORT_AUSBILDER ?? 'ausbilder-start',
|
||||||
|
};
|
||||||
|
const AZUBI = {
|
||||||
|
email: process.env.LERNBAR_AZUBI ?? 'azubi@betrieb.de',
|
||||||
|
passwort: process.env.LERNBAR_PASSWORT_AZUBI ?? 'azubi-start-2026',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHREIBTISCH = { width: 1280, height: 900 };
|
||||||
|
const HANDY = { width: 390, height: 844 };
|
||||||
|
|
||||||
|
async function anmelden(seite: Page, konto: { email: string; passwort: string }) {
|
||||||
|
await seite.goto(`${BASIS}/anmelden`);
|
||||||
|
await seite.waitForSelector('#email');
|
||||||
|
await seite.fill('#email', konto.email);
|
||||||
|
await seite.fill('#passwort', konto.passwort);
|
||||||
|
await seite.click('button[type=submit]');
|
||||||
|
await seite.waitForURL(`${BASIS}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abmelden(seite: Page) {
|
||||||
|
await seite.click('button:has-text("Abmelden")');
|
||||||
|
await seite.waitForURL(/anmelden/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bild(seite: Page, name: string, ganzeSeite = true) {
|
||||||
|
const datei = path.join(ZIEL, `${name}.png`);
|
||||||
|
await seite.screenshot({ path: datei, fullPage: ganzeSeite });
|
||||||
|
console.log(` ${path.relative(process.cwd(), datei)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(ZIEL, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const umgebung = await browser.newContext({ viewport: SCHREIBTISCH, locale: 'de-DE', colorScheme: 'light' });
|
||||||
|
const seite = await umgebung.newPage();
|
||||||
|
|
||||||
|
console.log('Bildschirmfotos werden aufgenommen …');
|
||||||
|
|
||||||
|
// --- Anmeldung ---
|
||||||
|
await seite.goto(`${BASIS}/anmelden`);
|
||||||
|
await seite.waitForSelector('#email');
|
||||||
|
await bild(seite, '01-anmelden');
|
||||||
|
|
||||||
|
// --- Ausbilder ---
|
||||||
|
await anmelden(seite, AUSBILDER);
|
||||||
|
await bild(seite, '02-ausbilder-ueberblick');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/verwaltung`);
|
||||||
|
await seite.waitForSelector('#neu-name');
|
||||||
|
await bild(seite, '03-verwaltung');
|
||||||
|
|
||||||
|
await abmelden(seite);
|
||||||
|
|
||||||
|
// --- Azubi ---
|
||||||
|
await anmelden(seite, AZUBI);
|
||||||
|
await bild(seite, '10-azubi-start');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/handouts`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
await bild(seite, '11-handouts-liste');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/handouts?suche=subnetting`);
|
||||||
|
await seite.waitForSelector('h2');
|
||||||
|
await bild(seite, '12-handouts-suche');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/handouts/N3`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
await seite.click('.pruefungsfrage > summary');
|
||||||
|
await bild(seite, '13-handout-n3');
|
||||||
|
|
||||||
|
await seite.goto(`${BASIS}/glossar`);
|
||||||
|
await seite.waitForSelector('h1');
|
||||||
|
await bild(seite, '14-glossar');
|
||||||
|
|
||||||
|
// --- Dunkle Darstellung ---
|
||||||
|
await seite.goto(`${BASIS}/handouts/N3`);
|
||||||
|
await seite.waitForSelector('button[aria-pressed]');
|
||||||
|
await seite.click('button[aria-pressed="false"]');
|
||||||
|
await seite.waitForTimeout(200);
|
||||||
|
await bild(seite, '20-dunkel');
|
||||||
|
await seite.click('button[aria-pressed="true"]');
|
||||||
|
|
||||||
|
// --- Handy ---
|
||||||
|
const handy = await browser.newContext({
|
||||||
|
viewport: HANDY,
|
||||||
|
locale: 'de-DE',
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
const handySeite = await handy.newPage();
|
||||||
|
await anmelden(handySeite, AZUBI);
|
||||||
|
await handySeite.goto(`${BASIS}/handouts/N3`);
|
||||||
|
await handySeite.waitForSelector('h1');
|
||||||
|
await bild(handySeite, '30-handy-handout');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Fertig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((fehler) => {
|
||||||
|
console.error('Die Bildschirmfotos konnten nicht aufgenommen werden.');
|
||||||
|
console.error(fehler instanceof Error ? fehler.message : fehler);
|
||||||
|
console.error(`Läuft die Anwendung unter ${BASIS}? Stimmen LERNBAR_AUSBILDER und LERNBAR_AZUBI?`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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 Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele,
|
||||||
// die es auch wirklich gibt.
|
// 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[] = [
|
const fuerAusbilder: Navigationseintrag[] = [
|
||||||
{ pfad: '/', titel: 'Überblick' },
|
{ pfad: '/', titel: 'Überblick' },
|
||||||
|
{ pfad: '/handouts', titel: 'Handouts' },
|
||||||
|
{ pfad: '/glossar', titel: 'Glossar' },
|
||||||
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
{ pfad: '/verwaltung', titel: 'Verwaltung' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { erfordereAusbilder } from '@/lib/auth/rechte';
|
|||||||
import { passwortMangel } from '@/lib/auth/passwort';
|
import { passwortMangel } from '@/lib/auth/passwort';
|
||||||
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
|
import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer';
|
||||||
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
|
import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung';
|
||||||
|
import { alsDatumText } from '@/lib/zeit';
|
||||||
|
|
||||||
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
export type Rueckmeldung = { fehler?: string; erfolg?: string };
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath('/verwaltung');
|
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> {
|
export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise<Rueckmeldung> {
|
||||||
|
|||||||
@@ -323,9 +323,7 @@ select:disabled {
|
|||||||
color: #f2f8fa;
|
color: #f2f8fa;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
display: inline-flex;
|
white-space: nowrap;
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
.marke:hover { color: #fff; }
|
.marke:hover { color: #fff; }
|
||||||
.marke .marke-akzent { color: #e5a552; }
|
.marke .marke-akzent { color: #e5a552; }
|
||||||
@@ -488,3 +486,213 @@ table.tabelle th {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
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' },
|
title: { default: 'Lern-Bar', template: '%s · Lern-Bar' },
|
||||||
description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration',
|
description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration',
|
||||||
applicationName: 'Lern-Bar',
|
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 = {
|
export const viewport: Viewport = {
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ export async function register() {
|
|||||||
|
|
||||||
const { rohVerbindung } = await import('./lib/db');
|
const { rohVerbindung } = await import('./lib/db');
|
||||||
rohVerbindung(); // öffnet die Datenbank und wendet Migrationen an
|
rohVerbindung(); // öffnet die Datenbank und wendet Migrationen an
|
||||||
|
|
||||||
console.log('[lernbar] Datenbank bereit.');
|
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);
|
||||||
@@ -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. */
|
/** Rollen – bewusst nur zwei. */
|
||||||
export const rollen = ['ausbilder', 'azubi'] as const;
|
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)],
|
(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 Benutzer = typeof benutzer.$inferSelect;
|
||||||
export type Sitzung = typeof sitzung.$inferSelect;
|
export type Sitzung = typeof sitzung.$inferSelect;
|
||||||
export type Ausbildung = typeof ausbildung.$inferSelect;
|
export type Ausbildung = typeof ausbildung.$inferSelect;
|
||||||
|
export type HandoutGelesen = typeof handoutGelesen.$inferSelect;
|
||||||
|
|
||||||
/** Wird von der Schema-Abgleichprüfung im Test verwendet. */
|
/** 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;
|
||||||
|
}
|
||||||