diff --git a/CHANGELOG.md b/CHANGELOG.md index d956ee0..8a21ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,53 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue ## [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 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 [Unveröffentlicht]: #unveröffentlicht +[0.2.0]: #020--2026-09-10 [0.1.0]: #010--2026-09-10 diff --git a/DEVLOG.md b/DEVLOG.md index 945968f..adfa1b3 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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` erzwingt TypeScript Gleichheit und leitet den falschen Typ ab; + `` mit `z.output` 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 | Von | Bis | Dauer | Tätigkeit | diff --git a/ENTSCHEIDUNGEN.md b/ENTSCHEIDUNGEN.md index 3e1fac1..95d6acd 100644 --- a/ENTSCHEIDUNGEN.md +++ b/ENTSCHEIDUNGEN.md @@ -82,3 +82,56 @@ Erstfassung entsteht deshalb vollständig auf `feat/lernbar-erstfassung`. --- + +## 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. diff --git a/ROADMAP.md b/ROADMAP.md index b8c6ac6..57e02f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,12 +22,16 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig ## 0.2 – Inhalte -- [ ] **B2 · Inhalte einlesen und validieren, Handouts anzeigen, Suche** - - [ ] Markdown mit YAML-Frontmatter, Zod-Prüfung, Start bricht bei Fehler ab - - [ ] Fehlermeldung nennt Datei und Zeile - - [ ] Handout-Ansicht mit festem Aufbau, Themenblöcke, „gelesen" merken - - [ ] Volltextsuche über alle Handouts - - [ ] Neu einlesen bei Dateiänderung im Entwicklungsbetrieb +- [x] **B2 · Inhalte einlesen und validieren, Handouts anzeigen, Suche** + - [x] Markdown mit YAML-Frontmatter, Zod-Prüfung, Start bricht bei Fehler ab + - [x] Fehlermeldung nennt Datei und Zeile + - [x] Handout-Ansicht mit festem Aufbau, Themenblöcke, „gelesen" merken + - [x] Volltextsuche über alle Handouts + - [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 diff --git a/content/aufgaben/1.1-arbeitsplatz-uebernehmen.md b/content/aufgaben/1.1-arbeitsplatz-uebernehmen.md new file mode 100644 index 0000000..c597c63 --- /dev/null +++ b/content/aufgaben/1.1-arbeitsplatz-uebernehmen.md @@ -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. diff --git a/content/aufgaben/2.1-datentraeger-beurteilen.md b/content/aufgaben/2.1-datentraeger-beurteilen.md new file mode 100644 index 0000000..8bf3e4d --- /dev/null +++ b/content/aufgaben/2.1-datentraeger-beurteilen.md @@ -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. diff --git a/content/aufgaben/2.2-notebooks-vergleichen.md b/content/aufgaben/2.2-notebooks-vergleichen.md new file mode 100644 index 0000000..051236d --- /dev/null +++ b/content/aufgaben/2.2-notebooks-vergleichen.md @@ -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. diff --git a/content/aufgaben/3.1-clients-ins-netz-bringen.md b/content/aufgaben/3.1-clients-ins-netz-bringen.md new file mode 100644 index 0000000..8c040f3 --- /dev/null +++ b/content/aufgaben/3.1-clients-ins-netz-bringen.md @@ -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. diff --git a/content/checklisten/01-arbeitsplatz-uebergeben.yaml b/content/checklisten/01-arbeitsplatz-uebergeben.yaml new file mode 100644 index 0000000..81d52d0 --- /dev/null +++ b/content/checklisten/01-arbeitsplatz-uebergeben.yaml @@ -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 diff --git a/content/checklisten/02-windows-installieren.yaml b/content/checklisten/02-windows-installieren.yaml new file mode 100644 index 0000000..065392d --- /dev/null +++ b/content/checklisten/02-windows-installieren.yaml @@ -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 diff --git a/content/checklisten/03-client-ins-netz-bringen.yaml b/content/checklisten/03-client-ins-netz-bringen.yaml new file mode 100644 index 0000000..5da0eba --- /dev/null +++ b/content/checklisten/03-client-ins-netz-bringen.yaml @@ -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 diff --git a/content/checklisten/04-rechner-zerlegen.yaml b/content/checklisten/04-rechner-zerlegen.yaml new file mode 100644 index 0000000..73cb141 --- /dev/null +++ b/content/checklisten/04-rechner-zerlegen.yaml @@ -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 diff --git a/content/glossar.yaml b/content/glossar.yaml new file mode 100644 index 0000000..caa8ba5 --- /dev/null +++ b/content/glossar.yaml @@ -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] diff --git a/content/handouts/A1-ticket-schreiben.md b/content/handouts/A1-ticket-schreiben.md new file mode 100644 index 0000000..113d029 --- /dev/null +++ b/content/handouts/A1-ticket-schreiben.md @@ -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. diff --git a/content/handouts/A2-kundengespraech-annehmen.md b/content/handouts/A2-kundengespraech-annehmen.md new file mode 100644 index 0000000..47de095 --- /dev/null +++ b/content/handouts/A2-kundengespraech-annehmen.md @@ -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. diff --git a/content/handouts/A3-aufwand-schaetzen.md b/content/handouts/A3-aufwand-schaetzen.md new file mode 100644 index 0000000..be7ed3c --- /dev/null +++ b/content/handouts/A3-aufwand-schaetzen.md @@ -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. diff --git a/content/handouts/H1-rechner-von-innen.md b/content/handouts/H1-rechner-von-innen.md new file mode 100644 index 0000000..2f825a4 --- /dev/null +++ b/content/handouts/H1-rechner-von-innen.md @@ -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. diff --git a/content/handouts/H2-datentraeger-und-smart.md b/content/handouts/H2-datentraeger-und-smart.md new file mode 100644 index 0000000..1ff08fb --- /dev/null +++ b/content/handouts/H2-datentraeger-und-smart.md @@ -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. diff --git a/content/handouts/H3-geraete-vergleichen.md b/content/handouts/H3-geraete-vergleichen.md new file mode 100644 index 0000000..2adbd41 --- /dev/null +++ b/content/handouts/H3-geraete-vergleichen.md @@ -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. diff --git a/content/handouts/N1-schichtenmodell.md b/content/handouts/N1-schichtenmodell.md new file mode 100644 index 0000000..0468454 --- /dev/null +++ b/content/handouts/N1-schichtenmodell.md @@ -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. diff --git a/content/handouts/N2-ip-adressen-und-masken.md b/content/handouts/N2-ip-adressen-und-masken.md new file mode 100644 index 0000000..585ab0d --- /dev/null +++ b/content/handouts/N2-ip-adressen-und-masken.md @@ -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. diff --git a/content/handouts/N3-subnetting-rechnen.md b/content/handouts/N3-subnetting-rechnen.md new file mode 100644 index 0000000..8ed22e0 --- /dev/null +++ b/content/handouts/N3-subnetting-rechnen.md @@ -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. diff --git a/content/handouts/N4-dhcp-und-dns.md b/content/handouts/N4-dhcp-und-dns.md new file mode 100644 index 0000000..0667797 --- /dev/null +++ b/content/handouts/N4-dhcp-und-dns.md @@ -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. diff --git a/content/handouts/S1-windows-installieren.md b/content/handouts/S1-windows-installieren.md new file mode 100644 index 0000000..32e7bef --- /dev/null +++ b/content/handouts/S1-windows-installieren.md @@ -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. diff --git a/content/handouts/S2-benutzer-und-rechte.md b/content/handouts/S2-benutzer-und-rechte.md new file mode 100644 index 0000000..8c3a927 --- /dev/null +++ b/content/handouts/S2-benutzer-und-rechte.md @@ -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. diff --git a/content/lernzielkontrollen/woche-01.yaml b/content/lernzielkontrollen/woche-01.yaml new file mode 100644 index 0000000..14be7de --- /dev/null +++ b/content/lernzielkontrollen/woche-01.yaml @@ -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. diff --git a/content/lernzielkontrollen/woche-03.yaml b/content/lernzielkontrollen/woche-03.yaml new file mode 100644 index 0000000..a1ee124 --- /dev/null +++ b/content/lernzielkontrollen/woche-03.yaml @@ -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. diff --git a/content/rahmenplan.yaml b/content/rahmenplan.yaml new file mode 100644 index 0000000..8bc3767 --- /dev/null +++ b/content/rahmenplan.yaml @@ -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 diff --git a/content/wochen/01-arbeitsalltag.yaml b/content/wochen/01-arbeitsalltag.yaml new file mode 100644 index 0000000..6577b7a --- /dev/null +++ b/content/wochen/01-arbeitsalltag.yaml @@ -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. diff --git a/content/wochen/02-hardware.yaml b/content/wochen/02-hardware.yaml new file mode 100644 index 0000000..eb5982e --- /dev/null +++ b/content/wochen/02-hardware.yaml @@ -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. diff --git a/content/wochen/03-netzwerk.yaml b/content/wochen/03-netzwerk.yaml new file mode 100644 index 0000000..56e1033 --- /dev/null +++ b/content/wochen/03-netzwerk.yaml @@ -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. diff --git a/content/wochen/04-system.yaml b/content/wochen/04-system.yaml new file mode 100644 index 0000000..648b5bc --- /dev/null +++ b/content/wochen/04-system.yaml @@ -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. diff --git a/docs/bilder/01-anmelden.png b/docs/bilder/01-anmelden.png new file mode 100644 index 0000000..75116ef Binary files /dev/null and b/docs/bilder/01-anmelden.png differ diff --git a/docs/bilder/02-ausbilder-ueberblick.png b/docs/bilder/02-ausbilder-ueberblick.png new file mode 100644 index 0000000..8d85ef6 Binary files /dev/null and b/docs/bilder/02-ausbilder-ueberblick.png differ diff --git a/docs/bilder/03-verwaltung.png b/docs/bilder/03-verwaltung.png new file mode 100644 index 0000000..411167c Binary files /dev/null and b/docs/bilder/03-verwaltung.png differ diff --git a/docs/bilder/10-azubi-start.png b/docs/bilder/10-azubi-start.png new file mode 100644 index 0000000..34abd8a Binary files /dev/null and b/docs/bilder/10-azubi-start.png differ diff --git a/docs/bilder/11-handouts-liste.png b/docs/bilder/11-handouts-liste.png new file mode 100644 index 0000000..904579e Binary files /dev/null and b/docs/bilder/11-handouts-liste.png differ diff --git a/docs/bilder/12-handouts-suche.png b/docs/bilder/12-handouts-suche.png new file mode 100644 index 0000000..f277ff3 Binary files /dev/null and b/docs/bilder/12-handouts-suche.png differ diff --git a/docs/bilder/13-handout-n3.png b/docs/bilder/13-handout-n3.png new file mode 100644 index 0000000..58c363a Binary files /dev/null and b/docs/bilder/13-handout-n3.png differ diff --git a/docs/bilder/14-glossar.png b/docs/bilder/14-glossar.png new file mode 100644 index 0000000..066beb0 Binary files /dev/null and b/docs/bilder/14-glossar.png differ diff --git a/docs/bilder/20-dunkel.png b/docs/bilder/20-dunkel.png new file mode 100644 index 0000000..4029c49 Binary files /dev/null and b/docs/bilder/20-dunkel.png differ diff --git a/docs/bilder/30-handy-handout.png b/docs/bilder/30-handy-handout.png new file mode 100644 index 0000000..970d084 Binary files /dev/null and b/docs/bilder/30-handy-handout.png differ diff --git a/package-lock.json b/package-lock.json index 5bd1cd4..ef4d32f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "@node-rs/argon2": "^2.0.2", "better-sqlite3": "^12.11.1", "drizzle-orm": "^0.45.2", - "gray-matter": "^4.0.3", "markdown-it": "^14.1.0", "next": "^15.5.4", "pdfkit": "^0.17.1", @@ -4683,19 +4682,6 @@ "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": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -4771,18 +4757,6 @@ "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": { "version": "3.1.3", "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" } }, - "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5529,15 +5466,6 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5924,15 +5852,6 @@ "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": { "version": "0.3.23", "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==", "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": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -7394,12 +7300,6 @@ "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": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -7568,15 +7468,6 @@ "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/package.json b/package.json index dcc00f7..3fa1a48 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "@node-rs/argon2": "^2.0.2", "better-sqlite3": "^12.11.1", "drizzle-orm": "^0.45.2", - "gray-matter": "^4.0.3", "markdown-it": "^14.1.0", "next": "^15.5.4", "pdfkit": "^0.17.1", diff --git a/scripts/inhalte-pruefen.ts b/scripts/inhalte-pruefen.ts new file mode 100644 index 0000000..9d7cef6 --- /dev/null +++ b/scripts/inhalte-pruefen.ts @@ -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(); diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts new file mode 100644 index 0000000..9d352a5 --- /dev/null +++ b/scripts/screenshots.ts @@ -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); +}); diff --git a/src/app/(angemeldet)/glossar/GlossarSuche.tsx b/src/app/(angemeldet)/glossar/GlossarSuche.tsx new file mode 100644 index 0000000..db0586d --- /dev/null +++ b/src/app/(angemeldet)/glossar/GlossarSuche.tsx @@ -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 ( +
+ + setzeWert(e.target.value)} + autoComplete="off" + /> +
+ ); +} diff --git a/src/app/(angemeldet)/glossar/page.tsx b/src/app/(angemeldet)/glossar/page.tsx new file mode 100644 index 0000000..84fbcec --- /dev/null +++ b/src/app/(angemeldet)/glossar/page.tsx @@ -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 ( +
+
+

Glossar

+

+ {alle.glossar.length} Begriffe. In den Handouts sind sie verlinkt – ein Klick führt hierher. +

+
+ + + + {gefiltert.length === 0 ? ( +

Kein Begriff passt zu „{suchbegriff}“.

+ ) : ( +
+ {gefiltert.map((eintrag) => ( +
+
+ {eintrag.begriff} + {eintrag.steht_fuer ? {eintrag.steht_fuer} : null} +
+
+

0 ? '0.5rem' : 0 }}>{eintrag.erklaerung}

+ {eintrag.siehe_auch.length > 0 ? ( +

+ Siehe auch:{' '} + {eintrag.siehe_auch.map((verweis, index) => ( + + {index > 0 ? ', ' : ''} + {verweis} + + ))} +

+ ) : null} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/(angemeldet)/handouts/Suchfeld.tsx b/src/app/(angemeldet)/handouts/Suchfeld.tsx new file mode 100644 index 0000000..790068f --- /dev/null +++ b/src/app/(angemeldet)/handouts/Suchfeld.tsx @@ -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 ( +
+ +
+ setzeWert(e.target.value)} + autoComplete="off" + /> + +
+
+ ); +} diff --git a/src/app/(angemeldet)/handouts/[id]/aktionen.ts b/src/app/(angemeldet)/handouts/[id]/aktionen.ts new file mode 100644 index 0000000..6ff67a5 --- /dev/null +++ b/src/app/(angemeldet)/handouts/[id]/aktionen.ts @@ -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 { + 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('/'); +} diff --git a/src/app/(angemeldet)/handouts/[id]/page.tsx b/src/app/(angemeldet)/handouts/[id]/page.tsx new file mode 100644 index 0000000..a2f088f --- /dev/null +++ b/src/app/(angemeldet)/handouts/[id]/page.tsx @@ -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 { + 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 ( +
+ + +
+
+ {handout.id} + {gelesen ? ( + + ✓ gelesen am {alsZeitpunktText(gelesen)} + + ) : null} +
+

{handout.titel}

+

{handout.worum}

+
+ +
+
+
+
Ausbildungsrahmenplan
+
+ {positionen.length === 0 + ? '–' + : positionen.map((p) => ( + + {p.id} {p.kurz} + + ))} +
+
+
+
Lernfeld
+
+ {lernfeld ? ( + + {lernfeld.id} {lernfeld.titel} + + ) : ( + '–' + )} +
+
+
+
Woche
+
+ {woche ? ( + + {woche.nummer} {woche.titel} + + ) : ( + '–' + )} +
+
+
+
+ + {handout.wesentlich ? ( +
+

Das Wesentliche

+
+
+ ) : null} + + {handout.uebersicht ? ( +
+

Übersicht

+
+
+ ) : null} + + + + {handout.pruefungsfrage ? ( +
+ Prüfungsfrage anzeigen +
+
+ ) : null} + +
+

+ Merkkarten zu diesem Handout +

+

+ {handout.karten.length === 1 ? 'Eine Karte' : `${handout.karten.length} Karten`} – im Lernmodus tauchen sie + nach Fälligkeit auf. +

+
    + {handout.karten.map((karte, index) => ( +
  1. + {karte.frage} +
    + {karte.antwort} +
  2. + ))} +
+
+ + {istAzubi ? ( +
+ + + +
+ ) : null} + + + +

+ Quelle: {handout.datei} +

+
+ ); +} diff --git a/src/app/(angemeldet)/handouts/page.tsx b/src/app/(angemeldet)/handouts/page.tsx new file mode 100644 index 0000000..580d36a --- /dev/null +++ b/src/app/(angemeldet)/handouts/page.tsx @@ -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(); + + 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 ( +
+
+

Handouts

+

+ {alle.handouts.length} Lerneinheiten in {alle.bloecke.length} Themenblöcken + {benutzer.rolle === 'azubi' ? ` · ${gelesen.size} gelesen` : ''} +

+
+ + + + {treffer ? ( +
+

+ {treffer.length === 0 + ? `Nichts gefunden für „${suchbegriff}"` + : `${treffer.length} ${treffer.length === 1 ? 'Treffer' : 'Treffer'} für „${suchbegriff}"`} +

+ + {treffer.length === 0 ? ( +

+ Versuch es mit einem einzelnen Wort. Gesucht wird in Titel, Merksatz, Merkkarten und im gesamten Text. +

+ ) : ( +
    + {treffer.map(({ handout, ausschnitt }) => ( +
  • + + {handout.id} + + {handout.titel} +
    + {ausschnitt} +
    + {gelesen.has(handout.id) ? ( + + ✓ gelesen + + ) : null} + +
  • + ))} +
+ )} +
+ ) : ( + <> + + + {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 ( +
+
+

+ {block.titel} +

+ {benutzer.rolle === 'azubi' ? ( + + {gelesenImBlock} von {desBlocks.length} gelesen + + ) : ( + {desBlocks.length} Handouts + )} +
+ +
    + {desBlocks.map((handout) => ( +
  • + + + {handout.id} + {gelesen.has(handout.id) ? ( + + ✓ gelesen + + ) : ( + + Woche {handout.woche} + + )} + + {handout.titel} + + {handout.worum} + + +
  • + ))} +
+
+ ); + })} + + )} +
+ ); +} diff --git a/src/app/(angemeldet)/layout.tsx b/src/app/(angemeldet)/layout.tsx index 5913163..7a07350 100644 --- a/src/app/(angemeldet)/layout.tsx +++ b/src/app/(angemeldet)/layout.tsx @@ -6,10 +6,16 @@ export const dynamic = 'force-dynamic'; // Die Navigation wächst mit den Bausteinen mit – hier stehen nur Ziele, // die es auch wirklich gibt. -const fuerAzubi: Navigationseintrag[] = [{ pfad: '/', titel: 'Woche' }]; +const fuerAzubi: Navigationseintrag[] = [ + { pfad: '/', titel: 'Woche' }, + { pfad: '/handouts', titel: 'Handouts' }, + { pfad: '/glossar', titel: 'Glossar' }, +]; const fuerAusbilder: Navigationseintrag[] = [ { pfad: '/', titel: 'Überblick' }, + { pfad: '/handouts', titel: 'Handouts' }, + { pfad: '/glossar', titel: 'Glossar' }, { pfad: '/verwaltung', titel: 'Verwaltung' }, ]; diff --git a/src/app/(angemeldet)/verwaltung/aktionen.ts b/src/app/(angemeldet)/verwaltung/aktionen.ts index 9e7776d..c72e0bc 100644 --- a/src/app/(angemeldet)/verwaltung/aktionen.ts +++ b/src/app/(angemeldet)/verwaltung/aktionen.ts @@ -5,6 +5,7 @@ import { erfordereAusbilder } from '@/lib/auth/rechte'; import { passwortMangel } from '@/lib/auth/passwort'; import { aktivSetzen, benutzerAnlegen, benutzerNachEmail, benutzerNachId, passwortSetzen } from '@/lib/repos/benutzer'; import { ausbildungAendern, ausbildungAnlegen, ausbildungNachAzubi, ausbildungNachId } from '@/lib/repos/ausbildung'; +import { alsDatumText } from '@/lib/zeit'; export type Rueckmeldung = { fehler?: string; erfolg?: string }; @@ -39,7 +40,7 @@ export async function azubiAnlegen(_vorher: Rueckmeldung, formular: FormData): P }); revalidatePath('/verwaltung'); - return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${beginn} eingerichtet.` }; + return { erfolg: `Konto für ${name} angelegt und Ausbildung ab ${alsDatumText(beginn)} eingerichtet.` }; } export async function ausbildungBearbeiten(_vorher: Rueckmeldung, formular: FormData): Promise { diff --git a/src/app/globals.css b/src/app/globals.css index 1349f41..cc04932 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -323,9 +323,7 @@ select:disabled { color: #f2f8fa; text-decoration: none; letter-spacing: -0.01em; - display: inline-flex; - align-items: center; - gap: 0.5rem; + white-space: nowrap; } .marke:hover { color: #fff; } .marke .marke-akzent { color: #e5a552; } @@ -488,3 +486,213 @@ table.tabelle th { white-space: nowrap; border: 0; } + +/* --- Handouts --- */ + +.handout-kuerzel { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.6rem; + padding: 0.1rem 0.5rem; + border-radius: var(--radius-klein); + background: var(--petrol); + color: var(--text-auf-petrol); + font-weight: 700; + font-size: 0.85rem; + letter-spacing: 0.02em; + flex-shrink: 0; +} + +.handout-kuerzel-gross { + font-size: 1rem; + padding: 0.25rem 0.7rem; +} + +.handout-kachel { + display: block; + height: 100%; + background: var(--flaeche); + border: 1px solid var(--rand-zart); + border-radius: var(--radius); + padding: 0.9rem 1rem; + text-decoration: none; + color: var(--text); +} +.handout-kachel:hover { + background: var(--flaeche-kraeftig); + border-color: var(--rand); + color: var(--text); +} + +.handout-treffer { + display: flex; + gap: 0.75rem; + align-items: flex-start; + background: var(--flaeche); + border: 1px solid var(--rand-zart); + border-radius: var(--radius); + padding: 0.8rem 1rem; + text-decoration: none; + color: var(--text); +} +.handout-treffer:hover { + background: var(--flaeche-kraeftig); + color: var(--text); +} + +.handout-worum { + font-size: 1.05rem; + color: var(--text-leise); + margin: 0; +} + +.handout-zuordnung dl { + margin: 0; + display: grid; + gap: 0.6rem; +} +.handout-zuordnung dt { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-leise); + font-weight: 650; + margin-bottom: 0.2rem; +} +.handout-zuordnung dd { + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.zuordnung-marke { + display: inline-flex; + gap: 0.35rem; + align-items: baseline; + background: var(--grund); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + padding: 0.15rem 0.5rem; + font-size: 0.87rem; +} +.zuordnung-marke strong { + color: var(--petrol); +} + +.fliesstext > *:first-child { margin-top: 0; } +.fliesstext > *:last-child { margin-bottom: 0; } +.fliesstext ul, +.fliesstext ol { + padding-left: 1.3rem; + margin: 0 0 1em; +} +.fliesstext li + li { margin-top: 0.4rem; } +.fliesstext table { + border-collapse: collapse; + width: 100%; + font-size: 0.94rem; +} +.fliesstext th, +.fliesstext td { + border: 1px solid var(--rand-zart); + padding: 0.45rem 0.65rem; + text-align: left; + vertical-align: top; +} +.fliesstext th { + background: var(--flaeche); + font-weight: 650; +} + +.merksatz { + border-left: 5px solid var(--bernstein); + background: var(--warnung-flaeche); + border-radius: 0 var(--radius) var(--radius) 0; + padding: 0.9rem 1.1rem; +} +.merksatz-kennzeichnung { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; + color: var(--warnung); + margin-bottom: 0.2rem; +} +.merksatz p { + margin: 0; + font-size: 1.08rem; + font-weight: 600; +} + +.pruefungsfrage { + border: 1px solid var(--rand); + border-radius: var(--radius); + background: var(--grund); +} +.pruefungsfrage > summary { + cursor: pointer; + padding: 0.7rem 1rem; + font-weight: 650; + list-style: none; + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 44px; +} +.pruefungsfrage > summary::-webkit-details-marker { display: none; } +.pruefungsfrage > summary::before { + content: "▸"; + color: var(--petrol); + font-size: 0.9rem; +} +.pruefungsfrage[open] > summary::before { content: "▾"; } +.pruefungsfrage[open] > summary { border-bottom: 1px solid var(--rand-zart); } +.pruefungsfrage > div { padding: 0.9rem 1rem; } + +a.glossarbegriff { + color: inherit; + text-decoration: underline dotted var(--petrol); + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; + cursor: help; +} +a.glossarbegriff:hover { + color: var(--petrol); + text-decoration-style: solid; +} + +/* --- Glossar --- */ + +.glossar { + display: grid; + gap: 0.75rem; + margin: 0; +} +.glossar-eintrag { + scroll-margin-top: 1rem; +} +.glossar-eintrag dt { + font-weight: 700; + font-size: 1.05rem; + margin-bottom: 0.3rem; +} +.glossar-langform { + display: block; + font-weight: 400; + font-size: 0.85rem; + color: var(--text-leise); +} +.glossar-eintrag dd { margin: 0; } +.glossar-eintrag:target { + outline: 3px solid var(--bernstein); + outline-offset: 2px; +} + +@media print { + .pruefungsfrage > div { display: block !important; } + .handout-zuordnung { border: 1px solid #999; } + a.glossarbegriff { text-decoration: none; } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8a775f..3c8aa86 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,8 @@ export const metadata: Metadata = { title: { default: 'Lern-Bar', template: '%s · Lern-Bar' }, description: 'Betriebliche Ausbildung Fachinformatiker/-in für Systemintegration', applicationName: 'Lern-Bar', + // Sonst macht der Browser aus Zahlenkolonnen in Tabellen Telefonnummern. + formatDetection: { telephone: false, date: false, address: false, email: false }, }; export const viewport: Viewport = { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 452d884..0195092 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -8,6 +8,13 @@ export async function register() { const { rohVerbindung } = await import('./lib/db'); rohVerbindung(); // öffnet die Datenbank und wendet Migrationen an - console.log('[lernbar] Datenbank bereit.'); + + const { inhalteEinlesen } = await import('./lib/inhalte'); + const alle = inhalteEinlesen(); + console.log( + `[lernbar] Inhalte gelesen: ${alle.wochen.length} Wochen, ${alle.handouts.length} Handouts, ` + + `${alle.karten.length} Merkkarten, ${alle.aufgaben.length} Aufgaben, ${alle.checklisten.length} Checklisten, ` + + `${alle.kontrollen.length} Lernzielkontrollen, ${alle.glossar.length} Glossareinträge.`, + ); } diff --git a/src/lib/db/migrationen/0002_inhalte.sql b/src/lib/db/migrationen/0002_inhalte.sql new file mode 100644 index 0000000..39e2eeb --- /dev/null +++ b/src/lib/db/migrationen/0002_inhalte.sql @@ -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); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 7588a99..137a866 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -1,4 +1,4 @@ -import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; /** Rollen – bewusst nur zwei. */ export const rollen = ['ausbilder', 'azubi'] as const; @@ -51,9 +51,22 @@ export const ausbildung = sqliteTable( (t) => [uniqueIndex('ausbildung_azubi_idx').on(t.azubiId), index('ausbildung_ausbilder_idx').on(t.ausbilderId)], ); +export const handoutGelesen = sqliteTable( + 'handout_gelesen', + { + benutzerId: text('benutzer_id') + .notNull() + .references(() => benutzer.id, { onDelete: 'cascade' }), + handoutId: text('handout_id').notNull(), + gelesenAm: text('gelesen_am').notNull(), + }, + (t) => [primaryKey({ columns: [t.benutzerId, t.handoutId] }), index('handout_gelesen_benutzer_idx').on(t.benutzerId)], +); + export type Benutzer = typeof benutzer.$inferSelect; export type Sitzung = typeof sitzung.$inferSelect; export type Ausbildung = typeof ausbildung.$inferSelect; +export type HandoutGelesen = typeof handoutGelesen.$inferSelect; /** Wird von der Schema-Abgleichprüfung im Test verwendet. */ -export const alleTabellen = { benutzer, sitzung, ausbildung }; +export const alleTabellen = { benutzer, sitzung, ausbildung, handoutGelesen }; diff --git a/src/lib/inhalte/fehler.ts b/src/lib/inhalte/fehler.ts new file mode 100644 index 0000000..2be73be --- /dev/null +++ b/src/lib/inhalte/fehler.ts @@ -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); + } + } +} diff --git a/src/lib/inhalte/glossarverweise.ts b/src/lib/inhalte/glossarverweise.ts new file mode 100644 index 0000000..942ff7b --- /dev/null +++ b/src/lib/inhalte/glossarverweise.ts @@ -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, '"'); +} + +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(); + 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] + + `${gefunden[2]}` + + text.slice(gefunden.index + gefunden[0].length); + + bereitsVerlinkt.add(eintrag.begriff); + } + teile[i] = text; + } + + return teile.join(''); +} diff --git a/src/lib/inhalte/index.ts b/src/lib/inhalte/index.ts new file mode 100644 index 0000000..5b5d9bb --- /dev/null +++ b/src/lib/inhalte/index.ts @@ -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'; diff --git a/src/lib/inhalte/laden.test.ts b/src/lib/inhalte/laden.test.ts new file mode 100644 index 0000000..45985ce --- /dev/null +++ b/src/lib/inhalte/laden.test.ts @@ -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 { + const wurzel = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-inhalte-')); + aufraeumen.push(wurzel); + + const dateien: Record = { + '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(''); + 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); + }); +}); diff --git a/src/lib/inhalte/laden.ts b/src/lib/inhalte/laden.ts new file mode 100644 index 0000000..cb44f55 --- /dev/null +++ b/src/lib/inhalte/laden.ts @@ -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; + lernfelder: Lernfeld[]; + lernfeldNach: Map; + bloecke: Themenblock[]; + blockNach: Map; + + wochen: Woche[]; + wocheNach: Map; + + handouts: Handout[]; + handoutNach: Map; + handoutsNachBlock: Map; + + karten: Merkkarte[]; + karteNach: Map; + + aufgaben: Aufgabe[]; + aufgabeNach: Map; + + checklisten: Checkliste[]; + checklisteNach: Map; + + kontrollen: Kontrolle[]; + kontrolleNach: Map; + + glossar: GlossarEintrag[]; + glossarNach: Map; + + /** 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>(); + + 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>(); + + 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>(); + + 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>(); + + 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>(); + + 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(); + 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, + feld: string, + bezeichnung: string, + sammler: Fehlersammler, +): void { + const gesehen = new Set(); + 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; + lernfeldNach: Map; + blockNach: Map; + wochen: Woche[]; + wocheNach: Map; + wochenQuelle: Map>; + handouts: Handout[]; + handoutQuelle: Map>; + handoutNach: Map; + aufgaben: Aufgabe[]; + aufgabeQuelle: Map>; + aufgabeNach: Map; + checklisteNach: Map; + kontrollen: Kontrolle[]; + kontrolleQuelle: Map>; + kontrolleNach: Map; +}; + +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.`, + }); + } + } +} diff --git a/src/lib/inhalte/lesen.ts b/src/lib/inhalte/lesen.ts new file mode 100644 index 0000000..35262e2 --- /dev/null +++ b/src/lib/inhalte/lesen.ts @@ -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 = { + 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( + absolut: string, + anzeigepfad: string, + schema: S, + sammler: Fehlersammler, +): GeleseneDatei> | 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( + absolut: string, + anzeigepfad: string, + schema: S, + sammler: Fehlersammler, +): (GeleseneDatei> & { 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( + rohwerte: unknown, + ergebnis: Leseergebnis, + anzeigepfad: string, + schema: S, + sammler: Fehlersammler, +): GeleseneDatei> | 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 }; +} diff --git a/src/lib/inhalte/markdown.ts b/src/lib/inhalte/markdown.ts new file mode 100644 index 0000000..3658abb --- /dev/null +++ b/src/lib/inhalte/markdown.ts @@ -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); +} diff --git a/src/lib/inhalte/schema.ts b/src/lib/inhalte/schema.ts new file mode 100644 index 0000000..6033393 --- /dev/null +++ b/src/lib/inhalte/schema.ts @@ -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; +export type Lernfeld = z.infer; +export type Themenblock = z.infer; +export type Rahmenplan = z.infer; +export type Karte = z.infer; +export type HandoutKopf = z.infer; +export type AufgabeKopf = z.infer; +export type Woche = z.infer; +export type Checkliste = z.infer; +export type ChecklistenGruppe = z.infer; +export type Kontrolle = z.infer; +export type Kontrollfrage = z.infer; +export type GlossarEintrag = z.infer; diff --git a/src/lib/inhalte/suche.test.ts b/src/lib/inhalte/suche.test.ts new file mode 100644 index 0000000..a9c309e --- /dev/null +++ b/src/lib/inhalte/suche.test.ts @@ -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('

Die Subnetzmaske entscheidet.

', glossar); + expect(html).toContain('href="/glossar#subnetzmaske"'); + expect(html).toContain('>Subnetzmaske'); + }); + + it('verlinkt jeden Begriff höchstens einmal', () => { + const html = verlinkeGlossar('

DNS hier, DNS da, DNS überall.

', glossar); + expect(html.match(/glossarbegriff/g)).toHaveLength(1); + }); + + it('lässt Code, Überschriften und bestehende Verweise in Ruhe', () => { + expect(verlinkeGlossar('DNS', glossar)).toBe('DNS'); + expect(verlinkeGlossar('

DNS

', glossar)).toBe('

DNS

'); + expect(verlinkeGlossar('DNS', glossar)).toBe('DNS'); + expect(verlinkeGlossar('
DNS
', glossar)).toBe('
DNS
'); + }); + + 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('

Transportweg und Portweite.

', glossar); + expect(html).not.toContain('glossarbegriff'); + }); + + it('erkennt zusätzlich angegebene Formen', () => { + const html = verlinkeGlossar('

Die Maske sagt es.

', glossar); + expect(html).toContain('href="/glossar#subnetzmaske"'); + expect(html).toContain('>Maske'); + }); + + it('bevorzugt die längere Form – MAC-Adresse wird nicht zu Adresse', () => { + const html = verlinkeGlossar('

Die MAC-Adresse steht fest.

', glossar); + expect(html).toContain('>MAC-Adresse'); + }); + + it('maskiert Anführungszeichen in der Erklärung', () => { + const html = verlinkeGlossar('

Der Begriff Test.

', [ + { begriff: 'Test', formen: [], erklaerung: 'Ein "Anführungszeichen" und 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('

DNS

', [])).toBe('

DNS

'); + }); + + 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); + } + }); +}); diff --git a/src/lib/inhalte/suche.ts b/src/lib/inhalte/suche.ts new file mode 100644 index 0000000..ffff50c --- /dev/null +++ b/src/lib/inhalte/suche.ts @@ -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 ? ' …' : ''}`; +} diff --git a/src/lib/repos/fortschritt.ts b/src/lib/repos/fortschritt.ts new file mode 100644 index 0000000..cc3c10e --- /dev/null +++ b/src/lib/repos/fortschritt.ts @@ -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 { + 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; +}